Customization

仪表板定制

添加新的仪表板板块,自定义侧边栏导航,处理空状态/加载状态/错误状态,以及按套餐限制功能访问。

已认证的仪表板位于 apps/web/src/app/(dashboard)/。该路由组中的每个页面都会自动获得共享的侧边栏、顶栏以及认证守卫。

仪表板页面的结构

apps/web/src/app/(dashboard)/
  layout.tsx          ← 共享侧边栏 + 顶栏外壳
  analytics/
    page.tsx          ← 您的新板块
    loading.tsx       ← 可选的 Suspense 回退
    error.tsx         ← 可选的错误边界

一个标准页面有四项职责:

  1. 认证守卫 — 在服务端拒绝未认证的请求
  2. 租户上下文 — 解析当前组织信息
  3. 数据获取 — 在 Server Component 中加载数据
  4. 渲染 — 组合布局原语和功能组件
// apps/web/src/app/(dashboard)/analytics/page.tsx
import { PageHeader } from "@nebutra/ui/layout";
import { requireAuth, getTenantContext } from "@/lib/auth";

export default async function AnalyticsPage() {
  await requireAuth();
  const { tenantId, plan } = await getTenantContext();

  return (
    <div className="space-y-6">
      <PageHeader
        title="分析"
        description="跟踪整个组织的使用情况和性能"
        actions={<ExportButton />}
      />
      {/* 页面内容 */}
    </div>
  );
}

添加新的仪表板板块

mkdir -p apps/web/src/app/\(dashboard\)/my-section
// apps/web/src/app/(dashboard)/my-section/page.tsx
import { PageHeader } from "@nebutra/ui/layout";
import { requireAuth, getTenantContext } from "@/lib/auth";

export const metadata = {
  title: "我的板块",
};

export default async function MySectionPage() {
  await requireAuth();
  const { tenantId } = await getTenantContext();

  return (
    <div className="space-y-6">
      <PageHeader
        title="我的板块"
        description="管理您的组件"
      />
      {/* 在此添加您的内容组件 */}
    </div>
  );
}

打开 apps/web/src/components/sidebar/nav-items.ts,在适当的导航组中添加一条记录:

// apps/web/src/components/sidebar/nav-items.ts
export const mainNavItems: NavItem[] = [
  // ... 现有条目
  {
    title: "我的板块",
    href: "/my-section",
    icon: "layers",          // 任意 Lucide 图标名称
    group: "product",        // "product" | "settings" | "admin"
  },
];
// apps/web/src/app/(dashboard)/my-section/loading.tsx
import { LoadingState } from "@nebutra/ui/layout";
export default function Loading() {
  return <LoadingState message="正在加载数据…" />;
}
// apps/web/src/app/(dashboard)/my-section/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} />;
}

布局原语

@nebutra/ui/layout 提供四个即用的状态组件。请统一使用它们,而不是构建自定义变体。

PageHeader(页面标题)

import { PageHeader } from "@nebutra/ui/layout";

<PageHeader
  title="账单"
  description="管理您的订阅和发票"
  actions={
    <button type="button" className="btn-primary">
      升级套餐
    </button>
  }
  breadcrumbs={[
    { label: "设置", href: "/settings" },
    { label: "账单" },
  ]}
/>

EmptyState(空状态)

import { EmptyState } from "@nebutra/ui/layout";

<EmptyState
  icon="inbox"
  title="暂无发票"
  description="您的第一个计费周期完成后,发票将显示在这里。"
  action={<CreateButton />}
/>

LoadingState(加载状态)

import { LoadingState } from "@nebutra/ui/layout";

<LoadingState message="正在获取报告…" />

ErrorState(错误状态)

import { ErrorState } from "@nebutra/ui/layout";

<ErrorState
  message="加载账单数据失败"
  onRetry={() => router.refresh()}
/>

按套餐限制功能访问

使用 <PlanGate> 根据租户的订阅套餐有条件地渲染内容。该组件在服务端解析套餐,对不符合条件的租户不渲染任何内容(或渲染回退内容)。

import { PlanGate } from "@/components/plan-gate";

// 仅向 "pro" 和 "enterprise" 用户显示
<PlanGate plans={["pro", "enterprise"]}>
  <AdvancedAnalyticsPanel />
</PlanGate>

// 向免费用户显示升级提示
<PlanGate
  plans={["pro", "enterprise"]}
  fallback={<UpgradeBanner feature="高级分析" />}
>
  <AdvancedAnalyticsPanel />
</PlanGate>

<PlanGate> 仅是 UI 便利组件。务必在 API 层通过 Hono 路由中的 requirePermission 强制执行套餐限制。切勿仅依赖客户端限制。

侧边栏导航结构

// apps/web/src/components/sidebar/nav-items.ts

export interface NavItem {
  title: string;
  href: string;
  icon: string;           // Lucide 图标名称
  group: NavGroup;
  plans?: Plan[];         // 若设置,则仅对这些套餐的用户显示
  badge?: string;         // 可选徽章文本,例如 "新功能"、"Beta"
}

type NavGroup = "product" | "settings" | "admin";
type Plan = "free" | "starter" | "pro" | "enterprise";

侧边栏按 group 分组渲染条目。带有 plans 限制的条目对其他套餐的租户隐藏——但这仅是显示层面的处理。路由本身也必须进行套餐限制。

新板块的文件结构

一个包含详情页的完整仪表板板块遵循以下结构:

apps/web/src/app/(dashboard)/
  reports/
    page.tsx              ← 列表视图
    loading.tsx
    error.tsx
    [reportId]/
      page.tsx            ← 详情视图
      loading.tsx
  _components/            ← 板块本地组件(下划线 = 非路由)
    report-card.tsx
    report-filters.tsx

在将板块本地组件接入页面之前,先在 Storybook 中开发它们。运行 pnpm --filter @nebutra/storybook dev 并在 apps/storybook/src/stories/Reports.stories.tsx 创建一个故事。

使用 AnimateIn 实现页面入场动画

AnimateIn 包裹内容板块,以实现精致的入场动画。对折叠线以下的内容使用 inView

import { AnimateIn, AnimateInGroup } from "@nebutra/ui/components";

export default async function ReportsPage() {
  await requireAuth();
  const reports = await fetchReports();

  return (
    <div className="space-y-6">
      <AnimateIn preset="emerge">
        <PageHeader title="报告" description="使用情况的历史快照" />
      </AnimateIn>

      <AnimateInGroup stagger="normal" className="grid grid-cols-1 gap-4 md:grid-cols-2">
        {reports.map((report) => (
          <AnimateIn key={report.id} preset="fadeUp">
            <ReportCard report={report} />
          </AnimateIn>
        ))}
      </AnimateInGroup>
    </div>
  );
}

How is this guide?

目录