Recipes

构建使用量分析仪表板

端到端实战指南——从 Prisma 模式到已部署功能。涵盖数据库、API、前端、权限、图表和测试。

本实战指南构建一个使用量分析仪表板——一个显示每个租户过去 30 天 API 调用使用量时间序列的仪表板页面。它涵盖技术栈的每个层级,可作为任何数据密集型仪表板功能的模板。

您将构建的内容:

  • 用于每日聚合的 UsageSnapshot Prisma 模型
  • 基于 @nebutra/meteringGET /api/v1/analytics/usage Hono 端点
  • 带有加载/错误状态的 React Server Component 页面
  • 使用 CSS 变量颜色自适应深色模式的响应式 Recharts 面积图
  • 通过 analytics:read 权限进行 RBAC 访问控制
  • 侧边栏导航条目
  • API 处理器的 Vitest 单元测试
  • 页面的 Playwright E2E 测试
  • 图表组件的 Storybook 故事

前提条件: 运行中的开发环境(pnpm dev)以及可通过 DATABASE_URL 访问的 Postgres 数据库。


第 1 阶段:数据库

向 Prisma 模式添加 UsageSnapshot 模型。它存储预聚合的每日计数——相比查询原始 ClickHouse 数据,历史图表查询成本更低。

// packages/platform/db/prisma/schema.prisma

model UsageSnapshot {
  id         String   @id @default(cuid())
  tenantId   String
  date       DateTime @db.Date
  meterId    String           // 例如 "api_calls"、"storage_bytes"
  value      BigInt
  createdAt  DateTime @default(now())

  @@unique([tenantId, date, meterId])
  @@index([tenantId, meterId, date])
}
pnpm db:generate
pnpm db:migrate --name add-usage-snapshot

第 2 阶段:API 端点

在 api-gateway 中创建分析路由。该端点从 @nebutra/metering(ClickHouse)读取过去 30 天的数据,并返回时间序列数组。

// backends/gateway/src/routes/analytics.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { requirePermission } from "@nebutra/permissions";
import { getMetering } from "@nebutra/metering";
import { getCurrentTenant } from "@nebutra/tenant";

const analyticsRouter = new Hono();

const usageQuerySchema = z.object({
  meterId: z.string().default("api_calls"),
  days:    z.coerce.number().int().min(1).max(90).default(30),
});

analyticsRouter.get(
  "/usage",
  requirePermission("analytics:read"),
  zValidator("query", usageQuerySchema),
  async (c) => {
    const { meterId, days } = c.req.valid("query");
    const { tenantId } = getCurrentTenant();

    const metering = await getMetering();
    const since = new Date();
    since.setDate(since.getDate() - days);

    const series = await metering.getTimeSeries(tenantId, meterId, {
      from: since,
      to: new Date(),
      granularity: "day",
    });

    return c.json({
      success: true,
      data: {
        meterId,
        tenantId,
        series: series.map((point) => ({
          date:  point.timestamp.toISOString().split("T")[0],
          value: Number(point.value),
        })),
      },
    });
  }
);

export { analyticsRouter };
// backends/gateway/src/index.ts
import { analyticsRouter } from "./routes/analytics";

app.route("/api/v1/analytics", analyticsRouter);

requirePermission 是来自 @nebutra/permissions 的 Hono 中间件。它从 Authorization 请求头读取 JWT,若解析出的角色不包含所请求的权限范围,则返回 403。


第 3 阶段:TypeScript 类型

添加端点后,重新生成有类型的 API 客户端,使前端获得完整的类型安全。

pnpm generate:api-types

这会运行 openapi-typescript 流水线(脚本:scripts/generate-api-types.ts),从 backends/gateway/openapi.json 读取规范,写入 apps/web/src/lib/api/types.generated.ts。基于 openapi-fetch 的有类型客户端可通过以下方式使用:

// Server Components / route handlers(自动转发 Clerk JWT):
import { getTypedApi } from "@/lib/api/client";
const api = await getTypedApi();

// Client Components:
import { browserApiClient } from "@/lib/api/browser-client";

第 4 阶段:前端——Server Component

mkdir -p apps/web/src/app/\(dashboard\)/analytics
// apps/web/src/app/(dashboard)/analytics/page.tsx
import { Suspense } from "react";
import { PageHeader } from "@nebutra/ui/layout";
import { LoadingState, ErrorState } from "@nebutra/ui/layout";
import { requireAuth, getTenantContext } from "@/lib/auth";
import { getTypedApi } from "@/lib/api/client";
import { AnalyticsChart } from "./_components/analytics-chart";

export const metadata = { title: "分析" };

async function AnalyticsData() {
  const { tenantId } = await getTenantContext();
  const api = await getTypedApi();

  const { data, error } = await api.GET("/api/v1/analytics/usage", {
    params: { query: { meterId: "api_calls", days: 30 } },
    next: { revalidate: 300 },   // 缓存 5 分钟
  });

  if (error || !data?.success) {
    return <ErrorState message="加载分析数据失败" />;
  }

  return <AnalyticsChart series={data.data.series} />;
}

