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