fix(reports): one opening screen, one-row phone bar, roomier export sheet, keep the chapter across editions (BUG-1106..1108)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m19s

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:38:17 +08:00
co-authored by Claude Opus 5.5
parent c3162cdc4e
commit 94fbf37674
15 changed files with 238 additions and 34 deletions
+9
View File
@@ -1,5 +1,14 @@
# 印度占星 Skill 更新日志
## 2026-09-30 — 手机上报告页更紧凑:一个加载画面、操作栏一行、导出框看得到分块;切换中英停在同一章(待验收)
- 打开报告时只出现一个加载画面(带顶栏、居中),不再先闪一个贴在顶部的转圈(BUG-1106)。
- 手机上报告页顶部操作栏压成一行:返回只留箭头,「中文 / English」和「导出」并排,高度约为原来一半(BUG-1107)。
- 手机上分块导出:去掉说明句,统计改一行「已选 7/31 块 · 约 97 KB(全篇约 195 KB)」,套餐按钮一行可左右滑,「关闭」改为 ×,弹窗更高,分块列表能直接看到(BUG-1107)。
- 切换中文 / English 时停在你正在读的那一章(BUG-1108)。
- 「我的报告」生成卡片离顶栏再远一些(40px)。
- Skill 版本不 bump。不改数据库结构。
## 2026-09-29 — 星盘图标改为气泡说明、星图外框贴合、「那一刻的天空」按钮立即出现、报告卡片文案、新建对话提速(待验收)
- 星盘页:鼠标停在行星、上升、星座编号等标记上,旁边弹出小气泡说明;手机上轻点标记弹出,点别处或 3 秒后自动消失。盘下那句「停在或轻点盘内标记可查看说明。」去掉(BUG-1101)。
+37
View File
@@ -14905,3 +14905,40 @@
- 相关记录:BUG-1040、BUG-1052
- 复发自:无(BUG-1040 的覆盖缺口)
- 修复版本:分支 `codex/new-chat-prewarm-20260929`
## BUG-1106 | 打开报告详情先闪一个卡在顶部、没有顶栏的转圈,再换成居中的另一个转圈
- 状态:resolved(代码 + 回归测试 + 本机 Chromium 手机视口截图;iPhone 真机欠)
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
- 影响面:`app/(app)/reports/[reportId]/loading.tsx`、`personal-report-page.tsx`。
- 根因:路由级 `loading.tsx` 自成一套(裸 `<main className="personal-report-state">`,无 `SecondaryHeader`,高度不在面板网格里所以贴顶),页面代码到位后换成阅读页自己的加载态,两套外观。
- 修复:新增 `ReportOpening`(顶栏 +居中转圈 +「正在加载报告…」),路由 fallback 与阅读页首个请求都只渲染它。
- 验证:`surface-feedback-20260929.test.tsx`;本机 390×844 截图只有一个加载态。
- 防复发:路由级 fallback 必须与页面自身的等待态是同一组件。
- 相关记录:BUG-1094
- 修复版本:分支 `codex/surface-feedback-20260929`
## BUG-1107 | 手机上报告操作栏约 120px 高;分块导出弹窗头部占掉大半,分块列表看不见
- 状态:resolved(代码 + 回归测试 + 本机 Chromium 手机视口实测;iPhone 真机欠)
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
- 影响面:`report-actions.tsx`、`report-export-drawer.tsx`、`globals.css`(767px 断点)。
- 用户现象(产品真机截图):「返回报告中心」折两行、「中文 / English」与「导出」上下叠放;导出弹窗说明两行、统计两行、套餐折行,下面的分块只露出一格。
- 根因:BUG-1094 / 英文版加入按钮时只按桌面宽度排版;导出统计把字数与大小并列成长句。
- 修复:手机上返回只留箭头(读屏名不变),语言切换与导出压成一行(实测 119px → 53px);导出弹窗手机上隐藏说明句、统计改一行「已选 n/N 块 · 约 X KB(全篇约 Y KB)」、套餐单行横滑、关闭改 × 图标(读屏名「关闭分块导出」),窄屏上限 70 → 85dvh(列表可见高度 255px → 503px)。
- 验证:`surface-feedback-20260929.test.tsx`、`report-export-drawer.test.tsx`(三条断言按三栏更新)。
- 相关记录:BUG-1094、TASK-report-english-edition-20260929
- 修复版本:分支 `codex/surface-feedback-20260929`
## BUG-1108 | 报告切到 English 后画面仍是中文(真机),且切换会丢失阅读位置
- 状态:investigating(真机现象本机未复现);阅读位置问题 resolved
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
- 影响面:`personal-report-page.tsx`、`personal-report-markdown-view.tsx`。
- 用户现象:iPhone Safari 上点 English 后按钮显示已选 English,屏幕上的章节仍是中文;一分钟后打开导出弹窗,分块已是英文章节名。
- 已确认事实:staging 下发的前端代码与本机相同(核对了线上 JS 包);本机 Chromium 手机视口用引擎真实中英 golden 复现同一路径(滚到第二、三章后轻点 English),正文立即变英文,无额外网络请求。WebKit 本机缺系统库无法运行。
- 推断(未证实):切换会整篇换稿,后面的章节先以占位渲染,阅读中段时页面高度骤减,Safari 可能短暂保留旧画面。
- 修复(阅读位置):切换前记下当前章节序号,新版本从第一章到该章立即渲染,再滚回同一章;本机实测中英两版该章停在同一位置。
- 防复发:真机复测后更新本条状态;若仍复现需真机录屏与 Safari 版本。
- 相关记录:TASK-report-english-edition-20260929
- 修复版本:分支 `codex/surface-feedback-20260929`
@@ -37,3 +37,19 @@
## 环境缺口
- 气泡的真实几何、手机轻点手感、「新建对话」真实耗时:无登录态与 Chrome、无实体手机,清单见 `docs/testing/surface-feedback-20260929.md`(第 11 步可在开发者工具看 `jyotisha:new-chat` 耗时标记)。
## 第二轮:产品 2026-09-30 00:17–00:21 真机反馈(iPhone Safari)
注:反馈时 staging 仍是 `0120765f`(本轮第一批 `a29fbe03` 00:24 才部署完),截图里的生成卡片是旧版。
| # | 反馈 | 处理 | BUG |
|---|---|---|---|
| 1 | 生成卡片上边距仍不够 | 24 → 40px | 1103 追加 |
| 2 | 进报告先有一个卡顶部的转圈,再换另一个 | 路由 `loading.tsx` 与阅读页共用 `ReportOpening` | 1106 |
| 3 | 报告详情顶部操作区太高 | 手机一行:箭头返回 + 语言切换 + 导出;本机 390×844 实测 119 → 53px | 1107 |
| 4 | 点 English 不管用 / 英文报告里还有中文 | 本机 Chromium 用引擎真实中英 golden 复现不出(正文即时变英文,无额外请求);线上 JS 包与本机相同;WebKit 本机跑不起来。先修切换丢阅读位置(新版本立即渲染到当前章并滚回),真机状态留 investigating | 1108 |
| 5 | 分块导出头部占满,看不见分块 | 手机隐藏说明、统计一行、套餐横滑、× 关闭、上限 85dvh;列表可见高度 255 → 503px | 1107 |
本机浏览器验证方式:生产构建 + `next start`,Playwright 驱动本机 Chromium(390×844、触摸、深色),`/api/**` 由脚本拦截返回虚构报告(引擎真实 golden 中英对),不连任何真实账号。
既有断言改动(本轮):`report-export-drawer.test.tsx` 三处——窄屏 70 → 85(d)vh、桌面不继承窄屏上限的断言值 70 → 85、关闭按钮按读屏名查找;统计行格式断言改为新格式。原值 / 新值 / 原因写在测试里。
+10
View File
@@ -15,3 +15,13 @@
| 9 | 在「星盘」页刷新浏览器,等 3 秒,再点侧栏「新建对话」 | 直接进入首页,不出现加载动画 | |
| 10 | 同上但换成「我的报告」页刷新后等 3 秒再点「新建对话」 | 同上 | |
| 11(可选) | 电脑打开开发者工具 Performance 面板录一次第 9 步 | 能看到 `jyotisha:new-chat (warm)` 标记及耗时 | |
## 第二轮(2026-09-30)
| # | 操作 | 应该看到 | 结果 |
|---|---|---|---|
| 12 | 手机打开「我的报告」 | 生成卡片离顶栏有明显间距 | |
| 13 | 手机点开一份已完成的报告 | 只出现一个加载画面(顶栏在、转圈居中),不先闪一个贴顶部的转圈 | |
| 14 | 报告打开后看顶部 | 一行:左边一个返回箭头,右边「中文 / English」和「导出」并排 | |
| 15 | 滚到「强度、关系与 Ashtakavarga 技术页」,点 English | 页面停在同一章,标题变成「Strength, Relationships and Ashtakavarga」,下面的表格、说明都是英文。**如果仍是中文,请录屏发我,并告诉我 iOS 版本** | |
| 16 | 点「导出」 | 顶部只有标题、×、一行「已选 … 块 · 约 … KB(全篇约 … KB)」、一行可左右滑的按钮,下面能看到多个分块 | |
+4 -1
View File
@@ -581,7 +581,7 @@ 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 70vh/70dvh remain unchanged. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
- **分块导出对话框 (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.
- **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`.
@@ -589,6 +589,9 @@ one skeleton, none of them carrying the sidebar.
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows a two-segment pill (`.report-language-switch`, 40px segments, selected = `--color-canvas-muted` + ink + 600) before 「导出」. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
- **导出框里的英文版提示:** reading Chinese with an English edition available, the drawer header adds one `--color-canvas-muted` band (`.report-export-ai-hint`) with the AI sentence and an outline 「切到 English」; it stacks below 767px. Old reports get one caption line (`.report-export-edition-note`) instead. The drawer is keyed by edition, so switching resets the selection to that edition's blocks; the file name gains `-EN`.
- **Opening a report (2026-09-30, BUG-1106):** one waiting screen, `ReportOpening` — the 46px header 「个人报告」 over a centred spinner and 「正在加载报告…」. The route's `loading.tsx` and the reader's first fetch both render it, so nothing swaps or jumps between them.
- **Phones (2026-09-30, BUG-1107):** the action bar is one row, about 53px: an arrow-only back link (screen readers still hear 「返回报告中心」), the 中文 / English pill at 36px segments, and 「导出」 at 40px. In the export sheet below 767px the description line is hidden, the count is one line 「已选 n/N 块 · 约 X KB(全篇约 Y KB)」, the presets scroll sideways in a single row, 关闭 is a × icon, and the sheet may take up to 85dvh so the block list gets most of it.
- **Switching editions keeps your place (BUG-1108):** the chapter at the top of the reader is remembered; the new edition renders every chapter up to it at once and scrolls back to it.
- **到底部 / 回到顶部:** one floating pill (`.report-edge-jump`, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper.
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
+1 -1
View File
@@ -145,7 +145,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
- 导出对话框标题仍为「分块导出」。说明:「勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。」这里全选包含完整正文及另选的盘 / 表;与旧整篇逐字节相同的套餐是「常用」(全部正文)。
- 套餐:「常用 / 全部分盘 / 仅核对表」;动作:「全选 / 取消全选」「导出选中 (N)」。成功才提示「已导出 N 块」,下载失败或微信限制不提示成功。本轮 D5 保持既有微信系统浏览器提示原文,不借删除打印入口顺带改它。
- 「共 N 块 · X 字 · 约 Z KB · 已选 n/N · Y 字 · 约 W KB」:字数来自实际可导出块及 `Array.from(text).length`,大小是实际下载内容的字节数(`TextEncoder`),写「约 186 KB」「约 1.4 MB」,不按字数估;包含安全 SVG / Markdown 标记,不是估计文章字数。此为第 2 条的可直接计算文本统计,不是口算领域数字;列表页仍不杜撰章数或盘数。
- 统计行(2026-09-30 起,BUG-1107):「已选 n/N 块 · 约 X KB(全篇约 Y KB)」一行说完;大小是实际下载内容的字节数(`TextEncoder`),写「约 186 KB」「约 1.4 MB」,不按字数估。每个分块仍在卡片上显示自己的字数(`Array.from(text).length`,含安全 SVG / Markdown 标记),不是估计文章字数;列表页仍不杜撰章数或盘数。
- 删除确认:「确定删除这份报告?」;按钮:「删除 / 取消」;请求中:「删除中…」;失败:「报告没能删除,请稍后重试。」失败保留原行。
- 日期中文全写;深度「简版 / 标准深度 / 深度 / 研究级」;主题复用现有中文映射,未知 id 原样回退,不造含义。
@@ -1,11 +1,6 @@
import { InlineSpinner } from "@/components/inline-spinner";
import { ReportOpening } from "@/components/personal-report/report-opening";
import "../../../site-styles";
export default function ReportLoading() {
return (
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">正在加载报告…</p>
</main>
);
return <ReportOpening />;
}
+31 -6
View File
@@ -3884,8 +3884,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
still reads calm; the button inside is the page's one filled action. */
.report-center-create {
width: min(900px, 100%);
/* Top margin: the card used to sit flush under the 46px header (2026-09-29). */
margin: var(--space-6) auto var(--space-8);
/* Top margin: the card used to sit flush under the 46px header; 40px after
the phone check on 2026-09-30 found 24px still read as cramped. */
margin: var(--space-10) auto var(--space-8);
display: flex;
flex-direction: column;
align-items: center;
@@ -3970,7 +3971,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
inset: auto 0 0 var(--report-export-sidebar);
width: min(760px, calc((100% - var(--report-export-sidebar)) * .92));
max-width: none;
max-height: 70vh;
max-height: 85vh;
margin: 0 auto;
padding: 0;
border: 1px solid var(--color-border);
@@ -3985,11 +3986,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
[data-viewport="desktop"][data-state="collapsed"] .report-export-drawer,
[data-viewport="tablet"][data-state="collapsed"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-icon); }
.report-export-drawer::backdrop { background: var(--color-scrim); }
.report-export-panel { display: flex; flex-direction: column; max-height: 70vh; }
.report-export-panel { display: flex; flex-direction: column; max-height: 85vh; }
@supports (height: 1dvh) {
.report-export-drawer, .report-export-panel { max-height: 70dvh; }
.report-export-drawer, .report-export-panel { max-height: 85dvh; }
}
/* After the narrow-screen dvh upgrade so it cannot cap desktop at 70dvh. */
/* After the narrow-screen dvh upgrade so it cannot cap desktop at 85dvh. */
@media (min-width: 860px) {
.report-export-drawer {
inset: 0;
@@ -4029,6 +4030,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.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; } }
/* ============================================================
@@ -4771,6 +4787,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (max-width: 767px) {
.personal-report-reader-body { padding: 0 var(--space-4) var(--space-12); }
.personal-report-actions > div { min-height: 50px; padding: var(--space-2) var(--space-4); }
/* One row on phones (2026-09-30: the bar had grown to ~120px — the back label
wrapped onto two lines and the switch and export stacked). Arrow-only back,
compact switch and export; every target stays at least 40px tall. */
.personal-report-actions > div { gap: var(--space-2); padding-block: var(--space-1); }
.personal-report-back { padding: 0 var(--space-2); min-width: 44px; justify-content: center; }
.personal-report-back-label { display: none; }
.personal-report-action-end { flex-wrap: nowrap; gap: var(--space-2); }
.report-language-switch button { min-height: 36px; min-width: 0; padding: 0 var(--space-3); }
.personal-report-action-end > [data-slot="button"] { height: 40px; padding: 0 var(--space-3); }
.personal-report-action-end > span { display: none; }
.personal-report-document { margin-top: var(--space-5); padding: var(--space-6) var(--space-4) var(--space-5); }
.personal-report-section { margin-bottom: var(--space-10); }
@@ -124,13 +124,16 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
section,
reveal,
language,
drawn = false,
}: {
section: LongformSection;
reveal: RevealSignal;
language: ReportLanguage;
/** Drawn from the first render: the reader is about to jump to it or past it. */
drawn?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
const [seen, setVisible] = useState(section.eager);
const [seen, setVisible] = useState(section.eager || drawn);
const subscribe = useMemo(() => subscribeReveal(reveal), [reveal]);
const forced = useSyncExternalStore(subscribe, () => reveal.revealed, () => false);
const visible = seen || forced;
@@ -194,7 +197,12 @@ function ReportToc({ headings, language }: { headings: readonly LongformHeading[
);
}
export function PersonalReportMarkdownView({ markdown, language = "zh" }: { markdown: string; language?: ReportLanguage }) {
export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThrough = null }: {
markdown: string;
language?: ReportLanguage;
/** Chapter index (h2 order) through which sections render immediately; used when switching editions. */
drawnThrough?: number | null;
}) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const reveal = useMemo<RevealSignal>(() => ({ revealed: false, listeners: new Set() }), []);
// Synchronous on purpose: the caller scrolls to scrollHeight right after, and
@@ -217,9 +225,16 @@ export function PersonalReportMarkdownView({ markdown, language = "zh" }: { mark
{lead}
</section>
) : null}
{outline.sections.filter((section) => !section.eager).map((section) => (
<LazyMarkdownSection key={section.id} language={language} reveal={reveal} section={section} />
))}
{/* Each section is one chapter heading, in the same order as the article's h2s. */}
{outline.sections.map((section, chapter) => (section.eager ? null : (
<LazyMarkdownSection
key={section.id}
drawn={drawnThrough !== null && chapter <= drawnThrough}
language={language}
reveal={reveal}
section={section}
/>
)))}
</article>
<ReportEdgeJump revealAll={revealAll} />
</div>
@@ -25,6 +25,7 @@ import { reportDocumentV2Schema, type ReportDocumentV2 } from "@/lib/personal-re
import { reportFactTablesSchema, type ReportFactTable } from "@/lib/report-fact-table-schema";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import { ReportOpening } from "./report-opening";
import {
PERSONAL_REPORT_GENERATING_COPY,
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
@@ -215,6 +216,19 @@ function readEnglishEdition(json: Record<string, unknown>): Pick<
};
}
/** Index of the chapter heading at or above the top of the reader, or null at the very top. */
function currentChapterIndex(): number | null {
const reader = document.querySelector<HTMLElement>(".personal-report-reader");
if (!reader || reader.scrollTop < 40) return null;
const top = reader.getBoundingClientRect().top + 80;
const headings = [...document.querySelectorAll<HTMLElement>(".personal-report-md-article h2")];
let index: number | null = null;
headings.forEach((heading, position) => {
if (heading.getBoundingClientRect().top <= top) index = position;
});
return index;
}
/**
* 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,
@@ -254,9 +268,23 @@ export function formatWaitedDuration(elapsedMs: number): string {
export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { reportId: string; initialLanguage?: ReportLanguage }) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage);
const [drawnThrough, setDrawnThrough] = useState<number | null>(null);
// The URL carries the edition so a refresh or a shared link opens the same one.
const switchLanguage = useCallback((next: ReportLanguage) => {
// Keep the reader on the same chapter: both editions have the same chapters
// in the same order, so the n-th chapter heading is the one to return to.
// Without this the swap resets the article (later chapters are drawn
// lazily) and a reader deep in the report is left somewhere else.
const chapter = currentChapterIndex();
setLanguage(next);
// Chapters up to this one are drawn at once in the new edition, so nothing
// above it grows after the jump and pushes it out of place.
setDrawnThrough(chapter);
if (chapter !== null) {
requestAnimationFrame(() => requestAnimationFrame(() => {
document.querySelectorAll<HTMLElement>(".personal-report-md-article h2")[chapter]?.scrollIntoView({ block: "start" });
}));
}
const url = new URL(window.location.href);
if (next === "en") url.searchParams.set("lang", "en");
else url.searchParams.delete("lang");
@@ -354,7 +382,9 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
onPoll: tick,
});
if (state.phase === "loading" || state.phase === "generating") {
if (state.phase === "loading") return <ReportOpening />;
if (state.phase === "generating") {
// The stall clock rides the existing one-second tick: waitStartedAt +
// waitedMs is "now" without a second timer or an effect.
const stalled = state.phase === "generating"
@@ -375,7 +405,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
<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) : "正在加载报告…"}
{progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY}
</p>
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
{generating && (
@@ -533,7 +563,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
onLanguageChange={switchLanguage}
/>
<div className="personal-report-reader-body" lang={shown === "en" ? "en" : "zh-CN"}>
<PersonalReportMarkdownView key={shown} language={shown} markdown={markdown} />
<PersonalReportMarkdownView key={shown} language={shown} markdown={markdown} drawnThrough={drawnThrough} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} language={shown} />}
{factTables && <ReportFactTables key={shown} language={shown} tables={factTables} />}
</div>
@@ -45,8 +45,9 @@ export function ReportActions({
return (
<nav aria-label="报告操作" className="personal-report-actions personal-report-screen-only">
<div>
<Link href="/reports" className="personal-report-back">
<ArrowLeft aria-hidden="true" />返回报告中心
{/* On phones only the arrow shows; the link keeps its full name for screen readers. */}
<Link href="/reports" className="personal-report-back" aria-label="返回报告中心">
<ArrowLeft aria-hidden="true" /><span className="personal-report-back-label">返回报告中心</span>
</Link>
<div className="personal-report-action-end">
{/* Only reports that carry an English edition show the switch (2026-09-29). */}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { toast } from "sonner";
import { detectPrintRestriction } from "@/lib/client-report-export";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
@@ -27,7 +28,6 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
const exporting = useRef(false);
const selected = blocks.filter(block => selection.has(block.id));
const allSelected = selected.length === blocks.length;
const chars = (items: readonly ReportExportBlock[]) => items.reduce((sum, block) => sum + block.chars, 0);
const totalSize = useMemo(() => formatExportSize(exportByteSize(blocks)), [blocks]);
const selectedSize = useMemo(() => formatExportSize(exportByteSize(blocks.filter(block => selection.has(block.id)))), [blocks, selection]);
@@ -73,7 +73,7 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
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" onClick={onClose} aria-label="关闭分块导出">关闭</Button></div>
<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">
@@ -86,7 +86,9 @@ export function ReportExportDrawer({ blocks, reportDate, onClose, language = "zh
) : englishEdition === "unavailable" ? (
<p className="report-export-edition-note">这份报告的英文版没有生成成功,只能导出中文版。</p>
) : null}
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · {totalSize} · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字 · {selected.length ? selectedSize : "0 KB"}</p>
{/* 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>
@@ -0,0 +1,24 @@
"use client";
import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
export const REPORT_OPENING_COPY = "正在加载报告…";
/**
* The one waiting screen for opening a report. The route's `loading.tsx` and the
* reader's own first fetch both render exactly this, so the hand-over between
* them is invisible (2026-09-30: the route fallback used to be a bare spinner
* stuck at the top without the header, then a second, centred one replaced it).
*/
export function ReportOpening() {
return (
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">{REPORT_OPENING_COPY}</p>
</div>
</>
);
}
+10 -5
View File
@@ -119,7 +119,8 @@ 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"));
await h.event(button(h, "关闭"));
// 原值:按可见文字「关闭」找按钮;新值:按读屏名「关闭分块导出」找(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(); }
@@ -152,13 +153,17 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed
assert.match(narrow, /width: min\(760px, calc\(\(100% - var\(--report-export-sidebar\)\) \* \.92\)\)/);
assert.match(narrow, /margin: 0 auto/);
assert.match(narrow, /border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0/);
assert.match(narrow, /max-height: 70vh/);
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 70dvh;/);
// 原值:窄屏抽屉 `max-height: 70vh` / `70dvh`
// 新值:`85vh` / `85dvh`
// 原因:2026-09-30 产品真机:手机上头部占掉大半,分块列表看不见;头部压缩后再给列表更多高度(桌面 80vh 不变)
assert.match(narrow, /max-height: 85vh/);
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\);/);
assert.match(desktop, /\.report-export-panel \{ max-height: calc\(80vh - 2px\); \}/);
assert.doesNotMatch(desktop, /max-height: 70(?:d)?vh|inset:.*--report-export-sidebar/);
// 原值 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;/);
});
@@ -185,6 +190,6 @@ test("the drawer shows the real download size, not a character count (BUG-1094)"
assert.ok(count.text.includes(formatExportSize(exportByteSize(body))), count.text);
await h.event(button(h, "全选"));
await h.event(button(h, "取消全选"));
assert.ok(h.elements().find(node => node.getAttribute("class") === "report-export-count")?.text.endsWith("0 KB"));
assert.match(h.elements().find(node => node.getAttribute("class") === "report-export-count")?.text ?? "", /^已选 0\/\d+ 块 · 0 KB(全篇约 \d+ KB)$/);
} finally { await h.close(); }
});
@@ -96,5 +96,37 @@ test("report centre card: spaced from the header, title says what, button says d
const centre = read("../src/components/personal-report/personal-report-center.tsx");
assert.match(centre, /<h2 id="report-center-create-title">完整本命报告<\/h2>/);
assert.doesNotMatch(centre, /<h2[^>]*>生成个人报告/);
assert.match(read("../src/app/globals.css"), /\.report-center-create \{\n width: min\(900px, 100%\);\n(?: \/\*[^\n]*\*\/\n)? margin: var\(--space-6\) auto var\(--space-8\);/);
assert.match(read("../src/app/globals.css"), /\.report-center-create \{\n width: min\(900px, 100%\);\n(?: \/\*[\s\S]*?\*\/\n)? margin: var\(--space-10\) auto var\(--space-8\);/);
});
// Phone check 2026-09-30 (BUG-1106..1107).
test("opening a report shows one waiting screen, the same from the route and from the reader (BUG-1106)", () => {
const route = read("../src/app/(app)/reports/[reportId]/loading.tsx");
assert.match(route, /return <ReportOpening \/>;/);
assert.doesNotMatch(route, /InlineSpinner|<main/);
const page = read("../src/components/personal-report/personal-report-page.tsx");
assert.match(page, /if \(state\.phase === "loading"\) return <ReportOpening \/>;/);
const opening = read("../src/components/personal-report/report-opening.tsx");
assert.match(opening, /<SecondaryHeader title="个人报告" \/>/);
});
test("on phones the reader bar is one row and the export sheet leaves room for the list (BUG-1107)", () => {
const css = read("../src/app/globals.css");
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;/);
const drawer = read("../src/components/personal-report/report-export-drawer.tsx");
assert.match(drawer, /aria-label="关闭分块导出" title="关闭"><X aria-hidden="true" \/>/);
});
test("switching editions keeps the reader on the same chapter (BUG-1108)", () => {
const page = read("../src/components/personal-report/personal-report-page.tsx");
assert.match(page, /const chapter = currentChapterIndex\(\);\n setLanguage\(next\);/);
assert.match(page, /setDrawnThrough\(chapter\);/);
assert.match(page, /drawnThrough=\{drawnThrough\}/);
const view = read("../src/components/personal-report/personal-report-markdown-view.tsx");
assert.match(view, /drawn=\{drawnThrough !== null && chapter <= drawnThrough\}/);
assert.match(view, /useState\(section\.eager \|\| drawn\)/);
});