用 Claude Code 构建数据可视化收入仪表盘
用 Claude Code 实现可转化的数据可视化:Recharts、D3、CSV 聚合、可访问性和 Playwright 截图检查。
先确定业务问题,而不是先画图
只对 Claude Code 说“做一个数据可视化”,很容易得到一个漂亮但没法指导决策的 demo。真正有价值的图表要先回答一个问题:谁看这个数字,要做什么决定?对 ClaudeCodeLab 来说,单纯看 PV 不够。更重要的是读者是否读完文章、是否点击内部链接、是否进入产品页、是否提交培训咨询表单,以及免费清单是否把读者带向付费模板。
本文用 React、TypeScript、Recharts、少量 D3、CSV 聚合、可访问性标签、响应式布局和 Playwright 截图检查,构建一个面向内容分析和变现的小仪表盘。Recharts 可以理解为适合 React 的图表组件库;D3 更像底层工具箱,用来做比例尺、数据变换和自定义计算。大多数内容型网站先用 Recharts 足够,只有遇到特殊坐标或复杂注释时再补 D3。
先解释几个词。数据契约就是前端和数据源之间的约定,例如 date 必须是 ISO 日期、sessions 必须是数字、revenue 必须是数值金额、channel 只能来自固定列表。聚合是把明细行汇总成“按渠道收入”“转化率”这种可决策数据。可访问性是让图表不只依赖颜色和鼠标,还能通过标题、表格、对比度和键盘操作被理解。
建议配合 ClaudeCodeLab 的分析实现、仪表盘开发和可访问性指南阅读。官方资料请参考 Claude Code docs、Recharts getting started、D3 getting started、Playwright screenshots 和 WCAG non-text contrast。
适合落地的使用场景
| 场景 | 核心指标 | 图表类型 | 后续动作 |
|---|---|---|---|
| 文章优化 | 完读率、CTA 点击、搜索流量 | 折线图、柱状图 | 重写开头、内部链接和 CTA 位置 |
| 产品变现 | 产品点击、收入、渠道占比 | 柱状图、占比图 | 优化产品卡片、价格文案和比较表 |
| 培训咨询 | 表单完成、资料下载、B2B 流量 | KPI 卡、漏斗 | 让培训入口更清晰 |
| 广告质量 | 完读率、滚动深度、广告附近跳出 | 带注释的趋势图 | 在增加广告前保护阅读体验 |
图表选择要克制。时间变化用折线,类别比较用柱状,少量类别占比才用饼图或占比图,需要立刻看到的数字用 KPI 卡。不要轻易使用双轴图,因为收入和转化率可能因为轴的设置看起来“相关”,实际只是视觉误导。
flowchart LR
Raw["CSV / analytics events"]
Contract["data contract"]
Aggregate["aggregation"]
Chart["Recharts dashboard"]
Review["accessibility and Playwright check"]
CTA["training / products / consultation CTA"]
Raw --> Contract --> Aggregate --> Chart --> Review --> CTA
给 Claude Code 的提示词
Build a React + TypeScript content analytics dashboard for ClaudeCodeLab.
Use Recharts for the main charts and a small D3 helper only for scale calculations.
Input data must follow a documented data contract: date, channel, sessions, signups, revenue, readRate.
Include sample data, CSV parsing, channel aggregation, loading state, error state, empty state, accessible labels, a table fallback, and responsive layout.
Avoid misleading charts: no truncated bar axis, no dual-axis chart, no color-only meaning.
Add Playwright screenshot checks for mobile and desktop.
Return copy-pasteable code and explain the failure modes to review.
这段提示词的重点不是“生成图表”,而是要求 Claude Code 同时交付数据形状、异常状态、可访问性和验证证据。
安装依赖
npm i recharts d3
npm i -D @types/d3 @playwright/test
定义数据契约并聚合 CSV
先固定数据格式,再写图表。否则模型可能把收入当字符串、把日期顺序排错,或者把空值变成 NaN 传进 tooltip。
// dashboard-data.ts
export type Channel = "organic" | "email" | "social" | "referral" | "paid";
export type TrafficRow = {
date: string;
channel: Channel;
sessions: number;
signups: number;
revenue: number;
readRate: number;
};
export type ChannelSummary = {
channel: Channel;
sessions: number;
signups: number;
revenue: number;
conversionRate: number;
arps: number;
};
const channels: Channel[] = ["organic", "email", "social", "referral", "paid"];
export const sampleRows: TrafficRow[] = [
{ date: "2026-05-01", channel: "organic", sessions: 1280, signups: 42, revenue: 840, readRate: 0.61 },
{ date: "2026-05-01", channel: "email", sessions: 420, signups: 31, revenue: 1240, readRate: 0.74 },
{ date: "2026-05-01", channel: "social", sessions: 680, signups: 18, revenue: 260, readRate: 0.49 },
{ date: "2026-05-02", channel: "organic", sessions: 1360, signups: 48, revenue: 980, readRate: 0.64 },
{ date: "2026-05-02", channel: "referral", sessions: 310, signups: 17, revenue: 510, readRate: 0.58 },
{ date: "2026-05-02", channel: "paid", sessions: 540, signups: 22, revenue: 730, readRate: 0.52 },
];
function toNumber(value: string | undefined, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toChannel(value: string | undefined): Channel {
return channels.includes(value as Channel) ? (value as Channel) : "referral";
}
export function parseAnalyticsCsv(csv: string): TrafficRow[] {
const [headerLine, ...lines] = csv.trim().split(/\r?\n/);
if (!headerLine) return [];
const headers = headerLine.split(",").map((header) => header.trim());
return lines
.filter(Boolean)
.map((line) => {
const columns = line.split(",").map((column) => column.trim());
const get = (name: string) => columns[headers.indexOf(name)];
return {
date: get("date") ?? "",
channel: toChannel(get("channel")),
sessions: toNumber(get("sessions")),
signups: toNumber(get("signups")),
revenue: toNumber(get("revenue")),
readRate: toNumber(get("readRate")),
};
})
.filter((row) => row.date && !Number.isNaN(Date.parse(row.date)));
}
export function aggregateByChannel(rows: TrafficRow[]): ChannelSummary[] {
const map = new Map<Channel, ChannelSummary>();
for (const row of rows) {
const current =
map.get(row.channel) ??
{ channel: row.channel, sessions: 0, signups: 0, revenue: 0, conversionRate: 0, arps: 0 };
current.sessions += row.sessions;
current.signups += row.signups;
current.revenue += row.revenue;
map.set(row.channel, current);
}
return [...map.values()]
.map((row) => ({
...row,
conversionRate: row.sessions === 0 ? 0 : row.signups / row.sessions,
arps: row.sessions === 0 ? 0 : row.revenue / row.sessions,
}))
.sort((a, b) => b.revenue - a.revenue);
}
这个解析器适合简单 CSV。若导出文件包含引号、字段内逗号、本地化小数或时区差异,应让 Claude Code 改用专用 CSV 解析库,并加入对应 fixture。
实现 React 仪表盘
下面的组件包含 KPI 卡、图表切换、加载状态、错误状态、空数据状态、响应式容器和表格 fallback。表格不是多余内容,它能让数值被审查,也能让辅助技术读取到同一份信息。
// ContentAnalyticsDashboard.tsx
"use client";
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { aggregateByChannel, sampleRows, type Channel, type TrafficRow } from "./dashboard-data";
type DashboardProps = {
rows?: TrafficRow[];
isLoading?: boolean;
error?: string | null;
};
type ViewMode = "revenue" | "conversion" | "mix";
const colors: Record<Channel, string> = {
organic: "#2563eb",
email: "#16a34a",
social: "#f97316",
referral: "#7c3aed",
paid: "#dc2626",
};
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const percent = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
});
export function ContentAnalyticsDashboard({
rows = sampleRows,
isLoading = false,
error = null,
}: DashboardProps) {
const [view, setView] = useState<ViewMode>("revenue");
const summary = useMemo(() => aggregateByChannel(rows), [rows]);
const totals = useMemo(
() =>
summary.reduce(
(total, row) => ({
sessions: total.sessions + row.sessions,
signups: total.signups + row.signups,
revenue: total.revenue + row.revenue,
}),
{ sessions: 0, signups: 0, revenue: 0 },
),
[summary],
);
if (isLoading) return <p role="status">Loading dashboard data...</p>;
if (error) return <p role="alert">Dashboard data could not be loaded: {error}</p>;
if (summary.length === 0) return <p role="status">No data matches this date range. Try widening the filters.</p>;
const totalConversion = totals.sessions === 0 ? 0 : totals.signups / totals.sessions;
return (
<section aria-labelledby="content-analytics-title" style={{ display: "grid", gap: 24 }}>
<header>
<p style={{ margin: 0, color: "#64748b" }}>ClaudeCodeLab revenue dashboard</p>
<h2 id="content-analytics-title" style={{ margin: "4px 0 0" }}>Content analytics dashboard</h2>
</header>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))" }}>
<MetricCard label="Sessions" value={totals.sessions.toLocaleString("en-US")} />
<MetricCard label="Signups" value={totals.signups.toLocaleString("en-US")} />
<MetricCard label="Revenue" value={money.format(totals.revenue)} />
<MetricCard label="Conversion" value={percent.format(totalConversion)} />
</div>
<div role="tablist" aria-label="Chart view" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{[
["revenue", "Revenue by channel"],
["conversion", "Conversion rate"],
["mix", "Revenue mix"],
].map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={view === key}
onClick={() => setView(key as ViewMode)}
style={{
border: "1px solid #cbd5e1",
borderRadius: 8,
padding: "8px 12px",
background: view === key ? "#0f172a" : "#ffffff",
color: view === key ? "#ffffff" : "#0f172a",
}}
>
{label}
</button>
))}
</div>
<figure aria-labelledby="dashboard-chart-title" style={{ margin: 0 }}>
<h3 id="dashboard-chart-title" style={{ margin: "0 0 12px" }}>Dashboard chart</h3>
<ResponsiveContainer width="100%" height={320}>
{view === "revenue" ? (
<BarChart data={summary} margin={{ top: 8, right: 24, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="channel" />
<YAxis tickFormatter={(value) => money.format(Number(value))} domain={[0, "dataMax"]} />
<Tooltip formatter={(value) => money.format(Number(value))} />
<Bar dataKey="revenue" name="Revenue">
{summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
</Bar>
</BarChart>
) : view === "conversion" ? (
<BarChart data={summary} margin={{ top: 8, right: 24, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="channel" />
<YAxis tickFormatter={(value) => percent.format(Number(value))} domain={[0, "dataMax"]} />
<Tooltip formatter={(value) => percent.format(Number(value))} />
<Bar dataKey="conversionRate" name="Conversion rate">
{summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
</Bar>
</BarChart>
) : (
<PieChart>
<Pie data={summary} dataKey="revenue" nameKey="channel" outerRadius={110} label>
{summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
</Pie>
<Tooltip formatter={(value) => money.format(Number(value))} />
<Legend />
</PieChart>
)}
</ResponsiveContainer>
<figcaption style={{ color: "#64748b" }}>
Bars start at zero, values are available in the table, and color is never the only label.
</figcaption>
</figure>
<table aria-label="Channel summary" style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
{["Channel", "Sessions", "Signups", "Revenue", "CVR", "Revenue/session"].map((heading) => (
<th key={heading} scope="col" style={{ borderBottom: "1px solid #cbd5e1", textAlign: "left", padding: 8 }}>{heading}</th>
))}
</tr>
</thead>
<tbody>
{summary.map((row) => (
<tr key={row.channel}>
<th scope="row" style={{ borderBottom: "1px solid #e2e8f0", textAlign: "left", padding: 8 }}>{row.channel}</th>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{row.sessions.toLocaleString("en-US")}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{row.signups.toLocaleString("en-US")}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{money.format(row.revenue)}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{percent.format(row.conversionRate)}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{money.format(row.arps)}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div style={{ border: "1px solid #cbd5e1", borderRadius: 8, padding: 16 }}>
<p style={{ margin: 0, color: "#64748b" }}>{label}</p>
<strong style={{ display: "block", fontSize: 24, marginTop: 4 }}>{value}</strong>
</div>
);
}
D3 只做辅助
// d3-scales.ts
import { extent, scaleLinear, scaleTime } from "d3";
export function buildRevenueScales(
rows: Array<{ date: string; revenue: number }>,
width: number,
height: number,
) {
const dates = rows.map((row) => new Date(row.date)).filter((date) => !Number.isNaN(date.valueOf()));
const revenues = rows.map((row) => row.revenue).filter((value) => Number.isFinite(value));
const dateExtent = extent(dates);
const revenueExtent = extent(revenues);
const minDate = dateExtent[0] ?? new Date("2026-01-01");
const maxDate = dateExtent[1] ?? minDate;
const maxRevenue = Math.max(revenueExtent[1] ?? 0, 1);
return {
x: scaleTime().domain([minDate, maxDate]).range([0, width]),
y: scaleLinear().domain([0, maxRevenue]).nice().range([height, 0]),
};
}
常见失败模式
第一,柱状图的 Y 轴不从零开始。这样会夸大差异,导致错误的广告、CTA 或产品排序判断。第二,CSV 里有空值、非法日期或未知渠道,却没有在聚合前清洗。第三,只用颜色表达含义,没有图例、文字或表格。第四,没有加载、错误和空数据状态。第五,只看桌面,不看 390px 手机宽度下标签是否重叠。
用 Playwright 做截图检查
// tests/content-analytics-dashboard.spec.ts
import { expect, test } from "@playwright/test";
test.describe("content analytics dashboard", () => {
for (const viewport of [
{ width: 390, height: 844 },
{ width: 1280, height: 900 },
]) {
test(`renders at ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/analytics-demo");
await expect(page.getByRole("heading", { name: /Content analytics dashboard/i })).toBeVisible();
await expect(page.getByRole("figure", { name: /Dashboard chart/i })).toBeVisible();
await expect(page.getByRole("table", { name: /Channel summary/i })).toBeVisible();
await expect(page).toHaveScreenshot(`content-analytics-${viewport.width}.png`, {
fullPage: true,
animations: "disabled",
});
});
}
});
npx playwright test tests/content-analytics-dashboard.spec.ts
截图不能证明计算完全正确,所以聚合逻辑仍然需要单元测试。但它能很快发现空白图表、标签重叠、横向溢出和表格缺失。
把图表连接到收入路径
数据可视化不是装饰。若读者读完文章却没有进入产品库,说明 CTA 可能不够具体。若咨询类文章有流量但没有表单提交,培训和咨询页面可能需要更明确的承诺。若新手连续阅读多篇教程,可以把免费清单作为下一步入口。
这套模式实际测试后,最有价值的是先固定数据契约,再让 Claude Code 写图表。只要一开始要求空数据、错误状态、表格 fallback、移动端截图和误导性图表检查,输出就会更接近可发布的仪表盘。真正需要额外验证的是项目自己的 CSV:引号、时区和不同分析工具导出的字段都要用真实样本检查。
我还会把“图表是否好看”和“图表是否能带来收入判断”分开看。前者用截图和视觉检查确认,后者必须回到问题本身:哪个渠道带来咨询、哪个页面带来商品点击、哪一段内容让读者离开。Claude Code 可以很快补齐组件和测试,但如果原始指标没有定义清楚,再漂亮的图也只是在放大噪音。上线前至少要拿一周真实数据跑一次,确认排序、百分比和金额没有被样例数据误导。
免费 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 与咨询路径都要可审查。