feat(reports): redesign the export sheet — edition, whole or chosen chapters, size on the button

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:
Jesse_Chen
2026-09-30 00:44:28 +08:00
co-authored by Claude Opus 5.5
parent 94fbf37674
commit 7950ae93d3
13 changed files with 310 additions and 167 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-30 — 报告导出面板重新设计(待验收)
- 点「导出」后先选语言(中文 / English,下面一句建议问 AI 时选 English),再选「整篇报告」或「自选章节」。选整篇直接点底部按钮就导出;选自选才展开清单,按正文 / 分盘 / 核对表分组,每组一个勾选框可整组选,分盘和核对表默认收起。
- 文件大小只在底部按钮上显示一次,例如「导出整篇 · 约 195 KB」。去掉了「常用 / 全部分盘 / 仅核对表」、全选按钮和每块的字数。
- 导出面板里选语言不会改变你正在读的页面语言。
- Skill 版本不 bump。不改数据库结构。
## 2026-09-30 — 手机上报告页更紧凑:一个加载画面、操作栏一行、导出框看得到分块;切换中英停在同一章(待验收)
- 打开报告时只出现一个加载画面(带顶栏、居中),不再先闪一个贴在顶部的转圈(BUG-1106)。
@@ -53,3 +53,10 @@
本机浏览器验证方式:生产构建 + `next start`,Playwright 驱动本机 Chromium(390×844、触摸、深色),`/api/**` 由脚本拦截返回虚构报告(引擎真实 golden 中英对),不连任何真实账号。
既有断言改动(本轮):`report-export-drawer.test.tsx` 三处——窄屏 70 → 85(d)vh、桌面不继承窄屏上限的断言值 70 → 85、关闭按钮按读屏名查找;统计行格式断言改为新格式。原值 / 新值 / 原因写在测试里。
## 第三轮:导出面板重设计(产品 2026-09-30「分块导出太丑,需要重新设计」,选定「两步:整篇 / 自选」)
- `report-export-drawer.tsx` 重写:语言分段(面板内独立,不切整页)→ 整篇 / 自选单选卡 → 自选时分组清单(三态组复选框、分盘与核对表默认收起)→ 按钮上显示大小。`report-actions.tsx` 只在面板打开时为中英两版各建一次导出块;阅读页把两版都传进来。
- CSS:删除套餐、网格卡片、统计行、提示条等旧类;桌面面板宽 720 → 520px。
- 本机 390×844 与 1280×860 截图检查(整篇、自选、部分勾选的 − 状态)。
- 既有断言改动:`report-export-drawer.test.tsx` 7 条测试名全部保留、主体按新设计重写(每条开头写原值 / 新值 / 原因);`report-english-edition.test.tsx`「the export drawer suggests English…」、`surface-feedback-20260929.test.tsx` 手机面板断言、`report-buttons-scope-20260928.test.ts` 选中态不得用 action-soft 的约束迁到新选中态,均写明三栏。
+3 -1
View File
@@ -24,4 +24,6 @@
| 13 | 手机点开一份已完成的报告 | 只出现一个加载画面(顶栏在、转圈居中),不先闪一个贴顶部的转圈 | |
| 14 | 报告打开后看顶部 | 一行:左边一个返回箭头,右边「中文 / English」和「导出」并排 | |
| 15 | 滚到「强度、关系与 Ashtakavarga 技术页」,点 English | 页面停在同一章,标题变成「Strength, Relationships and Ashtakavarga」,下面的表格、说明都是英文。**如果仍是中文,请录屏发我,并告诉我 iOS 版本** | |
| 16 | 点「导出」 | 顶部只有标题、×、一行「已选 … 块 · 约 … KB(全篇约 … KB)」、一行可左右滑的按钮,下面能看到多个分块 | |
| 16 | 点「导出」 | 面板从下往上弹出:标题「导出报告」和 ×;「中文 / English」;「整篇报告」(已选中)和「自选章节」两张卡;底部按钮「导出整篇 · 约 … KB」 | |
| 17 | 点「自选章节」 | 下面出现清单:「正文 7/7」展开、「分盘」「核对表」收起;取消一章后「正文」前的框变成 −,按钮变成「导出 6 项 · 约 … KB」 | |
| 18 | 在面板里点 English,再点导出 | 下载的文件名以 `-EN.md` 结尾,内容是英文;页面本身仍是原来的语言 | |
+2 -2
View File
@@ -581,8 +581,8 @@ one skeleton, none of them carrying the sidebar.
### Personal report reader
- **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 85vh/85dvh (70 until 2026-09-30, BUG-1107) 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. 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.
- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** native modal `<dialog>`, bottom sheet below 860px (up to 85dvh) and a centred `min(520px, 92vw)` panel at ≥860px, app colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); when the report has an English edition, a two-way segment 中文 / English (starts on the edition being read, does not switch the page) with one caption 「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」, otherwise one caption saying why there is no English; two radio cards 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」; only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The file size appears once, on the footer button: 「导出整篇 · 约 195 KB」 / 「导出 6 项 · 约 80 KB」 / disabled 「先勾选要导出的部分」. Selected states use `--color-canvas-muted` + `--color-ink`, never `--color-action-soft` (BUG-1078). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch.
- **Export selection:** 整篇 = every block of the chosen edition; 自选 starts with all 正文 chapters ticked. Switching the sheet's language resets the checklist to that edition's chapters (block ids differ per edition). Presets 常用 / 全部分盘 / 仅核对表 and 全选 were retired with the redesign.
- **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 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 “旧版本报告,请重新生成”.
+1 -4
View File
@@ -143,13 +143,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 报告分块导出与删除
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
- 导出对话框标题仍为「分块导出」。说明:「勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。」这里全选包含完整正文及另选的盘 / 表;与旧整篇逐字节相同的套餐是「常用」(全部正文)。
- 套餐:「常用 / 全部分盘 / 仅核对表」;动作:「全选 / 取消全选」「导出选中 (N)」。成功才提示「已导出 N 块」,下载失败或微信限制不提示成功。本轮 D5 保持既有微信系统浏览器提示原文,不借删除打印入口顺带改它。
- 统计行(2026-09-30 起,BUG-1107):「已选 n/N 块 · 约 X KB(全篇约 Y KB)」一行说完;大小是实际下载内容的字节数(`TextEncoder`),写「约 186 KB」「约 1.4 MB」,不按字数估。每个分块仍在卡片上显示自己的字数(`Array.from(text).length`,含安全 SVG / Markdown 标记),不是估计文章字数;列表页仍不杜撰章数或盘数。
- 导出面板(2026-09-30 重设计):标题「导出报告」;语言「中文 / English」下一句「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」;范围「整篇报告」(副行如「7 章正文 + 22 张分盘 + 8 张核对表」)/「自选章节」(副行「只带走需要的部分」);分组名「正文 / 分盘 / 核对表」带「n/N」;按钮「导出整篇 · 约 X KB」「导出 n 项 · 约 X KB」,一项没选时「先勾选要导出的部分」。成功提示「已导出」。没有英文版时只写一句:「这份报告生成于英文版上线前,重新生成即可获得中英两版。」或「这份报告的英文版没有生成成功,只能导出中文版。」
- 删除确认:「确定删除这份报告?」;按钮:「删除 / 取消」;请求中:「删除中…」;失败:「报告没能删除,请稍后重试。」失败保留原行。
- 日期中文全写;深度「简版 / 标准深度 / 深度 / 研究级」;主题复用现有中文映射,未知 id 原样回退,不造含义。
- 英文版提示(2026-09-29):当前看中文、且这份报告有英文版时,导出框顶部写「要拿去问 ChatGPT、Claude 等 AI?建议切到 English 再导出,术语与原典一致,AI 读得更准。」按钮「切到 English」。当前是英文时说明句末加「导出的是英文版。」。英文版上线前的报告写「这份报告生成于英文版上线前,重新生成即可获得中英两版。」;英文版没生成成功的写「这份报告的英文版没有生成成功,只能导出中文版。」
- 英文版正文的口径:用 BPHS / PL9 通行的英文术语(Exalted、Own Sign、Mooltrikona、Sub lord……),不音译中文、不中英混写;限制说明用固定英文四句(见 `report-public-projection.ts` 的 `ENGLISH_LIMITATION_COPY`)。应用外壳(侧栏、标题、按钮、到底部)始终是中文。
## 报告里的限制说明
+36 -43
View File
@@ -3994,57 +3994,50 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (min-width: 860px) {
.report-export-drawer {
inset: 0;
width: min(720px, 92vw);
width: min(520px, 92vw);
max-height: 80vh;
margin: auto;
border-radius: var(--radius-lg);
}
.report-export-panel { max-height: calc(80vh - 2px); }
}
.report-export-header, .report-export-footer { padding: var(--space-4); flex-shrink: 0; }
.report-export-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.report-export-heading h2 { margin: 0; font-size: var(--type-title-lg); }
.report-export-header p { margin: var(--space-2) 0; font-size: var(--type-body-sm); color: var(--color-ink-secondary); }
.report-export-count { font-variant-numeric: tabular-nums; }
/* 「问 AI 建议导出英文版」: one quiet band inside the header, with the switch beside it. */
.report-export-ai-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: var(--space-2) 0; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.report-export-ai-hint p { margin: 0 !important; color: var(--color-ink) !important; }
.report-export-ai-hint > button { flex-shrink: 0; }
.report-export-edition-note { color: var(--color-ink-tertiary) !important; font-size: var(--type-caption) !important; }
@media (max-width: 767px) { .report-export-ai-hint { flex-direction: column; align-items: stretch; } }
.report-export-presets { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* Selected preset: neutral raised fill + full ink + a visible edge. The old
--color-action-soft fill reads as a dark red-brown block in the dark theme
(#3a2620) — BUG-1078. */
.report-export-presets [aria-pressed="true"] { background: var(--color-canvas-muted); color: var(--color-ink); border-color: var(--color-ink-secondary); }
.report-export-list { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 0 var(--space-4) var(--space-4); }
.report-export-list h3 { font-size: var(--type-title-sm); margin: var(--space-3) 0 var(--space-2); }
.report-export-grid { display: grid; gap: var(--space-2); }
.report-export-block { display: flex; align-items: center; text-align: left; gap: var(--space-3); min-height: 44px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; }
.report-export-block[aria-checked="true"] { background: var(--color-selected); border-color: var(--color-action); }
.report-export-block small { display: block; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.report-export-check { flex-shrink: 0; width: 18px; height: 18px; border: 1px solid var(--color-border-strong); border-radius: 4px; text-align: center; line-height: 16px; }
.report-export-block:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
/* 导出报告 sheet (redesigned 2026-09-30): title row; the edition as a two-way
segment with one caption; two radio cards, 整篇报告 / 自选章节; the checklist
only in 自选; the size on the button. No bordered block cards, no presets. */
.report-export-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-1) var(--space-5); flex-shrink: 0; }
.report-export-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 600; }
.report-export-body { display: grid; gap: var(--space-4); min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-5) var(--space-4); }
.report-export-field { display: grid; gap: var(--space-2); }
.report-export-segment { display: grid; grid-template-columns: 1fr 1fr; padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.report-export-segment button { border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; }
.report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-ink); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent); }
.report-export-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
.report-export-modes { display: grid; gap: var(--space-2); }
.report-export-mode { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; }
.report-export-mode[aria-checked="true"] { border-color: var(--color-ink-secondary); background: var(--color-canvas-muted); }
.report-export-mode strong { display: block; font-size: var(--type-body-md); font-weight: 600; }
.report-export-mode small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.report-export-radio { flex-shrink: 0; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 50%; }
.report-export-mode[aria-checked="true"] .report-export-radio { border: 5px solid var(--color-ink); }
.report-export-list { display: grid; gap: var(--space-1); }
.report-export-group + .report-export-group { border-top: 1px solid var(--color-border); padding-top: var(--space-1); }
.report-export-group-head { display: flex; align-items: center; }
.report-export-row { display: flex; flex: 1; align-items: center; gap: var(--space-3); width: 100%; padding: 0 var(--space-1); border: 0; background: transparent; color: var(--color-ink); font: inherit; font-size: var(--type-body-sm); text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.report-export-row:not(.is-group) { padding-left: calc(var(--space-1) + 18px + var(--space-3)); }
.report-export-row:not(.is-group) .report-export-box { margin-left: calc(-18px - var(--space-3)); }
.report-export-row.is-group { font-weight: 600; }
.report-export-row small { margin-left: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 400; font-variant-numeric: tabular-nums; }
.report-export-box { flex-shrink: 0; display: grid; place-items: center; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 4px; color: var(--color-canvas); }
.report-export-box svg { width: 13px; height: 13px; stroke-width: 3; }
.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box { border-color: var(--color-ink); background: var(--color-ink); }
.report-export-expand { flex-shrink: 0; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.report-export-expand svg { width: 18px; height: 18px; transition: transform 140ms ease-out; }
.report-export-expand[aria-expanded="true"] svg { transform: rotate(90deg); }
.report-export-drawer button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.report-export-drawer button { min-height: 44px; min-width: 44px; }
.report-export-footer { border-top: 1px solid var(--color-border); padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
.report-export-footer { flex-shrink: 0; padding: var(--space-3) var(--space-5); border-top: 1px solid var(--color-border); padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
.report-export-footer > button { width: 100%; }
.report-export-footer p { margin: 0 0 var(--space-2); color: var(--color-danger); font-size: var(--type-body-sm); }
@media (min-width: 860px) { .report-export-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Phones (2026-09-30): the header had grown to half the sheet — title, a two-line
description, a two-line count, wrapped presets — and the block list was
pushed out of sight. The description goes (the title and presets say it),
the count is one short line, presets scroll sideways in one row, and the
sheet may use more of the screen. */
@media (max-width: 767px) {
#report-export-description { display: none; }
.report-export-header { padding: var(--space-3) var(--space-4) var(--space-2); }
.report-export-header p { margin: var(--space-1) 0; }
.report-export-count { font-size: var(--type-caption); }
.report-export-presets { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-inline: calc(-1 * var(--space-4)); padding-inline: var(--space-4); }
.report-export-presets [data-slot="button"] { flex: 0 0 auto; height: 40px; padding: 0 var(--space-3); }
.report-export-ai-hint { padding: var(--space-2) var(--space-3); gap: var(--space-2); }
.report-export-ai-hint p { font-size: var(--type-caption); }
}
@media print { .report-export-drawer { display: none !important; } }
/* ============================================================
@@ -5208,7 +5201,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (min-width: 860px) {
.chart-page-type-sheet {
inset: 0;
width: min(720px, 92vw);
width: min(520px, 92vw);
max-height: 80vh;
margin: auto;
border-radius: var(--radius-lg);
@@ -554,9 +554,10 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
<ReportActions
markdown={markdown}
markdown={state.markdown}
charts={state.calculationCharts}
factTables={factTables}
factTables={state.factTables}
english={state.englishEdition === "ready" && state.markdownEn ? { markdown: state.markdownEn, factTables: state.factTablesEn } : null}
reportDate={state.createdAt}
language={shown}
englishEdition={state.englishEdition}
@@ -20,7 +20,9 @@ interface ReportActionsProps {
charts?: ReportDocumentV2["charts"];
factTables?: readonly ReportFactTable[];
reportDate?: string | null;
/** Edition currently shown; `markdown` and `factTables` are that edition. */
/** The English edition, when the report has one; `markdown` / `factTables` are always Chinese. */
english?: Readonly<{ markdown: string; factTables?: readonly ReportFactTable[] }> | null;
/** Edition currently shown; the switch highlights it and the export sheet starts on it. */
language?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
onLanguageChange?: (language: ReportLanguage) => void;
@@ -31,15 +33,17 @@ export function ReportActions({
charts,
factTables,
reportDate,
english = null,
language = "zh",
englishEdition = "legacy",
onLanguageChange,
}: ReportActionsProps) {
const [exportOpen, setExportOpen] = useState(false);
const blocks = useMemo(
() => buildReportExportBlocks({ markdown, charts, factTables, language }),
[markdown, charts, factTables, language],
);
// Built only while the sheet is open; each edition labels its own blocks.
const editions = useMemo(() => (exportOpen ? {
zh: buildReportExportBlocks({ markdown, charts, factTables, language: "zh" }),
...(english ? { en: buildReportExportBlocks({ markdown: english.markdown, charts, factTables: english.factTables, language: "en" }) } : {}),
} : null), [exportOpen, markdown, charts, factTables, english]);
const canSwitch = englishEdition === "ready" && onLanguageChange !== undefined;
return (
@@ -75,14 +79,12 @@ export function ReportActions({
>
<Download aria-hidden="true" />导出
</Button>
{exportOpen && (
{editions && (
<ReportExportDrawer
key={language}
blocks={blocks}
reportDate={reportDate}
language={language}
editions={editions}
initialLanguage={language}
englishEdition={englishEdition}
onSwitchToEnglish={canSwitch ? () => onLanguageChange("en") : undefined}
reportDate={reportDate}
onClose={() => setExportOpen(false)}
/>
)}
@@ -1,35 +1,53 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { Check, ChevronRight, Minus, X } from "lucide-react";
import { toast } from "sonner";
import { detectPrintRestriction } from "@/lib/client-report-export";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, type ReportExportBlock } from "@/lib/report-export-blocks";
import { Button } from "@/components/ui/button";
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const;
const GROUPS = { body: "正文", chart: "分盘", table: "核对表" } as const;
type Group = ReportExportBlock["group"];
const GROUP_LABELS: Record<Group, string> = { body: "正文", chart: "分盘", table: "核对表" };
const GROUP_UNITS: Record<Group, string> = { body: "章", chart: "张", table: "张" };
const GROUPS = Object.keys(GROUP_LABELS) as Group[];
export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh", englishEdition = "legacy", onSwitchToEnglish }: {
blocks: readonly ReportExportBlock[];
/** Export blocks for each edition the report has; Chinese always, English when it exists. */
export type ReportExportEditions = Readonly<{ zh: readonly ReportExportBlock[]; en?: readonly ReportExportBlock[] }>;
/**
* 导出报告 (redesigned 2026-09-30, product: the block-picker sheet was ugly and
* hid the list). Two steps: pick the edition, then 「整篇报告」 (one tap, the
* common case) or 「自选章节」, which only then opens a plain checklist grouped
* into 正文 / 分盘 / 核对表 with one checkbox per group. The file size appears
* once, on the export button.
*/
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, onClose }: {
editions: ReportExportEditions;
/** The edition the reader is showing; the sheet starts on it. */
initialLanguage?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
reportDate?: string | null;
onClose: () => void;
/** Edition the blocks are in; the file is exported in it. */
language?: ReportLanguage;
englishEdition?: EnglishEditionStatus;
onSwitchToEnglish?: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [selection, setSelection] = useState(() => presetSelection("common", blocks));
const [preset, setPreset] = useState<ReportExportPreset | null>("common");
const hasEnglish = englishEdition === "ready" && Boolean(editions.en?.length);
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage === "en" && hasEnglish ? "en" : "zh");
const blocks = (language === "en" ? editions.en : editions.zh) ?? editions.zh;
const [mode, setMode] = useState<"whole" | "pick">("whole");
const [selection, setSelection] = useState<ReadonlySet<string>>(() => bodyIds(blocks));
const [open, setOpen] = useState<ReadonlySet<Group>>(() => new Set<Group>(["body"]));
const [error, setError] = useState<string | null>(null);
const exporting = useRef(false);
const selected = blocks.filter(block => selection.has(block.id));
const allSelected = selected.length === blocks.length;
const totalSize = useMemo(() => formatExportSize(exportByteSize(blocks)), [blocks]);
const selectedSize = useMemo(() => formatExportSize(exportByteSize(blocks.filter(block => selection.has(block.id)))), [blocks, selection]);
const selected = useMemo(
() => (mode === "whole" ? blocks : blocks.filter(block => selection.has(block.id))),
[blocks, mode, selection],
);
const size = useMemo(() => formatExportSize(exportByteSize(selected)), [selected]);
const counts = useMemo(() => countByGroup(blocks), [blocks]);
useEffect(() => {
const element = dialog.current;
@@ -41,9 +59,22 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
};
}, []);
function choose(next: ReportExportPreset) {
setSelection(presetSelection(next, blocks));
setPreset(next);
function switchLanguage(next: ReportLanguage) {
if (next === language) return;
const nextBlocks = (next === "en" ? editions.en : editions.zh) ?? editions.zh;
setLanguage(next);
// Block ids differ per edition; start the new edition from its chapters.
setSelection(bodyIds(nextBlocks));
}
function toggle(ids: readonly string[], on: boolean) {
setSelection(current => {
const next = new Set(current);
for (const id of ids) {
if (on) next.add(id); else next.delete(id);
}
return next;
});
}
function download() {
@@ -57,55 +88,109 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
setError(null);
try {
const body = blocks.filter(block => block.group === "body");
const full = body.length > 0 && body.every(block => selection.has(block.id));
const full = mode === "whole" || (body.length > 0 && body.every(block => selection.has(block.id)));
downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected));
dialog.current?.close();
onClose();
toast.success(`已导出 ${selected.length} 块`);
toast.success("已导出");
} catch (cause) {
exporting.current = false;
setError(cause instanceof Error ? cause.message : "报告暂不可导出");
}
}
return <dialog ref={dialog} className="report-export-drawer" aria-labelledby="report-export-title" aria-describedby="report-export-description"
const summary = GROUPS.filter(group => counts[group] > 0).map(group => `${counts[group]} ${GROUP_UNITS[group]}${GROUP_LABELS[group]}`).join(" + ");
return <dialog ref={dialog} className="report-export-drawer" aria-labelledby="report-export-title"
onCancel={event => { event.preventDefault(); onClose(); }}
onClick={event => { if (event.target === event.currentTarget) onClose(); }}>
<div className="report-export-panel">
<header className="report-export-header">
<div className="report-export-heading"><h2 id="report-export-title">分块导出</h2><Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭分块导出" title="关闭"><X aria-hidden="true" /></Button></div>
<p id="report-export-description">勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。{language === "en" ? "导出的是英文版。" : ""}</p>
{language === "zh" && englishEdition === "ready" && onSwitchToEnglish ? (
<div className="report-export-ai-hint">
<p>要拿去问 ChatGPT、Claude 等 AI?建议切到 English 再导出,术语与原典一致,AI 读得更准。</p>
<Button variant="outline" onClick={onSwitchToEnglish}>切到 English</Button>
<h2 id="report-export-title">导出报告</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭导出" title="关闭"><X aria-hidden="true" /></Button>
</header>
<div className="report-export-body">
{hasEnglish ? (
<div className="report-export-field">
<div className="report-export-segment" role="radiogroup" aria-label="导出语言">
{(["zh", "en"] as const).map(option => (
<button key={option} type="button" role="radio" aria-checked={language === option} onClick={() => switchLanguage(option)}>
{option === "zh" ? "中文" : "English"}
</button>
))}
</div>
<p className="report-export-caption">拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。</p>
</div>
) : (
<p className="report-export-caption">
{englishEdition === "unavailable" ? "这份报告的英文版没有生成成功,只能导出中文版。" : "这份报告生成于英文版上线前,重新生成即可获得中英两版。"}
</p>
)}
<div className="report-export-modes" role="radiogroup" aria-label="导出范围">
<button type="button" role="radio" aria-checked={mode === "whole"} className="report-export-mode" onClick={() => setMode("whole")}>
<span aria-hidden="true" className="report-export-radio" />
<span><strong>整篇报告</strong><small>{summary}</small></span>
</button>
<button type="button" role="radio" aria-checked={mode === "pick"} className="report-export-mode" onClick={() => setMode("pick")}>
<span aria-hidden="true" className="report-export-radio" />
<span><strong>自选章节</strong><small>只带走需要的部分</small></span>
</button>
</div>
{mode === "pick" ? (
<div className="report-export-list">
{GROUPS.map(group => {
const items = blocks.filter(block => block.group === group);
if (!items.length) return null;
const on = items.filter(block => selection.has(block.id)).length;
const expanded = open.has(group);
return <section key={group} className="report-export-group" aria-label={GROUP_LABELS[group]}>
<div className="report-export-group-head">
<button type="button" role="checkbox" aria-checked={on === 0 ? false : on === items.length ? true : "mixed"}
className="report-export-row is-group" onClick={() => toggle(items.map(block => block.id), on < items.length)}>
<CheckMark state={on === 0 ? false : on === items.length ? true : "mixed"} />
<span>{GROUP_LABELS[group]}<small>{on}/{items.length}</small></span>
</button>
<button type="button" className="report-export-expand" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${GROUP_LABELS[group]}`}
onClick={() => setOpen(current => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); return next; })}>
<ChevronRight aria-hidden="true" />
</button>
</div>
{expanded ? items.map(block => (
<button key={block.id} type="button" role="checkbox" aria-checked={selection.has(block.id)} className="report-export-row"
onClick={() => toggle([block.id], !selection.has(block.id))}>
<CheckMark state={selection.has(block.id)} />
<span>{block.title}</span>
</button>
)) : null}
</section>;
})}
</div>
) : null}
{englishEdition === "legacy" ? (
<p className="report-export-edition-note">这份报告生成于英文版上线前,重新生成即可获得中英两版。</p>
) : englishEdition === "unavailable" ? (
<p className="report-export-edition-note">这份报告的英文版没有生成成功,只能导出中文版。</p>
) : null}
{/* One short line (2026-09-30): the old 「共 N 块 · X 字 · … · Y 字 · …」 ran to two
lines on a phone; each block still shows its own character count. */}
<p className="report-export-count" aria-live="polite">已选 {selected.length}/{blocks.length} 块 · {selected.length ? selectedSize : "0 KB"}(全篇{totalSize})</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>
</div>
</header>
<div className="report-export-list">
{(Object.keys(GROUPS) as ReportExportBlock["group"][]).map(group => {
const items = blocks.filter(block => block.group === group);
return items.length ? <section key={group} aria-label={GROUPS[group]}><h3>{GROUPS[group]}</h3><div className="report-export-grid">
{items.map(block => <button key={block.id} type="button" className="report-export-block" role="checkbox" aria-checked={selection.has(block.id)} onClick={() => {
setPreset(null);
setSelection(current => { const next = new Set(current); if (next.has(block.id)) next.delete(block.id); else next.add(block.id); return next; });
}}><span aria-hidden="true" className="report-export-check">{selection.has(block.id) ? "✓" : ""}</span><span>{block.title}<small>{block.chars} 字</small></span></button>)}
</div></section> : null;
})}
</div>
<footer className="report-export-footer">{error && <p role="alert">{error}</p>}<Button disabled={!selected.length} onClick={download}>导出选中 ({selected.length})</Button></footer>
<footer className="report-export-footer">
{error && <p role="alert">{error}</p>}
<Button disabled={!selected.length} onClick={download}>
{!selected.length ? "先勾选要导出的部分" : mode === "whole" ? `导出整篇 · ${size}` : `导出 ${selected.length} 项 · ${size}`}
</Button>
</footer>
</div>
</dialog>;
}
function bodyIds(blocks: readonly ReportExportBlock[]): ReadonlySet<string> {
return new Set(blocks.filter(block => block.group === "body").map(block => block.id));
}
function countByGroup(blocks: readonly ReportExportBlock[]): Record<Group, number> {
const counts: Record<Group, number> = { body: 0, chart: 0, table: 0 };
for (const block of blocks) counts[block.group] += 1;
return counts;
}
function CheckMark({ state }: { state: boolean | "mixed" }) {
return <span aria-hidden="true" className="report-export-box">
{state === "mixed" ? <Minus /> : state ? <Check /> : null}
</span>;
}
@@ -110,7 +110,12 @@ test("the BUG-1078 report-only override is gone; the selected export preset stay
assert.doesNotMatch(cssNoComments, /report-center-generate/);
assert.doesNotMatch(generateSource, /report-center-generate/);
assert.doesNotMatch(cssNoComments, /\[class~="bg-primary"\]/);
const preset = rule('.report-export-presets [aria-pressed="true"]');
assert.doesNotMatch(preset, /--color-action-soft|--color-action\b/);
assert.match(preset, /--color-ink/);
// 原值:`.report-export-presets [aria-pressed="true"]` 用中性底 + ink,不用 action-soft
// 新值:套餐已删(2026-09-30 导出面板重设计);同一约束落到新的选中态——范围单选卡与语言分段
// 原因:BUG-1078 的主语(导出面板选中态不得用 --color-action-soft 深色块)不变
for (const selector of ['.report-export-mode[aria-checked="true"]', '.report-export-segment button[aria-checked="true"]']) {
const selected = rule(selector);
assert.doesNotMatch(selected, /--color-action-soft|--color-action\b/);
assert.match(selected, /--color-ink|--color-canvas/);
}
});
+20 -10
View File
@@ -174,19 +174,29 @@ test("the language switch only exists when both editions do", async () => {
});
test("the export drawer suggests English for AI only when it can switch", async () => {
const blocks = buildReportExportBlocks({ markdown: zhReader.markdown });
// 原值:中文时出现提示条 +「切到 English」按钮(切换整页语言);英文时说明句「导出的是英文版」
// 新值:导出面板自带「中文 / English」选择(不切换整页),下方一句「拿去问 ChatGPT、Claude 等 AI,建议选 English」;
// 选 English 后导出的就是英文块;没有英文版时只剩一句说明
// 原因:2026-09-30 导出面板重设计(两步:语言 → 整篇 / 自选)
const zhBlocks = buildReportExportBlocks({ markdown: zhReader.markdown });
const enBlocks = buildReportExportBlocks({ markdown: `# Report\n\n## Summary\n\n${ENGLISH_PROSE}\n`, language: "en" });
const h = createClientLifecycleHarness();
let switched = 0;
try {
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => {}} language="zh" englishEdition="ready" onSwitchToEnglish={() => { switched += 1; }} />);
assert.ok(h.container.text.includes("建议切到 English 再导出"));
await h.event(h.elements().find(node => node.tagName === "BUTTON" && node.text === "切到 English")!);
assert.equal(switched, 1);
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => {}} language="en" englishEdition="ready" />);
assert.ok(!h.container.text.includes("建议切到 English"));
assert.ok(h.container.text.includes("导出的是英文版"));
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => {}} englishEdition="legacy" />);
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks, en: enBlocks }} onClose={() => {}} englishEdition="ready" />);
assert.ok(h.container.text.includes("建议选 English"));
const english = h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!;
assert.equal(english.getAttribute("aria-checked"), "false");
await h.event(english);
assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true");
await h.event(h.elements().find(node => node.getAttribute("role") === "radio" && node.text.startsWith("自选章节"))!);
assert.ok(h.container.text.includes("Summary"), "the checklist is the English edition");
await h.render(null);
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks, en: enBlocks }} initialLanguage="en" onClose={() => {}} englishEdition="ready" />);
assert.equal(h.elements().find(node => node.getAttribute("role") === "radio" && node.text === "English")!.getAttribute("aria-checked"), "true", "starts on the edition being read");
await h.render(null);
await h.render(<ReportExportDrawer editions={{ zh: zhBlocks }} onClose={() => {}} englishEdition="legacy" />);
assert.ok(h.container.text.includes("重新生成即可获得中英两版"));
assert.ok(!h.elements().some(node => node.getAttribute("role") === "radio" && node.text === "English"));
} finally { await h.close(); }
});
+70 -36
View File
@@ -13,34 +13,60 @@ Object.assign(globalThis, { React });
const reader = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8"));
const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
const blocks = buildReportExportBlocks({ markdown: reader.markdown, factTables: assembleReportFactTables(engine) });
const editions = { zh: blocks };
const source = readFileSync(new URL("../src/components/personal-report/report-export-drawer.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
type Harness = ReturnType<typeof createClientLifecycleHarness>;
const button = (h: Harness, text: string) => { const node = h.elements().find(node => node.tagName === "BUTTON" && node.text === text); assert.ok(node, text); return node; };
const button = (h: Harness, text: string) => { const node = h.elements().find(node => node.tagName === "BUTTON" && node.text.startsWith(text)); assert.ok(node, text); return node; };
const checks = (h: Harness) => h.elements().filter(node => node.getAttribute("role") === "checkbox");
const radio = (h: Harness, text: string) => { const node = h.elements().find(node => node.getAttribute("role") === "radio" && node.text.startsWith(text)); assert.ok(node, text); return node; };
const exportButton = (h: Harness) => h.elements().filter(node => node.tagName === "BUTTON").find(node => /^导出|^先勾选/.test(node.text))!;
// 2026-09-30 redesign (product: 「分块导出太丑,需要重新设计」, chose 「两步:整篇 / 自选」).
// Test names are kept for suite continuity; the presets 常用 / 全部分盘 / 仅核对表 and
// 全选 are retired. Each rewritten test states 原值 / 新值 / 原因 at its head.
test("drawer mounts all golden blocks, presets replace selection and manual choices clear highlight", async () => {
// 原值:打开即列出全部块(默认勾正文),套餐按钮替换选择,手动勾选清除套餐高亮
// 新值:打开是「整篇报告」一步导出(全部块);选「自选章节」才出现清单,正文默认全勾、分组复选框可整组开关,分盘/核对表默认收起
// 原因:2026-09-30 产品重设计;主语(能拿到全部块、能按组与逐块选择)不变
const h = createClientLifecycleHarness();
try {
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => {}} />);
await h.render(<ReportExportDrawer editions={editions} onClose={() => {}} />);
assert.deepEqual(h.errors, []);
assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open);
assert.equal(checks(h).length, blocks.length);
assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, blocks.filter(block => block.group === "body").length);
await h.event(button(h, "全部分盘"));
assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, blocks.filter(block => block.group === "chart").length);
await h.event(button(h, "仅核对表"));
assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, 8);
await h.event(checks(h)[0]);
assert.equal(button(h, "仅核对表").getAttribute("aria-pressed"), "false");
await h.event(button(h, "全选"));
await h.event(button(h, "取消全选"));
assert.equal(button(h, "导出选中 (0)").disabled, true);
assert.equal(checks(h).length, 0, "no checklist until 自选章节");
assert.equal(radio(h, "整篇报告").getAttribute("aria-checked"), "true");
assert.equal(exportButton(h).text, `导出整篇 · ${formatExportSize(exportByteSize(blocks))}`);
await h.event(radio(h, "自选章节"));
const body = blocks.filter(block => block.group === "body");
// Group rows (3) + the expanded 正文 rows.
assert.equal(checks(h).length, 3 + body.length);
assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, 1 + body.length);
const chartGroup = checks(h).find(node => node.text.startsWith("分盘"))!;
assert.equal(chartGroup.getAttribute("aria-checked"), "false");
await h.event(chartGroup);
assert.equal(checks(h).find(node => node.text.startsWith("分盘"))!.getAttribute("aria-checked"), "true");
await h.event(checks(h).find(node => node.text.startsWith(body[0]!.title))!);
assert.equal(checks(h).find(node => node.text.startsWith("正文"))!.getAttribute("aria-checked"), "mixed");
for (const group of ["正文", "分盘", "核对表"]) {
const row = checks(h).find(node => node.text.startsWith(group))!;
if (row.getAttribute("aria-checked") === "true") await h.event(row);
if (checks(h).find(node => node.text.startsWith(group))!.getAttribute("aria-checked") === "mixed") {
await h.event(checks(h).find(node => node.text.startsWith(group))!);
await h.event(checks(h).find(node => node.text.startsWith(group))!);
}
}
assert.equal(exportButton(h).text, "先勾选要导出的部分");
assert.equal(exportButton(h).disabled, true);
} finally { await h.close(); }
});
test("drawer download uses the existing Blob downloader exactly once and only then closes", async () => {
// 原值:选「仅核对表」后按「导出选中 (8)」,toast「已导出 8 块」,文件名为节选
// 新值:自选里只开「核对表」组、关掉正文组后按「导出 8 项 · …」,toast「已导出」,文件名为节选;内容仍是这些块逐字节拼接
// 原因:2026-09-30 重设计去掉套餐
const h = createClientLifecycleHarness();
const oldCreate = URL.createObjectURL;
const oldRevoke = URL.revokeObjectURL;
@@ -61,18 +87,22 @@ test("drawer download uses the existing Blob downloader exactly once and only th
return { set download(value: string) { filename = value; }, click() { clicked += 1; }, remove() {} } as unknown as HTMLAnchorElement;
}) as typeof document.createElement;
try {
await h.render(<ReportExportDrawer blocks={blocks} reportDate="2026-09-24" onClose={() => { closed += 1; }} />);
await h.event(button(h, "仅核对表"));
await h.event(button(h, "导出选中 (8)"));
await h.render(<ReportExportDrawer editions={editions} reportDate="2026-09-24" onClose={() => { closed += 1; }} />);
await h.event(radio(h, "自选章节"));
await h.event(checks(h).find(node => node.text.startsWith("正文"))!);
await h.event(checks(h).find(node => node.text.startsWith("核对表"))!);
const tables = blocks.filter(block => block.group === "table");
await h.event(button(h, `导出 ${tables.length} 项`));
assert.equal(clicked, 1);
assert.equal(closed, 1);
assert.deepEqual(successes, ["已导出 8 块"]);
assert.deepEqual(successes, ["已导出"]);
assert.equal(filename, exportFilename("2026-09-24", false) + ".md");
assert.equal(await blob?.text(), joinExportBlocks(blocks.filter(block => block.group === "table")));
assert.equal(await blob?.text(), joinExportBlocks(tables));
} finally { toast.success = successBefore; URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; await h.close(); }
});
test("download failure and WeChat restrictions leave drawer open without a success close", async () => {
// 原值:按「导出选中 (正文块数)」;新值:按「导出整篇 · …」;原因:2026-09-30 重设计,失败与微信限制的行为断言不变
const h = createClientLifecycleHarness();
const oldCreate = URL.createObjectURL;
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
@@ -81,12 +111,12 @@ test("download failure and WeChat restrictions leave drawer open without a succe
toast.success = () => { successes += 1; return 1; };
let closed = 0;
try {
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => { closed += 1; }} />);
await h.render(<ReportExportDrawer editions={editions} onClose={() => { closed += 1; }} />);
URL.createObjectURL = () => { throw new Error("下载失败"); };
await h.event(button(h, `导出选中 (${blocks.filter(block => block.group === "body").length})`));
await h.event(button(h, "导出整篇"));
assert.ok(h.container.text.includes("下载失败"));
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "MicroMessenger" } });
await h.event(button(h, `导出选中 (${blocks.filter(block => block.group === "body").length})`));
await h.event(button(h, "导出整篇"));
assert.ok(h.container.text.includes("系统浏览器"));
assert.equal(closed, 0);
assert.equal(successes, 0);
@@ -119,20 +149,21 @@ test("reader actions keep only the accessible export icon", async () => {
assert.ok(!h.container.text.includes("打印"));
await h.event(exportButton!);
assert.ok(h.elements().some(node => node.tagName === "DIALOG"));
// 原值:按可见文字「关闭」找按钮;新值:按读屏名「关闭分块导出」找(2026-09-30 手机上改成 × 图标,名称不变)
await h.event(h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "关闭分块导出")!);
// 原值:按可见文字「关闭」找按钮;新值:按读屏名「关闭导出」找(2026-09-30 改成 × 图标,标题改「导出报告」)
await h.event(h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "关闭导出")!);
await h.idle();
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"));
} finally { await h.close(); }
});
test("mounted drawer handles Escape and backdrop but not inner clicks and restores focus", async () => {
// 原值:内部点击目标是「常用」套餐按钮;新值:「整篇报告」单选;原因:套餐已删除
const h = createClientLifecycleHarness(); let closes = 0;
const previous = document.createElement("button"); previous.focus();
try {
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => { closes += 1; }} />);
await h.render(<ReportExportDrawer editions={editions} onClose={() => { closes += 1; }} />);
const dialog = h.elements().find(node => node.tagName === "DIALOG")!;
await h.event(dialog, "onClick", { target: button(h, "常用") }); assert.equal(closes, 0);
await h.event(dialog, "onClick", { target: radio(h, "整篇报告") }); assert.equal(closes, 0);
await h.event(dialog, "onCancel"); assert.equal(closes, 1);
await h.event(dialog); assert.equal(closes, 2);
await h.render(null); assert.equal(document.activeElement, previous);
@@ -160,12 +191,17 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 85dvh;/);
const desktopStart = css.indexOf("@media (min-width: 860px)");
const desktop = css.slice(desktopStart);
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(720px, 92vw\);\s*max-height: 80vh;\s*margin: auto;\s*border-radius: var\(--radius-lg\);/);
// 原值:桌面 `width: min(720px, 92vw)`;新值:`min(520px, 92vw)`;原因:2026-09-30 重设计后是单列的紧凑面板,720 宽只剩空白
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(520px, 92vw\);\s*max-height: 80vh;\s*margin: auto;\s*border-radius: var\(--radius-lg\);/);
assert.match(desktop, /\.report-export-panel \{ max-height: calc\(80vh - 2px\); \}/);
// 原值 70(d)vh → 新值 85(d)vh:窄屏上限改为 85 后,桌面仍不得继承窄屏上限(原因同上)
assert.doesNotMatch(desktop, /max-height: 85(?:d)?vh|inset:.*--report-export-sidebar/);
assert.match(css, /\.report-export-list \{[^}]*overflow-y: auto;[^}]*min-height: 0;/);
assert.match(css, /\.report-export-header, \.report-export-footer \{[^}]*flex-shrink: 0;/);
// 原值:`.report-export-list` 负责滚动、头尾 `flex-shrink: 0`
// 新值:滚动区是 `.report-export-body`(语言、范围与清单在同一滚动区),头尾仍 `flex-shrink: 0`
// 原因:2026-09-30 重设计,清单只在「自选」时出现
assert.match(css, /\.report-export-body \{[^}]*min-height: 0;[^}]*overflow-y: auto;/);
assert.match(css, /\.report-export-header \{[^}]*flex-shrink: 0;/);
assert.match(css, /\.report-export-footer \{[^}]*flex-shrink: 0;/);
});
test("the drawer shows the real download size, not a character count (BUG-1094)", async () => {
@@ -181,15 +217,13 @@ test("the drawer shows the real download size, not a character count (BUG-1094)"
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");
// 原值:统计行同时显示全篇与已选大小;新值:大小只在导出按钮上出现一次(整篇 = 全部块,自选 = 所选块)
// 原因:2026-09-30 重设计,统计行删除
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.match(h.elements().find(node => node.getAttribute("class") === "report-export-count")?.text ?? "", /^已选 0\/\d+ 块 · 0 KB(全篇约 \d+ KB)$/);
await h.render(<ReportExportDrawer editions={editions} onClose={() => {}} />);
assert.equal(exportButton(h).text, `导出整篇 · ${formatExportSize(exportByteSize(blocks))}`);
await h.event(radio(h, "自选章节"));
assert.equal(exportButton(h).text, `导出 ${body.length} 项 · ${formatExportSize(exportByteSize(body))}`);
} finally { await h.close(); }
});
@@ -115,10 +115,10 @@ test("on phones the reader bar is one row and the export sheet leaves room for t
const phone = css.slice(css.indexOf(" .personal-report-back-label { display: none; }") - 800, css.indexOf(" .personal-report-back-label { display: none; }") + 400);
assert.match(phone, /\.personal-report-action-end \{ flex-wrap: nowrap;/);
assert.match(read("../src/components/personal-report/report-actions.tsx"), /aria-label="返回报告中心"[\s\S]*<span className="personal-report-back-label">返回报告中心<\/span>/);
assert.match(css, /@media \(max-width: 767px\) \{\n #report-export-description \{ display: none; \}/);
assert.match(css, /\.report-export-presets \{ flex-wrap: nowrap; overflow-x: auto;/);
// The export sheet was redesigned the same night (two steps, no presets); its own tests are in report-export-drawer.test.tsx.
const drawer = read("../src/components/personal-report/report-export-drawer.tsx");
assert.match(drawer, /aria-label="关闭分块导出" title="关闭"><X aria-hidden="true" \/>/);
assert.match(drawer, /aria-label="关闭导出" title="关闭"><X aria-hidden="true" \/>/);
assert.doesNotMatch(drawer, /report-export-presets|常用|全部分盘|仅核对表/);
});
test("switching editions keeps the reader on the same chapter (BUG-1108)", () => {