feat(ui): 报告中心改行式列表,阅读页并入外壳并把目录挪到右侧常驻
报告中心原来是卡片方阵,状态只靠三块底色区分;报告一多,扫读成本
按卡片数线性涨。现在一列一行:带色点的状态 chip、标题、创建时间 ·
深度 · 主题,操作靠右。失败原因从右边一小块挪进行内,能完整读到。
chip 里原来的 StatusIcon(generating 转圈、ready 打勾、failed 警告)
换成 5px 色点——原型如此,且列表是轮询不是演出。失败行不加「重新
生成」,顶栏已经有唯一的生成入口。
行内小字只写接口真给的东西。GET /api/reports 不返回节数和盘数,所以
不写「9 节 · 22 张盘」(VOICE.md 第 2 条),原型图上那行是 mock。
/reports/[reportId] 是最后一个脱离外壳的全屏路由,九个 phase 全部并进
SecondaryShell,根节点从 <main> 改成 <div>(外壳自己就是 main)。
任务书 E10「没有目录」已过期:目录在 cfcd369d 就存在。本轮把它从左栏
挪到正文右侧并定稿视觉,位置用 grid-column 显式指定而不是靠 DOM 次序,
这样窄屏抽屉仍能排在源码最前面,不会掉到全文末尾。860px 以下常驻栏
消失、折叠抽屉保留——删掉抽屉等于窄屏彻底失去章节定位。
挂外壳带出一个真实风险:window.print() 打的是整篇,而 .chat-app /
.chat-panel 是 height:100%;overflow:hidden,会把九节报告裁成一页。阅读
页因此多挂一条 media="print" 样式,把外壳既隐藏又解锁。放组件里而不是
globals.css:它只在阅读页挂载期存在,对话页的打印不受影响,也不用
:has() 去够祖先,更不越界到并行轮次的 CSS 区段。
纸面三个 token 一个没动,--report-accent 仍是 #85432f;目录栏用应用
调色板,因为它是 chrome 不是纸。report-chart-grid-rehype.ts 一行未动,
新增断言守住它仍然接线(BUG-616/617)。
tsc 0 错;lint 0 error / 118 warning(未增);3350→3355 条,0 条既有
断言被改,31 条无 Docker 失败与基线逐条一致;四个路由渲染标记不变,
/ 首屏 gzip −0.58%。
「20 张盘以上不重叠、滚动不卡」与「挂外壳后的打印真实输出」无真机做
不了,已写成环境缺口,清单在 docs/testing/cend-report-20260916.md。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Opus 5
parent
c3bcddb652
commit
a1a78c7ebc
@@ -310,3 +310,44 @@ test("ready reports expose Markdown export only in the ready branch", () => {
|
||||
assert.match(readyBranch, /PERSONAL_REPORT_EXPORT_LABEL/);
|
||||
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /PERSONAL_REPORT_EXPORT_LABEL/);
|
||||
});
|
||||
|
||||
test("report centre is a single-column row list with a dotted status chip", () => {
|
||||
// D12. The card grid is gone; a row puts every status chip on the same x, so
|
||||
// a list of a dozen reports costs one scan instead of one per card.
|
||||
assert.doesNotMatch(globalStyles, /\.report-center-card/);
|
||||
assert.doesNotMatch(reportCenterSource, /report-center-card/);
|
||||
assert.match(reportCenterSource, /className=\{`report-center-row is-\$\{report\.status\}`\}/);
|
||||
|
||||
const list = cssDeclarations(".report-center-list", globalStyles);
|
||||
assert.match(list, /display: grid/);
|
||||
assert.doesNotMatch(list, /grid-template-columns/, "one column, always");
|
||||
const row = cssDeclarations(".report-center-row", globalStyles);
|
||||
assert.match(row, /grid-template-columns: minmax\(0, 1fr\) auto/);
|
||||
|
||||
// The chip's colour dot — the three states used to differ only by fill.
|
||||
const dot = cssDeclarations(".report-center-status::before", globalStyles);
|
||||
assert.match(dot, /border-radius: 50%/);
|
||||
assert.match(dot, /background: currentColor/);
|
||||
for (const status of ["ready", "generating", "failed"]) {
|
||||
assert.match(globalStyles, new RegExp(`\\.report-center-status\\.is-${status}`));
|
||||
}
|
||||
});
|
||||
|
||||
test("every report state keeps the affordance its row carried before", () => {
|
||||
// The generating row still announces itself to assistive tech, the failed row
|
||||
// still says why, and a failed export is still an alert.
|
||||
assert.match(
|
||||
reportCenterSource,
|
||||
/role=\{report\.status === "generating" \? "status" : undefined\}/,
|
||||
);
|
||||
assert.match(reportCenterSource, /report\.failureSummary \?\? report\.failureCode \?\? "生成失败"/);
|
||||
assert.match(reportCenterSource, /className="report-center-export-error" role="alert"/);
|
||||
// Empty and read-failure states are untouched by the row rewrite.
|
||||
assert.match(reportCenterSource, /还没有个人报告/);
|
||||
assert.match(reportCenterSource, /列表读取失败,请检查网络后重试。/);
|
||||
// Row meta is only what GET /api/reports returns: no invented section or
|
||||
// chart counts (VOICE.md 第 2 条).
|
||||
const meta = reportCenterSource.slice(reportCenterSource.indexOf("function rowMeta"));
|
||||
assert.match(meta, /formatDate\(report\.createdAt\)/);
|
||||
assert.doesNotMatch(meta, /张盘|节,共/);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { PersonalReportDocumentView } from "../src/components/personal-report/personal-report-document-view.tsx";
|
||||
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
import { safeParseReportDocument } from "../src/lib/personal-report-contract.ts";
|
||||
import type { LegacyReportDocumentV1, ReportDocument, ReportDocumentV2 } from "../src/lib/personal-report-contract.ts";
|
||||
|
||||
@@ -436,3 +437,72 @@ test("ready reports expose the browser print/PDF action with capability and hydr
|
||||
assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/);
|
||||
assert.match(actionsSource, /disabled=\{printBusy \|\| exportBusy \|\| !printSupported\}/);
|
||||
});
|
||||
|
||||
test("the reader renders inside the app shell, in every phase", () => {
|
||||
// /reports/[reportId] was the last standalone full-screen route: opening a
|
||||
// report dropped the nav out of the app. Every phase carries the shell now,
|
||||
// so the way back is the rail rather than one in-page link.
|
||||
const pageSource = readFileSync(
|
||||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(pageSource, /import \{ SecondaryShell \} from "@\/components\/secondary-shell";/);
|
||||
// No phase may return a bare <main>: that would nest inside the shell's own.
|
||||
assert.doesNotMatch(pageSource, /<main className="personal-report-(?:state|reader)"/);
|
||||
const shells = pageSource.match(/<SecondaryShell title="个人报告">/g) ?? [];
|
||||
const phases = pageSource.match(/className="personal-report-(?:state|reader)"/g) ?? [];
|
||||
assert.equal(shells.length, phases.length, "every rendered phase is wrapped exactly once");
|
||||
assert.ok(phases.length >= 8, `all report phases render, got ${phases.length}`);
|
||||
});
|
||||
|
||||
test("printing a report inside the shell drops the chrome and its height lock", () => {
|
||||
const pageSource = readFileSync(
|
||||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
// The @page rule is untouched.
|
||||
assert.match(pageSource, /<style media="print">\{"@page \{ size: A4; margin: 13mm 12mm 14mm; \}"\}<\/style>/);
|
||||
// The shell is hidden AND unlocked. Hiding alone is not enough: .chat-app and
|
||||
// .chat-panel are `height: 100%; overflow: hidden`, which would clip a
|
||||
// nine-section report to one page.
|
||||
assert.match(pageSource, /<style media="print">\{REPORT_SHELL_PRINT_CSS\}<\/style>/);
|
||||
const start = pageSource.indexOf("const REPORT_SHELL_PRINT_CSS");
|
||||
assert.ok(start >= 0, "the shell print stylesheet must be declared");
|
||||
const end = pageSource.indexOf("/** Wall-clock budget", start);
|
||||
assert.ok(end > start, "the declaration must end before the next export");
|
||||
const shellPrintCss = pageSource.slice(start, end);
|
||||
assert.match(shellPrintCss, /\.chat-app, \.chat-panel/);
|
||||
assert.match(shellPrintCss, /overflow: visible !important/);
|
||||
assert.match(shellPrintCss, /\[data-slot='sidebar'\]/);
|
||||
assert.match(shellPrintCss, /\.chat-header/);
|
||||
assert.match(shellPrintCss, /display: none !important/);
|
||||
// The TOC is screen furniture and stays off paper.
|
||||
const printBlock = globals.slice(globals.indexOf("personal-report: unique block"));
|
||||
assert.match(printBlock, /@media print/);
|
||||
assert.match(printBlock, /\.personal-report-toc \{ display: none !important; \}/);
|
||||
});
|
||||
|
||||
test("the report TOC is a persistent right-hand rail that leaves the paper palette alone", () => {
|
||||
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const viewSource = readFileSync(
|
||||
new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
// Built from the outline's own heading ids — no second slugger, and nothing
|
||||
// that would need report-chart-grid-rehype to change (BUG-616/617).
|
||||
assert.match(viewSource, /<ReportToc headings=\{outline\.headings\} \/>/);
|
||||
assert.match(viewSource, /aria-current=\{item\.id === activeId \? "location" : undefined\}/);
|
||||
assert.match(viewSource, /rehypePlugins=\{\[reportChartGrid\]\}/, "BUG-616/617 grid stays wired");
|
||||
|
||||
const toc = cssDeclarations(".personal-report-toc", globals);
|
||||
assert.match(toc, /grid-column: 2/, "the rail sits right of the paper");
|
||||
assert.match(toc, /position: sticky/);
|
||||
const layout = cssDeclarations(".personal-report-md-layout", globals);
|
||||
assert.match(layout, /grid-template-columns: minmax\(0, 1fr\) minmax\(11rem, 15rem\)/);
|
||||
// The rail is app chrome; the three paper tokens are not on it (D11/D3).
|
||||
const current = cssDeclarations(".personal-report-toc a.is-current", globals);
|
||||
assert.match(current, /border-left-color: var\(--color-action\)/);
|
||||
assert.doesNotMatch(toc + current, /--report-(?:paper|rule|accent)/);
|
||||
assert.match(globals, /--report-accent: #85432f;/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user