feat(reports): generate card, jump to end, labelled export, TOC levels, table bands, export size; drop 字段 jargon (BUG-1092..1094)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
64aa0ec20c
commit
d85ab9a2d2
@@ -16,6 +16,15 @@
|
||||
- 核实上游「年运面板装配丢失」修复对本仓不适用,未抄(BUG-1096)。
|
||||
- Skill 版本不 bump(Skill 文本未改)。不改数据库结构。
|
||||
|
||||
## 2026-09-29 — 报告页:生成入口挪到页面中间、一键到底部、导出更显眼、目录分层、表格加底色、导出显示文件大小(待验收)
|
||||
|
||||
- 「我的报告」页面顶部是一张「生成个人报告」卡片,按钮在卡片正中;下面是「过往的报告」列表。标题栏右上角的生成按钮去掉,只留这一个入口。
|
||||
- 报告详情页右下角有「到底部」按钮,点一次直接到文末(长报告也不会停在半路);接近底部时变成「回到顶部」。
|
||||
- 导出按钮改成带「导出」文字的按钮,不再是一个灰色图标。分块导出弹窗里显示文件大小(全篇和已选各一个,例如「约 186 KB」),按真实下载内容计算。
|
||||
- 目录里章节名加粗加深,小节往里缩,一眼能分出层级。报告里的表格加了淡淡的表头底色和隔行底色,打印时更浅;聊天里的表格不变。
|
||||
- 报告技术页去掉「字段」这类词:「Birth Chart 星主与状态字段」改叫「行星主星与状态」,表头 RL / NL / SL / SS / SB 改成星座主 / 星宿主 / 分主 / 分分主 / 力量比,并在表下说明;状态列不再出现「入旺(入旺)」这种重复(BUG-1092~1094)。
|
||||
- Skill 版本不 bump(Skill 文本未改)。不改数据库结构、不改计算。
|
||||
|
||||
## 2026-09-29 — 生时校正不再一路索要经历,选择题问完就给范围(待验收)
|
||||
|
||||
- 说够开场那几件经历(至少 3 件、两类事)之后,校正只问点选题,问完直接给「目前范围」;不再逐条追问家里、钱、身体等七类事,也不再「身体这边再问一次」或按某年某月点名补经历。原因:打字补的经历在这之后几乎不能把范围缩小,反而把用户拖到十几二十件(BUG-1084)。自己主动打字补的经历照常记下。
|
||||
|
||||
@@ -14688,6 +14688,50 @@
|
||||
- 复发自:无
|
||||
- 修复版本:未修
|
||||
|
||||
## BUG-1092 | 报告技术页标题出现「字段」,行星主星表头是看不懂的 RL / NL / SL / SS / SB
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;未部署,真机待合入 staging 后复核)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`scripts/pl9_reader_export.py`(p3 / p6 / p17 三个 `####` 标题、p6 表头、`_strip_pl9_user_engineering_markers` 的 `field_状态` 替换)。
|
||||
- 用户现象:个人报告「出生资料与图盘」里出现「Birth Chart 星主与状态字段」「原版补充字段」;表头是 `Planet | RL | NL | SL | SS | 状态 | SB`,产品反馈「用户都不知道这是啥」。
|
||||
- 触发条件:任何 reader_main 报告;p3 / p17 补充表只在引擎返回对应原版资料时出现。
|
||||
- 根因:模板标题照搬了引擎侧的工程命名(field / 字段)和 PL9 原版缩写,没有做面向读者的命名。
|
||||
- 修复:标题改为「行星主星与状态」「出生资料补充」「特殊上升与派生点补充」;`field_状态` 改译「状态」;表头改为「行星 | 星座主 | 星宿主 | 分主 | 分分主 | 尊贵状态 | 力量比」,表下加一句名称说明(力量比 = `ishta_bala_pct / 100`,1 以上为达标)。
|
||||
- 验证:`tests/test_report_reader_main.py::test_reader_headings_drop_field_jargon_and_dignity_is_one_name`;虚构案例同机改前 / 改后两次渲染 diff 只含这几处文案(golden `frontend/tests/fixtures/report-reader-main-fictional.json` 按该 diff 打补丁,其余逐字节不变)。
|
||||
- 防复发:报告标题不得出现「字段」;新增表头用读者能懂的中文,缩写须在表下说明。
|
||||
- 相关记录:BUG-1093、TASK-report-reader-polish-20260929
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/report-reader-polish-20260929`
|
||||
|
||||
## BUG-1093 | 报告尊贵状态显示成「入旺(入旺)」「中性(中性)」
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;未部署)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`scripts/pl9_reader_export.py` p6「行星主星与状态」与「宫主(Bhavesh)落宫解释」两张表的状态列。
|
||||
- 用户现象:状态列同一个词写两遍(入旺(入旺)、入庙(入庙)、中性(中性)),另有「入敌(敌对星座)」「极敌(Great Enemy)」中英混杂。
|
||||
- 触发条件:任何 reader_main 报告。
|
||||
- 根因:引擎 `DIGNITY_LABELS` 本身是中英双写(`入旺(Exalted)`);reader 的汉化是全文逐词替换,把括号里的英文也译成中文,于是两边相同;glossary 里没有的词(Great Enemy)保持英文。
|
||||
- 修复:新增 `_dignity_label`,只取双写标签的中文部分;两张表的状态单元格都经它输出。引擎 `DIGNITY_LABELS` 不动(其它消费方依赖它)。
|
||||
- 验证:同 BUG-1092 的回归测试断言全文不再出现 `X(X)` 形式、两张表状态列无括号;`_dignity_label` 单测覆盖双写、纯中文、空值。
|
||||
- 防复发:reader 表格里的引擎枚举值先归一再进汉化,不依赖逐词替换处理括号。
|
||||
- 相关记录:BUG-1092
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/report-reader-polish-20260929`
|
||||
|
||||
## BUG-1094 | 报告页可用性:生成入口不显眼、没有到底部、导出按钮找不到、目录层级分不清、表格行难对齐、导出不知道多大
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;未部署,浏览器级真机欠,见 `docs/testing/report-reader-polish-20260929.md`)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`personal-report-center.tsx`、`generate-personal-report-button.tsx`、`personal-report-markdown-view.tsx`、新增 `report-edge-jump.tsx`、`report-actions.tsx`、`report-export-drawer.tsx`、`lib/report-export-blocks.ts`、`globals.css`。
|
||||
- 用户现象(产品截图):生成按钮是标题栏右上角的小描边按钮,空状态文案写「生成完整报告」而按钮叫「生成报告」;长报告没法一下到底;导出只是一个灰色图标;目录里「强度、关系与 Ashtakavarga 技术页」像上一组的子项;表格无底色;分块导出只显示字数。
|
||||
- 根因:入口与导出做成了次要样式;按章节懒渲染却没有配套的到底部;目录一级 / 二级只差 12px 缩进、同色同字重;报告表格沿用聊天的极简表格样式;导出计数只统计字符(图盘块含 SVG,与文件大小差距大)。
|
||||
- 修复:生成入口挪进页面顶部卡片(删标题栏按钮),下方「过往的报告」;右下角「到底部 / 回到顶部」单按钮,点击先 `flushSync` 挂载全部懒渲染章节再滚到 `scrollHeight`;导出改为带文字的描边按钮;目录一级加粗用 ink、组间留白,二级缩进 `--space-5`;仅报告表格加表头淡底与隔行底色(打印更浅);导出弹窗显示 `TextEncoder` 实测字节(全篇与已选)。
|
||||
- 验证:`frontend/tests/report-reader-polish.test.tsx`(6 条)、`report-export-drawer.test.tsx` 新增文件大小用例;既有断言改动三栏见 `docs/tasks/PROGRESS-report-reader-polish-20260929.md`。
|
||||
- 防复发:报告页只有一个生成入口;到底部不得接入会话滚动锚;表格底色选择器限定在报告作用域。
|
||||
- 相关记录:BUG-1092、BUG-1093
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/report-reader-polish-20260929`
|
||||
|
||||
## BUG-1095 | 太阳返照本地时间是 UT 原样:读者版「Varshapravesha」差一个时区,Sahams 昼夜按错误时刻判
|
||||
|
||||
- 状态:resolved(2026-09-29 Claude 独立验收通过:6 测试文件+隐私守卫 156 passed;基线树 vs 分支北京虚构盘 A/B:solar-return 6 处差异全为修正项、tajika 0 差异;快速门 Python 步 1001 passed / 1 skipped 与基线一致,npm 步为 worktree 无 node_modules 的环境缺口)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# PROGRESS · 报告页打磨 · 2026-09-29
|
||||
|
||||
> 任务书:`docs/tasks/TASK-report-reader-polish-20260929.md`。执行:Claude(直接执行模式,产品 2026-09-29「请你开始执行」)。
|
||||
> 分支 `codex/report-reader-polish-20260929`,基线 `origin/staging` = `6e393780`。BUG-1092~1094。
|
||||
|
||||
## 交付对照
|
||||
|
||||
| 编号 | 结论 | 说明 |
|
||||
|---|---|---|
|
||||
| R1 报告中心改版 | 完成 | 标题栏无按钮;`.report-center-create` 卡片居中放「生成报告」(实心,默认尺寸);下方「过往的报告」;无报告时一行「还没有个人报告。」。按钮的防重复点击、`classifyCreateResponse` 未改 |
|
||||
| R2 到底部 | 完成 | 新组件 `report-edge-jump.tsx`;`LazyMarkdownSection` 新增 `forceVisible`,点击时 `flushSync` 挂载全部章节后滚到 `scrollHeight`;距底 480px 内变「回到顶部」,一次只显示一个 |
|
||||
| R3 导出按钮 | 完成 | 描边、默认高度、可见文字「导出」,`aria-label="导出报告"` 保留 |
|
||||
| R4 目录层级 | 完成 | 一级 ink / 600 / 组间 `--space-3`;二级缩进 `--space-5` |
|
||||
| R5 字段文案与表头 | 完成 | 三个标题、`field_状态`、p6 表头改中文 + 表下说明;状态列经 `_dignity_label` 去掉双写;同样的去重也用在「宫主(Bhavesh)落宫解释」表(排查时发现的同源问题) |
|
||||
| R6 表格底色 | 完成 | 只作用于 `.personal-report-table-wrap` 与 `.personal-report-fact-tables`;打印 5% / 2.5% |
|
||||
| R7 导出大小 | 完成 | `exportByteSize` = `TextEncoder` 字节;`formatExportSize` 显示「约 N KB / 约 N.N MB」;全篇与已选各一个 |
|
||||
|
||||
## 既有断言改动(原值 / 新值 / 原因)
|
||||
|
||||
| 文件 | 原值 | 新值 | 原因 |
|
||||
|---|---|---|---|
|
||||
| `frontend/tests/personal-report-view.test.ts`「ready reports expose only the back link and accessible export icon」 | `match size="icon"`、`match <Download aria-hidden="true" />`、`match title="导出"` | `doesNotMatch size="icon"`、`match variant="outline"`、`match <Download aria-hidden="true" />导出` | §3-3:图标按钮改为带文字的描边按钮。`aria-label="导出报告"`、返回链接、禁用项断言不变 |
|
||||
| `frontend/tests/report-export-drawer.test.tsx`「reader actions keep only the accessible export icon」 | `title` 等于「导出」 | 按钮文字等于「导出」,`title` 为空 | 同上。按钮数量仍为 1、对话框开合断言不变 |
|
||||
| `frontend/tests/personal-report-entry.test.ts`「entry stays on one line…」 | `match shrink-0` | `doesNotMatch shrink-0`(`whitespace-nowrap` 仍 match) | §3-1:按钮移出 46px 标题栏,不再与标题争宽;「一行不折」要求不变 |
|
||||
| `frontend/tests/react-client-lifecycle-test-support.ts`(测试宿主) | 无 `closest` / `scrollTo` | 增加按类名的 `closest` 与记录调用的 `scrollTo`、`scrollTop/scrollHeight/clientHeight` | 让「到底部」能在宿主里被真实点击验证;不影响其它用例(全量对比见下) |
|
||||
|
||||
测试名均未改(名字里的 "icon" 保留,避免名单消失;语义以断言为准)。
|
||||
|
||||
## Golden
|
||||
|
||||
`frontend/tests/fixtures/report-reader-main-fictional.json`:用同一虚构输入(`tests/test_report_reader_main.py::FICTIONAL`)、同一引擎,分别以改前(`origin/staging` 的 `pl9_reader_export.py`)与改后模块渲染,取两者 diff 打到 golden 上。golden 与改前渲染本身只差一行 Shadbala 小数(`-18.80` / `-18.79`,同机两次运行也会抖动,与本单无关,未带入)。golden 改动 = 标题 1 行、表头 2 行、状态列 9 行、说明 2 行、宫主表状态 7 行。
|
||||
|
||||
## 验证
|
||||
|
||||
(见下方「命令与结果」,由 Claude 实跑填写)
|
||||
@@ -0,0 +1,18 @@
|
||||
# 真机清单 · 报告页打磨(2026-09-29)
|
||||
|
||||
对应 `docs/tasks/TASK-report-reader-polish-20260929.md`(BUG-1092~1094)。电脑(Chrome 或 Safari)和手机(iPhone Safari)各走一遍。用你自己的账号即可,不需要生成新报告(第 7 条除外)。
|
||||
|
||||
| # | 操作 | 应该看到 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 1 | 打开「我的报告」 | 标题栏右上角**没有**生成按钮;页面顶部一张卡片写「生成个人报告」,实心按钮「生成报告」在卡片正中;下方是「过往的报告」列表 | |
|
||||
| 2 | (没有报告的账号)打开「我的报告」 | 卡片下面只有一行小字「还没有个人报告。」,没有第二个大空框 | |
|
||||
| 3 | 打开一份已完成的报告 | 顶部操作栏右侧是带文字的「导出」按钮;右下角有「到底部」 | |
|
||||
| 4 | 刚打开报告就点「到底部」 | 一次就到文末最后的表格,没有停在半路;按钮变成「回到顶部」,点它回到开头 | |
|
||||
| 5 | 看右侧目录(电脑) | 「出生资料与图盘」「强度、关系与 Ashtakavarga 技术页」等章节名加粗、颜色更深,小节往里缩一级 | |
|
||||
| 6 | 滚到「出生资料与图盘」里的行星表格 | 表头有淡底,行与行之间隔一行一层很淡的底色;深色模式下同样能看出来 | |
|
||||
| 7 | 生成一份**新**报告后找「行星主星与状态」表 | 标题里没有「字段」;表头是「行星 / 星座主 / 星宿主 / 分主 / 分分主 / 尊贵状态 / 力量比」,表下有一句说明;状态列只写「入旺」「中性」这样一个词 | |
|
||||
| 8 | 点「导出」 | 弹窗第一行写着字数和「约 xxx KB」(全篇和已选各一个);换「全部分盘」,已选大小明显变大 | |
|
||||
| 9 | 浏览器打印预览(Ctrl/⌘+P) | 看不到「到底部」按钮;表格隔行底色比屏幕上更浅但还看得出 | |
|
||||
| 10 | 打开任意聊天里有表格的回答 | 聊天里的表格和以前一样,没有加底色 | |
|
||||
|
||||
旧报告(改动前生成的)第 7 条仍是旧标题,这是预期:报告内容在生成时就定下了,要看新标题请重新生成。
|
||||
+8
-6
@@ -565,7 +565,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
|
||||
### Personal report centre
|
||||
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits in the 46px header with 「生成完整报告」 as the header's one action; the body starts straight at 「报告记录」 and the **row list** of reports. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits alone in the 46px header. The body opens with the **generate card** (`.report-center-create`, 2026-09-29, product sketch): a centred `--color-canvas` sheet with a file icon, 「生成个人报告」, one line 「创建后即可离开,完成时下方列表会自动更新。」 and the filled 「生成报告」 button — the page's only generate entry; the header button was deleted rather than kept as a second one. Below it, 「过往的报告」 heads the **row list** of reports; with none yet, a single quiet line 「还没有个人报告。」 replaces the old second big empty card. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link.
|
||||
- **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12.
|
||||
- **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut.
|
||||
@@ -574,7 +574,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
- **Surface:** `--color-canvas-soft` floor; completed rows are paper, other rows canvas, with a warm hairline and `--radius-lg` on the list as a whole. No drop shadow.
|
||||
- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **Actions:** “生成报告” in the generate card is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the card above already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures remain in the open drawer; only success closes it and emits a toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
|
||||
- **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert.
|
||||
|
||||
@@ -582,10 +582,12 @@ one skeleton, none of them carrying the sidebar.
|
||||
|
||||
- **Buttons on the report surfaces:** the BUG-1078 report-only override (2026-09-28) was replaced the same day by the site-wide solid-button pair (BUG-1079, see Button); report buttons look as before. Outline buttons there (the header 「生成报告」 while submitting) now fade like outline buttons elsewhere instead of the report-only grey. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode).
|
||||
- **分块导出对话框 (2026-09-24):** native modal `<dialog>`, bottom-aligned below 860px and centered at ≥860px, app colours only. Desktop is viewport-centered (`inset: 0; margin: auto`) regardless of sidebar state, width `min(720px, 92vw)`, max-height 80vh, with full `--radius-lg` corners. Its flex panel is capped at `calc(80vh - 2px)` to reserve the dialog border; desktop rules follow the narrow-screen dvh upgrade so 70dvh cannot override them. Below 860px the content-column offset, bottom alignment and at most 70vh/70dvh remain unchanged. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
|
||||
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts.
|
||||
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts. Beside them the header shows the file size for the whole report and for the selection — the UTF-8 byte length of exactly what would download (`TextEncoder` over `joinExportBlocks`), as 「约 186 KB」 / 「约 1.4 MB」, never a per-character estimate.
|
||||
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`.
|
||||
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the export icon button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
|
||||
- **到底部 / 回到顶部:** one floating pill (`.report-edge-jump`, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper.
|
||||
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
|
||||
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
|
||||
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
|
||||
@@ -593,10 +595,10 @@ one skeleton, none of them carrying the sidebar.
|
||||
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
|
||||
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
|
||||
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
|
||||
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them.
|
||||
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%.
|
||||
- **Width:** 1120px for the reader chrome; the article takes `minmax(0, 1fr)` and the TOC rail `minmax(11rem, 15rem)` to its right. The TOC collapses into a drawer below 860px.
|
||||
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, markdown-ready, legacy-unavailable. Waiting uses `InlineSpinner` and one-step copy.
|
||||
- **Accessibility:** the action bar has only 返回报告中心 and a 44×44 Download icon button (`aria-label="导出报告"`, native tooltip「导出」). No raw-appendix or print button, and old `#export` links no longer auto-open a dialog. Browser Ctrl+P keeps the existing print styles and beforeprint/afterprint fact-table expansion/restoration. Generating copy uses `role="status"`. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
- **Accessibility:** the action bar has only 返回报告中心 and an outlined, full-height 「导出」 button with the Download icon (`aria-label="导出报告"`); 2026-09-29 it replaced the 44×44 ghost icon button nobody found. No raw-appendix or print button, and old `#export` links no longer auto-open a dialog. Browser Ctrl+P keeps the existing print styles and beforeprint/afterprint fact-table expansion/restoration. Generating copy uses `role="status"`. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
|
||||
### Starter home
|
||||
|
||||
|
||||
@@ -3879,34 +3879,45 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
.report-center-empty {
|
||||
min-height: 220px;
|
||||
/* The page's generate entry: one centred card above the archive (2026-09-29,
|
||||
product sketch). Same sheet as an empty state used to be, so a first visit
|
||||
still reads calm; the button inside is the page's one filled action. */
|
||||
.report-center-create {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8);
|
||||
padding: var(--space-8) var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.report-center-empty h3 {
|
||||
.report-center-create h2 {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.report-center-empty p {
|
||||
.report-center-create p {
|
||||
max-width: 460px;
|
||||
margin: 0;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* No second big empty card under the generate card: one quiet line. */
|
||||
.report-center-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-6) 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: center;
|
||||
}
|
||||
.report-center-alert {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -4066,7 +4077,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-3); }
|
||||
/* Two levels you can tell apart without the current-item pill (2026-09-29):
|
||||
chapters are ink and semibold with air above each group; sections sit a
|
||||
full step in and one tone lighter. */
|
||||
.personal-report-toc li[data-level="2"] + li[data-level="2"],
|
||||
.personal-report-toc li[data-level="3"] + li[data-level="2"] { margin-top: var(--space-3); }
|
||||
.personal-report-toc li[data-level="2"] > a { color: var(--color-ink); font-weight: 600; }
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-5); }
|
||||
.personal-report-toc a {
|
||||
display: block;
|
||||
padding: 6px var(--space-2);
|
||||
@@ -4085,6 +4102,39 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.personal-report-toc a:hover { color: var(--color-ink); }
|
||||
/* 「到底部」/「回到顶部」: one floating pill over the reader's bottom-right
|
||||
corner, same look as the chat's 跳到最新. Out of flow, so the paper never
|
||||
reflows when it flips direction. Hidden on paper via .personal-report-screen-only. */
|
||||
.report-edge-jump {
|
||||
position: fixed;
|
||||
right: var(--space-6);
|
||||
bottom: var(--space-6);
|
||||
z-index: 15;
|
||||
}
|
||||
.report-edge-jump__button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.report-edge-jump__button svg { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
.report-edge-jump__button:hover { background: var(--color-canvas-muted); }
|
||||
.report-edge-jump__button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.report-edge-jump { right: var(--space-4); bottom: calc(var(--space-4) + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
.personal-report-toc-drawer { display: none; }
|
||||
.personal-report-toc-desktop p {
|
||||
margin: 0 0 var(--space-2);
|
||||
@@ -4543,6 +4593,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Report tables only, never chat bubbles (2026-09-29): a faint header band and
|
||||
zebra rows so a nine-column row can be followed across. Both are ink mixed
|
||||
into transparency, so they sit on paper and on the dark sheet alike. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th {
|
||||
background: color-mix(in srgb, var(--color-ink) 6%, transparent);
|
||||
}
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--color-ink) 3%, transparent);
|
||||
}
|
||||
.personal-report-key-evidence tbody th { width: 30%; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; }
|
||||
.personal-report-key-evidence tbody td { color: var(--color-ink-strong); font-size: var(--type-body-sm); }
|
||||
|
||||
@@ -4800,6 +4861,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
||||
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
||||
.personal-report-table-wrap { max-width: none !important; overflow: visible !important; }
|
||||
/* Lighter on paper; the print block already forces exact colour. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th { background: color-mix(in srgb, var(--color-ink) 5%, transparent) !important; }
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) { background: color-mix(in srgb, var(--color-ink) 2.5%, transparent) !important; }
|
||||
|
||||
.personal-report-table-wrap table,
|
||||
.personal-report-key-evidence table { width: 100% !important; min-width: 0 !important; table-layout: fixed !important; }
|
||||
.personal-report-table-wrap th,
|
||||
|
||||
@@ -200,14 +200,12 @@ export function GeneratePersonalReportButton({
|
||||
}
|
||||
|
||||
return (
|
||||
/* Sized for the 46px page header it now lives in. It used to be the
|
||||
default filled Button at full height, which read as a banner CTA
|
||||
floating in the top-right corner rather than the header's own action. */
|
||||
/* The report centre's one filled action, centred in its own card at the
|
||||
top of the page (2026-09-29). It used to be a small outline button in
|
||||
the 46px header, easy to miss beside the page name. */
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={submitting}
|
||||
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { Clock3, FileText, RefreshCw } from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -151,14 +151,19 @@ export function PersonalReportCenter() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageShell
|
||||
title="我的报告"
|
||||
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
|
||||
>
|
||||
<SecondaryPageShell title="我的报告">
|
||||
<div className="report-center-body">
|
||||
{/* Generating is the page's first thing, in its own card, and the only
|
||||
entry: the header no longer carries a second button (2026-09-29). */}
|
||||
<section className="report-center-create" aria-labelledby="report-center-create-title">
|
||||
<FileText aria-hidden="true" className="size-7" />
|
||||
<h2 id="report-center-create-title">生成个人报告</h2>
|
||||
<p>创建后即可离开,完成时下方列表会自动更新。</p>
|
||||
<GeneratePersonalReportButton onCreated={() => void load()} />
|
||||
</section>
|
||||
<section className="report-center-section">
|
||||
<div className="report-center-section-heading">
|
||||
<h2>报告记录</h2>
|
||||
<h2>过往的报告</h2>
|
||||
<button type="button" onClick={() => void load(true)} aria-label="刷新报告列表">
|
||||
{state.phase === "loading" ? <InlineSpinner size={14} /> : <RefreshCw aria-hidden="true" />}刷新
|
||||
</button>
|
||||
@@ -169,11 +174,7 @@ export function PersonalReportCenter() {
|
||||
)}
|
||||
|
||||
{state.reports.length === 0 ? (
|
||||
<div className="report-center-empty">
|
||||
<Clock3 aria-hidden="true" className="size-7" />
|
||||
<h3>还没有个人报告</h3>
|
||||
<p>点击“生成完整报告”,创建后即可离开;完成时这里会自动更新。</p>
|
||||
</div>
|
||||
<p className="report-center-empty">还没有个人报告。</p>
|
||||
) : (
|
||||
<div className="report-center-list">
|
||||
{state.reports.map((report) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "@/lib/report-chart-block";
|
||||
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump";
|
||||
import {
|
||||
buildLongformOutline,
|
||||
type LongformHeading,
|
||||
@@ -104,11 +106,15 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
||||
|
||||
const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
section,
|
||||
forceVisible,
|
||||
}: {
|
||||
section: LongformSection;
|
||||
/** 「到底部」 mounts every section at once so the jump lands on the real end. */
|
||||
forceVisible: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [visible, setVisible] = useState(section.eager);
|
||||
const [seen, setVisible] = useState(section.eager);
|
||||
const visible = seen || forceVisible;
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
@@ -170,6 +176,10 @@ function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
|
||||
|
||||
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
// Synchronous on purpose: the caller scrolls to scrollHeight right after, and
|
||||
// that height is only final once every lazy section has committed.
|
||||
const revealAll = useCallback(() => flushSync(() => setRevealed(true)), []);
|
||||
const lead = useMemo(
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null),
|
||||
[outline],
|
||||
@@ -185,9 +195,10 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
</section>
|
||||
) : null}
|
||||
{outline.sections.filter((section) => !section.eager).map((section) => (
|
||||
<LazyMarkdownSection key={section.id} section={section} />
|
||||
<LazyMarkdownSection forceVisible={revealed} key={section.id} section={section} />
|
||||
))}
|
||||
</article>
|
||||
<ReportEdgeJump revealAll={revealAll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,15 +37,15 @@ export function ReportActions({
|
||||
<ArrowLeft aria-hidden="true" />返回报告中心
|
||||
</Link>
|
||||
<div className="personal-report-action-end">
|
||||
{/* Labelled, outlined and full height (2026-09-29): the icon-only ghost
|
||||
button it replaces was the one control people could not find. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
aria-label="导出报告"
|
||||
title="导出"
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" />
|
||||
<Download aria-hidden="true" />导出
|
||||
</Button>
|
||||
{exportOpen && <ReportExportDrawer blocks={blocks} reportDate={reportDate} onClose={() => setExportOpen(false)} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
|
||||
/** Within this many px of the end the button flips to 「回到顶部」. */
|
||||
export const REPORT_EDGE_JUMP_NEAR_END_PX = 480;
|
||||
|
||||
export type ReportEdgeDirection = "end" | "top";
|
||||
|
||||
export function reportEdgeDirection(scroller: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">): ReportEdgeDirection {
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= REPORT_EDGE_JUMP_NEAR_END_PX ? "top" : "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* One floating control on the reader: 「到底部」 until the reader is near the
|
||||
* end, then 「回到顶部」 — never both at once. Not a scroll follower: it moves
|
||||
* only when clicked, and it owns no second scroll anchor (AGENTS §6).
|
||||
*
|
||||
* Sections below the fold render lazily, so the document is short until they
|
||||
* mount. `revealAll` must synchronously mount every section before the jump,
|
||||
* or the scroll would stop wherever the placeholders ended.
|
||||
*/
|
||||
export function ReportEdgeJump({ revealAll }: Readonly<{ revealAll: () => void }>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [direction, setDirection] = useState<ReportEdgeDirection>("end");
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = ref.current?.closest<HTMLElement>(".personal-report-reader");
|
||||
if (!scroller) return;
|
||||
const update = () => setDirection(reportEdgeDirection(scroller));
|
||||
scroller.addEventListener("scroll", update, { passive: true });
|
||||
return () => scroller.removeEventListener("scroll", update);
|
||||
}, []);
|
||||
|
||||
function jump() {
|
||||
if (direction === "top") {
|
||||
ref.current?.closest<HTMLElement>(".personal-report-reader")?.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
revealAll();
|
||||
const scroller = ref.current?.closest<HTMLElement>(".personal-report-reader");
|
||||
scroller?.scrollTo({ top: scroller.scrollHeight });
|
||||
}
|
||||
|
||||
const toEnd = direction === "end";
|
||||
return (
|
||||
<div ref={ref} className="report-edge-jump personal-report-screen-only">
|
||||
<button type="button" className="report-edge-jump__button" onClick={jump}>
|
||||
{toEnd ? <ArrowDown aria-hidden="true" /> : <ArrowUp aria-hidden="true" />}
|
||||
{toEnd ? "到底部" : "回到顶部"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { detectPrintRestriction } from "@/lib/client-report-export";
|
||||
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
|
||||
import { exportFilename, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
|
||||
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const;
|
||||
@@ -23,6 +23,8 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {
|
||||
const selected = blocks.filter(block => selection.has(block.id));
|
||||
const allSelected = selected.length === blocks.length;
|
||||
const chars = (items: readonly ReportExportBlock[]) => items.reduce((sum, block) => sum + block.chars, 0);
|
||||
const totalSize = useMemo(() => formatExportSize(exportByteSize(blocks)), [blocks]);
|
||||
const selectedSize = useMemo(() => formatExportSize(exportByteSize(blocks.filter(block => selection.has(block.id)))), [blocks, selection]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
@@ -68,7 +70,7 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {
|
||||
<header className="report-export-header">
|
||||
<div className="report-export-heading"><h2 id="report-export-title">分块导出</h2><Button variant="ghost" onClick={onClose} aria-label="关闭分块导出">关闭</Button></div>
|
||||
<p id="report-export-description">勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。</p>
|
||||
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字</p>
|
||||
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · {totalSize} · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字 · {selected.length ? selectedSize : "0 KB"}</p>
|
||||
<div className="report-export-presets" aria-label="导出套餐">
|
||||
{(Object.keys(PRESETS) as ReportExportPreset[]).map(key => <Button key={key} variant="outline" aria-pressed={preset === key} onClick={() => choose(key)}>{PRESETS[key]}</Button>)}
|
||||
<Button variant="ghost" onClick={() => { setPreset(null); setSelection(allSelected ? new Set() : new Set(blocks.map(block => block.id))); }}>{allSelected ? "取消全选" : "全选"}</Button>
|
||||
|
||||
@@ -109,3 +109,14 @@ export function presetSelection(preset: ReportExportPreset, blocks: readonly Rep
|
||||
export function exportFilename(reportDate: string | null | undefined, isFull: boolean): string {
|
||||
return `${personalReportMarkdownFilename(reportDate)}${isFull ? "" : "-节选"}`;
|
||||
}
|
||||
|
||||
/** UTF-8 bytes of exactly what the download would contain; not a per-character guess. */
|
||||
export function exportByteSize(blocks: readonly ReportExportBlock[]): number {
|
||||
return blocks.length ? new TextEncoder().encode(joinExportBlocks(blocks)).length : 0;
|
||||
}
|
||||
|
||||
/** 「约 186 KB」 / 「约 1.4 MB」; under 1 KB still reads 「约 1 KB」 so it never says 0. */
|
||||
export function formatExportSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `约 ${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `约 ${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -158,7 +158,11 @@ test("repeat clicks are guarded while a request is in flight", () => {
|
||||
});
|
||||
|
||||
test("entry stays on one line and sends failures to the existing toast", () => {
|
||||
assert.match(componentSource, /shrink-0/);
|
||||
// 原值:`assert.match(componentSource, /shrink-0/)`(按钮在 46px 标题栏里不被挤压)
|
||||
// 新值:`assert.doesNotMatch(componentSource, /shrink-0/)`;`whitespace-nowrap` 保留
|
||||
// 原因:TASK-report-reader-polish-20260929 §3-1——按钮从标题栏挪进页面主体的生成卡片居中,
|
||||
// 不再与标题争宽;「一行不折」这条要求没变
|
||||
assert.doesNotMatch(componentSource, /shrink-0/);
|
||||
assert.match(componentSource, /whitespace-nowrap/);
|
||||
assert.match(componentSource, /toast\.error\(outcome\.message\)/);
|
||||
assert.match(componentSource, /toast\.error\("网络异常,请检查连接后重试。"\)/);
|
||||
|
||||
@@ -430,11 +430,14 @@ test("ready reports expose only the back link and accessible export icon", () =>
|
||||
assert.match(actionsSource, /href="\/reports"/);
|
||||
assert.match(actionsSource, /返回报告中心/);
|
||||
assert.match(actionsSource, /ReportExportDrawer/);
|
||||
assert.match(actionsSource, /size="icon"/);
|
||||
// 原值:`assert.match(actionsSource, /size="icon"/)` 与 `/title="导出"/`
|
||||
// 新值:不再是图标按钮(doesNotMatch size="icon"),可见文字「导出」,描边样式
|
||||
// 原因:TASK-report-reader-polish-20260929 §3-3——图标按钮太不显眼,产品要求做明显
|
||||
assert.doesNotMatch(actionsSource, /size="icon"/);
|
||||
assert.match(actionsSource, /variant="outline"/);
|
||||
assert.match(actionsSource, /<Download aria-hidden="true" \/>导出/);
|
||||
assert.doesNotMatch(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL|#export|reportId|reportTitle/);
|
||||
assert.match(actionsSource, /<Download aria-hidden="true" \/>/);
|
||||
assert.match(actionsSource, /aria-label="导出报告"/);
|
||||
assert.match(actionsSource, /title="导出"/);
|
||||
assert.doesNotMatch(actionsSource, /RawAppendixDownload|printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|useSyncExternalStore|detectPrintRestriction/);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ class HostElement {
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
scrollIntoView() {}
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
readonly scrollCalls: { top?: number }[] = [];
|
||||
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
|
||||
/** Class selectors only (`.a` / `.a.b`); enough for components that find their scroller. */
|
||||
closest(selector: string): HostElement | null {
|
||||
const wanted = selector.split(".").filter(Boolean);
|
||||
const classes = (this.getAttribute("class") ?? "").split(/\s+/);
|
||||
if (wanted.every(name => classes.includes(name))) return this;
|
||||
return this.parentNode?.closest(selector) ?? null;
|
||||
}
|
||||
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
|
||||
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
|
||||
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer";
|
||||
import { ReportActions } from "../src/components/personal-report/report-actions";
|
||||
import { buildReportExportBlocks, exportFilename, joinExportBlocks } from "../src/lib/report-export-blocks";
|
||||
import { buildReportExportBlocks, exportByteSize, exportFilename, formatExportSize, joinExportBlocks } from "../src/lib/report-export-blocks";
|
||||
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
@@ -110,7 +110,11 @@ test("reader actions keep only the accessible export icon", async () => {
|
||||
assert.deepEqual(h.errors, []);
|
||||
const exportButton = h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "导出报告");
|
||||
assert.ok(exportButton);
|
||||
assert.equal(exportButton?.getAttribute("title"), "导出");
|
||||
// 原值:`title` 属性等于「导出」(只有图标的按钮靠原生提示说明用途)
|
||||
// 新值:按钮自己的可见文字就是「导出」,不再挂 title
|
||||
// 原因:TASK-report-reader-polish-20260929 §3-3——产品反馈图标按钮找不到,改为带文字的描边按钮
|
||||
assert.equal(exportButton?.text, "导出");
|
||||
assert.equal(exportButton?.getAttribute("title"), null);
|
||||
assert.ok(!h.container.text.includes("下载原始附录"));
|
||||
assert.ok(!h.container.text.includes("打印"));
|
||||
await h.event(exportButton!);
|
||||
@@ -158,3 +162,29 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed
|
||||
assert.match(css, /\.report-export-list \{[^}]*overflow-y: auto;[^}]*min-height: 0;/);
|
||||
assert.match(css, /\.report-export-header, \.report-export-footer \{[^}]*flex-shrink: 0;/);
|
||||
});
|
||||
|
||||
test("the drawer shows the real download size, not a character count (BUG-1094)", async () => {
|
||||
const body = blocks.filter(block => block.group === "body");
|
||||
const charts = blocks.filter(block => block.group === "chart");
|
||||
assert.ok(charts.length > 0, "golden carries chart blocks");
|
||||
// Exact bytes of what downloadMarkdownReport would receive.
|
||||
assert.equal(exportByteSize(body), new TextEncoder().encode(joinExportBlocks(body)).length);
|
||||
assert.equal(exportByteSize([]), 0);
|
||||
// Chinese is three bytes a character, so bytes run past the character count.
|
||||
const bodyChars = body.reduce((sum, block) => sum + block.chars, 0);
|
||||
assert.ok(exportByteSize(body) > bodyChars);
|
||||
assert.equal(formatExportSize(186 * 1024), "约 186 KB");
|
||||
assert.equal(formatExportSize(300), "约 1 KB");
|
||||
assert.equal(formatExportSize(Math.round(1.44 * 1024 * 1024)), "约 1.4 MB");
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => {}} />);
|
||||
const count = h.elements().find(node => node.getAttribute("class") === "report-export-count");
|
||||
assert.ok(count);
|
||||
assert.ok(count.text.includes(formatExportSize(exportByteSize(blocks))), count.text);
|
||||
assert.ok(count.text.includes(formatExportSize(exportByteSize(body))), count.text);
|
||||
await h.event(button(h, "全选"));
|
||||
await h.event(button(h, "取消全选"));
|
||||
assert.ok(h.elements().find(node => node.getAttribute("class") === "report-export-count")?.text.endsWith("0 KB"));
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view";
|
||||
import { REPORT_EDGE_JUMP_NEAR_END_PX, reportEdgeDirection } from "../src/components/personal-report/report-edge-jump";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
// TASK-report-reader-polish-20260929 (BUG-1094): generate entry in the page
|
||||
// body, 到底部 that lands on the real end, two-level TOC, report-only table bands.
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const centre = read("../src/components/personal-report/personal-report-center.tsx");
|
||||
const globals = read("../src/app/globals.css");
|
||||
|
||||
function rule(selector: string): string {
|
||||
const start = globals.indexOf(`${selector} {`);
|
||||
assert.notEqual(start, -1, `missing rule: ${selector}`);
|
||||
return globals.slice(start, globals.indexOf("}", start));
|
||||
}
|
||||
|
||||
test("the report centre has one generate entry, in a card above the archive", () => {
|
||||
assert.doesNotMatch(centre, /actions=\{<GeneratePersonalReportButton/, "the header button is gone");
|
||||
assert.equal(centre.match(/<GeneratePersonalReportButton/g)?.length, 1);
|
||||
const card = centre.indexOf('className="report-center-create"');
|
||||
const archive = centre.indexOf("<h2>过往的报告</h2>");
|
||||
assert.ok(card > 0 && archive > card, "generate card comes first, then 过往的报告");
|
||||
assert.ok(centre.indexOf("<GeneratePersonalReportButton", card) < archive);
|
||||
assert.doesNotMatch(centre, /报告记录|点击“生成完整报告”/, "old heading and mismatched button name are gone");
|
||||
assert.match(rule(".report-center-create"), /align-items: center/);
|
||||
const button = read("../src/components/personal-report/generate-personal-report-button.tsx");
|
||||
assert.doesNotMatch(button, /variant="outline"|size="sm"/, "the page's one filled action");
|
||||
});
|
||||
|
||||
test("the edge jump flips near the end and never shows both directions", () => {
|
||||
assert.equal(reportEdgeDirection({ scrollTop: 0, scrollHeight: 10_000, clientHeight: 800 }), "end");
|
||||
assert.equal(reportEdgeDirection({ scrollTop: 10_000 - 800 - REPORT_EDGE_JUMP_NEAR_END_PX, scrollHeight: 10_000, clientHeight: 800 }), "top");
|
||||
assert.equal(reportEdgeDirection({ scrollTop: 0, scrollHeight: 600, clientHeight: 800 }), "top", "a short report has no bottom to jump to");
|
||||
});
|
||||
|
||||
test("到底部 mounts every lazy section before it scrolls, in one click", async () => {
|
||||
const markdown = ["# 报告", "", "## 摘要", "", "开头", "",
|
||||
...Array.from({ length: 6 }, (_, index) => [`## 第${index + 1}章`, "", `正文${index + 1}`, ""]).flat()].join("\n");
|
||||
const originalObserver = globalThis.IntersectionObserver;
|
||||
// Nothing ever intersects: without the jump, lazy sections stay placeholders.
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", {
|
||||
configurable: true, writable: true,
|
||||
value: class { observe() {} disconnect() {} },
|
||||
});
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
const scroller = h.container;
|
||||
scroller.setAttribute("class", "personal-report-reader");
|
||||
scroller.scrollHeight = 12_345;
|
||||
await h.render(<PersonalReportMarkdownView markdown={markdown} />);
|
||||
assert.ok(!h.container.text.includes("正文6"), "far sections start unmounted");
|
||||
const buttons = h.elements().filter(node => node.tagName === "BUTTON" && /到底部|回到顶部/.test(node.text));
|
||||
assert.equal(buttons.length, 1);
|
||||
assert.equal(buttons[0].text, "到底部");
|
||||
await h.event(buttons[0]);
|
||||
assert.ok(h.container.text.includes("正文6"), "every section rendered by the time the scroll runs");
|
||||
assert.deepEqual(scroller.scrollCalls, [{ top: 12_345 }]);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
await h.close();
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, writable: true, value: originalObserver });
|
||||
}
|
||||
});
|
||||
|
||||
test("the edge jump stays off paper", () => {
|
||||
const source = read("../src/components/personal-report/report-edge-jump.tsx");
|
||||
assert.match(source, /className="report-edge-jump personal-report-screen-only"/);
|
||||
assert.match(rule(".report-edge-jump"), /position: fixed/);
|
||||
});
|
||||
|
||||
test("TOC chapters and sections read as two levels", () => {
|
||||
assert.match(globals, /\.personal-report-toc li\[data-level="2"\] > a \{ color: var\(--color-ink\); font-weight: 600; \}/);
|
||||
assert.match(globals, /\.personal-report-toc li\[data-level="3"\] \{ padding-left: var\(--space-5\); \}/);
|
||||
});
|
||||
|
||||
test("zebra bands live on report tables only, never on chat tables", () => {
|
||||
assert.match(globals, /\.personal-report-table-wrap tbody tr:nth-child\(even\),\n\.personal-report-fact-tables tbody tr:nth-child\(even\) \{/);
|
||||
assert.match(globals, /\.personal-report-table-wrap thead th,\n\.personal-report-fact-tables thead th \{\n background: color-mix/);
|
||||
assert.doesNotMatch(globals, /\.markdown-table tbody tr:nth-child\(even\)/);
|
||||
assert.doesNotMatch(globals, /\.markdown-table thead th \{[^}]*background/);
|
||||
});
|
||||
@@ -83,6 +83,17 @@ def _pl9_english_report_title(packet: dict) -> str:
|
||||
return f"{name}'s Vedic Astrology Chart Report" if name else "Vedic Astrology Chart Report"
|
||||
|
||||
|
||||
def _dignity_label(value) -> str:
|
||||
"""Engine dignity labels are bilingual (``入旺(Exalted)``); the reader shows one name.
|
||||
|
||||
The glossary pass would otherwise translate the English half too and print
|
||||
``入旺(入旺)``. Anything not in that shape passes through unchanged.
|
||||
"""
|
||||
text = str(value or '').strip()
|
||||
match = re.fullmatch(r'([^()()]+?)\s*[((][A-Za-z][A-Za-z \-]*[))]', text)
|
||||
return match.group(1).strip() if match else text
|
||||
|
||||
|
||||
def _pl9_report_language(packet: dict) -> str:
|
||||
language = str((packet or {}).get('report_language') or '').strip().lower()
|
||||
return language if language in {'zh', 'en'} else 'zh'
|
||||
@@ -327,7 +338,7 @@ def _strip_pl9_user_engineering_markers(markdown: str) -> str:
|
||||
text = text.replace("Results of Muntha in the Sign of a Graha", "Muntha 落入行星星座的结果")
|
||||
text = text.replace("Results of Munthesh", "Munthesh 结果")
|
||||
text = text.replace("Results of Muntha", "Muntha 结果")
|
||||
text = text.replace("field_状态", "字段状态")
|
||||
text = text.replace("field_状态", "状态")
|
||||
text = text.replace("Dasha", "大运")
|
||||
text = re.sub(r"(?<=[\u4e00-\u9fff])\s+大运", "大运", text)
|
||||
text = re.sub(r"大运\s+(?=[\u4e00-\u9fff])", "大运", text)
|
||||
@@ -735,7 +746,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
birth_extra = _list(source_visible.get('birth_particulars_extra'))
|
||||
if birth_extra:
|
||||
lines.extend([
|
||||
'#### p3 Birth Particulars 原版补充字段',
|
||||
'#### p3 出生资料补充',
|
||||
'',
|
||||
'| Field | Value |',
|
||||
'|-------|-------|',
|
||||
@@ -781,10 +792,10 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
)
|
||||
lines.append('')
|
||||
lines.extend([
|
||||
'#### p6 Birth Chart 星主与状态字段',
|
||||
'#### p6 行星主星与状态',
|
||||
'',
|
||||
'| Planet | RL | NL | SL | SS | Status | SB |',
|
||||
'|--------|----|----|----|----|--------|----|',
|
||||
'| 行星 | 星座主 | 星宿主 | 分主 | 分分主 | 尊贵状态 | 力量比 |',
|
||||
'|------|--------|--------|------|--------|----------|--------|',
|
||||
])
|
||||
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
|
||||
row = _dict(planets.get(planet))
|
||||
@@ -793,9 +804,13 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
lines.append(
|
||||
f"| {_cell(_token(planet))} | {_cell(_token(row.get('rasi_lord')))} | "
|
||||
f"{_cell(_token(row.get('nakshatra_lord')))} | {_cell(_token(row.get('sub_lord')))} | "
|
||||
f"{_cell(_token(row.get('sub_sub_lord')))} | {_cell(row.get('status'))} | {_cell(_num(row.get('sb'), 2))} |"
|
||||
f"{_cell(_token(row.get('sub_sub_lord')))} | {_cell(_dignity_label(row.get('status')))} | {_cell(_num(row.get('sb'), 2))} |"
|
||||
)
|
||||
lines.append('')
|
||||
lines.extend([
|
||||
'',
|
||||
'星座主、星宿主、分主、分分主:这颗行星所在星座、星宿与 KP 细分各自的主星。力量比:六维力量与所需力量之比,1 以上为达标。',
|
||||
'',
|
||||
])
|
||||
moon_chart = d1_sheet.get('moon_chart') if isinstance(d1_sheet.get('moon_chart'), dict) else {}
|
||||
bhava = d1_sheet.get('bhava_chalit') if isinstance(d1_sheet.get('bhava_chalit'), dict) else {}
|
||||
sudarshana = divisional.get('sudarshana') if isinstance(divisional.get('sudarshana'), dict) else {}
|
||||
@@ -919,7 +934,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
source_special_points = _list(source_visible.get('source_special_points'))
|
||||
if source_special_points:
|
||||
lines.extend([
|
||||
'#### p17 Special Lagnas and Points 原版补充字段',
|
||||
'#### p17 特殊上升与派生点补充',
|
||||
'',
|
||||
'| Point | Value |',
|
||||
'|-------|-------|',
|
||||
@@ -2352,7 +2367,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
placement = f"{_token(lord_row.get('sign'))} H{lord_row.get('house') or '-'} {_degree(lord_row.get('degree_in_sign'))}"
|
||||
lines.append(
|
||||
f"| {index} | {_cell(_token(house.get('cusp_sign') or house.get('sign')))} | {_cell(_token(lord))} | "
|
||||
f"{_cell(placement)} | {_cell(lord_row.get('status') or lord_row.get('dignity'))} | {_cell('active' if str(lord) in active_lords else 'background')} |"
|
||||
f"{_cell(placement)} | {_cell(_dignity_label(lord_row.get('status') or lord_row.get('dignity')))} | {_cell('active' if str(lord) in active_lords else 'background')} |"
|
||||
)
|
||||
lines.append('')
|
||||
bhavesh_pages = _list(bhavesh_pack.get('pages'))
|
||||
|
||||
@@ -292,3 +292,27 @@ def test_reader_omits_missing_name_and_panchanga_machine_keys(reader_packet) ->
|
||||
assert "| 综合性质 |" in reader
|
||||
assert "| 吉性要素数量 |" in reader
|
||||
assert "| 要素总数 |" in reader
|
||||
|
||||
|
||||
def test_reader_headings_drop_field_jargon_and_dignity_is_one_name(reader_packet) -> None:
|
||||
"""BUG-1092 / BUG-1093: no 「字段」 in what a reader sees, readable planet-lord
|
||||
headers, and the bilingual engine dignity label printed once, not 「入旺(入旺)」."""
|
||||
from scripts.pl9_reader_export import _dignity_label
|
||||
|
||||
reader = _pl9_export_markdown_for_edition(reader_packet, "reader_main")
|
||||
assert "字段" not in reader
|
||||
assert "#### 行星主星与状态" in reader
|
||||
assert "| 行星 | 星座主 | 星宿主 | 分主 | 分分主 | 尊贵状态 | 力量比 |" in reader
|
||||
assert "| Planet | RL | NL | SL | SS |" not in reader
|
||||
assert "力量比:六维力量与所需力量之比" in reader
|
||||
table = reader.split("#### 行星主星与状态", 1)[1].split("\n\n", 2)[1]
|
||||
for row in table.splitlines()[2:]:
|
||||
dignity = row.split("|")[6].strip()
|
||||
assert "(" not in dignity and "(" not in dignity, row
|
||||
assert not re.search(r"([一-鿿]+)\(\1\)", reader)
|
||||
assert _dignity_label("入旺(Exalted)") == "入旺"
|
||||
assert _dignity_label("落陷取消(Neecha Bhanga)") == "落陷取消"
|
||||
assert _dignity_label("极敌(Great Enemy)") == "极敌"
|
||||
assert _dignity_label("中性") == "中性"
|
||||
assert _dignity_label(None) == ""
|
||||
assert _leak_hits(reader) == []
|
||||
|
||||
Reference in New Issue
Block a user