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
@@ -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>
</>
);
}