从两份规格文件开始
本文提出以规格文件为唯一真源驱动AI协作的开发流程,涵盖清单生成、纯函数排序规则、拖拽三段式实现、spec纠偏优于改代码的实践,以及npm空安装、容器host绑定等部署踩坑经验。
下面按这次任务看板的实现记录,整理规格、任务拆分和验收时需要注意的地方。文中的 AI 指负责执行代码修改的助手。
一、起点:两份规格文件
一切的起点是两份 Markdown:
specs/feature-task-board.md—— 数据模型(Task / TaskStatus / Priority / User)、5 个 API 接口、功能需求清单、4 条业务规则specs/ui-task-board.md—— 页面结构、6 个组件的详细规格、交互规范、颜色 / 字体 / 间距 / 圆角四套设计系统
这两份文件的价值不在于”写得很全”,而在于它把判断权从 AI 手里收了回来。
举个具体的例子:UI 规格里写着「卡片宽度 280px、圆角 8px、内边距 16px、hover 时 box-shadow: 0 4px 12px rgba(0,0,0,0.1)、过渡 all 0.2s ease」,还写着「优先级颜色:critical red-500 / high orange-500 / medium yellow-500 / low green-500」。这些数字不是建议,是验收口径。AI 不需要猜,我也不需要在代码评审时争论”这里是不是应该用 12px 的圆角”。
反过来说,规格里没写的,AI 就会自由发挥 —— 这正是后面踩坑的根源。
二、操作:把”实现一个看板”变成”勾选 24 个动作”
2.1 第一步不是写代码,是生成清单
我给 AI 的指令是这样的:
请阅读以下 speckit 文件:@Files specs/feature-task-board.md@Files specs/ui-task-board.md
根据 Speckit 的内容,帮我生成一份详细的开发任务清单(SpeckitList),要求:1. 按照依赖关系排序(先做的排最前面)2. 每个任务标注优先级(P0/P1/P2,与 Speckit 中的优先级对应)3. 每个任务有明确的验收标准4. 每个任务预估工作量(以"AI 生成轮数"为单位)5. 用 Markdown Checkbox 格式这 5 条要求,每一条都在解决一个具体问题:
| 要求 | 解决什么问题 |
|---|---|
| 依赖排序 | AI 不会先写组件再补类型,避免返工 |
| 优先级 | 时间不够时,知道砍哪些不心疼 |
| 验收标准 | 任务”完成”的定义是客观的,不是 AI 说了算 |
| 工作量(AI 轮数) | 能估算一轮对话能推几个任务,避免一次喂太多 |
| Checkbox | 进度可视化,中断后能无缝续上 |
最终得到 specs/speckit-task-list.md:24 个任务、6 个阶段、总计约 21.5 轮。阶段划分是:
阶段 0 数据模型与设计系统(无依赖) T1-T2阶段 1 后端 API 层 T3-T7阶段 2 UI 基础组件 T8-T11阶段 3 核心交互(CRUD + 拖拽) T12-T16阶段 4 筛选与搜索 T17-T18阶段 5 体验优化(Loading/空态/响应式) T19-T21阶段 6 可选项增强 T22-T24这里有个容易被忽略的细节:阶段 0 里我把”数据模型”和”设计系统 Token”并列放在最前面,二者互不依赖。原因是它们分别是后续所有逻辑代码和样式代码的共同上游。类型定不下来,API 层写不下去;色板定不下来,每个组件都要单独调颜色。
2.2 第二步:一次只推一个任务
分步执行的指令模板长这样:
我正在执行 SpeckitList 中的任务 T1:数据模型。请参考:@Files specs/feature-task-board.md(数据模型章节)创建 src/types/task.ts,严格按照 Speckit 中的字段定义。完成后我会将该任务标记为已完成。三个要素缺一不可:
- 引用具体的 spec 章节 —— 不是”参考那个规格文件”,而是”参考 §1 数据模型”。AI 的注意力是有限的,指得越准,跑偏越少
- 明确产出 —— 具体到文件路径,而不是”把类型定义好”
- 写清验收方式 —— 完成后要核对哪些字段、运行哪些检查,直接写进任务;一句“我会验收”不能代替这些标准
我用这个方式推进了 T1(类型定义)和 T2(脚手架 + 设计系统),两个任务加起来 1.5 轮,非常快。快的原因是任务边界清晰 —— T1 只有一个文件、四个类型,AI 不需要做任何架构决策。
2.3 第三步:跑偏时,改 Speckit 而不是改代码
这是整个方法论里最反直觉、也最有用的一条。
实现拖拽(对应清单里的 T15)前,feature-task-board.md 补充了一节技术选型约束:
## 技术选型约束- 拖拽库:必须使用 @dnd-kit/core + @dnd-kit/sortable(不用 react-beautiful-dnd)- 原因:更好的 TypeScript 支持和更小的包体积然后指令是:
Speckit 里的技术选型已经更新,请重新看一下:@Files specs/feature-task-board.md然后重新实现任务 3.1(拖拽排序),使用 @dnd-kit 而不是 react-beautiful-dnd。注意这里的操作顺序:先改 spec,再让 AI 重读 spec,最后重新实现。
为什么不能直接在代码里改?因为代码是产物,spec 是源头。你手动把 200 行 react-beautiful-dnd 改成 @dnd-kit,代码是对了,但 AI 的”认知”还停留在旧方案 —— 下次让它加个”批量拖拽”功能,它照样按 react-beautiful-dnd 的 API 写。你在代码层修的是一个 bug,在 spec 层修的是一类 bug。
所以在 spec 里加约束时,“原因”那一栏不是形式主义。写上”更好的 TypeScript 支持和更小的包体积”,AI 就理解了这条约束背后的意图,后续遇到 spec 没覆盖的灰色地带(比如要不要引入配套的 utilities 包),它会顺着意图推断,而不是机械执行。
三、模块:代码是怎么被切成一块块的
3.1 目录结构,以及依赖为什么是单向的
最终 src/ 长这样:
src/├── types/task.ts # 数据模型:Task / TaskStatus / Priority / User├── constants/board.ts # 列配置、优先级权重、优先级展示、业务常量├── lib/taskOrder.ts # 纯函数:排序 / 分组 / 移动计算├── api/tasks.ts # 接口封装(含 mock 开关)├── data/mockTasks.ts # 占位数据├── components/│ ├── Board.tsx # DndContext:拖拽编排 + 状态管理│ ├── BoardColumn.tsx # 列:droppable + SortableContext│ └── TaskCard.tsx # 卡片:useSortable├── App.tsx├── index.css # 设计系统 Token(CSS 变量,单一真源)├── main.tsx└── vite-env.d.ts依赖方向是严格单向的:
types ← constants ← lib ← api ← componentstypes 不知道任何人,所有人都知道 types。这条约束带来一个直接好处:业务规则可以脱离 UI 独立演进。
3.2 把业务规则放进纯函数里
看板有 4 条业务规则,其中两条直接影响数据流:
- 规则 1:移动到”已完成”时,自动更新
updatedAt - 规则 2:同列内按优先级排序(critical > high > medium > low),同优先级按创建时间排序
这两条我全部放在 lib/taskOrder.ts 的纯函数里:
export function sortTasks(tasks: Task[]): Task[] // 规则 2export function applyMove(task: Task, status: TaskStatus): Task // 规则 1export function groupByStatus(tasks: Task[]): Record<TaskStatus, Task[]> // 分列export function findTaskStatus(columns, taskId): TaskStatus | null这么切的理由是:拖拽组件只应该关心”位置”,不应该关心”语义”。Board.tsx 里的逻辑是”把这个 id 从 A 数组移到 B 数组的第 N 位”,至于移进 done 列要不要刷新时间戳、新列要不要重排 —— 那是 applyMove 和 sortTasks 的事。
这个分离在改需求时会立刻体现价值。如果产品经理明天说”已完成列按更新时间倒序排”,我只需要改 sortTasks 一个函数,拖拽那套状态机一行都不用动。
3.3 设计系统也是一个”模块”
UI 规格里有整整四节是设计系统:颜色(主题色 4 个 + 中性色 10 档 + 语义色 4 个)、字体(6 档字号 + 4 档字重 + 中英文字体栈)、间距(6 档)、圆角(5 档)。
我的处理是两层结构:
第一层:src/index.css 的 CSS 变量(值的单一真源) --color-primary-600: #2563eb; --space-24: 24px; --radius-8: 8px;
第二层:tailwind.config.js 通过 var() 引用(语义化的类名) colors.primary.600 = 'var(--color-primary-600)' spacing.xl = 'var(--space-24)' borderRadius.card = 'var(--radius-8)'组件里写的因此是 p-lg、rounded-card、text-caption、bg-gray-100 这样的语义类名,而不是 p-[16px]、rounded-[8px]。
这么做有三个好处:
- 可追溯:spec 里的每个 hex 值和 px 值,在
index.css里都能一行对一行地找到,验收时不用满仓库搜 - 可替换:改一个变量,全站生效
- 可读:
rounded-card比rounded-[8px]更能说明”这是卡片的圆角”
一个踩过的坑:用 CSS 变量定义的颜色,不支持 Tailwind 的透明度修饰符(bg-state-error/10 会失效)。因为 var() 对 Tailwind 来说是不透明的字符串,它没法往里插 alpha 通道。解决办法是要么多定义一个变量,要么改用边框/实心背景表达状态。
3.4 未完成的模块,留的是接口不是坑
清单里 T8(Header)、T12(创建任务)、T13(详情抽屉)都还没做,但代码里已经能看到它们的位置:
onClick={() => onOpenDetail?.(task)} // T13 详情抽屉接入点
// BoardColumn.tsx<button type="button">+ 新建任务</button> // T12 创建任务接入点
// App.tsx{/* T8 Header 接入点 */}<Board tasks={mockTasks} />以及 api/tasks.ts 里的开关:
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false';现在所有”移动任务”的请求走 mock(延迟 200ms 后返回本地计算结果),等 T6 的 POST /api/tasks/:id/move 真实接口就绪,把环境变量设成 false 就切过去了,组件层零改动。
这是模块设计里我最想强调的一条:为没做的任务预留接口位置,但不要提前实现它。提前实现的代码,在真做那个任务时大概率要重写,而且会污染当前任务的验收。
四、交互:这个词在这里有两重含义
4.1 人与 AI 的交互:什么时候该问,什么时候该自己决定
这轮实践里有两次判断,我认为是”人机协作”的典型样本。
第一次:指令有歧义,但可以按最合理的理解继续。
任务指令里把路径写成了 src/types/ask.ts,结合 Task 类型的上下文,应该是 task.ts 少了一个 t。AI 按 task.ts 创建,并在回复里说明了这个判断,方便确认或改名。
这种小歧义可以先按更合理的解释处理,但要说明假设。文件改名很容易;如果涉及对外接口或现有引用,就不能只凭文件名判断。
第二次:技术栈缺失,必须停下来问。
T1 做完后,工作区里只有 specs/,没有 package.json、没有 tsconfig.json。T2 要搭脚手架,但规格里没写用什么框架。这时 AI 暂停实现,列出框架选项和推荐理由,请求确认(UI 规格里的 gray-50、blue-600 这类命名与 Tailwind 高度契合)。
框架选型会影响后面 20 多个任务,返工代价远大于改文件名,所以需要先确认。
总结成一条可操作的规则:低成本可回退的分歧 → 按最合理解释继续并声明假设;高成本不可逆的分歧 → 停下来问,并给出带理由的推荐选项。
4.2 产品的交互:拖拽这件事,比看上去复杂得多
“拖一张卡片到另一列”,用户感知里是一个动作,代码里是三段式:
第一段:onDragOver 处理跨列插入
const handleDragOver = ({ active, over }) => { const fromStatus = findTaskStatus(columns, activeId); const toStatus = parseColumnId(overId) ?? findTaskStatus(columns, overId); if (fromStatus === toStatus) return; // 同列的排序交给 dragEnd
setColumns(prev => ({ ...prev, [fromStatus]: source.filter(t => t.id !== activeId), [toStatus]: [...target.slice(0, insertIndex), applyMove(moved, toStatus), ...target.slice(insertIndex)], }));};跨列的实时插入必须放在 onDragOver,不能等到 onDragEnd。因为用户在拖的过程中需要立刻看到”这张卡会落在哪一列的哪个位置”,等到松手才知道结果,体验是断裂的。
第二段:onDragEnd 确定最终索引并同步服务端
const reordered = oldIndex === newIndex ? items : arrayMove(items, oldIndex, newIndex);const movedTask = applyMove(reordered[newIndex], toStatus);setColumns(nextColumns); // 乐观更新void persistMove(columns, movedTask, toStatus, newIndex); // 同步第三段:失败回滚
try { await moveTask(task, status, position);} catch { setColumns(snapshot); // 恢复拖拽前的快照 setError('移动失败,已恢复到原来的位置');}乐观更新必须配回滚,否则会出现”幽灵卡片” —— UI 上卡片已经在 done 列,刷新后发现它还在 todo 列。
几个容易被忽略的细节:
- 5px 激活距离:
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })。没有它,鼠标按下再松开(点击)会被判定为拖拽,卡片的点击事件就永远触发不了 - 列自身要作为 droppable:空列里没有卡片,
SortableContext里没有可碰撞的目标,必须让列容器自己能接收放置,否则拖不进空列 - 碰撞检测策略:多容器场景用
closestCorners(比closestCenter更符合直觉),配合MeasuringStrategy.Always持续更新容器测量值 - 视觉反馈四件套:拖拽中卡片
opacity-80+ 阴影增强、目标列ring高亮、悬停位置显示虚线放置框、移动过渡 200ms
4.3 规格里的矛盾,要在实现时说出来
实现拖拽时我撞上了一个 spec 内部的冲突:
- 业务规则 2 说:同一列内,任务按优先级排序
- 功能需求 3.1 说:在列内拖拽调整任务顺序
这两条不能同时严格成立。如果始终按优先级自动排序,用户手动拖的顺序立刻就被覆盖,拖拽等于白拖。
这轮实现采用的规则是:初始分组时按优先级 + 创建时间排序,用户手动拖拽后保留手动顺序(不把卡片自动拽回优先级位置)。AI 在交付说明里单独列出了这个冲突、当前处理方式和另一种选择,留待确认。
这是 AI 协作里非常关键的一点:规格是自相矛盾的时候,不要静默地替用户做决策。AI 的职责是”实现 + 暴露分歧”,决策权应该留在人手里 —— 尤其是这种影响产品行为、且两种选择都合理的分歧。
五、踩坑记录:那些和代码无关的问题
坑 1:npm install 的输出被吞
第一次 npm install 返回的执行结果是空的(Tool execution failed: No result found),但退出码是 0。AI 没有直接重跑,而是先检查 node_modules/@dnd-kit 是否真的存在 —— 结果依赖是装好的。
经验:命令输出不可信时,用文件系统状态来验证,而不是反复重跑命令。
坑 2:localhost 拒绝连接
服务在容器里跑得好好的(curl 127.0.0.1:5173 返回 200),但本机浏览器访问 localhost:5173 连不上。
原因很简单:容器的 localhost 和本机的 localhost 不是同一个东西。这类云端 IDE 环境里,正确路径是用 IDE 提供的预览能力,而不是直连 localhost。
修法是配置 .vscode/preview.yml:
autoOpen: trueapps: - port: 5173 run: npm run dev -- --host 0.0.0.0 root: ./ name: task-board autoOpen: true顺带一个细节:启动命令必须带 --host 0.0.0.0。Vite 默认只监听 localhost,容器外的任何人都访问不到 —— 这个默认值在本地开发时无害,在容器里是致命的。
坑 3:后台进程被回收
直接在终端 npm run dev,命令”执行完”后进程就没了。原因是进程挂在命令的会话上,会话结束就被回收。解决办法是用 setsid nohup 让进程脱离会话:
(setsid nohup npm run dev -- --host 0.0.0.0 > /tmp/vite-dev.log 2>&1 &)六、回头看:三个反直觉的收获
第一,最贵的不是写代码,是写规格。
这轮里,生成 24 条清单花了 20 分钟,T1(类型定义)花了 0.5 轮,拖拽(最复杂的任务)花了 2 轮。但凡是规格没写清楚的地方,返工成本都是这个的几倍 —— 技术选型那次纠偏,就是典型的”规格差一句话,代码多走一段路”。
第二,纠偏的成本取决于你在哪一层动手。
改代码 = 修一个实例,改 spec = 修一类问题。判断标准很简单:如果同样的错误,AI 下次还会犯,那你就改错层了。
第三,角色定位要换。
具体错误仍要在代码里修;如果错误来自遗漏或冲突的约束,还要同步更新规格,避免后续实现重复踩坑。我的主要工作量分布是:读规格、生成清单、下指令、验收、发现矛盾时更新规格 —— 真正”写代码”的部分,几乎全是 AI 做的。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
正在加载 GitHub 评论…