# 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 + ``)。**只能整篇导出,没有按章节。** 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 是报告域已有断点,不新增)。用原生 ``(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 / `` 逻辑;不引服务端 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`、`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 ```` 与 ``;头部(标题「分块导出」、一句说明、计数行、套餐 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 选中禁用;`` 存在且无 `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`:报告中心段更新行结构、状态色、⋯ 菜单、动作从「查看 + 导出」改为「查看 + 分块导出」;报告阅读页段加「分块导出抽屉」小节(``、底部、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. 原生 `` 若在 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**,开工时核对最大号,被占用则顺延并写进进度记录。