预计工期
约 2 周
分四个切片交付,每一片独立可评审、独立藏在 feature flag 后面。第 4 片落地前用户什么都看不到。
新建 comments 和 comment_reads 两张表,写好 migration,搭好 tRPC router 的 stub。不做 UI。契约要在其他东西落地之前先被评审通过。
用 fixture 数据渲染静态版 <CommentThread>。提交时走乐观更新,失败回滚,只支持一层嵌套。
当前打开的卡片订阅它的评论频道。按用户记录已读游标,这样侧边栏不用二次查询就能显示未读数。
识别 @ 提及 → 写入通知,再加邮件摘要兜底;task_comments_v1 先放给内部,然后三天内从 10% 灰到 100%。
左边是乐观写入路径,右边是分发路径。已读游标的更新是 fire-and-forget——评论串的渲染绝不会等它。
实线 = 请求/响应路径。橙色虚线 = 实时分发。输入框永远不等虚线那条路。
不追求像素级精确——只要够让评审者和我对齐嵌套深度、输入框位置、以及侧边栏摘要长什么样就行。
projects.count > 0 的判断,回退到精简版。最容易写错的两块:migration(软删除、已读游标),以及乐观写入的 mutation(临时 id 对账)。
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);
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)); }, }); }
id 去重;socket 负载携带真实 id,对账时把临时行过滤掉。comment_reads 的 upsert;客户端把自己的游标取 max(本地, 远端)。@media、@2x)。edited_at 字段和"已编辑"的视觉提示。删后重发更简单,但会丢失原回复的锚点。倾向于 v1 只支持删除。