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
@@ -1,5 +1,15 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-16 — 报告列表改成一行一份,报告正文旁边常驻目录
|
||||
|
||||
「我的报告」原来是一格一格的卡片方阵,报告一多就得一格一格扫。现在是一行一份:每行左边一枚带色点的状态标签(已完成、生成中、未完成),后面是报告名、创建时间和主题,右边是操作。失败的那份,失败原因直接写在这一行里,不再挤成右边一小块。
|
||||
|
||||
报告的阅读页也并进了同一个外壳,侧栏不再消失。正文右侧常驻一条目录,滚到哪一节就高亮哪一节,点一下直接跳过去;窗口拉窄到 860 像素以内时目录收成一个可展开的「目录」。报告的纸面颜色一点没动——它是一份文档,本来就不跟应用的强调色走。
|
||||
|
||||
打印和导出 PDF 仍然只印报告本身:侧栏、顶栏和目录都不上纸。
|
||||
|
||||
Skill 版本不变(纯界面改动)。
|
||||
|
||||
## 2026-09-16 — 星盘、星历、我的报告三个页面不再把侧栏弄丢
|
||||
|
||||
此前从对话点进「星盘」「星历」或「我的报告」,左侧栏会整个消失,页面顶部只剩一个「返回对话」链接——想换一段对话,得先退回去再点。现在这三个页面和对话共用同一个外壳:侧栏常驻,最近的对话一直在左边,点哪条都能直接过去。
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# PROGRESS · R6 报告中心 + 报告阅读(2026-09-16)
|
||||
|
||||
任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` 的 **R6**(T6.1 / T6.2 + 顺带把 `/reports/[reportId]` 挂进 `SecondaryShell`)。
|
||||
|
||||
- 基线 commit:`origin/staging` = `50ce02c8`(R4 外壳已合入)
|
||||
- 分支:`codex/cend-report-20260916`
|
||||
- 执行方:Claude subagent(直接执行模式)
|
||||
- **未 push**,按任务要求只提交到本地分支
|
||||
- BUG 编号:**未占号**。本轮是产品改造,实现中没有发现新的真实缺陷;`docs/BUG_HISTORY.md` 当前最大号 BUG-738 不变
|
||||
|
||||
---
|
||||
|
||||
## 一、开工基线数字(均在本 worktree 实测)
|
||||
|
||||
| 项 | 基线值 |
|
||||
|---|---|
|
||||
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | **0 error**,118 warning |
|
||||
| `npm test` | tests **3350** / pass 3304 / **fail 31** / skipped 15 |
|
||||
| `npm run build` 渲染标记 | `/` **○ Static**、`/chart` ○、`/ephemeris` ○、`/reports` ƒ、`/reports/[reportId]` ƒ |
|
||||
| `/` 首屏 gzip(`.next/server/app/index.html`) | 4,109 B(复测 4,110 B) |
|
||||
| globals CSS chunk gzip | 36,334 B(raw 242,608 B) |
|
||||
|
||||
31 条失败全部是**无 Docker** 的既有失败(数据库套件 / 部署套件),与任务书「环境缺口」一致。基线清单存为 `base.txt`(`npm test` 的 `^not ok` 行去掉序号后排序)。
|
||||
|
||||
---
|
||||
|
||||
## 二、逐条验收对照
|
||||
|
||||
### T6.1 · 报告中心改行式列表(D12)
|
||||
|
||||
| 验收标准 | 结论 | 证据 |
|
||||
|---|---|---|
|
||||
| `.report-center-list` 从卡片网格改成单列行式列表 | **通过** | `.report-center-list` 改为单列 `display: grid` + `gap: 1px`,靠 `--color-border` 底色透出一条发丝线(不是两条相邻边框);`.report-center-card*` 全部改名为 `.report-center-row*`,`git grep report-center-card` 在 `src/` `tests/` 与 `globals.css` 均 0 命中 |
|
||||
| 每行:状态 chip(带色点)+ 标题 + 元信息 + 右侧操作 | **通过** | `.report-center-status::before` 加 5px `currentColor` 圆点;`.report-center-row` 是 `minmax(0, 1fr) auto` 两列;元信息在 `.report-center-row-meta` |
|
||||
| 四种状态都有对应行样式 | **三种通过,第四种是接口缺口**(见 §四) | `is-ready` / `is-generating` / `is-failed` 三个 chip 变体保留并各有行内表现;「生成中的分节进度」**没有服务端投影**:`GET /api/reports`(`frontend/src/app/api/reports/route.ts` 的 `REPORT_LIST_COLUMNS`)不返回节数,只在 `status === "failed"` 时才去查 `personal_report_sections`。按 `VOICE.md` 第 2 条「数字只来自服务端投影,不口算、不编」,前端不得自造「第 4 节,共 9 节」。原型图上那行是 mock 文案 |
|
||||
| `role="status"` 在 `generating` 行上保留 | **通过** | `role={report.status === "generating" ? "status" : undefined}` 原样保留在状态 chip 上 |
|
||||
| 失败行仍显示 `failureSummary ?? failureCode` | **通过** | 从右栏移到行内正文(meta 行下方),表达式一字未改:`report.failureSummary ?? report.failureCode ?? "生成失败"` |
|
||||
| 导出错误 `report-center-export-error` 的 `role="alert"` 保留 | **通过** | 原样 |
|
||||
| 空态与读取失败态不变 | **通过** | `report-center-empty` / `report-center-alert` / 未登录 `report-center-message` 三个分支的 JSX 与文案一字未动 |
|
||||
|
||||
补充设计决定(写进 `frontend/DESIGN.md`):
|
||||
|
||||
- 状态 chip 里原来的 `StatusIcon`(generating 用 `InlineSpinner`、ready 用 `Check`、failed 用 `TriangleAlert`)**删除**,改为 CSS 色点。理由有二:原型图就是一枚色点;列表是轮询不是演出,chip 里再转一个圈与「揭幕后不得出现 spinner」的口径不合。`InlineSpinner` 仍用于首次读取态与导出按钮内(两者都是真实在等)。
|
||||
- 失败行右侧**不加**「重新生成」按钮。顶栏已经有唯一的生成入口,再加一个就是第二个入口(用户偏好:多余入口宁可删除也不修)。
|
||||
- ready 行的按钮次序改为「导出报告(.md)」在前、「查看报告」在后,让主操作(outline)落在行尾靠右——这是行式列表里视线终点的位置。测试 `ready reports expose Markdown export only in the ready branch` 按分支切片取文本,不依赖次序,未受影响。
|
||||
|
||||
### T6.2 · 报告阅读页常驻目录(D11)+ 挂进 `SecondaryShell`
|
||||
|
||||
**先说一条与任务书事故实证不一致的事实:E10 写的「整篇直下,没有目录、没有章节定位」在 `50ce02c8` 上已经不成立。** 目录在 `cfcd369d`(长文 Markdown 那一轮)就已经存在:`personal-report-markdown-view.tsx` 的 `ReportToc` 从 `buildLongformOutline` 的 `##`/`###` 标题生成,用 `IntersectionObserver` 高亮当前节,`@media print` 下已隐藏。任务书是按更早的状态写的。所以本轮 T6.2 的实际工作是**把它做成原型图那条右侧常驻栏,并把阅读页并进外壳**,不是从零加目录。
|
||||
|
||||
| 验收标准 | 结论 | 证据 |
|
||||
|---|---|---|
|
||||
| 在 `.personal-report-reader` 右侧加一条常驻目录 | **通过(改为右侧 + 视觉定稿)** | `.personal-report-md-layout` 从 `minmax(11rem,15rem) minmax(0,1fr)`(目录在左)改为 `minmax(0,1fr) minmax(11rem,15rem)`;位置用 `grid-column: 1 / 2` 显式指定而不是靠 DOM 次序,这样窄屏抽屉仍然能排在源码最前面(否则抽屉会掉到全文末尾)。目录去掉外框,改成原型图的安静竖栏:当前项 2px `--color-action` 左条 + `--color-canvas-muted` 底 |
|
||||
| 从 Markdown 的 `##` 标题生成 | **通过(且保留 `###` 子项)** | `buildLongformOutline` 原有实现,未改;`###` 子项是既有能力,本轮不删弱 |
|
||||
| 滚动时高亮当前节 | **通过** | `ReportToc` 的 `IntersectionObserver`(`rootMargin: "-20% 0px -70% 0px"`)原样,`aria-current="location"` + `.is-current` |
|
||||
| 目录项点击滚动到对应节 | **通过** | `href="#{id}"` + `.personal-report-md-article h2, h3 { scroll-margin-top: 80px }`(既有) |
|
||||
| 窄屏(< 860px)隐藏目录 | **通过** | `@media (max-width: 860px)` 下 `.personal-report-toc-desktop { display: none }`,**常驻栏消失**。同一断点下 `.personal-report-toc-drawer`(折叠的「目录」`<details>`)仍然显示——那是既有的、默认收起的另一种形态,不是常驻栏;删掉它等于窄屏彻底失去章节定位,属于功能倒退,所以保留并给它补回栏消失后没人画的那个盒子(边框 + 圆角 + `--color-canvas`) |
|
||||
| `@page` 规则保留 | **通过** | `<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>` 一字未改,既有断言仍然精确匹配 |
|
||||
| `@media print` 下目录隐藏 | **通过** | `globals.css` 里 `.personal-report-toc { display: none !important; }` 原样(两处,均为既有) |
|
||||
| 纸面 token 一个不改 | **通过** | `--report-paper` / `--report-rule` / `--report-accent` 三处定义(:root、两处 dark、print 覆盖)全部未动;`--report-accent` 仍是 `#85432f`。目录栏用的是 `--color-action` / `--color-canvas-muted` / `--color-ink*`——它是应用 chrome,不是纸面,新增断言 `assert.doesNotMatch(toc + current, /--report-(?:paper\|rule\|accent)/)` 守住这条 |
|
||||
| 不触碰 `report-chart-grid-rehype.ts` | **通过** | `git diff --stat` 里没有这个文件;新增断言 `assert.match(viewSource, /rehypePlugins=\{\[reportChartGrid\]\}/)` 守住它仍然接线 |
|
||||
| `/reports/[reportId]` 挂进 `SecondaryShell` | **通过** | 九个 phase(loading/generating、timed-out、unauthorized、not-found、failed、invalid\|network-error、legacy-unavailable、兜底、markdown-ready)**全部**包进 `<SecondaryShell title="个人报告">`,根节点从 `<main>` 改成 `<div>`(外壳自己就是 `<main className="chat-app">`,不能再套一层 main)。新增断言:`doesNotMatch(/<main className="personal-report-(?:state\|reader)"/)`,且 `SecondaryShell` 出现次数 == phase 根节点次数 |
|
||||
|
||||
**滚动所有权。** `.personal-report-reader` 仍然是自己的滚动容器(`height: 100%; overflow-y: auto`),现在坐在 `.chat-panel` 的 `minmax(0, 1fr)` 行里,同时被 `.secondary-panel > :not(.chat-header)` 覆盖到——两条规则给的是同一件事,既有断言 `report centre and ready reader scroll inside the chat shell lock` 无需改动即通过。目录栏的 `top: 72px` 相对的是这个滚动容器(避开报告自己那条 54px 的 sticky 操作条),`max-height` 从 `calc(100vh - 96px)` 调到 `calc(100vh - 150px)`,因为外壳那 46px 顶栏在滚动容器之外。`max-height` 不在 `viewport-unit-fallback-contract` 的管辖范围(该测试只管 `height`),也没有引入 `dvh`,红线 8 不受影响。
|
||||
|
||||
**打印行为(挂外壳带来的真实风险,已处理)。** `printPersonalReport()` 走的是 `window.print()`,打的是整个文档。挂进外壳后,侧栏和 46px 顶栏会跟着上纸;更要命的是 `.chat-app` / `.chat-panel` 是 `height: 100%; overflow: hidden`,会把九节报告**裁成一页**。处理方式:阅读页多挂一条 `<style media="print">{REPORT_SHELL_PRINT_CSS}</style>`,把 `.chat-app` / `.chat-panel` / `[data-slot='sidebar-inset']` 解成 `display: block; height: auto; overflow: visible`,并隐藏 `[data-slot='sidebar']` / `sidebar-rail` / `sidebar-scrim` / `.chat-header`。
|
||||
|
||||
放在组件里而不是 `globals.css`,有三个理由:(1) 这条样式只在阅读页挂载期间存在,对话页自己的打印行为一点不受影响;(2) 它要选的是 `.chat-app` 这类**祖先**,在 `globals.css` 里没法用 `.personal-report-*` 前缀限定,只能靠 `:has()`,那是更脆的写法;(3) 任务约束要求本轮只动 `.report-center-*` / `.personal-report-*` 两段 CSS,不得越界到 R5/R7/R8 的地盘——放组件里就完全不越界。新增断言覆盖了这条样式的存在与内容。
|
||||
|
||||
**没有做、也不打算顺手做的:** `ReportActions`(返回报告中心 / 打印 / 导出)保持原样留在阅读页正文顶部的 sticky 条里,没有提到外壳顶栏。任务书 T6.2 没有要求,原型图顶栏里也确实有一个返回入口(列表 → 详情的返回是合理的上下文导航,和 R4 删掉的「返回对话」不是一回事)。提上去会连带动 `.personal-report-actions` / `.personal-report-back` 的既有 CSS 与 `touch-target` / `theme-contract` 两条断言,超出本轮范围。建议留作后续单。
|
||||
|
||||
---
|
||||
|
||||
## 三、改了哪些既有断言
|
||||
|
||||
**一条都没有改。** 基线 3350 条全部保持原样,31 条无 Docker 失败逐条一致。
|
||||
|
||||
新增 5 条测试(不是修改):
|
||||
|
||||
| 文件 | 测试名 | 守什么 |
|
||||
|---|---|---|
|
||||
| `frontend/tests/personal-report-entry.test.ts` | `report centre is a single-column row list with a dotted status chip` | `.report-center-card` 不得复活;列表是单列;行是两列;chip 有色点;三个状态变体都在 |
|
||||
| `frontend/tests/personal-report-entry.test.ts` | `every report state keeps the affordance its row carried before` | `role="status"` / 失败原因 / `role="alert"` / 空态 / 读取失败态;行元信息不得出现「张盘」「节,共」这类前端口算 |
|
||||
| `frontend/tests/personal-report-view.test.ts` | `the reader renders inside the app shell, in every phase` | 每个 phase 恰好包一层外壳;不得有裸 `<main>` |
|
||||
| `frontend/tests/personal-report-view.test.ts` | `printing a report inside the shell drops the chrome and its height lock` | `@page` 不变;外壳打印样式既隐藏又解锁;目录不上纸 |
|
||||
| `frontend/tests/personal-report-view.test.ts` | `the report TOC is a persistent right-hand rail that leaves the paper palette alone` | 目录在右(`grid-column: 2`)、sticky;`--report-*` 三个 token 不进 chrome;`--report-accent: #85432f` 仍在;`reportChartGrid` 仍接线(BUG-616/617) |
|
||||
|
||||
测试总数 3350 → **3355**。
|
||||
|
||||
---
|
||||
|
||||
## 四、收工数字
|
||||
|
||||
| 项 | 基线 | 收工 | 判定 |
|
||||
|---|---|---|---|
|
||||
| `tsc --noEmit` | 0 错 | **0 错** | 通过 |
|
||||
| `npm run lint` | 0 error / 118 warning | **0 error / 118 warning** | 通过(warning 未增,也未顺手修范围外的) |
|
||||
| `npm test` | 3350 / 3304 / 31 / 15 | **3355 / 3309 / 31 / 15** | 通过;`diff base.txt after.txt` 输出为空(逐条一致) |
|
||||
| `/` 渲染标记 | ○ Static | **○ Static** | 通过 |
|
||||
| `/chart` / `/ephemeris` | ○ / ○ | **○ / ○** | 通过(未触碰) |
|
||||
| `/reports` / `/reports/[reportId]` | ƒ / ƒ | **ƒ / ƒ** | 通过(`force-dynamic` 维持) |
|
||||
| `/` 首屏 gzip | 4,109 B | **4,085 B** | −0.58%,在 ±2% 内 |
|
||||
| globals CSS chunk gzip | 36,334 B | **36,447 B** | +113 B(+0.31%)。来源:行式列表与目录栏的新规则,减去删掉的卡片规则 |
|
||||
|
||||
> 量 CSS chunk 时踩过一个坑,记下来:`.next/static/chunks/` 里会留上一轮构建的 css 文件,对着通配符求和会把两轮的都算进去,得到两次「完全一样」的假数字。chunk 名是内容哈希(基线 `1vtco9m60b6ps.css` / 收工 `24xydqkp8fxdk.css`),要按名字对,不要按目录求和。
|
||||
|
||||
---
|
||||
|
||||
## 五、是否触发让步顺序
|
||||
|
||||
**没有触发。** 任务书让步顺序第 4 条是「若从 Markdown 生成标题锚点与现有 rehype 链冲突 → 退到只做『回到顶部』」。实际情况是锚点与 rehype 链**本来就共存**:`buildLongformOutline` 只按行扫 `^#{2,3}` 生成 id 并交给 `markdownComponents` 的 `h2`/`h3` 渲染,`reportChartGrid` 只动 `h4`(`D\d+` / `Moon Chart`)与其后的图盘块,两者作用的标题层级不重叠。`report-chart-grid-rehype.ts` 一行未动。
|
||||
|
||||
---
|
||||
|
||||
## 六、环境缺口(不得记作通过)
|
||||
|
||||
1. **20 张盘以上的报告加目录后仍不重叠、滚动不卡** —— **做不了,缺口**。本会话无登录态、无 Chrome、无真实报告数据,`npm test` 只能证明 `reportChartGrid` 仍然接线、grid CSS 未被改动,**证明不了真机渲染**。这是任务书红线 9(BUG-616/617 不得回退)的配套实测,必须由产品负责人在真机上走一遍。已写进 `docs/testing/` 清单(见下)。
|
||||
2. **打印 / 导出 PDF 在挂外壳后的真实输出** —— **做不了,缺口**。新加的 `REPORT_SHELL_PRINT_CSS` 是本轮改动里唯一无法自动验证的部分:测试只能断言这条样式存在且内容正确,浏览器实际怎么分页得真打一次。这是本轮**风险最高**的一项。
|
||||
3. **窄屏(< 860px)目录抽屉的展开、焦点与滚动** —— 做不了,缺口。
|
||||
4. **侧栏在阅读页移动端抽屉的手势与焦点** —— 与 R4 同一条缺口,未解除。
|
||||
|
||||
### 给产品负责人的真机清单(建议并入 `docs/testing/`)
|
||||
|
||||
1. 打开一份**九节、20 张盘以上**的完整报告。逐屏滚到底:每张北印盘各占一格,**没有两张叠在同一位置**(BUG-616);连续滚动不掉帧、不卡顿(BUG-617)。
|
||||
2. 同一份报告,右侧目录栏:滚动时高亮跟着走;点任一节直接跳到该节标题,标题不被顶部操作条盖住。
|
||||
3. 同一份报告点「打印」→ 另存为 PDF:**侧栏和顶部窄栏不出现在纸上**;报告**不是只印出一页**,九节完整分页;目录栏不上纸;纸面底色与正文颜色是浅色(打印锁定浅色调色板)。
|
||||
4. 把浏览器窗口拉窄到 860px 以内:右侧目录栏消失,正文上方出现一个收起的「目录」;展开可用。
|
||||
5. 报告中心:至少同时存在一份**生成中**和一份**已完成**的报告,确认行式列表里状态色点一眼可辨,生成中那行能被读屏软件播报。
|
||||
6. 制造一次失败报告(或用历史失败记录),确认失败原因写在行内、能完整读到。
|
||||
@@ -157,7 +157,7 @@
|
||||
| `TASK-engine-runtime-hygiene-20260901.md` | `PROGRESS-engine-runtime-hygiene-20260901.md` | Python 引擎运行时治理 | 已验收 | `124d3990`(staging 实机三条待部署窗口) |
|
||||
| `TASK-billing-pricing-20260830.md` | `PROGRESS-billing-pricing-20260830.md` | 计费闭环与功能级定价 | 已合入 | `aaaa5fb7`~`a499c344` |
|
||||
| `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `<details>` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — |
|
||||
| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=<uuid>`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4、R8 已实现,R5/R6/R7 进行中(subagent 并行)** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置 |
|
||||
| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md`、`PROGRESS-cend-report-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=<uuid>`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4、R6、R7、R8 已实现,R5 进行中** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置。R6:报告中心卡片网格改行式列表、阅读页并入外壳并把目录挪到右侧常驻。**任务书 E10 过期**——目录在 `cfcd369d` 就已存在,本轮是挪位置定稿而非从零加。挂外壳带出一个真实打印风险已处理:`.chat-app`/`.chat-panel` 是 `height:100%;overflow:hidden`,裸 `window.print()` 会把九节报告裁成一页,阅读页因此多挂一条只在挂载期生效的 print 样式解锁外壳。「生成中的分节进度」做不了——`REPORT_LIST_COLUMNS` 不返回节数,按 VOICE.md 不许前端编 |
|
||||
|
||||
### 待开的任务书(2026-09-03 审查决定)
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# 真人验收清单 · 报告中心行式列表 + 报告阅读常驻目录(R6)
|
||||
|
||||
- 日期:2026-09-16
|
||||
- 分支:`codex/cend-report-20260916`
|
||||
- 对应:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md`(R6)、`docs/tasks/PROGRESS-cend-report-20260916.md`
|
||||
|
||||
会话侧已经做完的(**不用再做**):`tsc` 0 错、`lint` 0 error、3355 条测试(31 条无 Docker 失败与基线逐条一致)、`next build` 四个路由渲染标记不变、`/` 首屏 gzip −0.58%。纸面三个 token 未动、`report-chart-grid-rehype.ts` 未动,均有断言守着。
|
||||
|
||||
下面全是会话做不了的:需要登录态、真实报告数据、真实浏览器和打印机/PDF 导出。
|
||||
|
||||
---
|
||||
|
||||
## A. 20 张盘以上的报告仍然不重叠、滚动不卡(最高优先,关联 BUG-616 / BUG-617)
|
||||
|
||||
阅读页这一轮并进了 app 外壳,滚动容器的所有权变了,盘面 grid 必须复验。
|
||||
|
||||
1. 打开一份**九节、20 张盘以上**的完整个人报告。
|
||||
2. 从头滚到底,不要跳。
|
||||
3. 通过标准:
|
||||
- 每张北印盘各占一格,**没有任何两张叠在同一位置**;
|
||||
- 连续滚动不掉帧、不卡顿,尤其是经过密集盘面那几节时;
|
||||
- 页面**不出现横向滚动条**(宽表格在自己的框里横滚是对的)。
|
||||
|
||||
## B. 打印 / 导出 PDF(本轮风险最高的一项)
|
||||
|
||||
阅读页现在坐在外壳里,而 `window.print()` 打的是整个文档。
|
||||
|
||||
1. 在同一份报告里点「打印」,另存为 PDF。
|
||||
2. 通过标准:
|
||||
- 纸上**没有**左侧栏、**没有**顶部那条 46px 窄栏、**没有**右侧目录;
|
||||
- 报告**不是只印出一页**——九节应当完整分页;
|
||||
- 纸面是浅色(打印锁定浅色调色板),正文不是白底白字;
|
||||
- 页边距是 A4 / 13mm 12mm 14mm 的样子;
|
||||
- 宽表格在纸上不被裁掉右边。
|
||||
3. 顺带在微信内置浏览器里打开同一页点打印,应当出现「请在系统浏览器中打开本页后打印」的提示,而不是印出一份坏的。
|
||||
|
||||
## C. 右侧常驻目录
|
||||
|
||||
1. 桌面宽度(> 860px)打开报告。
|
||||
2. 通过标准:
|
||||
- 目录在**正文右侧**,滚动时一直在;
|
||||
- 往下滚,高亮项跟着当前看到的章节走;
|
||||
- 点任一节,跳到该节标题,**标题没有被顶部那条操作条盖住**;
|
||||
- 目录自己内容超长时在目录框里滚,不把页面撑长。
|
||||
|
||||
## D. 窄屏
|
||||
|
||||
1. 把窗口拉窄到 860px 以内(或用手机)。
|
||||
2. 通过标准:右侧目录栏消失;正文上方出现一个**收起的**「目录」,点开可用、能跳转。
|
||||
3. 再窄到手机宽度:侧栏变抽屉,从顶栏的按钮能拉开、能关上,关上后焦点不丢。
|
||||
|
||||
## E. 报告中心行式列表
|
||||
|
||||
1. 账号里同时存在**生成中**与**已完成**至少各一份报告时打开「我的报告」。
|
||||
2. 通过标准:
|
||||
- 列表是**一列行**,不是卡片方阵;每行左边一枚带色点的状态 chip;
|
||||
- 「已完成」「生成中」「未完成」三种色点一眼分得开(也请在深色模式下看一遍);
|
||||
- 生成中那行能被读屏软件播报(macOS VoiceOver / Windows 讲述人均可);
|
||||
- 行右侧:已完成是「导出报告(.md)」+「查看报告」,生成中是「查看进度」,失败行右侧**没有**按钮;
|
||||
- 每行的第二行小字是「创建时间 · 深度 · 主题」,**不应出现**任何「N 节」「N 张盘」之类的数字(接口没有给,前端不许编)。
|
||||
3. 把窗口拉窄到 767px 以内:行内操作换到正文下方、左对齐,不出现横向滚动。
|
||||
|
||||
## F. 失败行
|
||||
|
||||
1. 找一份失败的报告(或等一次真实失败)。
|
||||
2. 通过标准:失败原因写在**行内**(不是挤在右边一小块),能完整读到;不因为太长把行撑破。
|
||||
|
||||
## G. 导出报告(.md)的错误提示
|
||||
|
||||
1. 断网后在「已完成」那行点「导出报告(.md)」。
|
||||
2. 通过标准:该行出现一行红色错误文字,读屏软件按 alert 播报;再联网重试能成功;期间不会重复触发第二次导出。
|
||||
+14
-8
@@ -401,24 +401,30 @@ three copies of 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 is supporting copy, the overview line, then the list of report cards.
|
||||
- **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 is supporting copy, the overview line, then the **row list** of reports.
|
||||
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.
|
||||
- **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block.
|
||||
- **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Cards stack at the global 767px cut.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and “导出报告(.md)” beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **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.
|
||||
- **Status chip:** a caption pill carrying a 5px `currentColor` dot before the label. The dot is the change: the three states used to differ only by fill, which is a weak signal at caption size. Ready reads success, generating reads action-soft, failed reads danger. There is no spinner in the chip — the list polls, it does not perform.
|
||||
- **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line.
|
||||
- **Surface:** `--color-canvas-soft` floor; rows use `--color-canvas`, a warm hairline, and `--radius-lg` on the list as a whole. No drop shadow.
|
||||
- **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 “查看报告” as the primary document action with “导出报告(.md)” quiet beside it. The export downloads the same longform Markdown the detail page renders. 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.
|
||||
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
|
||||
- **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating.
|
||||
- **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert.
|
||||
|
||||
### Personal report reader
|
||||
|
||||
- **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. 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, secondary print, primary “导出报告(.md)”) 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 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.
|
||||
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the nav rail and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
|
||||
- **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.
|
||||
- **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px.
|
||||
- **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:** back, print, and Markdown export are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
|
||||
|
||||
@@ -3444,19 +3444,28 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.report-center-section-heading button:hover { color: var(--color-action); }
|
||||
.report-center-list { display: grid; gap: var(--space-3); }
|
||||
.report-center-card {
|
||||
/* Row list, not a card grid. A card grid costs one scan per card; past five or
|
||||
six reports the status is what the reader is looking for, and a single column
|
||||
of rows puts every status chip on the same x. D12. */
|
||||
.report-center-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
/* The 1px gap shows this through: one hairline per row, no double borders. */
|
||||
background: var(--color-border);
|
||||
}
|
||||
.report-center-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.report-center-card-body { min-width: 0; display: grid; gap: var(--space-1); }
|
||||
.report-center-row-main { min-width: 0; display: grid; gap: var(--space-1); }
|
||||
.report-center-status {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
@@ -3469,33 +3478,53 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* The status dot. It carries the state at a glance; the three chips used to be
|
||||
told apart only by their fill, which is a weak signal at caption size. */
|
||||
.report-center-status::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.report-center-status.is-ready { background: var(--color-success-muted); color: var(--color-success); }
|
||||
.report-center-status.is-generating { background: var(--color-action-soft); color: var(--color-action); }
|
||||
.report-center-status.is-failed,
|
||||
.report-center-failure { background: var(--color-danger-muted); color: var(--color-danger); }
|
||||
/* The failure reason reads inside the row now, under the meta line, instead of
|
||||
being a right-hand block competing with the row's action. */
|
||||
.report-center-failure {
|
||||
max-width: 220px;
|
||||
padding: 8px 12px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: var(--space-1) 0 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.report-center-card h3 {
|
||||
.report-center-row h3 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.report-center-card p {
|
||||
.report-center-row-meta {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.report-center-row-note {
|
||||
max-width: 62ch;
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.report-center-card small { color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.report-center-card-actions {
|
||||
.report-center-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -3564,10 +3593,8 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.report-center-hero { grid-template-columns: 1fr; align-items: start; gap: var(--space-6); padding-block: var(--space-8) var(--space-6); }
|
||||
.report-center-card { grid-template-columns: 1fr; gap: var(--space-4); }
|
||||
.report-center-card-actions,
|
||||
.report-center-card > a,
|
||||
.report-center-card > button,
|
||||
.report-center-row { grid-template-columns: 1fr; gap: var(--space-3); }
|
||||
.report-center-row-actions { justify-content: flex-start; justify-self: start; }
|
||||
.report-center-failure { justify-self: start; }
|
||||
.report-center-export-error { text-align: left; }
|
||||
.report-center-section-heading { align-items: center; }
|
||||
@@ -3595,23 +3622,33 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.personal-report-reader-body { width: min(1120px, 100%); margin: 0 auto; padding: 0 var(--space-6) var(--space-16); }
|
||||
/* The rail sits to the right of the paper, and stays there: a report is nine
|
||||
sections and twenty-odd charts, so the reader needs to see where they are
|
||||
without scrolling back. Placement is explicit rather than DOM-ordered, so the
|
||||
narrow-screen drawer can keep coming first in the source. D11. */
|
||||
.personal-report-md-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(11rem, 15rem) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(11rem, 15rem);
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
.personal-report-md-article { grid-column: 1; grid-row: 1; }
|
||||
.personal-report-toc {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: sticky;
|
||||
/* The report's own action bar is sticky at the top of the same scroller and
|
||||
runs ~54px tall; 72px clears it. */
|
||||
top: 72px;
|
||||
max-height: calc(100vh - 96px);
|
||||
/* The shell's 46px header is outside the scroller, so the rail's cap has to
|
||||
allow for it on top of the action bar it is already clearing. */
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
padding: var(--space-2) 0 var(--space-3);
|
||||
}
|
||||
.personal-report-toc ol {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -3619,18 +3656,30 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-3); }
|
||||
.personal-report-toc a {
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
padding: 6px var(--space-2);
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.45;
|
||||
text-decoration: none;
|
||||
}
|
||||
.personal-report-toc a.is-current,
|
||||
/* The rail is app chrome, not paper, so it reads in the app palette. The three
|
||||
--report-* tokens stay off it by design (D11/D3). */
|
||||
.personal-report-toc a.is-current {
|
||||
border-left-color: var(--color-action);
|
||||
background: var(--color-canvas-muted);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.personal-report-toc a:hover { color: var(--color-ink); }
|
||||
.personal-report-toc-drawer { display: none; }
|
||||
.personal-report-toc-desktop p {
|
||||
margin: 0 0 var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.personal-report-md-article {
|
||||
min-width: 0;
|
||||
@@ -3688,7 +3737,19 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
body. 860 is the content width of that rail, not a device cut. */
|
||||
@media (max-width: 860px) {
|
||||
.personal-report-md-layout { grid-template-columns: 1fr; }
|
||||
.personal-report-toc { position: static; max-height: none; }
|
||||
.personal-report-md-article { grid-column: auto; grid-row: auto; }
|
||||
/* The persistent rail is gone below this width; what is left is the collapsed
|
||||
drawer, which needs the box the rail no longer draws. */
|
||||
.personal-report-toc {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
position: static;
|
||||
max-height: none;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.personal-report-toc-desktop { display: none; }
|
||||
.personal-report-toc-drawer { display: block; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { Clock3, FileText, RefreshCw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -87,10 +87,14 @@ function formatDate(value: string): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: ReportListItem["status"] }) {
|
||||
if (status === "generating") return <InlineSpinner size={16} />;
|
||||
if (status === "ready") return <Check aria-hidden="true" className="size-4" />;
|
||||
return <TriangleAlert aria-hidden="true" className="size-4" />;
|
||||
/**
|
||||
* One row's quiet second line. Only what the list endpoint actually returns:
|
||||
* `GET /api/reports` carries no section or chart counts, so the row says the
|
||||
* creation time, the depth and the requested themes and nothing more. Inventing
|
||||
* 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条).
|
||||
*/
|
||||
function rowMeta(report: ReportListItem): string {
|
||||
return `${formatDate(report.createdAt)} · ${report.depth} · ${report.themes.join(" / ") || "综合主题"}`;
|
||||
}
|
||||
|
||||
export function PersonalReportCenter() {
|
||||
@@ -219,21 +223,29 @@ export function PersonalReportCenter() {
|
||||
<div className="report-center-list">
|
||||
{state.reports.map((report) => {
|
||||
const copy = STATUS_COPY[report.status];
|
||||
const note = report.status === "ready"
|
||||
? report.cardSummary
|
||||
: report.status === "generating"
|
||||
? copy.description
|
||||
: null;
|
||||
return (
|
||||
<article className="report-center-card" key={report.id}>
|
||||
<div className="report-center-card-body">
|
||||
<div className={`report-center-status is-${report.status}`} role={report.status === "generating" ? "status" : undefined}>
|
||||
<StatusIcon status={report.status} />{copy.label}
|
||||
</div>
|
||||
<article className={`report-center-row is-${report.status}`} key={report.id}>
|
||||
<div className="report-center-row-main">
|
||||
<span
|
||||
className={`report-center-status is-${report.status}`}
|
||||
role={report.status === "generating" ? "status" : undefined}
|
||||
>
|
||||
{copy.label}
|
||||
</span>
|
||||
<h3>{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}</h3>
|
||||
<p>{report.status === "ready" && report.cardSummary ? report.cardSummary : copy.description}</p>
|
||||
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
|
||||
<p className="report-center-row-meta">{rowMeta(report)}</p>
|
||||
{note ? <p className="report-center-row-note">{note}</p> : null}
|
||||
{report.status === "failed" ? (
|
||||
<p className="report-center-failure">{report.failureSummary ?? report.failureCode ?? "生成失败"}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{report.status === "ready" ? (
|
||||
<div className="report-center-card-actions">
|
||||
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
|
||||
查看报告
|
||||
</Button>
|
||||
<div className="report-center-row-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -243,6 +255,9 @@ export function PersonalReportCenter() {
|
||||
{exportingReportId === report.id ? <InlineSpinner size={14} /> : null}
|
||||
{PERSONAL_REPORT_EXPORT_LABEL}
|
||||
</Button>
|
||||
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
|
||||
查看报告
|
||||
</Button>
|
||||
{exportError?.reportId === report.id ? (
|
||||
<span className="report-center-export-error" role="alert">
|
||||
{exportError.message}
|
||||
@@ -250,12 +265,12 @@ export function PersonalReportCenter() {
|
||||
) : null}
|
||||
</div>
|
||||
) : report.status === "generating" ? (
|
||||
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="ghost">
|
||||
查看进度
|
||||
</Button>
|
||||
) : (
|
||||
<span className="report-center-failure">{report.failureSummary ?? report.failureCode ?? "生成失败"}</span>
|
||||
)}
|
||||
<div className="report-center-row-actions">
|
||||
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
|
||||
查看进度
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Clock3, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
|
||||
import { SecondaryShell } from "@/components/secondary-shell";
|
||||
import { ReportActions } from "./report-actions";
|
||||
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
|
||||
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
|
||||
@@ -169,6 +170,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader renders inside the app shell now (nav rail + 46px header), the same
|
||||
* one /chart, /ephemeris and /reports carry. Printing takes the whole document,
|
||||
* so the shell has to be neutralised on paper — not only hidden but unlocked:
|
||||
* `.chat-app` and `.chat-panel` are `height: 100%; overflow: hidden`, which would
|
||||
* clip a nine-section report down to one page.
|
||||
*
|
||||
* It lives here rather than in globals.css on purpose. This style tag exists only
|
||||
* while the reader is mounted, so the chat page's own print behaviour is
|
||||
* untouched and no other surface has to know about it.
|
||||
*/
|
||||
const REPORT_SHELL_PRINT_CSS = ".chat-app, .chat-panel, [data-slot='sidebar-inset']"
|
||||
+ " { display: block !important; width: auto !important; height: auto !important;"
|
||||
+ " min-height: 0 !important; overflow: visible !important; }"
|
||||
+ " [data-slot='sidebar'], [data-slot='sidebar-rail'], [data-slot='sidebar-scrim'], .chat-header"
|
||||
+ " { display: none !important; }";
|
||||
|
||||
/** Wall-clock budget for one uninterrupted wait: 8 minutes, then we stop and say so. */
|
||||
export const POLL_BUDGET_MS = 8 * 60 * 1000;
|
||||
const POLL_TICK_MS = 1000;
|
||||
@@ -298,137 +316,156 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
: null;
|
||||
const writing = progress?.stage === "writing";
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
|
||||
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
|
||||
{generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}
|
||||
</p>
|
||||
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
|
||||
{generating && (
|
||||
<>
|
||||
<p>已等待 {formatWaitedDuration(waitedMs)}</p>
|
||||
<p>生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。</p>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="ghost">返回报告中心</Button>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
|
||||
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
|
||||
{generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}
|
||||
</p>
|
||||
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
|
||||
{generating && (
|
||||
<>
|
||||
<p>已等待 {formatWaitedDuration(waitedMs)}</p>
|
||||
<p>生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。</p>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="ghost">返回报告中心</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "timed-out") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
|
||||
<h1>生成时间超出预期</h1>
|
||||
<p>已等待 {formatWaitedDuration(waitedMs)},页面已暂停自动刷新。</p>
|
||||
<p>报告仍在后台生成,不会因为你离开而中断。你可以继续等待,也可以稍后回到报告中心查看结果。</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="button" variant="default" onClick={() => keepWaiting()}>
|
||||
继续等待
|
||||
</Button>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心
|
||||
</Button>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
|
||||
<h1>生成时间超出预期</h1>
|
||||
<p>已等待 {formatWaitedDuration(waitedMs)},页面已暂停自动刷新。</p>
|
||||
<p>报告仍在后台生成,不会因为你离开而中断。你可以继续等待,也可以稍后回到报告中心查看结果。</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="button" variant="default" onClick={() => keepWaiting()}>
|
||||
继续等待
|
||||
</Button>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "unauthorized") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<h1>请先登录</h1>
|
||||
<p>个人报告仅对登录用户开放。请登录后重试。</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false} variant="default">
|
||||
去登录
|
||||
</Button>
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<h1>请先登录</h1>
|
||||
<p>个人报告仅对登录用户开放。请登录后重试。</p>
|
||||
<Button render={<Link href="/login" />} nativeButton={false} variant="default">
|
||||
去登录
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "not-found") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<h1>报告不存在</h1>
|
||||
<p>该报告不存在、已删除,或不属于当前账号。</p>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心
|
||||
</Button>
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<h1>报告不存在</h1>
|
||||
<p>该报告不存在、已删除,或不属于当前账号。</p>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "failed") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>报告生成失败</h1>
|
||||
<p>{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}</p>
|
||||
{state.failureCode && (
|
||||
<p>错误码:{state.failureCode}</p>
|
||||
)}
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心重新生成
|
||||
</Button>
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>报告生成失败</h1>
|
||||
<p>{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}</p>
|
||||
{state.failureCode && (
|
||||
<p>错误码:{state.failureCode}</p>
|
||||
)}
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心重新生成
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "invalid" || state.phase === "network-error") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
|
||||
<h1>报告暂时无法显示</h1>
|
||||
<p>
|
||||
{state.phase === "invalid"
|
||||
? `报告数据未通过校验(${state.message}),已停止渲染。`
|
||||
: "网络连接失败,请检查网络后重试。"}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={() => void reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
|
||||
<h1>报告暂时无法显示</h1>
|
||||
<p>
|
||||
{state.phase === "invalid"
|
||||
? `报告数据未通过校验(${state.message}),已停止渲染。`
|
||||
: "网络连接失败,请检查网络后重试。"}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={() => void reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === "legacy-unavailable") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>旧版本报告</h1>
|
||||
<p>{PERSONAL_REPORT_LEGACY_PLACEHOLDER}</p>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心重新生成
|
||||
</Button>
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>旧版本报告</h1>
|
||||
<p>{PERSONAL_REPORT_LEGACY_PLACEHOLDER}</p>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
|
||||
返回报告中心重新生成
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase !== "markdown-ready") {
|
||||
return (
|
||||
<main className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
|
||||
<h1>报告暂时无法显示</h1>
|
||||
<p>报告状态无法识别。</p>
|
||||
<Button type="button" variant="outline" onClick={() => void reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</main>
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
|
||||
<h1>报告暂时无法显示</h1>
|
||||
<p>报告状态无法识别。</p>
|
||||
<Button type="button" variant="outline" onClick={() => void reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="personal-report-reader">
|
||||
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
|
||||
<ReportActions
|
||||
reportId={state.reportId || reportId}
|
||||
reportTitle="个人报告"
|
||||
markdown={state.markdown}
|
||||
reportDate={state.createdAt}
|
||||
/>
|
||||
<div className="personal-report-reader-body">
|
||||
<PersonalReportMarkdownView markdown={state.markdown} />
|
||||
<SecondaryShell title="个人报告">
|
||||
<div className="personal-report-reader">
|
||||
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
|
||||
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
|
||||
<ReportActions
|
||||
reportId={state.reportId || reportId}
|
||||
reportTitle="个人报告"
|
||||
markdown={state.markdown}
|
||||
reportDate={state.createdAt}
|
||||
/>
|
||||
<div className="personal-report-reader-body">
|
||||
<PersonalReportMarkdownView markdown={state.markdown} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SecondaryShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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