添加功能

把新模块做成一个垂直切片——表结构、服务端函数、路由、注册表——并用经过验证的删除步骤让它随时可以拆掉。

ShipKit 里所有可选的东西——积分、文件、反馈、候补名单——都是垂直切片:src/features/<name>/下的一个文件夹,自己管自己的表、服务端代码、查询和组件,再加上共享文件里寥寥几行注册代码。你自己的模块也应该长这个样子。这样每个模块都好找,而且日后想拆掉它,也能和内置模块一样放心(见删除功能)。

从 /add-feature 开始

如果你用 Claude Code 或其他 agent,运行 /add-feature 这个 skill(.claude/skills/add-feature/SKILL.md)。它会按顺序走完下面整份接线清单,最后跑的检查和本页结尾一样。各个 skill 怎么配合,见 AI 编程助手。

用不用它都好,建议复制一个现成的切片,而不是从空文件夹写起。src/features/credits/ 是参考实现,几乎每个注册表都用到了;src/features/feedback/ 小一些,适合拿来当“一张表、一个用户表单、一个后台页面”的范本。

切片的结构

src/features/<name>/
├── schema.ts          # Drizzle 表(功能自己有数据时才需要)
├── config.ts          # 客户端安全的常量(状态列表、上限等)
├── permissions.ts     # 后台权限(有后台页面时)
├── settings.ts        # 运营可改的配置项(如有)
├── queries.ts         # 包装服务端函数的 queryOptions
├── server/
│   ├── fns.ts         # 服务端函数(authedFn / adminFn)
│   └── ...            # 按需:events.ts、stats.ts、account.ts、jobs.ts
└── components/        # 功能自己的组件
src/routes/_app/<name>.tsx        # 用户页面(需登录)
src/routes/_app/admin/<name>.tsx  # 后台页面(如有)

只建需要的文件。没有表就没有 schema.ts,不进后台就没有 permissions.ts。

一步一步来

1. 表结构

在 schema.ts 里定义表:id 用 crypto.randomUUID() 生成的字符串,时间戳用integer({ mode: 'timestamp_ms' }),金额用整数分,并给 user 加外键、设onDelete: 'cascade',账号删除时这些行跟着一起删。然后在 schema 汇总文件里加一行,再生成并应用迁移:

// src/core/db/schema.ts
export * from '@/features/<name>/schema'
bun run db:generate
bun run db:migrate:local

D1 的特殊之处(包括为什么没有交互式事务)见数据库。

2. 服务端函数

所有读写都走服务端函数,从 src/core/server/fn.ts 里的 authedFn(已登录用户)或 adminFn(后台)起步。session 在 context 里,输入用 zod 校验:

export const submitThingFn = authedFn
  .validator(z.object({ title: z.string().trim().min(1).max(200) }))
  .handler(async ({ data, context }) => {
    await getDb().insert(thing).values({
      userId: context.session.user.id,
      title: data.title,
    })
    return { success: true }
  })

adminFn 只能证明调用者可以打开后台。每个 adminFn 还必须用await assertPermission(context.session, '<name>.read') 写明自己要的权限。服务端函数只返回 key 和数值,不返回翻译好的文字。细节见服务端函数。

3. 查询和路由

每个读操作在 queries.ts 里包成一个 queryOptions。路由的 loader 调用context.queryClient.ensureQueryData(...),组件用 useSuspenseQuery 读取,mutation 完成后让对应的 query key 失效。放在 src/routes/_app/ 下的页面自动继承登录守卫。加了路由文件之后运行:

bun run generate-routes

4. 文案

所有文字同时加进 messages/en.json 和 messages/zh.json,key 以 <name>_ 开头,组件里调用 m.<key>()。见国际化。

5. 导航

用户页面在 src/components/app-sidebar.tsx 的 mainNav 里加一行,并用标记包起来(标记的作用见下文):

// [feature: <name>] start
{ to: '/<name>', label: () => m.<name>_title(), icon: SomeIcon },
// [feature: <name>] end

/dashboard 上的卡片可加可不加;加的话同样用标记包好。

接入注册表

