管理后台

/admin 管理后台:五个分区、用户抽屉、概览指标、运营设置,以及怎样加一个自己的页面。

管理后台位于 /admin,和用户自己的页面共用同一个登录后的外壳。只要角色持有admin.access,侧边栏底部就会出现 管理后台 入口;进入 /admin 后,侧边栏切换成后台的各个分区,外加 返回应用。每个人在里面能看到什么,取决于他的角色,见角色与权限。本地给自己设置内置的 admin角色,请按快速开始操作。

五个分区

侧边栏里每个分区占一行。分区下的页面以标签页的形式显示在页面标题下方;如果某个分区里当前用户只能打开一个页面,就不显示标签。角色无权访问的页面会被隐藏,一个页面都不剩的分区会从侧边栏里消失。

分区页面(所需权限)
概览概览(admin.access)
用户用户管理(users.read)、等待列表(waitlist.read)、反馈(feedback.read)
收入订阅和订单(billing.read)、积分(credits.read)、推广返佣(affiliate.read)
开发者API 密钥(api_keys.manage)、邮件日志(email.read)、审计日志(audit.read)
设置通用(settings.read)、角色权限(roles.write)、邮件(email.read)

可选模块的页面会随模块一起删除,见删除功能。

这些行都在同一个数组里:src/components/app-sidebar.tsx 的 adminNav。每一行的 section 决定它归属哪个分区,它在数组里的位置决定标签顺序。

概览

/admin 展示最近 7、30 或 90 天的核心数字,每个数字都附带与上一个等长窗口相比的变化。点击任意一条序列,图表就会画出它。那些意味着“有人该去处理”的数字——未处理的反馈、等待列表里待审批的人、发送失败的邮件、排队中的推广返佣提现——会作为待办显示,并链接到处理它们的页面。自定义的 UTC 时间范围也可以通过 URL 指定:/admin?from=2026-01-01&to=2026-01-31。

所有数字都来自一个统计注册表。每个功能在自己的 server/stats.ts 里注册一个provider,并在 src/core/stats/handlers.ts 里加一行:

registerStats('feedback', async (range) => ({
  metrics: [
    { key: 'feedback.open', value: open, format: 'count' },
    { key: 'feedback.received', value: current, format: 'count', previous },
  ],
  series: [
    { key: 'feedback.received', format: 'count', points: fillDays(range, perDay) },
  ],
}))

带 previous 且有同名序列的指标是“期间”数字(窗口内发生了什么);不带的是“状态”数字(此刻是什么样)。每个新 key 都要在 src/routes/_app/admin/index.tsx的 labels 里配一个标签。同样的数据也通过 /api/v1/admin/stats 提供给 AI agent,见 API 密钥。

概览只检查 admin.access,所以任何能进后台的角色都能看到上面的每个数字,包括收入。如果这对你的团队有影响,就在想隐藏的 provider 里检查更细的权限。

用户与用户抽屉

后台表格里凡是出现某个人,名字都是一个 <UserLink userId>(src/components/user-link.tsx)。点击它会在当前 URL 上加 ?user=<id>,在页面上方打开用户抽屉;下面的列表保留原来的筛选和滚动位置,这个链接刷新或分享后依然有效。抽屉里有按钮可以打开完整页面 /admin/users/<id>。没有users.read 的人看到的是纯文本,而不是链接。

两种视图渲染的是同一个组件,内容包括:

  • 账户信息:角色(可就地修改)、注册时间、最近活跃、id;
  • 一排核心数字,比如当前套餐和积分余额;
  • 每个功能一个标签页:账单、积分、审计、邮件、反馈、文件、等待列表;
  • 一个菜单,里面有封禁/解封;对仍在保留期内的已注销账户,还有恢复。

这些标签页来自注册表。功能在自己的 components/admin-user-section.tsx 里调用registerAdminUserSection({ key, label, query, component }),并在src/core/user-detail/handlers.ts 里加一行 import。行的顺序就是标签的顺序。

表格

后台列表用的是 DataTable(src/components/data-table.tsx):搜索、下拉筛选、排序、分页,以及把当前视图导出为 CSV。搜索和筛选条件保存在 URL 里,筛好的视图就是一个可以直接分享的链接。

设置

设置 → 通用(/admin/settings)集中了运营人员无需重新部署就能修改的值:已注销账户、审计记录、邮件日志和 webhook 记录各保留多久,订阅到期未续费后的宽限期,积分余额偏低的提醒阈值,以及反馈图片的限制。推广返佣的条款也是设置,但它们放在推广返佣页面上,和它们约束的数据挨在一起。每个功能在一个客户端安全的 settings.ts 里注册自己的设置,并在 src/core/settings/handlers.ts 里加一行:

registerSetting({
  key: 'auth.deleted_account_retention_days',
  group: 'accounts',
  groupLabel: () => m.admin_settings_group_accounts(),
  kind: 'number',
  fallback: ACCOUNT_RETENTION_DAYS,
  min: 7,
  max: 3650,
  label: () => m.setting_account_retention(),
  unit: () => m.setting_unit_days(),
})

数据库里只存修改过的值。没动过的设置会回落到代码里的默认值,所以你在代码里改了默认值,所有没覆盖过它的部署都会拿到新值。类型有 number、boolean、string 和 secret 四种;secret 会加密存储,存进去之后不会再发回浏览器。服务端用 src/core/settings/store.ts 里的 getSetting 或 getNumberSetting读取,每个 isolate 缓存 60 秒。修改设置需要 settings.write;只有settings.read 时页面是只读的。完整列表见配置。

设置分区的另外两个标签页:角色权限,即角色编辑器;邮件,可以用示例数据预览每个邮件模板的每种语言,并给自己的地址发一封测试邮件(见邮件与通知)。

新增一个页面

假设要在“收入”分区里加一个报表页面。

  1. 声明权限:在功能的 permissions.ts 里声明 reports.read,并在src/core/authz/handlers.ts 里加上它的 import。细节见角色与权限。

  2. 编写 server function:从 adminFn 开始,在 handler 的第一行检查权限:

    export const listReportsAdminFn = adminFn.handler(async ({ context }) => {
      await assertPermission(context.session, 'reports.read')
      return getReports()
    })
    
  3. 创建路由 src/routes/_app/admin/reports.tsx,包含守卫、loader 和组件。页面顶部用 src/components/page-header.tsx 的 PageHeader,它会渲染分区标题和标签页:

    export const Route = createFileRoute('/_app/admin/reports')({
      beforeLoad: async ({ context }) => {
        await requirePermission(context.queryClient, 'reports.read')
      },
      loader: ({ context }) =>
        context.queryClient.ensureQueryData(adminReportsQuery),
      component: ReportsPage,
    })
    

    然后运行 bun run generate-routes。

  4. 加一行导航:在 src/components/app-sidebar.tsx 的 adminNav 里加上:

    {
      to: '/admin/reports',
      section: 'revenue',
      permission: 'reports.read',
      label: () => m.admin_reports_title(),
    },
    

    如果页面属于某个可选功能,用 // [feature: <name>] start 和 end 注释把这一行包起来,删除脚本才能把它切掉。

  5. 补上文案:写进 messages/en.json 和 messages/zh.json。以 admin_开头的 key 会随后台一起加载,不会进入营销站点的包。

如果路由里没有 requirePermission,或者 server function 里没有assertPermission,bun run test 就会失败。包括数据层在内的完整流程见新增功能;/add-feature skill 会带着 AI agent一步步完成。