用 Claude Code 构建可信的 SaaS 仪表盘
从 KPI 定义、SQL 聚合、Next.js API 到 React 图表,构建可审查的 SaaS 仪表盘。
SaaS 仪表盘不是把几个图表放到页面上就结束。真正会被业务团队使用的仪表盘,必须回答三个问题:这个数字是什么意思、它来自哪里、现在还能不能相信。MRR、激活率、流失率、工单 SLA、试用转付费率这些指标,一旦单位、时区、筛选条件或权限边界写错,就会让团队做出错误判断。
Claude Code 很适合帮我们加速仪表盘开发,但前提是先把规则写清楚。不要直接说“做一个 dashboard”,而是把 KPI 定义、API contract、SQL 聚合、页面状态、权限规则和审查清单交给它。本文用一个 SaaS 经营仪表盘做例子,整理一套从数据到 UI 的完整流程。
flowchart LR
A["KPI 定义"] --> B["API contract"]
B --> C["SQL 聚合"]
C --> D["Next.js Route Handler"]
D --> E["React Dashboard UI"]
E --> F["审查与回归测试"]
F --> A
先定义可信条件
仪表盘的第一个交付物不是页面,而是指标定义表。Claude Code 可以生成组件,但它无法自动知道公司内部的“激活用户”到底是登录一次、完成 onboarding,还是创建了第一个项目。
| 维度 | 不可信的仪表盘 | 可信的仪表盘 |
|---|---|---|
| 单位 | 只显示 123,456 | 明确显示 JPY、USD、users、% |
| 日期范围 | 只写“本月” | 显示 2026-05-01 到 2026-05-31 |
| 时区 | 默认数据库时区 | API 和 UI 都显示 Asia/Tokyo 或 UTC |
| 权限 | 前端隐藏卡片 | API 和 SQL 都按 tenant 与 role 过滤 |
| 新鲜度 | 旧数据看起来正常 | 显示 generatedAt 与 stale 状态 |
| 解释 | 只有图表 | 显示公式、比较期间、异常条件 |
实际项目里建议分三类 KPI。第一类是经营层 KPI,例如 MRR、ARR、流失率、ARPA 和净新增收入。第二类是产品 KPI,例如激活率、试用转付费、功能使用率和 cohort 变化。第三类是客户成功 KPI,例如未处理工单、SLA 超时、健康分数和负责人分布。每类指标都要有 owner,否则出了差异没人能判断应该相信仪表盘还是会计系统。
API contract 先于 UI
让 Claude Code 直接写 React 卡片,很容易出现“前端猜字段”的问题。更稳的做法是先写 contract,再让后端和前端都围绕同一个结构实现。
{
"meta": {
"tenantId": "tenant_123",
"dateRange": { "from": "2026-05-01", "to": "2026-05-31" },
"timezone": "Asia/Tokyo",
"generatedAt": "2026-06-01T09:00:00+09:00",
"staleAfterMinutes": 15
},
"metrics": [
{
"id": "mrr",
"label": "Monthly Recurring Revenue",
"value": 1250000,
"unit": "JPY",
"deltaPct": 8.4,
"formula": "sum(active subscriptions monthly amount)"
}
],
"series": [
{ "date": "2026-05-01", "mrr": 1100000, "activationRate": 0.42 }
]
}
TypeScript 类型也要先定义好。把它保存到 lib/dashboard-types.ts,Claude Code 的改动范围就会稳定很多。
export type MetricUnit = "JPY" | "USD" | "users" | "percent";
export type DashboardMetric = {
id: "mrr" | "activationRate" | "churnRate" | "openTickets";
label: string;
value: number;
unit: MetricUnit;
deltaPct: number;
formula: string;
};
export type DashboardPayload = {
meta: {
tenantId: string;
dateRange: { from: string; to: string };
timezone: string;
generatedAt: string;
staleAfterMinutes: number;
};
metrics: DashboardMetric[];
series: Array<{
date: string;
mrr: number;
activationRate: number;
}>;
};
SQL 聚合要可审查
仪表盘最容易出错的地方就是 SQL,尤其是对 trial、refund、已取消订阅、timezone、test tenant 这些情况的处理。SQL 可以写得短,但一定要把这些条件明确写出来。
-- sql/dashboard-summary.sql
with active_subscriptions as (
select
tenant_id,
date_trunc('day', current_period_start at time zone 'Asia/Tokyo') as day,
sum(monthly_amount_jpy) as mrr
from subscriptions
where status = 'active'
and is_test = false
and tenant_id = $1
and current_period_start >= $2
and current_period_start < $3
group by tenant_id, day
),
activated_users as (
select
tenant_id,
date_trunc('day', activated_at at time zone 'Asia/Tokyo') as day,
count(*) as activated_count
from users
where tenant_id = $1
and activated_at >= $2
and activated_at < $3
group by tenant_id, day
)
select
coalesce(a.day, u.day) as date,
coalesce(a.mrr, 0) as mrr,
coalesce(u.activated_count, 0) as activated_users
from active_subscriptions a
full outer join activated_users u
on a.tenant_id = u.tenant_id and a.day = u.day
order by date;
让 Claude Code 帮忙时,不要说“把 SQL 写短一点”,而要说“这些条件绝对不能漏掉”。把 tenant filter、test data exclusion、refund handling、timezone 固定为必查的审查项。
Next.js Route Handler
Next.js App Router 的 Route Handler 很适合用来以 BFF 的方式聚合 API。可以参考官方的 Next.js App Router docs 和 Route Handlers,在服务端校验权限和 tenant。
// app/api/dashboard/summary/route.ts
import { NextResponse } from "next/server";
import type { DashboardPayload } from "@/lib/dashboard-types";
export async function GET(request: Request) {
const url = new URL(request.url);
const from = url.searchParams.get("from") ?? "2026-05-01";
const to = url.searchParams.get("to") ?? "2026-06-01";
const session = await requireSession();
if (!["admin", "finance", "ops"].includes(session.role)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const rows = await loadDashboardRows({
tenantId: session.tenantId,
from,
to,
timezone: "Asia/Tokyo",
});
const payload: DashboardPayload = {
meta: {
tenantId: session.tenantId,
dateRange: { from, to },
timezone: "Asia/Tokyo",
generatedAt: new Date().toISOString(),
staleAfterMinutes: 15,
},
metrics: buildMetrics(rows),
series: rows.map((row) => ({
date: row.date,
mrr: row.mrr,
activationRate: row.activationRate,
})),
};
return NextResponse.json(payload, {
headers: { "Cache-Control": "private, max-age=60" },
});
}
这里的关键是 private 缓存和 role check。经营指标最好不要放在共享 CDN 上,这样更安全。如果要做对外公开的统计页面,应该拆成独立的 API 和独立的 contract。
React UI 要把所有状态都呈现出来
正如 React 官方 Learn React 所讲,UI 是由状态推导出来的。对仪表盘来说,关键是不要省略 loading、error、empty、stale 这几种状态。图表还需要考虑可访问性,可以一并参考 MDN 的 Accessibility 和 Recharts 的 accessibility note。
// app/dashboard/page.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { DashboardPayload, DashboardMetric } from "@/lib/dashboard-types";
const money = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY",
});
type State =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; data: DashboardPayload };
export default function DashboardPage() {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
fetch("/api/dashboard/summary?from=2026-05-01&to=2026-06-01")
.then((res) => {
if (!res.ok) throw new Error(`Dashboard API failed: ${res.status}`);
return res.json();
})
.then((data: DashboardPayload) => setState({ status: "ready", data }))
.catch((error: Error) => setState({ status: "error", message: error.message }));
}, []);
if (state.status === "loading") {
return <p role="status">Loading dashboard...</p>;
}
if (state.status === "error") {
return <p role="alert">Dashboard failed: {state.message}</p>;
}
if (state.data.metrics.length === 0) {
return <p>No metrics for this date range.</p>;
}
return <DashboardContent data={state.data} />;
}
function DashboardContent({ data }: { data: DashboardPayload }) {
const stale = useMemo(() => isStale(data), [data]);
return (
<main className="space-y-6 p-6">
<header className="space-y-2">
<h1 className="text-2xl font-bold">SaaS KPI Dashboard</h1>
<p className="text-sm text-slate-600">
{data.meta.dateRange.from} to {data.meta.dateRange.to} / timezone {data.meta.timezone} / generated {data.meta.generatedAt}
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
This dashboard may be stale. Confirm the source data before making a decision.
</p>
)}
</header>
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" aria-label="Key metrics">
{data.metrics.map((metric) => (
<article key={metric.id} className="rounded border bg-white p-4">
<p className="text-sm text-slate-500">{metric.label}</p>
<p className="mt-2 text-2xl font-bold">{formatMetric(metric)}</p>
<p className="text-sm text-slate-600">Previous period {metric.deltaPct >= 0 ? "+" : ""}{metric.deltaPct.toFixed(2)}%</p>
<p className="mt-2 text-xs text-slate-500">Formula: {metric.formula}</p>
</article>
))}
</section>
<section className="rounded border bg-white p-4">
<h2 className="text-lg font-semibold">MRR trend</h2>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data.series} accessibilityLayer aria-label="MRR trend chart">
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => money.format(Number(value))} />
<Tooltip formatter={(value) => money.format(Number(value))} />
<Line type="monotone" dataKey="mrr" stroke="#2563eb" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
<table className="sr-only">
<caption>MRR trend table</caption>
<thead><tr><th>Date</th><th>MRR</th><th>Activation rate</th></tr></thead>
<tbody>{data.series.map((point) => <tr key={point.date}><td>{point.date}</td><td>{point.mrr}</td><td>{point.activationRate}</td></tr>)}</tbody>
</table>
</section>
</main>
);
}
function isStale(data: DashboardPayload) {
const generatedAt = new Date(data.meta.generatedAt).getTime();
return Date.now() - generatedAt > data.meta.staleAfterMinutes * 60 * 1000;
}
function formatMetric(metric: DashboardMetric) {
if (metric.unit === "JPY") return money.format(metric.value);
if (metric.unit === "percent") return `${(metric.value * 100).toFixed(2)}%`;
return `${metric.value.toLocaleString()} ${metric.unit}`;
}
三个常见用例
经营 dashboard 用来每周检查收入、流失、试用转付费和现金流。这里最重要的是 finance 权限和会计口径。Claude Code 可以生成 SQL 和卡片,但最终定义必须由业务负责人确认。
产品 dashboard 用来观察功能 adoption、onboarding 完成率、活跃项目数和错误率。这里要注意 cohort,不要把新用户和老用户混在一个平均值里。
客户成功 dashboard 用来显示未处理工单、SLA 超时、健康分数和负责人负载。这里要注意权限,因为一个客户经理不一定能看到所有 tenant。
容易失败的点
第一,指标名字看起来一样,公式却不同。MRR 是否扣除 discount,是否包含 trial,是否排除 test tenant,必须写出来。
第二,只在前端过滤 tenant。仪表盘 API 必须在服务端检查权限,SQL 也必须带 tenant 条件。
第三,图表没有 fallback。颜色、tooltip、图例和表格都要考虑,不能让屏幕阅读器用户只看到一块 canvas。
第四,缓存和延迟没有显示。仪表盘可以缓存,但必须显示生成时间和 stale 状态,否则会议上会因为旧数据争论。
第五,Claude Code 一次性重写太多。仪表盘最好拆成 contract、SQL、API、UI、测试五个小 PR,每次都能验证。
Claude Code 审查提示词
官方的 Claude Code common workflows 强调小步调查、修改、验证。仪表盘开发也应该这样做。
You are reviewing a SaaS KPI dashboard implementation.
Check these files:
- app/api/dashboard/summary/route.ts
- app/dashboard/page.tsx
- lib/dashboard-types.ts
- sql/dashboard-summary.sql
Review priorities:
1. KPI correctness: units, formulas, date range, timezone, and previous-period comparison.
2. Trust signals: generatedAt, staleAfterMinutes, empty state, loading state, error state.
3. Security: tenant isolation, role boundaries, finance-only metrics, API-side authorization.
4. Accessibility: chart labels, non-color-only meaning, keyboard navigation, table fallback.
5. Maintainability: duplicated formatter logic, unsafe casts, missing tests.
Return findings as P0, P1, or P2. Include file paths, exact code references, and a suggested fix.
Do not rewrite the whole dashboard unless a finding requires it.
更多相关内容可以参考 Claude Code 数据可视化 和 Claude Code RBAC 实现。如果你想把 dashboard contract、review prompt、SQL checklist 做成团队模板,可以从 ClaudeCodeLab 模板库 或 Claude Code 培训与咨询 开始。
把本文方法用于 ClaudeCodeLab 的 SaaS 示例数据时,最明显的发现是:页面代码并不是风险最大的地方。真正容易出错的是 KPI contract、时区、生成时间和权限条件。先固定这些规则之后,Claude Code 的 UI 生成和审查建议会稳定很多。可信的 dashboard 不是更漂亮的图表,而是团队敢据此做决定的数据产品。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。