Files
Jyotisha/docs/tasks/TASK-report-chapter-export-20260924.md
T
jesse-uxandClaude Code 1420471ab1
Independent Staging Quality Gate / validate (push) Successful in 10m34s
Independent Staging Quality Gate / publish (push) Successful in 3m36s
feat: add chart waiting states and report block exports
Add bounded chart loading, per-layer retry states, and shared SVG skeletons. Add report block downloads with SVG, localized metadata, and inline deletion.

Record verification and retain CRLF export, full-build, and controlled-device acceptance blockers. User authorized staging delivery with these gaps documented.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 12:33:09 +08:00

128 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 我的报告:分块导出抽屉 + 列表页元信息 / 状态 / 删除(2026-09-24)
原型(产品已看过并拍板):https://claude.ai/artifact/TM7SQetho1p3cDpVwj3RgE
(原型里主按钮还写着「复制」,产品随后改为**下载 .md**,以本文为准;Artifact 沙箱不允许真下载,按钮只是示意。)
## 基线
- `origin/staging = cd95aae0`(文档),代码基线同 `8902e484`。
- 执行分支:`codex/report-chapter-export-20260924`,worktree `.worktrees/report-chapter-export-20260924`。
- 串行 / 并行:与 `TASK-chart-page-skeleton-wait-20260924.md` 同期,两单都改 `frontend/src/app/globals.css` 但分别在报告段(≈L3638–4600)与星盘段,**后合者 rebase**。`TASK-report-public-content-20260922.md` 仍待验收,若其改动了 `report-public-projection.ts` 的导出签名,本单以合入后的签名为准。
## 事故实证(按 `origin/staging` 8902e484)
**列表页** `frontend/src/components/personal-report/personal-report-center.tsx` `PersonalReportCenter`:
1. `rowMeta()`:`` `${formatDate(report.createdAt)} · ${report.depth} · ${report.themes.join(" / ") || "综合主题"}` `` —— `depth` 与 `themes` 直接输出英文 id(真机:`standard`、`career / marriage / wealth / timing / health`)。中文映射其实已经有:`frontend/src/lib/personal-report-progress.ts` `REPORT_THEME_LABELS`;深度没有映射表(`REPORT_DEPTHS = ["concise","standard","deep","research"]` 在 `personal-report-contract.ts`)。
2. 状态 chip `span.report-center-status` 三种状态同一灰色,仅文字不同。
3. 没有删除入口:`DELETE /api/reports/[reportId]`(`route.ts` `DELETE` → `resolveReportDelete` → `deleteOwned`)存在且有测试,客户端无调用。
4. 已完成行动作:「查看报告」「导出报告(.md)」。
**阅读页** `frontend/src/components/personal-report/personal-report-page.tsx` `PersonalReportPage`:
5. 正文是整篇 Markdown(`personal_report_longform_appendices.markdown`,density fixture 约 31 万字、23 个 H2、22 张盘、8 张核对表);`frontend/src/lib/personal-report-longform-outline.ts` `buildLongformOutline()` 已把它切成 `LongformSection[] {id,title,markdown,headings,eager}`,目录 `ReportToc` 与懒渲染 `LazyMarkdownSection` 都吃这份大纲。
6. 操作条 `report-actions.tsx` `ReportActions`:返回 / 下载原始附录 / 打印 / 导出报告(.md)。导出走 `personal-report-longform-download.ts` `ordinaryReportDownloadMarkdown()` = `stripReportChartBlocks(projectOrdinaryReportMarkdown(md))` → `consultation-report-export.ts` `downloadMarkdownReport`(Blob + `<a download>`)。**只能整篇导出,没有按章节。**
7. 盘:每个 `#### D… ` 标题下有 ```` ```jyotish-chart ```` JSON 围栏,页面用 `parseReportChartBlock` / `toNorthIndianChart` 画 SVG;核对表 `factTables` 走 API 结构化字段,由 `ReportFactTables` 渲染,两者都在 Markdown 之外。
8. `frontend/src/components/ui/` 只有 button / calendar / popover / select / sidebar / sonner / tabs / textarea,没有抽屉 / sheet / dialog 组件。
## 根因
不是 Bug,是缺功能:报告只能整篇导出,用户要把某几章带走时没有入口;列表页元信息把内部 id 当文案(这一条是 Bug)。
## 决策记录(产品 2026-09-24,两次)
- **D1 形态是"部分导出",不是复制**:抽屉叫「分块导出」,勾选后点主按钮**下载一个 .md 文件**,不写剪贴板。全选 = 整篇导出,因此**原来的「导出报告(.md)」按钮删掉**——阅读页操作条与列表页已完成行的两个都删(产品偏好:多余入口宁可删除)。抽屉里只有一个主按钮「导出选中 (N)」,没有「尝试导出全部」(全选后点主按钮即可)。
- **D2 章节全列、不做白名单**:块 = 阅读页实际渲染的 Markdown(`projectOrdinaryReportMarkdown` 之后)的**每个二级标题**一块,按正文顺序;`buildLongformOutline().sections` 就是这份列表,**不得另写第二个切分器**(DESIGN §报告阅读页"no second slugger")。被投影层删掉的内部章节(Technique Audit、质量验收矩阵等,BUG-999 / 1003)本来就不在阅读页,自然不出现。
- **D3 盘与核对表另成两组**:「分盘」22 块(每张盘一块,标题 = `chartAriaLabel(id)` 的盘名)、「核对表」8 块(每表一块,标题 = `FACT_TABLE_GROUPS` 的中文名)。
- **D4 导出内容是 Markdown**:正文块 = 该 `LongformSection.markdown` 经 `stripReportChartBlocks`;盘块 = `#### 标题` + 由围栏 `parseReportChartBlock()` 生成的行星落宫纯文本列表(每行「第 N 宫 · 星座:行星…」),不含 SVG / JSON;核对表块 = 该表的 GFM 表格(新纯函数 `factTableToMarkdown(group)`,列与页面 `ReportFactTables` 一致)。块之间空一行,**不加** `=== 标题 ===` 分隔(块本身带标题,文件里再加分隔是噪音)。文件开头保留现有导出的报告头(若现有 `ordinaryReportDownloadMarkdown` 有则沿用,没有不新造)。
- **D5 全选时的文件必须与删掉的「导出报告(.md)」逐字节相同**:即 `joinExportBlocks(全部正文块)` ≡ 旧 `ordinaryReportDownloadMarkdown(markdown)`。这条是回归锁,保证删按钮不丢功能。盘块与核对表块是旧导出没有的**新增**内容,只在勾选时附在正文之后。
- **D6 文件名**:全选 → 现有 `personalReportMarkdownFilename(reportDate)`(`个人报告-YYYY-MM-DD.md`);部分 → 同名加 `-节选`。
- **D7 字数从实际文本算**(`Array.from(text).length`),头部显示「共 N 块 · X 字 · 已选 n/N · Y 字」。不显示"已导出"状态(下载不需要分批)。列表页**不**显示章节数(DESIGN "Meta honesty")。
- **D8 套餐三枚**:「常用」= 正文组全部;「全部分盘」= 分盘组;「仅核对表」= 核对表组。点套餐替换当前勾选;手动改勾选后套餐取消高亮。另有「全选 / 取消全选」。默认进入 = 常用。
- **D9 抽屉形态**:手机与桌面都是**底部抽屉**;桌面宽度 `min(760px, 92%)` 居中,不盖侧栏,最高 70%,块列表 ≥860px 两列(860 是报告域已有断点,不新增)。用原生 `<dialog>`(Esc / 点遮罩关闭、焦点自然困住),不引第三方。
- **D10 入口**:操作条「分块导出」放在原「导出报告(.md)」的位置;列表页已完成行「查看报告」旁加「分块导出」→ 链到 `/reports/:id#export`,阅读页 ready 后读 `location.hash === "#export"` 自动开抽屉并 `replaceState` 去掉 hash。列表页原「导出报告(.md)」删除(它走的 `downloadPersonalReportLongformAppendix` 请求路径若再无调用者,一并删,不留死代码)。
- **D11 列表页**:`rowMeta` 改中文——日期全写(`2026年9月23日`)、深度映射 `REPORT_DEPTH_LABELS = {concise:"简版", standard:"标准深度", deep:"深度", research:"研究级"}`(放在 `personal-report-progress.ts` 与 `REPORT_THEME_LABELS` 并排)、主题走 `REPORT_THEME_LABELS`;状态 chip 分色(已完成 `--ok`、生成中 `--warn`、未完成 `--bad`,用 `color-mix` 12% 底);已完成行底色 `--report-paper`,其它行 `--color-surface`;行右上 `⋯` 菜单只有「删除」,点击后行内展开「确定删除这份报告? 删除 / 取消」,不用 `window.confirm`;删除成功后行移除并使列表缓存失效。仍是单列行列表,不做卡片网格(D12)。
- **D12 不做**:不做剪贴板、不做 PDF、不做分享链接、不改生成管线、不改数据库、不改 `GET /api/reports` 字段;微信内置浏览器下载限制沿用现有 `client-report-export.ts` 的检测与提示。
### D13 · SVG 后续裁决(2026-09-24,执行中追加)
原任务 D4 的“盘纯文本、不含 SVG”及 T1“每块无 SVG”与 D5 冲突:旧普通导出实际保留安全引擎 SVG。执行中提出该冲突后,产品明确回答“分盘导出也需要带出svg”,随后要求“请你继续”。此为后续授权,不追溯声称原单已经授权。
- D5 保持不变:常用 / 全部正文逐字节等于旧普通导出(包括已有安全 SVG)。
- 新增分盘块必须包含安全 SVG,可同时保留落宫纯文本。优先复用投影后原有 SVG;无现成 SVG 时复用现有报告图盘组件,不另造几何、不增加请求。
- 有限替代 D4 的“不含 SVG”及 T1 的无 SVG 断言;JSON 图盘围栏仍禁止导出,每块 `ordinaryOutputLeaks()` 仍必须为空,不放宽投影安全边界。
- “所有组全选”包含附加分盘 / 核对表,不等于“全部正文”。真人清单③的逐字节对照针对常用 / 正文全选;所有组全选需另验附加内容。
## 硬红线
1. 导出文本必须过 `ordinaryOutputLeaks()` = 空(BUG-999 / 1003):合同测试把 golden fixture 每一块都跑一遍。
2. 章节来源只有 `buildLongformOutline()`;不得新写切分、slug 或第二份目录数据。
3. 不新增网络请求:抽屉只用阅读页已经拿到的 `longformMarkdown` / `calculationCharts` / `factTables`。
4. 下载只走现有 `downloadMarkdownReport`(`consultation-report-export.ts`),不新写 Blob / `<a download>` 逻辑;不引服务端 PDF(`personal-report-export.test.ts` "client export never touches a server PDF pipeline" 继续成立)。
5. D5 逐字节回归锁不得让步。
6. 不得出现 spinner / 骨架 / 「正在加载」(报告页规则不变;星盘页的骨架例外不外溢)。
7. 纸色边界(DESIGN D11):抽屉是 chrome,用 app 色;`--report-*` 只留在正文。
8. 44px 触控目标;断点只用 860;不改 `viewport-breakpoint-contract`。
9. `/reports` 与 `/reports/[reportId]` 的渲染标记与基线一致(`ƒ`);不碰 `report-chart-grid-rehype.ts`。
10. 既有断言改动三栏说明;测试总数不低于开工实测;不顺手修 warning;不升级依赖。
## 任务分解
### T1 · 纯函数层(`frontend/src/lib/report-export-blocks.ts`,新)
- `buildReportExportBlocks({ markdown, charts, factTables }): ReportExportBlock[]`,`ReportExportBlock = {id, group: "body"|"chart"|"table", title, text, chars}`;正文组来自 `buildLongformOutline(projectOrdinaryReportMarkdown(markdown)).sections`(lead 段落算「摘要」一块)。
- `chartBlockText(block: ReportChartBlock): string`、`factTableToMarkdown(group): string`、`joinExportBlocks(blocks): string`、`presetSelection(preset, blocks): Set<id>`、`exportFilename(reportDate, isFull): string`。
验收:`frontend/tests/report-export-blocks.test.ts` ≥9 条,用 `tests/fixtures/report-density-fictional-reader.json` 做 golden:块数 = 投影后 H2 数 + 22 + 8;顺序与大纲一致;每块 `text` 不含 ```` ```jyotish-chart ```` 与 `<svg`;每块 `ordinaryOutputLeaks(text)` 为空;`chars` 等于 `Array.from(text).length`;三套餐各选中且只选中对应组;**`joinExportBlocks(正文组全部) === ordinaryReportDownloadMarkdown(markdown)`(D5)**;文件名全选 / 节选两种。
### T2 · 抽屉组件(`frontend/src/components/personal-report/report-export-drawer.tsx`,新)
- 原生 `<dialog>`;头部(标题「分块导出」、一句说明、计数行、套餐 chips、全选 / 取消全选)、可滚动块列表(三组,`role="checkbox"` + `aria-checked`)、底部一个主按钮「导出选中 (N)」(0 选中时禁用)。
- 点主按钮 → `downloadMarkdownReport(exportFilename(...), joinExportBlocks(选中))` → 关闭抽屉 → `sonner` 提示「已导出 N 块」。
- CSS 放 `globals.css` 报告段,类名前缀 `.report-export-`;`@media (min-width: 860px)` 两列。
验收:`frontend/tests/report-export-drawer.test.tsx`:渲染 golden 块列表;点套餐替换勾选;手动勾选取消套餐高亮;主按钮调用 `downloadMarkdownReport` 一次且参数 = `(文件名, joinExportBlocks(选中))`;0 选中禁用;`<dialog>` 存在且无 `InlineSpinner`;860 断点只出现在允许列表。
### T3 · 阅读页接入(`report-actions.tsx`、`personal-report-page.tsx`)
- 操作条「导出报告(.md)」→「分块导出」(同位置);`#export` 自动打开一次并清 hash;数据从页面已有 state 传入,不新增请求。
- 删掉阅读页整篇导出路径中不再被调用的函数(`downloadPersonalReportLongformAppendix` 若无调用者则删;`ordinaryReportDownloadMarkdown` 保留,D5 的回归锁要用它)。
验收:`personal-report-view.test.ts` / `personal-report-export.test.ts` 相关断言三栏更新("native print remains a secondary action"、"ready reports expose the browser print/PDF action" 仍成立);新增:ready 之外的状态不渲染按钮;`#export` 打开一次后 hash 被清;`grep -rn "导出报告(.md)" frontend/src` = 0。
### T4 · 列表页(`personal-report-center.tsx`、`personal-report-progress.ts`、`globals.css`)
- `REPORT_DEPTH_LABELS`;`rowMeta` 中文化;状态 chip 分色;已完成行纸色;`⋯` → 行内确认删除 → `DELETE /api/reports/:id` → 移除行 + 缓存失效;已完成行动作 = 「查看报告」(主)+「分块导出」链接;原「导出报告(.md)」及 `exportingReportId` / `exportError` 状态一并删。
验收:`personal-report-entry.test.ts` 既有五条按三栏更新("ready reports expose Markdown export only in the ready branch" → 主语改为「分块导出」链接只在 ready 分支);新增:`rowMeta` 对 `standard` / `career` 输出「标准深度」「事业」;未知 id 回退原值不抛;删除路径 mock `fetch` 断言 `DELETE` 一次、行消失、失败时行保留并出错误文案;`grep -n "window.confirm" frontend/src/components/personal-report` = 0。
### T5 · 记录
- `frontend/DESIGN.md`:报告中心段更新行结构、状态色、⋯ 菜单、动作从「查看 + 导出」改为「查看 + 分块导出」;报告阅读页段加「分块导出抽屉」小节(`<dialog>`、底部、760px、70%、两列、860);语义色沿用现有 token,不新造。
- `frontend/docs/VOICE.md`:新增文案——「勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。」「已导出 N 块」「确定删除这份报告?」;对照第 2 条(数字来自实际文本)。
- `docs/BUG_HISTORY.md`:**BUG-1018** 列表页元信息输出英文 id;**BUG-1019** 报告删除接口无 UI 入口。分块导出是功能,只进 `CHANGELOG.md`(写明整篇导出按钮被抽屉的全选取代)。
- `docs/tasks/PROGRESS-report-chapter-export-20260924.md`;`docs/testing/report-chapter-export-20260924.md` 真机清单:① iPhone 阅读页点「分块导出」→ 底部抽屉、默认常用、计数正确;② 勾 3 块导出 → 文件名带「节选」、用文件 App 打开看到三章;③ 全选导出 → 文件名无「节选」,内容与升级前整篇导出一致;④ 桌面 Chrome 抽屉宽度不盖侧栏、Esc 关闭、两列;⑤ 列表页元信息中文、状态三色、⋯ 删除后行消失刷新不回来;⑥ 列表页「分块导出」直接开抽屉且地址栏无 `#export`;⑦ 微信内打开点导出 → 现有"请在系统浏览器打开"提示。
## 让步顺序
1. 核对表 Markdown 若列宽在手机上难读,允许改为「每行一条 `字段:值`」的列表格式,写进进度记录。
2. 原生 `<dialog>` 若在 iOS Safari 16 的 `::backdrop` 有已知渲染问题,允许用 `div[role="dialog"]` + 自写焦点循环,但不得引第三方。
3. 不得让步:红线 1(泄漏检查)、红线 2(单一切分器)、红线 3(零新请求)、红线 5(全选逐字节等于旧导出)、D1(下载而非复制)。
## 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/report-chapter-export-20260924 .worktrees/report-chapter-export-20260924 origin/staging
cd .worktrees/report-chapter-export-20260924/frontend
./node_modules/.bin/tsc --noEmit && npm run lint
npm test 2>&1 | tail -5
npx tsx --test tests/personal-report-entry.test.ts tests/personal-report-view.test.ts tests/personal-report-export.test.ts tests/personal-report-markdown-view.test.ts tests/report-public-projection.test.ts tests/personal-report-api.test.ts
```
完成后:`tsc` 0 错、`lint` 0 error、失败清单与基线逐条一致、`next build` 四个路由标记与基线一致、首屏 gzip ±2%(抽屉代码应只进 `/reports/[reportId]` 的 chunk,不进 `/`)。纯前端,不要求 `pre_work_check.py`;Node 22 下跑报告相关测试(BUG-1008 备忘)。
## BUG 编号起点
写单时 BUG-1016 / 1017 已被星盘单预留;本单用 **BUG-1018 / 1019**,开工时核对最大号,被占用则顺延并写进进度记录。