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
@@ -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)", () => {