-
+
{lead ? (
@@ -213,7 +218,7 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
) : null}
{outline.sections.filter((section) => !section.eager).map((section) => (
-
+
))}
diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx
index 4bef3ea1..7332bea2 100644
--- a/frontend/src/components/personal-report/personal-report-page.tsx
+++ b/frontend/src/components/personal-report/personal-report-page.tsx
@@ -30,6 +30,7 @@ import {
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
} from "@/lib/personal-report-longform-copy";
import { projectOrdinaryReportMarkdown } from "@/lib/report-public-projection";
+import { containsHan, type EnglishEditionStatus, type ReportLanguage } from "@/lib/report-language";
import {
describeReportProgress,
REPORT_PROGRESS_STALL_MS,
@@ -54,7 +55,18 @@ export type ReportLoadState =
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
- | { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
+ | {
+ phase: "markdown-ready";
+ markdown: string;
+ reportId: string;
+ createdAt: string;
+ factTables?: ReportFactTable[];
+ calculationCharts?: ReportDocumentV2["charts"];
+ /** `legacy`: generated before the English edition existed; no switch is shown. */
+ englishEdition: EnglishEditionStatus;
+ markdownEn?: string;
+ factTablesEn?: ReportFactTable[];
+ }
| { phase: "legacy-unavailable" };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
@@ -131,9 +143,11 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
if (markdown.length > 0) {
const facts = reportFactTablesSchema.safeParse(json.factTables);
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
+ const english = readEnglishEdition(json);
return {
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
+ ...english,
phase: "markdown-ready",
markdown,
reportId: typeof view.id === "string" ? view.id : "",
@@ -180,6 +194,27 @@ function isRecord(value: unknown): value is Record
{
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+/**
+ * The English edition is trusted only in the shape the route promises and only
+ * when it holds no Han character; anything else reads as unavailable, and the
+ * Chinese report renders as before.
+ */
+function readEnglishEdition(json: Record): Pick<
+ Extract, "englishEdition" | "markdownEn" | "factTablesEn"
+> {
+ const status = json.englishEdition;
+ if (status !== "ready") return { englishEdition: status === "unavailable" ? "unavailable" : "legacy" };
+ const markdown = typeof json.longformMarkdownEn === "string"
+ ? projectOrdinaryReportMarkdown(json.longformMarkdownEn, "en").trim() : "";
+ const facts = reportFactTablesSchema.safeParse(json.factTablesEn);
+ if (!markdown || containsHan(markdown)) return { englishEdition: "unavailable" };
+ return {
+ englishEdition: "ready",
+ markdownEn: markdown,
+ ...(facts.success && facts.data.length && !containsHan(JSON.stringify(facts.data)) ? { factTablesEn: facts.data } : {}),
+ };
+}
+
/**
* 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,
@@ -216,8 +251,17 @@ export function formatWaitedDuration(elapsedMs: number): string {
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
}
-export function PersonalReportPage({ reportId }: { reportId: string }) {
+export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { reportId: string; initialLanguage?: ReportLanguage }) {
const [state, setState] = useState({ phase: "loading" });
+ const [language, setLanguage] = useState(initialLanguage);
+ // The URL carries the edition so a refresh or a shared link opens the same one.
+ const switchLanguage = useCallback((next: ReportLanguage) => {
+ setLanguage(next);
+ const url = new URL(window.location.href);
+ if (next === "en") url.searchParams.set("lang", "en");
+ else url.searchParams.delete("lang");
+ window.history.replaceState(window.history.state, "", url);
+ }, []);
const [waitStartedAt, setWaitStartedAt] = useState(null);
const [waitedMs, setWaitedMs] = useState(0);
// When observable progress last changed. Only a real change to the phase,
@@ -469,6 +513,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
);
}
+ // A report without an English edition always reads in Chinese, whatever the URL says.
+ const shown: ReportLanguage = state.englishEdition === "ready" && state.markdownEn && language === "en" ? "en" : "zh";
+ const markdown = shown === "en" ? state.markdownEn ?? state.markdown : state.markdown;
+ const factTables = shown === "en" ? state.factTablesEn : state.factTables;
return (
<>
@@ -476,15 +524,18 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
-
-
- {state.calculationCharts &&
}
- {state.factTables &&
}
+
+
+ {state.calculationCharts &&
}
+ {factTables &&
}
>
diff --git a/frontend/src/components/personal-report/report-actions.tsx b/frontend/src/components/personal-report/report-actions.tsx
index 0e7275cd..a987f92d 100644
--- a/frontend/src/components/personal-report/report-actions.tsx
+++ b/frontend/src/components/personal-report/report-actions.tsx
@@ -12,6 +12,7 @@ import { buildReportExportBlocks } from "@/lib/report-export-blocks";
import type { ReportDocumentV2 } from "@/lib/personal-report-contract";
import type { ReportFactTable } from "@/lib/report-fact-table-schema";
import { ReportExportDrawer } from "./report-export-drawer";
+import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
import { Button } from "@/components/ui/button";
interface ReportActionsProps {
@@ -19,6 +20,10 @@ interface ReportActionsProps {
charts?: ReportDocumentV2["charts"];
factTables?: readonly ReportFactTable[];
reportDate?: string | null;
+ /** Edition currently shown; `markdown` and `factTables` are that edition. */
+ language?: ReportLanguage;
+ englishEdition?: EnglishEditionStatus;
+ onLanguageChange?: (language: ReportLanguage) => void;
}
export function ReportActions({
@@ -26,9 +31,16 @@ export function ReportActions({
charts,
factTables,
reportDate,
+ language = "zh",
+ englishEdition = "legacy",
+ onLanguageChange,
}: ReportActionsProps) {
const [exportOpen, setExportOpen] = useState(false);
- const blocks = useMemo(() => buildReportExportBlocks({ markdown, charts, factTables }), [markdown, charts, factTables]);
+ const blocks = useMemo(
+ () => buildReportExportBlocks({ markdown, charts, factTables, language }),
+ [markdown, charts, factTables, language],
+ );
+ const canSwitch = englishEdition === "ready" && onLanguageChange !== undefined;
return (