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>
17 KiB
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:
rowMeta():`${formatDate(report.createdAt)} · ${report.depth} · ${report.themes.join(" / ") || "综合主题"}`——depth与themes直接输出英文 id(真机:standard、career / marriage / wealth / timing / health)。中文映射其实已经有:frontend/src/lib/personal-report-progress.tsREPORT_THEME_LABELS;深度没有映射表(REPORT_DEPTHS = ["concise","standard","deep","research"]在personal-report-contract.ts)。- 状态 chip
span.report-center-status三种状态同一灰色,仅文字不同。 - 没有删除入口:
DELETE /api/reports/[reportId](route.tsDELETE→resolveReportDelete→deleteOwned)存在且有测试,客户端无调用。 - 已完成行动作:「查看报告」「导出报告(.md)」。
阅读页 frontend/src/components/personal-report/personal-report-page.tsx PersonalReportPage:
- 正文是整篇 Markdown(
personal_report_longform_appendices.markdown,density fixture 约 31 万字、23 个 H2、22 张盘、8 张核对表);frontend/src/lib/personal-report-longform-outline.tsbuildLongformOutline()已把它切成LongformSection[] {id,title,markdown,headings,eager},目录ReportToc与懒渲染LazyMarkdownSection都吃这份大纲。 - 操作条
report-actions.tsxReportActions:返回 / 下载原始附录 / 打印 / 导出报告(.md)。导出走personal-report-longform-download.tsordinaryReportDownloadMarkdown()=stripReportChartBlocks(projectOrdinaryReportMarkdown(md))→consultation-report-export.tsdownloadMarkdownReport(Blob +<a download>)。只能整篇导出,没有按章节。 - 盘:每个
#### D…标题下有```jyotish-chartJSON 围栏,页面用parseReportChartBlock/toNorthIndianChart画 SVG;核对表factTables走 API 结构化字段,由ReportFactTables渲染,两者都在 Markdown 之外。 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-mix12% 底);已完成行底色--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()仍必须为空,不放宽投影安全边界。 - “所有组全选”包含附加分盘 / 核对表,不等于“全部正文”。真人清单③的逐字节对照针对常用 / 正文全选;所有组全选需另验附加内容。
硬红线
- 导出文本必须过
ordinaryOutputLeaks()= 空(BUG-999 / 1003):合同测试把 golden fixture 每一块都跑一遍。 - 章节来源只有
buildLongformOutline();不得新写切分、slug 或第二份目录数据。 - 不新增网络请求:抽屉只用阅读页已经拿到的
longformMarkdown/calculationCharts/factTables。 - 下载只走现有
downloadMarkdownReport(consultation-report-export.ts),不新写 Blob /<a download>逻辑;不引服务端 PDF(personal-report-export.test.ts"client export never touches a server PDF pipeline" 继续成立)。 - D5 逐字节回归锁不得让步。
- 不得出现 spinner / 骨架 / 「正在加载」(报告页规则不变;星盘页的骨架例外不外溢)。
- 纸色边界(DESIGN D11):抽屉是 chrome,用 app 色;
--report-*只留在正文。 - 44px 触控目标;断点只用 860;不改
viewport-breakpoint-contract。 /reports与/reports/[reportId]的渲染标记与基线一致(ƒ);不碰report-chart-grid-rehype.ts。- 既有断言改动三栏说明;测试总数不低于开工实测;不顺手修 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;⑦ 微信内打开点导出 → 现有"请在系统浏览器打开"提示。
让步顺序
- 核对表 Markdown 若列宽在手机上难读,允许改为「每行一条
字段:值」的列表格式,写进进度记录。 - 原生
<dialog>若在 iOS Safari 16 的::backdrop有已知渲染问题,允许用div[role="dialog"]+ 自写焦点循环,但不得引第三方。 - 不得让步:红线 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,开工时核对最大号,被占用则顺延并写进进度记录。