export default async function AnalyticsPage() {
  await requireAuth();

  return (
    <div className="space-y-6">
      <PageHeader
        title="分析"
        description="过去 30 天的 API 使用情况"
      />
      <Suspense fallback={<LoadingState message="正在加载使用数据…" />}>
        <AnalyticsData />
      </Suspense>
    </div>
  );
}
// apps/web/src/app/(dashboard)/analytics/loading.tsx
import { LoadingState } from "@nebutra/ui/layout";
export default function Loading() {
  return <LoadingState message="正在加载分析数据…" />;
}
// apps/web/src/app/(dashboard)/analytics/error.tsx
"use client";
import { ErrorState } from "@nebutra/ui/layout";
export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return <ErrorState message={error.message} onRetry={reset} />;
}

第 5 阶段:图表组件

图表组件是 Client Component(Recharts 需要浏览器环境)。它从 CSS 变量读取颜色,因此能自动适应明暗模式和活跃主题预设。

mkdir -p apps/web/src/app/\(dashboard\)/analytics/_components
// apps/web/src/app/(dashboard)/analytics/_components/analytics-chart.tsx
"use client";

import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";
import { AnimateIn } from "@nebutra/ui/components";

interface DataPoint {
  date: string;
  value: number;
}

interface AnalyticsChartProps {
  series: DataPoint[];
}

function formatDate(dateStr: string): string {
  return new Date(dateStr).toLocaleDateString("zh-CN", {
    month: "short",
    day: "numeric",
  });
}

function formatValue(value: number): string {
  if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
  if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
  return String(value);
}

export function AnalyticsChart({ series }: AnalyticsChartProps) {
  return (
    <AnimateIn preset="emerge" inView>
      <div className="rounded-xl border border-[var(--neutral-7)] bg-[var(--neutral-1)] p-6 shadow-sm">
        <h2 className="mb-4 text-sm font-medium text-[var(--neutral-11)]">
          API 调用次数 — 过去 30 天
        </h2>
        <ResponsiveContainer width="100%" height={280}>
          <AreaChart data={series} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
            <defs>
              <linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%"  stopColor="var(--brand-primary)" stopOpacity={0.25} />
                <stop offset="95%" stopColor="var(--brand-primary)" stopOpacity={0} />
              </linearGradient>
            </defs>
            <CartesianGrid
              strokeDasharray="3 3"
              stroke="var(--neutral-7)"
              vertical={false}
            />
            <XAxis
              dataKey="date"
              tickFormatter={formatDate}
              tick={{ fill: "var(--neutral-11)", fontSize: 12 }}
              axisLine={false}
              tickLine={false}
            />
            <YAxis
              tickFormatter={formatValue}
              tick={{ fill: "var(--neutral-11)", fontSize: 12 }}
              axisLine={false}
              tickLine={false}
              width={48}
            />
            <Tooltip
              contentStyle={{
                background: "var(--neutral-2)",
                border: "1px solid var(--neutral-7)",
                borderRadius: "8px",
                color: "var(--neutral-12)",
                fontSize: "13px",
              }}
              labelFormatter={formatDate}
              formatter={(value: number) => [formatValue(value), "API 调用次数"]}
            />
            <Area
              type="monotone"
              dataKey="value"
              stroke="var(--brand-primary)"
              strokeWidth={2}
              fill="url(#usageGradient)"
              dot={false}
              activeDot={{ r: 4, fill: "var(--brand-primary)" }}
            />
          </AreaChart>
        </ResponsiveContainer>
      </div>
    </AnimateIn>
  );
}

所有 Recharts 颜色值均使用 CSS 变量("var(--brand-primary)")——从不使用硬编码的十六进制值。这确保了图表无需任何额外代码即可适应深色模式和主题预设变更。


第 6 阶段:权限控制

// packages/iam/permissions/src/roles.ts
export const ROLE_PERMISSIONS = {
  OWNER:  ["*"],
  ADMIN:  ["analytics:read", "analytics:export", /* …其他管理员权限 */],
  MEMBER: ["analytics:read"],
  VIEWER: [],                   // 访客不能查看分析数据
} as const;

第 2 阶段添加的 requirePermission("analytics:read") 中间件已经足够。它对不包含该权限范围的角色返回 HTTP 403。

// apps/web/src/app/(dashboard)/analytics/page.tsx
import { PlanGate } from "@/components/plan-gate";

// 在页面组件内,用以下代码包裹内容:
<PlanGate
  plans={["starter", "pro", "enterprise"]}
  fallback={
    <EmptyState
      icon="lock"
      title="免费套餐不包含分析功能"
      description="升级到 Starter 或更高套餐以访问使用量分析。"
      action={<UpgradeButton />}
    />
  }
>
  <Suspense fallback={<LoadingState message="正在加载使用数据…" />}>
    <AnalyticsData />
  </Suspense>
</PlanGate>

第 7 阶段:侧边栏导航

// apps/web/src/components/sidebar/nav-items.ts
export const mainNavItems: NavItem[] = [
  // ... 现有条目
  {
    title: "分析",
    href:  "/analytics",
    icon:  "chart-line",
    group: "product",
    plans: ["starter", "pro", "enterprise"],  // 对免费套餐用户隐藏
  },
];

