实施方案 · Birchline web 客户端

任务卡片上的评论串

Prompt 给任务卡片加上嵌套评论功能,写一份完整的实施方案。要包含 mockup、从客户端到持久层的数据流、 我需要写的关键代码,再加一张风险表。手机上要好读——我打算原样丢给实现者。
预计工期
约 2 周
改动范围
3 个 package
新增表
2 张
特性开关
task_comments_v1
01

里程碑

分四个切片交付,每一片独立可评审、独立藏在 feature flag 后面。第 4 片落地前用户什么都看不到。

第 1 周 · 周一~周二

表结构与 API 契约

新建 commentscomment_reads 两张表,写好 migration,搭好 tRPC router 的 stub。不做 UI。契约要在其他东西落地之前先被评审通过。

packages/dbpackages/apimigration 0042
第 1 周 · 周三~周五

评论串组件与输入框

用 fixture 数据渲染静态版 <CommentThread>。提交时走乐观更新,失败回滚,只支持一层嵌套。

apps/webstorybook
第 2 周 · 周一~周三

实时分发与未读状态

当前打开的卡片订阅它的评论频道。按用户记录已读游标,这样侧边栏不用二次查询就能显示未读数。

packages/realtimeapps/web
第 2 周 · 周四~周五

通知、灰度放量、文档

识别 @ 提及 → 写入通知,再加邮件摘要兜底;task_comments_v1 先放给内部,然后三天内从 10% 灰到 100%。

packages/notifygrowthbook
02

数据流

左边是乐观写入路径,右边是分发路径。已读游标的更新是 fire-and-forget——评论串的渲染绝不会等它。

<CommentComposer> apps/web React Query 缓存 乐观插入 comments.create tRPC · packages/api comments 表 postgres · packages/db 实时频道 task:{id}:comments 其他观看者 订阅同一卡片 notify worker @提及 → 队列 提交 (id=临时) mutate INSERT + 已读游标 入队 广播新行 实时追加 临时 id → 真实 id 对账

实线 = 请求/响应路径。橙色虚线 = 实时分发。输入框永远不等虚线那条路。

03

Mockup

不追求像素级精确——只要够让评审者和我对齐嵌套深度、输入框位置、以及侧边栏摘要长什么样就行。

A · 打开的任务卡片里的评论串
上线 onboarding 空状态改版
BIR-1142 · 指派给 Priya · 周五截止
JM
Jonah M. 2 小时前
如果工作区里已经有一个项目,插图要不要换一张?两次都展示"从这里开始"的图感觉怪怪的。
PS
Priya S. 40 分钟前
说得对——我加个 projects.count > 0 的判断,回退到精简版。
写一条评论…
发送
B · 侧边栏未读摘要
JM
JonahBIR-1142 下评论 — "如果工作区里已经有一个项目,插图要不要换……"
AK
AikoBIR-1098 @了你 — "@priya 这里的文案能确认一下吗?"
RW
RowanBIR-0971 回复 — "已合并,谢谢这么快出活。"
04

关键代码

最容易写错的两块:migration(软删除、已读游标),以及乐观写入的 mutation(临时 id 对账)。

packages/db/migrations/0042_comments.sql
create table comments (
  id          uuid primary key default gen_random_uuid(),
  task_id     uuid not null references tasks(id),
  parent_id   uuid references comments(id),   -- 只允许一层嵌套,
                                                -- 由 API 层保证
  author_id   uuid not null references users(id),
  body        text not null,
  created_at  timestamptz not null default now(),
  deleted_at  timestamptz                       -- 软删除
);

create table comment_reads (
  task_id    uuid not null references tasks(id),
  user_id    uuid not null references users(id),
  read_up_to timestamptz not null,
  primary key (task_id, user_id)
);

create index comments_task_created
  on comments (task_id, created_at);
apps/web/hooks/useAddComment.ts
export function useAddComment(taskId: string) {
  const qc = useQueryClient();
  return trpc.comments.create.useMutation({
    onMutate: async (input) => {
      const temp = { ...input, id: `temp-${nanoid()}`,
                     createdAt: new Date(), pending: true };
      qc.setQueryData(key(taskId), (prev) =>
        [...(prev ?? []), temp]);
      return { tempId: temp.id };
    },
    onSuccess: (row, _v, ctx) => {
      // 把临时 id 对账成真实 id,
      // 避免实时追加时重复插入
      qc.setQueryData(key(taskId), (prev) =>
        prev.map((c) => c.id === ctx.tempId ? row : c));
    },
    onError: (_e, _v, ctx) => {
      qc.setQueryData(key(taskId), (prev) =>
        prev.filter((c) => c.id !== ctx.tempId));
    },
  });
}
05

风险与对策

风险
等级
对策
实时分发重复:socket 追加和 HTTP 响应、临时 id 对账之间存在竞态。
在缓存更新器里基于服务端分配的 id 去重;socket 负载携带真实 id,对账时把临时行过滤掉。
用户在另一台设备读了评论后,未读数没及时同步。
在同一频道上广播 comment_reads 的 upsert;客户端把自己的游标取 max(本地, 远端)。
@提及识别在粘贴的 markdown 上误判(如 @media@2x)。
仅在写入时按工作区成员名册解析提及,存下解析后的 user id——读取时不再重新解析。
06

待决问题

允许编辑评论,还是只能删后重发?
编辑要加 edited_at 字段和"已编辑"的视觉提示。删后重发更简单,但会丢失原回复的锚点。倾向于 v1 只支持删除。
决策方 · design,第 2 片之前
App 没开时的邮件摘要节奏
每条提及立即发邮件会很吵。建议:按 15 分钟窗口聚合,一个任务合并成一封邮件,并尊重 settings 表里已有的勿扰时段。
决策方 · platform,第 4 片之前