From 5ff43b47f38e378918c8b6bd967228185d7f02fe Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 24 Sep 2026 10:23:09 +0800 Subject: [PATCH] docs(tasks): report chapter export is a download, replaces whole-report export Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- docs/tasks/README.md | 2 +- .../TASK-report-chapter-copy-20260924.md | 115 ----------------- .../TASK-report-chapter-export-20260924.md | 118 ++++++++++++++++++ 3 files changed, 119 insertions(+), 116 deletions(-) delete mode 100644 docs/tasks/TASK-report-chapter-copy-20260924.md create mode 100644 docs/tasks/TASK-report-chapter-export-20260924.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b5e6250e..ba2aafbf 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -170,7 +170,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-report-chapter-copy-20260924.md` | — | **我的报告:分块复制抽屉 + 列表页中文元信息 / 状态色 / 删除**(原型 https://claude.ai/artifact/TM7SQetho1p3cDpVwj3RgE )。列表页 `rowMeta` 直接输出 `standard` / `career` 等英文 id(BUG-1018);`DELETE /api/reports/:id` 有接口无 UI(BUG-1019);阅读页只能整篇导出,没有复制。**产品 09-24 拍板**:主用途是复制到别的 AI,主按钮「复制选中」;章节全列不做白名单(以 `projectOrdinaryReportMarkdown` 后的 H2 为准、`buildLongformOutline` 唯一切分器);盘 22 块 + 核对表 8 块另成两组;复制 Markdown(盘块由围栏生成落宫文本);手机与桌面都是底部原生 `` 抽屉,桌面限宽 760 不盖侧栏、≥860 两列;套餐常用 / 全部分盘 / 仅核对表。红线:复制文本过 `ordinaryOutputLeaks()`、零新请求、不做 PDF / 分享 | 待领取 | — | +| `TASK-report-chapter-export-20260924.md` | — | **我的报告:分块导出抽屉 + 列表页中文元信息 / 状态色 / 删除**(原型 https://claude.ai/artifact/TM7SQetho1p3cDpVwj3RgE ,主按钮已由「复制」改为下载)。列表页 `rowMeta` 直接输出 `standard` / `career` 等英文 id(BUG-1018);`DELETE /api/reports/:id` 有接口无 UI(BUG-1019);阅读页只能整篇导出。**产品 09-24 拍板(两次)**:形态是部分导出——勾选章节后下载一个 .md,**不写剪贴板**;阅读页与列表页原「导出报告(.md)」按钮都删,全选 = 整篇导出(回归锁:全选文件逐字节等于旧导出);章节全列不做白名单(以 `projectOrdinaryReportMarkdown` 后的 H2 为准、`buildLongformOutline` 唯一切分器);盘 22 块 + 核对表 8 块另成两组;手机与桌面都是底部原生 `` 抽屉,桌面限宽 760 不盖侧栏、≥860 两列;套餐常用 / 全部分盘 / 仅核对表。红线:导出文本过 `ordinaryOutputLeaks()`、零新请求、只走现有 `downloadMarkdownReport`、不做 PDF / 分享 | 待领取 | — | | `TASK-report-density-20260922.md` | `PROGRESS-report-density-20260922.md` | **报告信息密度与原始附录(BUG-1003~1007)**:与源头仓 PL9 全量报告同资料实测:对照 847,632 字符 / 8,044 表格行,我方 309,357 / 1,478。缺口不在引擎——20 张分盘全表、KP 四表、Ashtakavarga 四类、Shadbala 六分量、Avastha、Sahams、年度都已算得出,但 `personal-report-contract.ts` 的 `REPORT_SECTION_KINDS` **没有表格类型**、`CHART_IDS` 只有 9 种,这些表在产品页报告里没有落脚字段。另测出对外原始附录泄漏 925 种 / 3,797 处工程标识符(`parameter_sensitive` 697、`cmd_full_reading` 68、`PyJHora`/`JHora` 35、`PL9` 页码 59),对照物同项为 0。**产品 2026-09-22 三点拍板**:层 1 按 8 组表 + 分盘扩到 16 张;层 2 原始附录直接给 C 端且先全显示;B 类大运族要显示。**决策记录已写明推翻 BUG-999 的两条红线**(专业参考不得作普通下载 fallback;`parameter_sensitive`/「参数敏感」命中整行剔除)——**仅限附录通道**,普通正文投影不动。硬红线:表格层服务端装配、writer 不得写表;对照物的替代大运族日期与 Shadbala 分量在源头仍未闭环(506 行 date mismatch、`production_tuning_allowed=false`),不得照搬升级。让步顺序与串行依赖(任务 3 → 任务 4 同改契约)见任务书 | **验收未通过**(2026-09-23):任务 1/2/4/5 通过;任务 3 事实表在普通报告里显示 447 个引擎键路径(P1);隐私门新增失败(任务书自身写入真实出生资料 BUG-1008 已清 HEAD,fixture 数值碰撞待登记);当时 BUG-1003~1007、PROGRESS、testing 清单缺失;F3 现已补前两项,testing 新建受工具边界阻塞仍缺。Node 22 前端 0 新增失败、`/` Static、gzip +0.007% | 实现 `bbd96d3b`(原 `f968cb21`,09-23 为清除 BUG-1008 重写历史;已在 staging,未部署);修复单 `TASK-report-density-fix-20260923.md` | | `TASK-report-density-fix-20260923.md` | `PROGRESS-report-density-20260922.md` | **报告密度验收修复单(BUG-1009/1010 + 补写 1003~1007)**:F1 事实表改成真正的表(逐组规定列,普通报告可见格 0 个引擎键 / 0 个下标 / 最多 2 位小数,`sourcePath` 保留供回查;主运年数不得填出生剩余年数);F2 按既有机制登记 fixture 的数值碰撞(已独立重跑确认是虚构输入);F3 补 Bug 历史 / PROGRESS / 真人清单并更正「未提交推送」;F4 两份清洗规则只留一份(Python 版漏第三方页码);F5 writer 写表拦截补测试与提示;F6 事实表可打印。硬红线:不得弱化隐私扫描、不得强推或重写 staging(历史清除由产品负责人决定);Sade Sati 三轮日期是原任务书错误、不在本单 | **已验收,F1–F6 全部通过**(2026-09-23,`a12f2c0d`):事实表 130 行 / 600 格 0 引擎键 0 下标、主运年数 18、隐私门转绿、两份规则逐字节一致、写表拦截变异测试红→绿;Node 22 前端 3,748 / 26(名单与基线一致)、Python 948/0。剩数据库测试 E2BIG 另开 fix2(BUG-1011) | `a12f2c0d`(门禁 run 6294 红,未部署);真人清单 `docs/testing/report-density-20260922.md` | | `TASK-report-density-fix2-20260923.md` | `PROGRESS-report-density-fix2-20260923.md` | **快照数据库测试无法启动(BUG-1011)——staging 恢复部署前的唯一阻塞**:门禁 run 2857 为 3,762 / 1 fail,唯一失败即此。测试把 428,557 字节 SQL 作为单个 psql 参数,超过 Linux 131,072 上限 → `E2BIG`。**09-23 更正做法**:不得改共享 `psql`/`psqlAs`(29 文件 522 次调用依赖 `-c` 单事务语义,`selectAsAuthenticated` 的事务内 `set_config` 会失效);新增单事务 + 标准输入 + 显式 `maxBuffer` 的专用函数,只给本测试用。修通后若真实断言失败是产品缺陷,不得改断言 | **已验收并部署 staging** | `018b2b48`;run 2858 / validate 6349:3,764/3,764、0 fail/skip,名称无删除新增2;47个DB名称全过。migrate 2859、deploy 2860 成功,health 双 SHA 已核对;本机 Docker/Windows 缺口见 PROGRESS | diff --git a/docs/tasks/TASK-report-chapter-copy-20260924.md b/docs/tasks/TASK-report-chapter-copy-20260924.md deleted file mode 100644 index 6b681fb1..00000000 --- a/docs/tasks/TASK-report-chapter-copy-20260924.md +++ /dev/null @@ -1,115 +0,0 @@ -# TASK · 我的报告:分块复制抽屉 + 列表页元信息 / 状态 / 删除(2026-09-24) - -原型(产品已看过并拍板):https://claude.ai/artifact/TM7SQetho1p3cDpVwj3RgE - -## 基线 - -- `origin/staging = d5ad57d9`(`docs(tasks): brief for chart-page skeleton wait…`),代码基线同 `8902e484`。 -- 执行分支:`codex/report-chapter-copy-20260924`,worktree `.worktrees/report-chapter-copy-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))` → `downloadMarkdownReport`。**整仓报告代码没有 clipboard / 复制 / 按章节。** -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,是缺功能:报告只能整篇导出,用户要把某几章贴到别的 AI 继续问时没有入口;列表页元信息把内部 id 当文案(这一条是 Bug)。 - -## 决策记录(产品 2026-09-24) - -- **D1 用途**:分块的主要用途是**复制到别的 AI 粘贴**。主按钮「复制选中 (N)」,次按钮「尝试复制全部」;下载 .md 仍是操作条上原来的按钮,抽屉里不重复。 -- **D2 章节全列、不做白名单**:块 = 阅读页实际渲染的 Markdown(`projectOrdinaryReportMarkdown` 之后)的**每个二级标题**一块,按正文顺序;`buildLongformOutline().sections` 就是这份列表,**不得另写第二个切分器**(DESIGN §报告阅读页"no second slugger")。被投影层删掉的内部章节(Technique Audit、质量验收矩阵等,BUG-999 / 1003)本来就不在阅读页,自然不出现——原型里列了「Technique Audit」是示意,以投影结果为准。 -- **D3 盘与核对表另成两组**:「分盘」22 块(每张盘一块,标题 = `chartAriaLabel(id)` 的盘名)、「核对表」8 块(每表一块,标题 = `FACT_TABLE_GROUPS` 的中文名)。 -- **D4 复制的是 Markdown**:正文块 = 该 `LongformSection.markdown` 经 `stripReportChartBlocks`;盘块 = `#### 标题` + 由围栏 `parseReportChartBlock()` 生成的行星落宫纯文本列表(每行「第 N 宫 · 星座:行星…」),不含 SVG / JSON;核对表块 = 该表的 GFM 表格(新纯函数 `factTableToMarkdown(group)`,列与页面 `ReportFactTables` 一致)。每块前加一行 `=== 标题 ===`,块间空一行。 -- **D5 字数从实际文本算**(`Array.from(text).length`),头部显示「共 N 块 · X 字 · 已选 n/N · Y 字 · 已复制 k/N」。列表页**不**显示章节数(DESIGN "Meta honesty":列表只说 `GET /api/reports` 给的东西)。 -- **D6 套餐三枚**:「常用」= 正文组全部;「全部分盘」= 分盘组;「仅核对表」= 核对表组。点套餐替换当前勾选;手动改勾选后套餐取消高亮。另有「全选 / 取消全选」。默认进入 = 常用。 -- **D7 已复制状态**:复制成功后该块字数旁变 `✓`(`--ok` 色),换勾选不清;只存组件 state,不落 localStorage。 -- **D8 抽屉形态**:手机与桌面都是**底部抽屉**;桌面宽度 `min(760px, 92%)` 居中,不盖侧栏,最高 70%,块列表 ≥860px 两列(860 是报告域已有断点,不新增)。用原生 ``(Esc / 点遮罩关闭、焦点自然困住),不引第三方。 -- **D9 入口**:操作条「分块复制」放在「导出报告(.md)」之前;列表页已完成行加「分块复制」→ 链到 `/reports/:id#copy`,阅读页 ready 后读 `location.hash === "#copy"` 自动开抽屉并 `replaceState` 去掉 hash。 -- **D10 列表页**:`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`;删除成功后行移除并 `invalidate` 列表缓存。仍是单列行列表,不做卡片网格(D12)。 -- **D11 不做**:不做 PDF、不做分享链接、不做按章节下载文件、不改生成管线、不改数据库、不改 `GET /api/reports` 字段。 - -## 硬红线 - -1. 复制文本必须过 `ordinaryOutputLeaks()` = 空(BUG-999 / 1003):新增合同测试把 golden fixture 每一块都跑一遍。 -2. 章节来源只有 `buildLongformOutline()`;不得新写切分、slug 或第二份目录数据。 -3. 不新增网络请求:抽屉只用阅读页已经拿到的 `longformMarkdown` / `calculationCharts` / `factTables`。 -4. 剪贴板只写不读;`navigator.clipboard.writeText` 必须在点击处理器内同步调用,失败提示「这台设备不让直接复制,试试少选几块。」;微信内置浏览器沿用 `client-report-export.ts` 里的检测,提示改在系统浏览器打开。 -5. 不得出现 spinner / 骨架 / 「正在加载」(报告页规则不变;星盘页的骨架例外不外溢)。 -6. 纸色边界(DESIGN D11):抽屉是 chrome,用 app 色;`--report-*` 只留在正文。 -7. 44px 触控目标;断点只用 860;不改 `viewport-breakpoint-contract`。 -8. `/reports` 与 `/reports/[reportId]` 的渲染标记与基线一致(`ƒ`);不碰 `report-chart-grid-rehype.ts`。 -9. 既有断言改动三栏说明;测试总数不低于开工实测;不顺手修 warning;不升级依赖。 - -## 任务分解 - -### T1 · 纯函数层(`frontend/src/lib/report-copy-blocks.ts`,新) - -- `buildReportCopyBlocks({ markdown, charts, factTables }): ReportCopyBlock[]`,`ReportCopyBlock = {id, group: "body"|"chart"|"table", title, text, chars}`;正文组来自 `buildLongformOutline(projectOrdinaryReportMarkdown(markdown)).sections`(lead 段落算「摘要」一块,若大纲已把摘要提到 lead,就以 lead 为第一块)。 -- `chartBlockText(block: ReportChartBlock): string`、`factTableToMarkdown(group): string`、`joinCopyBlocks(blocks): string`、`presetSelection(preset, blocks): Set`。 - -验收:`frontend/tests/report-copy-blocks.test.ts` ≥8 条,用 `tests/fixtures/report-density-fictional-reader.json` 做 golden:块数 = 投影后 H2 数 + 22 + 8;顺序与大纲一致;每块 `text` 不含 ```` ```jyotish-chart ```` 与 ``;头部(标题、一句说明、计数行、套餐 chips、全选 / 取消全选)、可滚动块列表(三组,`role="checkbox"` + `aria-checked`)、底部两按钮;toast 用现有 `sonner`。 -- 复制成功后标记 `✓`;失败 toast;微信提示。 -- CSS 放 `globals.css` 报告段,类名前缀 `.report-copy-`;`@media (min-width: 860px)` 两列。 - -验收:`frontend/tests/report-copy-drawer.test.tsx`:渲染 golden 块列表;点套餐替换勾选;手动勾选取消套餐高亮;复制成功调用 `writeText` 一次且参数 = `joinCopyBlocks(选中)`;`writeText` reject → 提示文案出现、不标 ✓;`` 存在且无 `InlineSpinner`;860 断点只出现在允许列表。 - -### T3 · 阅读页接入(`report-actions.tsx`、`personal-report-page.tsx`) - -- 操作条加「分块复制」按钮(在「导出报告(.md)」前);`#copy` 自动打开一次并清 hash。 -- 数据从页面已有 state 传入,不新增请求。 - -验收:`personal-report-view.test.ts` / `personal-report-export.test.ts` 相关断言三栏更新("native print remains a secondary action" 等仍成立);新增:ready 之外的状态不渲染按钮;`#copy` 打开一次后 hash 被清。 - -### T4 · 列表页(`personal-report-center.tsx`、`personal-report-progress.ts`、`globals.css`) - -- `REPORT_DEPTH_LABELS`;`rowMeta` 中文化;状态 chip 分色;已完成行纸色;`⋯` → 行内确认删除 → `DELETE /api/reports/:id` → 移除行 + 缓存失效;已完成行加「分块复制」链接。 - -验收:`personal-report-entry.test.ts` 既有五条按三栏更新;新增:`rowMeta` 对 `standard` / `career` 输出「标准深度」「事业」;未知 id 回退原值不抛;删除路径 mock `fetch` 断言 `DELETE` 一次、行消失、失败时行保留并出错误文案;`grep -n "window.confirm" frontend/src/components/personal-report` = 0。 - -### T5 · 记录 - -- `frontend/DESIGN.md`:报告中心段更新行结构、状态色、⋯ 菜单;报告阅读页段加「分块复制抽屉」小节(``、底部、760px、70%、两列、860);`--ok/--warn/--bad` 若尚无 token 名,用现有语义色 token,不新造。 -- `frontend/docs/VOICE.md`:新增文案——「勾选要带走的章节,粘贴到别的 AI 继续问。手机剪贴板有长度限制,一次复制一批更稳。」「已复制 N 块」「这台设备不让直接复制,试试少选几块。」「确定删除这份报告?」;对照第 2 条(数字来自实际文本)。 -- `docs/BUG_HISTORY.md`:**BUG-1018** 列表页元信息输出英文 id;**BUG-1019** 报告删除接口无 UI 入口。分块复制是功能,只进 `CHANGELOG.md`。 -- `docs/tasks/PROGRESS-report-chapter-copy-20260924.md`;`docs/testing/report-chapter-copy-20260924.md` 真机清单:① iPhone 阅读页点「分块复制」→ 底部抽屉、默认常用、计数正确;② 复制 3 块 → 微信 / 备忘录粘贴看到 `=== 标题 ===` 与表格;③ 「尝试复制全部」在 iOS 失败时出提示不静默;④ 桌面 Chrome 抽屉宽度不盖侧栏、Esc 关闭、两列;⑤ 列表页元信息中文、状态三色、⋯ 删除后行消失刷新不回来;⑥ 列表页「分块复制」直接开抽屉且地址栏无 `#copy`。 - -## 让步顺序 - -1. 核对表 Markdown 若列宽在手机粘贴处溢出,允许改为「每行一条 `字段:值`」的列表格式,写进进度记录。 -2. 原生 `` 若在 iOS Safari 16 的 `::backdrop` 有已知渲染问题,允许用 `div[role="dialog"]` + 自写焦点循环,但不得引第三方。 -3. 不得让步:红线 1(泄漏检查)、红线 2(单一切分器)、红线 3(零新请求)、D1 主按钮是复制。 - -## 开工前置命令 - -```bash -git fetch origin --prune -git worktree add -b codex/report-chapter-copy-20260924 .worktrees/report-chapter-copy-20260924 origin/staging -cd .worktrees/report-chapter-copy-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**,开工时核对最大号,被占用则顺延并写进进度记录。 diff --git a/docs/tasks/TASK-report-chapter-export-20260924.md b/docs/tasks/TASK-report-chapter-export-20260924.md new file mode 100644 index 00000000..966174ba --- /dev/null +++ b/docs/tasks/TASK-report-chapter-export-20260924.md @@ -0,0 +1,118 @@ +# 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` 的检测与提示。 + +## 硬红线 + +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**,开工时核对最大号,被占用则顺延并写进进度记录。