# PROGRESS · 报告生成分章进度(2026-09-09) - 分支:`codex/report-progress-20260909`,基线 `origin/staging` = `a43a6db8` - 模式:直接执行模式(用户指定 subagent 执行),无独立任务书;设计口径来自会话内定稿 - BUG 编号:**BUG-601**(开工核对当时最大号 598,原写 599;交付前 rebase 发现 staging 已有别的会话占用 599「回访登录自动打开生时校正」与 600「新用户保存账户资料 500」,故顺延到 601) ## 一、做了什么 把 `/reports/[reportId]` 的生成等待屏从「spinner + 秒表」改成分章进度。 ### 后端(本轮唯一的服务端改动) 1. `app/api/reports/[reportId]/route.ts`:`listSections` 原先只返回 `{status, lastErrorCode}`,现补上 `sectionId` 与 `attemptCount`。两者都只在服务端消费。 2. `lib/personal-report-route-core.ts`: - 分章行原先**只在 `row.status === "failed"` 时才读**,`reportView` 也不含任何分章字段——也就是说生成中根本没有章节数据离开服务端。现改为 `failed` 与 `generating` 两种状态都读;`ready` 路径不变,不增加查询。 - 新增 `sectionProgress` 推导,随 `reportView` 第四参数下发,只带 `{id, state}`。 ### 新增共享层 3. `lib/personal-report-progress.ts`(新): - `REPORT_THEME_LABELS` / `reportThemeLabel`:从 `personal-report-document-view.tsx` 提取,原处改为 import,**不留第二份副本**。 - `deriveSectionProgressState(status, attemptCount)`:行状态 → `done / failed / writing / waiting`。这是全仓唯一读 `attemptCount` 的地方,且只在服务端跑。 - `classifyReportProgressStage(phase)`:`preparing / writing / finishing`。 - `describeReportProgress()`:纯函数,产出标题句与章节清单。 - `reportProgressSignature()`:进度指纹,用于停滞判定。 - `REPORT_PROGRESS_STALL_MS = 90_000`。 ### 前端 4. `components/personal-report/personal-report-progress-panel.tsx`(新):按章分格的进度条 + 章节清单。无 percent、无定时器、无 transition。 5. `components/personal-report/personal-report-page.tsx`:解析 `sections`(带白名单校验,形状不对就丢弃而不是渲染成谜之行);用 `progressMark` 记录进度指纹与时刻,停滞判定复用既有的一秒 tick(`waitStartedAt + waitedMs` 即"现在"),**未新增定时器、未新增 effect**;写作阶段隐藏 spinner 换成面板。 6. `app/globals.css`:新增等待屏进度样式。 7. `frontend/DESIGN.md` §9 新增「报告生成等待态」小节。 ## 二、三条实现事实(做错任一条功能就是错的) 1. **`section:` 命名的是刚写完的那一章。** `emitProgress` 在 `complete()`/`block()` 之后调用(`personal-report-generation.ts:3459 / 3485 / 3496`)。 2. **"已完成数 + 1"同样会指错。** `sectionService.list()` 按 `section_id` **字典序**返回(`personal-report-section-service-core.ts:113`),不是写作顺序。所以顺推出来的"下一章"与真正在写的那一章无关。 → 因此本轮改用**行状态**判定:`start_personal_report_section` 只 `attempt_count + 1` 并保持 `status='pending'`(迁移 `20260830020000` 第 102–111 行),所以"正在写"精确等于 `pending && attemptCount > 0`,不是推断。测试里专门构造了字典序会指错的用例(正确答案 `marriage`,顺推会得到 `timing`)。 3. **blocked 计入完成数**,进度条照常前进;收尾走既有 `summarizePersonalReportFailure()`。 ## 三、测试数字 | 项 | 基线(a43a6db8) | 改后 | 结论 | | --- | --- | --- | --- | | `tsc --noEmit` | 0 错 | 0 错 | 通过 | | `npm run lint` | 0 error / 108 warning | 0 error / 108 warning | 通过;warning 数不变,且无一条在本轮改动的文件里 | | `npm test` tests | 2929 | 2945 | +16 | | `npm test` pass | 2887 | 2903 | +16 | | `npm test` fail | 28 | 28 | 清单 `diff` 逐条一致 | | `npm test` skipped | 14 | 14 | 不变 | | `next build` `/` | `○ Static` | `○ Static` | 通过 | | `globals.css` gzip | 33,324 B | 33,641 B | +317 B / +0.95%,在 ±2% 内 | 28 条失败全部是无 Docker 的数据库/部署/迁移套件,基线即红。失败清单已逐条 `diff` 比对,**完全一致,无新增、无消失**。 新增测试:`frontend/tests/personal-report-progress.test.ts` 16 项,覆盖任务要求的六类——off-by-one(含字典序陷阱)、blocked 计入进度、全 ready 与部分 blocked 两种收尾、90 秒停滞文案切换且不出现「第 N 次尝试」、准备/写作/收尾三分支渲染、以及后台字段不出服务端。 **未改动任何既有断言**,因此无「原值 / 新值 / 原因」三栏。特别说明一条:`tests/report-polling-contract.test.ts:105` 有 `assert.doesNotMatch(pageSource, /章节/)`,本轮把章节渲染整体放进独立组件 `personal-report-progress-panel.tsx`,`pageSource` 里确实不出现「章节」,该断言原样通过,未被弱化或删除。 ## 四、偏离与判断 1. **任务交代「`sections[]` 目前被剥掉了 `sectionId`,把它加回去」,实际情况更严重一层。** 分章数组根本不在 `generating` 的响应里——`resolveReportRead` 只在 `failed` 时读分章行,`reportView` 也没有该字段。所以只补 `sectionId` 不够,必须同时让生成中也读分章行并下发。这是比交代范围略大的服务端改动,但不做则整个功能无数据可用。`ready` 路径未加查询。 2. **停滞判定没有用 `progressPhase` 的持续时长,而是用完整进度指纹**(phase + percent + 各章状态)。理由:重试时 phase 与 percent 都不动,但若同期别的信号变了,仍不该报"停滞"。指纹变化才重置计时,更严格。 3. **面板不重复渲染标题句。** 标题句(「已完成 N / M 章」)只出现在页面的 `

` 里,面板只画条与清单。这样读屏软件只播报一次,也不会出现同一句话上下各一遍。 4. **`--type-body-xs` 这个 token 不存在**,写样式时误用后已改掉,改用继承字号。未新增 token。 ## 五、没做的 - **真实报告生成一次都没跑过**(无登录态、无 Chrome、无模型凭据)。写作阶段是否真的出现、章节名是否错位、慢章节文案是否真的切换,全部无运行时证据。已写成 `docs/testing/report-progress-20260909.md` 八节清单,并记入 `BLOCKED.md`。 - **`npm run test:db` 未跑**(无 Docker,基线同样红)。本轮未动数据库结构、未加迁移。 - 未 push(按会话纪律,代码分支不自行推送)。