feat(report): add dense personal report appendix
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user