支付、后台概览、账号删除、定时任务这些共享行为都通过注册表接入。每个注册表在 src/core/ 下有一个 handlers.ts,每个功能占一行 import;你的文件在加载时调用注册函数。只接功能需要的那几个:

如果你的功能……要写并在这里加一行
有自己的表schema.tssrc/core/db/schema.ts
有后台页面permissions.ts——registerPermissionsrc/core/authz/handlers.ts
有运营可改的配置settings.ts——registerSettingsrc/core/settings/handlers.ts
要响应支付server/events.ts——onPaymentEventsrc/core/payment/handlers.ts
在 /admin 显示数据server/stats.ts——registerStatssrc/core/stats/handlers.ts
有按用户划分的数据components/admin-user-section.tsx——registerAdminUserSectionsrc/core/user-detail/handlers.ts
存有用户自己的行server/account.ts——onAccountExport、onBeforeAccountDelete、onAccountDeletedsrc/core/account/handlers.ts
有定时任务server/jobs.ts——registerJobsrc/core/jobs/handlers.ts

调用 audit()、notify()、sendEmailSafely() 或 track() 不需要注册:这些分发器在 core 里,需要在 sink 注册表里占一行的,是负责存储它们输出的功能(审计日志、通知、邮件日志、数据分析)。

注册表附带两条规则。permissions.ts 和 settings.ts 浏览器也会加载,所以不能引入任何只能在服务端运行的东西——默认值放进客户端安全的 config.ts。另外,支付处理函数必须幂等:webhook 会重复投递,所以要把外部订单号存进唯一约束的列,插入时用 onConflictDoNothing。定时任务出于同样的原因,也要经得起跑两次。

后台页面

一个后台页面需要:一个权限、一个带守卫的路由、带权限检查的服务端函数,以及侧边栏里的一行:

// src/features/<name>/permissions.ts
registerPermission({
  key: '<name>.read',
  group: '<name>',
  groupLabel: () => m.admin_<name>_title(),
  label: () => m.perm_read(),
})
// src/routes/_app/admin/<name>.tsx
export const Route = createFileRoute('/_app/admin/<name>')({
  beforeLoad: async ({ context }) => {
    await requirePermission(context.queryClient, '<name>.read')
  },
  // loader、component……
})

然后在 app-sidebar.tsx 的 adminNav 里加一行,写上 to、permission、label 和 section——取值为 overview、people、revenue、developers、settings 之一,页面会成为该分区下的一个标签页。表格用 src/components/data-table.tsx 里的 DataTable。完整说明见权限和管理后台。

这些不必全靠记性:后台路由漏了权限检查、某个 adminFn 导出没调用 assertPermission、或者某个 permissions.ts 没登记进 handlers.ts,src/core/authz/registry.test.ts 都会报错。

保持可删除

功能往不属于自己的文件里加的任何东西——侧边栏的一行、仪表盘的一张卡片、应用外壳里的一个 import——都要放在标记之间,脚本才能把它剪掉:

// [feature: <name>] start
…
// [feature: <name>] end

{/* [feature: <name>] start */}
…
{/* [feature: <name>] end */}

import { Thing } from '@/features/<name>/components/thing' // [feature: <name>]

然后在 scripts/verify-deletion.ts 里为它写一份删除步骤(recipe)。recipe 会删掉功能的文件夹和路由,用 stripLines(['features/<name>']) 去掉注册行,用 stripFeatureBlocks('<name>') 去掉标记块。照着现有的 recipe 抄就行。运行:

bun run verify:deletion <name>

它会复制一份仓库、执行你的 recipe,并要求构建、类型检查和单元测试全部通过。CI 的矩阵是从 recipe 列表生成的,所以新 recipe 不用改 workflow,每个 pull request 都会检查到。

完成标准

bun run typecheck && bun run check && bun run build && bun run verify:deletion <name>

再补一个端到端用例——一个 e2e/<name>.spec.ts,打开页面、断言标题和一次交互——然后运行 bun run e2e。视觉上遵循 src/CLAUDE.md 里的设计语言:细线边框,不用阴影和渐变,不用彩色强调色。