第 8 阶段:测试

Vitest 单元测试——API 处理器

// backends/gateway/src/routes/analytics.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { testClient } from "hono/testing";
import { analyticsRouter } from "./analytics";

vi.mock("@nebutra/metering", () => ({
  getMetering: vi.fn().mockResolvedValue({
    getTimeSeries: vi.fn().mockResolvedValue([
      { timestamp: new Date("2025-01-01"), value: BigInt(1500) },
      { timestamp: new Date("2025-01-02"), value: BigInt(2300) },
    ]),
  }),
}));

vi.mock("@nebutra/tenant", () => ({
  getCurrentTenant: vi.fn().mockReturnValue({ tenantId: "org_test_123" }),
}));

vi.mock("@nebutra/permissions", () => ({
  requirePermission: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));

describe("GET /usage", () => {
  it("返回所请求计量器的时间序列", async () => {
    const client = testClient(analyticsRouter);
    const res = await client.usage.$get({ query: { meterId: "api_calls", days: "30" } });

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.data.series).toHaveLength(2);
    expect(body.data.series[0]).toMatchObject({ date: "2025-01-01", value: 1500 });
  });

  it("省略 meterId 时默认使用 api_calls 计量器", async () => {
    const client = testClient(analyticsRouter);
    const res = await client.usage.$get({ query: { days: "7" } });
    expect(res.status).toBe(200);
  });
});

运行命令:

pnpm --filter @nebutra/gateway test

Playwright E2E 测试

// apps/web/e2e/analytics.spec.ts
import { test, expect } from "@playwright/test";

test.describe("分析仪表板", () => {
  test.beforeEach(async ({ page }) => {
    // 以具有 analytics:read 权限的成员身份登录
    await page.goto("/sign-in");
    await page.getByLabel("邮箱").fill("[email protected]");
    await page.getByLabel("密码").fill(process.env.E2E_TEST_PASSWORD!);
    await page.getByRole("button", { name: "登录" }).click();
    await page.waitForURL("/dashboard");
  });

  test("渲染带图表的分析页面", async ({ page }) => {
    await page.goto("/analytics");

    await expect(page.getByRole("heading", { name: "分析" })).toBeVisible();
    await expect(page.getByText("API 调用次数 — 过去 30 天")).toBeVisible();

    // Recharts 渲染一个 SVG——验证其存在
    const chart = page.locator("svg.recharts-surface");
    await expect(chart).toBeVisible();
  });

  test("对免费套餐用户显示升级提示", async ({ page }) => {
    // 以免费套餐用户身份登录
    await page.goto("/sign-in");
    await page.getByLabel("邮箱").fill("[email protected]");
    await page.getByLabel("密码").fill(process.env.E2E_TEST_PASSWORD!);
    await page.getByRole("button", { name: "登录" }).click();

    await page.goto("/analytics");
    await expect(page.getByText("免费套餐不包含分析功能")).toBeVisible();
  });
});

运行命令:

pnpm --filter @nebutra/web e2e

第 9 阶段:Storybook 故事

创建故事,以便图表可以独立开发和评审。

// apps/storybook/src/stories/AnalyticsChart.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { AnalyticsChart } from "../../web/src/app/(dashboard)/analytics/_components/analytics-chart";

// 生成 30 天的合成数据
function generateSeries(days = 30) {
  return Array.from({ length: days }, (_, i) => {
    const date = new Date();
    date.setDate(date.getDate() - (days - i));
    return {
      date:  date.toISOString().split("T")[0],
      value: Math.floor(Math.random() * 5000) + 500,
    };
  });
}

const meta: Meta<typeof AnalyticsChart> = {
  title:     "Patterns/AnalyticsChart",
  component: AnalyticsChart,
  tags:      ["autodocs"],
  parameters: {
    layout: "padded",
  },
};
export default meta;

type Story = StoryObj<typeof AnalyticsChart>;

export const Default: Story = {
  args: { series: generateSeries(30) },
};

export const ShortRange: Story = {
  args: { series: generateSeries(7) },
};

export const Empty: Story = {
  args: { series: [] },
};

export const HighVolume: Story = {
  args: {
    series: generateSeries(30).map((p) => ({ ...p, value: p.value * 1000 })),
  },
};

在 Storybook 中查看:

pnpm --filter @nebutra/storybook dev
# → http://localhost:6006 → Patterns/AnalyticsChart

总结

您已端到端构建了一个完整的、生产就绪的功能:

阶段产出物
1UsageSnapshot Prisma 模型
2GET /api/v1/analytics/usage Hono 端点
3自动生成的 TypeScript 类型
4AnalyticsPage React Server Component
5使用 Token 颜色的 AnalyticsChart Recharts 组件
6analytics:read RBAC 权限范围 + 套餐限制
7侧边栏导航条目
8Vitest 单元测试 + Playwright E2E 测试
9带合成数据的 Storybook 故事

构建任何数据驱动的仪表板板块时,均可将此实战指南作为模板使用。


How is this guide?

目录