Customization
仪表板定制
添加新的仪表板板块,自定义侧边栏导航,处理空状态/加载状态/错误状态,以及按套餐限制功能访问。
已认证的仪表板位于 apps/web/src/app/(dashboard)/。该路由组中的每个页面都会自动获得共享的侧边栏、顶栏以及认证守卫。
仪表板页面的结构
apps/web/src/app/(dashboard)/
layout.tsx ← 共享侧边栏 + 顶栏外壳
analytics/
page.tsx ← 您的新板块
loading.tsx ← 可选的 Suspense 回退
error.tsx ← 可选的错误边界一个标准页面有四项职责:
- 认证守卫 — 在服务端拒绝未认证的请求
- 租户上下文 — 解析当前组织信息
- 数据获取 — 在 Server Component 中加载数据
- 渲染 — 组合布局原语和功能组件
// 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?
在 GitHub 上编辑此页面
最后更新于