fix(reports): one opening screen, one-row phone bar, roomier export sheet, keep the chapter across editions (BUG-1106..1108)
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:
co-authored by
Claude Opus 5.5
parent
c3162cdc4e
commit
94fbf37674
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user