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

17 KiB
Raw Blame History

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:

  1. 正文是整篇 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 都吃这份大纲。
  2. 操作条 report-actions.tsx ReportActions:返回 / 下载原始附录 / 打印 / 导出报告(.md)。导出走 personal-report-longform-download.ts ordinaryReportDownloadMarkdown() = stripReportChartBlocks(projectOrdinaryReportMarkdown(md)) → consultation-report-export.ts downloadMarkdownReport(Blob + <a download>)。只能整篇导出,没有按章节。
  3. 盘:每个 #### D… 标题下有 ```jyotish-chart JSON 围栏,页面用 parseReportChartBlock / toNorthIndianChart 画 SVG;核对表 factTables 走 API 结构化字段,由 ReportFactTables 渲染,两者都在 Markdown 之外。
  4. 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(下载而非复制)。

开工前置命令

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,开工时核对最大号,被占用则顺延并写进进度记录。