feat(report): add dense personal report appendix

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-23 10:42:17 +08:00
co-authored by Claude Code
parent a7f82dc2a6
commit bbd96d3b6b
44 changed files with 48833 additions and 50 deletions
@@ -19,6 +19,10 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
import { ReportActions } from "./report-actions";
import { ReportFactTables } from "./report-fact-tables";
import { ReportCalculationCharts } from "./report-calculation-charts";
import { reportDocumentV2Schema, type ReportDocumentV2 } from "@/lib/personal-report-contract";
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 {
@@ -50,7 +54,7 @@ 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 }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
| { phase: "legacy-unavailable" };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
@@ -125,7 +129,11 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const stored = typeof json.longformMarkdown === "string" ? json.longformMarkdown : "";
const markdown = projectOrdinaryReportMarkdown(stored).trim();
if (markdown.length > 0) {
const facts = reportFactTablesSchema.safeParse(json.factTables);
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
return {
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
phase: "markdown-ready",
markdown,
reportId: typeof view.id === "string" ? view.id : "",
@@ -475,6 +483,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
/>
<div className="personal-report-reader-body">
<PersonalReportMarkdownView markdown={state.markdown} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} />}
{state.factTables && <ReportFactTables tables={state.factTables} />}
</div>
</div>
</>
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
export function RawAppendixDownload({ reportId }: { reportId: string }) {
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function download() {
if (busy) return;
setBusy(true);
setNotice(null);
try {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/raw-appendix`, {
method: "POST", credentials: "same-origin", headers: { Accept: "application/json" },
});
const result = await response.json();
if (!response.ok || result.documentKind !== "raw_appendix" || result.format !== "markdown"
|| typeof result.markdown !== "string" || !result.markdown.trim()) throw new Error("unavailable");
downloadMarkdownReport("个人报告-原始附录", result.markdown);
} catch {
setNotice("原始附录暂时无法下载,请稍后重试。");
} finally {
setBusy(false);
}
}
return <>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void download()}>
<FileText aria-hidden="true" />{busy ? "正在准备…" : "下载原始附录"}
</Button>
{notice && <span role="status">{notice}</span>}
</>;
}
@@ -20,6 +20,7 @@ import {
PERSONAL_REPORT_PRINT_LABEL,
} from "@/lib/personal-report-longform-copy";
import { Button } from "@/components/ui/button";
import { RawAppendixDownload } from "./raw-appendix-download";
interface ReportActionsProps {
reportId: string;
@@ -83,6 +84,7 @@ export function ReportActions({
<ArrowLeft aria-hidden="true" />返回报告中心
</Link>
<div className="personal-report-action-end">
<RawAppendixDownload reportId={reportId} />
{notice && <span role="status"><TriangleAlert aria-hidden="true" />{notice}</span>}
<Button type="button" size="sm" variant="ghost" onClick={() => void handlePrint()} disabled={printBusy || exportBusy || !printSupported}>
<Printer aria-hidden="true" />{printBusy ? "正在准备…" : PERSONAL_REPORT_PRINT_LABEL}
@@ -0,0 +1,20 @@
"use client";
import { memo } from "react";
import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract";
import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg";
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts }: { charts: ReportDocumentV2["charts"] }) {
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1));
if (!actual.length) return null;
return <section className="personal-report-section" aria-label="分盘核对">
<h2>分盘核对</h2>
<p>原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。</p>
<div className="personal-report-chart-grid">
{actual.map(chart => <article className="personal-report-chart-card" key={chart.id}>
<h3>{chart.title}</h3>
<VedicChartSvg chart={chart as ChartV1} ariaLabel={`${chart.title},十二宫数据`} />
</article>)}
</div>
</section>;
});
@@ -0,0 +1,41 @@
"use client";
import { memo } from "react";
import type { ReportFactTable } from "@/lib/report-fact-table-schema";
const STATUS_LABELS = {
multi_system_consensus: "多系统共识",
single_system_inference: "单一系统推断",
parameter_sensitive: "参数敏感",
unclosed_divisional_chart: "分盘尚未核验完整",
user_history_verification_required: "需要过往经历核对",
blocked: "暂不可用",
} as const;
/** Server-owned facts, separate from the ordinary Markdown and its TOC state. */
export const ReportFactTables = memo(function ReportFactTables({ tables }: { tables: readonly ReportFactTable[] }) {
return (
<section className="personal-report-fact-tables" aria-label="计算数据核对表">
<h2>计算数据核对表</h2>
{tables.map((table) => (
<details key={table.id}>
<summary>{table.title} · {STATUS_LABELS[table.claimStatus]}</summary>
<p>{table.note}</p>
{table.rows.length > 0 && (
<div className="overflow-x-auto" role="region" aria-label={table.title} tabIndex={0}>
<table>
<caption className="sr-only">{table.title}</caption>
<thead><tr>{table.columns.map((column) => <th scope="col" key={column}>{column}</th>)}</tr></thead>
<tbody>{table.rows.map((row) => (
<tr key={row.sourcePath}>{row.cells.map((cell, index) => (
<td key={index}>{cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : String(cell)}</td>
))}</tr>
))}</tbody>
</table>
</div>
)}
</details>
))}
</section>
);
});