feat(report): add browser-rendered report and PDF print

This commit is contained in:
Jesse
2026-08-06 12:52:05 +08:00
parent 47e829e971
commit d89073d296
16 changed files with 2047 additions and 0 deletions
+52
View File
@@ -1787,3 +1787,55 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; }
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
.payment-qr-badge svg { display: block; width: 100%; height: 100%; }
/* ============================================================
personal-report: unique block — report reader + A4 print
Owned by the report UI worker. Only used by /reports/[reportId].
Screen layout uses Tailwind utilities; this block only adds
print-critical and report-specific rules.
============================================================ */
@page {
size: A4;
margin: 14mm 12mm;
}
/* Small cards / short tables / charts avoid page breaks; long themes
(personal-report-theme) intentionally paginate. */
.personal-report-avoid-break {
break-inside: avoid-page;
}
.personal-report-avoid-break-row {
break-inside: avoid;
}
@media print {
html,
body {
background: #fff !important;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Navigation, disclosure toggle and other screen-only chrome. */
.personal-report-screen-only {
display: none !important;
}
/* Collapsed appendix is printed in full (product decision). */
.personal-report-print-always {
display: block !important;
}
.personal-report-document {
max-width: none !important;
padding: 0 !important;
}
.personal-report-chart-svg {
max-width: 120mm;
}
}
+28
View File
@@ -16,6 +16,7 @@ import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { GeneratePersonalReportButton } from "@/components/personal-report/generate-personal-report-button";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
import {
@@ -35,6 +36,7 @@ import {
birthTimePersistenceValues,
declaredBirthInputChanged,
describeBirthTimeDraft,
isBirthTimeReadyForConsultation,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
normalizePersistedBirthDate,
@@ -1126,6 +1128,26 @@ export default function Home() {
const profileComplete = isProfileComplete(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
// Client-side display hint only: an in-memory workflow receipt on the latest
// assistant answer. After a reload receipts are gone, so the state falls back
// to "unknown" (the button copy says the server will verify) instead of
// fabricating an evidence boolean. The server owns real evidence validation.
const latestAssistantMessage = [...(activeSession?.messages ?? [])]
.reverse()
.find((message) => message.role === "assistant");
const reportEvidenceState: "ready" | "unknown" = latestAssistantMessage?.workflowReceipt
? "ready"
: "unknown";
// Mirror the server-side birth-time gate (accepted/confirmed + usable active
// time): reported/candidate users must not see the entry, since the API would
// reject them with 422. profileComplete alone is not enough.
const reportBirthTimeUsable = isBirthTimeReadyForConsultation(profile);
const reportEntryVisible = !rectificationSurfaceOpen
&& profileComplete
&& reportBirthTimeUsable
&& activeSession?.sessionType === "consultation"
&& activeSession.messages.length > 0;
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
const onboardingPending = profileComplete && !onboarding && !onboardingError;
@@ -2819,6 +2841,12 @@ export default function Home() {
: personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}</span>
</div>
<div className="chat-header-actions">
{reportEntryVisible && activeSession && (
<GeneratePersonalReportButton
sessionId={activeSession.id}
evidenceState={reportEvidenceState}
/>
)}
{account.isAdmin && account.adminUrl ? (
<Link className="admin-button" href={account.adminUrl} aria-label="后台管理" title="后台管理">
<ShieldCheck aria-hidden="true" />
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function ReportError({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
useEffect(() => {
// Error boundary must log; digest matches server-side logs without leaking details.
console.error("report page error", error.digest ?? error.message);
}, [error]);
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button type="button" variant="outline" onClick={() => unstable_retry()}>
</Button>
</main>
);
}
@@ -0,0 +1,12 @@
import { LoaderCircle } from "lucide-react";
export default function ReportLoading() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
<p className="text-ink" role="status">
</p>
</main>
);
}
@@ -0,0 +1,19 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function ReportNotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button render={<Link href="/" />} variant="outline">
</Button>
</main>
);
}
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "个人报告 · Jyotisha",
robots: { index: false, follow: false },
};
export default async function ReportPage({
params,
}: {
params: Promise<{ reportId: string }>;
}) {
const { reportId } = await params;
return <PersonalReportPage reportId={reportId} />;
}
@@ -0,0 +1,233 @@
/**
* Home/chat entry point for generating a personal report.
*
* - Strictly client-side: it only appears inside the authenticated chat
* surface (the page gates the whole app on the account), and it never sends
* birth data or chat answer text. The server validates profile, session
* ownership and workflow evidence; the client never fabricates an evidence
* boolean (evidenceState "unknown" copy says the server will verify).
* - POST /api/reports with crypto.randomUUID requestId, sessionId only when a
* real session exists, reportType personal_full, presentationMode default,
* default themes. 201/200 navigate to /reports/<id>; 409/422/429/403 and
* other stable errors become friendly hints. Repeat clicks are guarded.
* - No server PDF: this button only creates the report; printing happens on
* the report reader via the user's own browser print dialog.
*/
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing"] as const;
export interface PersonalReportCreateRequest {
requestId: string;
sessionId: string | null;
reportType: "personal_full";
presentationMode: "default";
themes: readonly string[];
}
/**
* uuid v4 via crypto.randomUUID (secure contexts). Returns null when the
* environment cannot provide a secure request identifier; the caller must
* abort and show a browser hint instead of sending an unsigned request.
* No weak-identifier fallback: an insecure uuid must never reach the API.
*/
export function createReportRequestId(): string | null {
const c = globalThis.crypto;
if (typeof c?.randomUUID === "function") {
return c.randomUUID();
}
return null;
}
/** Request shape for POST /api/reports. Never includes birth data or chat text. */
export function buildPersonalReportCreateRequest(
requestId: string,
sessionId: string | null,
): PersonalReportCreateRequest {
return {
requestId,
sessionId,
reportType: "personal_full",
presentationMode: "default",
themes: [...DEFAULT_REPORT_THEMES],
};
}
export type CreateReportOutcome =
| { kind: "navigate"; reportId: string }
| { kind: "hint"; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readReportId(json: unknown): string | null {
if (!isRecord(json) || !isRecord(json.report)) {
return null;
}
const report = json.report;
return typeof report.id === "string" && report.id.length > 0 ? report.id : null;
}
function readServerError(json: unknown): string | null {
return isRecord(json) && typeof json.error === "string" && json.error.length > 0
? json.error
: null;
}
function readServerCode(json: unknown): string | null {
return isRecord(json) && typeof json.code === "string" ? json.code : null;
}
/**
* Classify POST /api/reports responses (route: 201 created, 200 idempotent
* replay, 409 conflict/in-progress, 422 profile insufficient, 403 entitlement,
* 429 rate limit, 400 invalid, 5xx unavailable).
*/
export function classifyCreateResponse(status: number, json: unknown): CreateReportOutcome {
if (status === 201 || status === 200) {
if (isRecord(json) && isRecord(json.report) && json.report.status === "failed") {
return { kind: "hint", message: "上次生成未成功,请稍后重试。" };
}
const reportId = readReportId(json);
if (reportId) {
return { kind: "navigate", reportId };
}
return { kind: "hint", message: "报告创建响应无法识别,请稍后重试。" };
}
const serverError = readServerError(json);
if (status === 409) {
const code = readServerCode(json);
if (code === "report_request_conflict") {
return { kind: "hint", message: serverError ?? "请求与已有记录不一致,请重试。" };
}
return { kind: "hint", message: serverError ?? "已有报告正在生成中,请稍后在报告页查看。" };
}
if (status === 422) {
return {
kind: "hint",
message: serverError ?? "出生资料不完整,请先完善出生时间与地点后再生成。",
};
}
if (status === 403) {
const code = readServerCode(json);
if (code === "report_export_disabled") {
return { kind: "hint", message: serverError ?? "个人报告功能暂未开放。" };
}
return { kind: "hint", message: serverError ?? "会话校验未通过,无法生成报告。" };
}
if (status === 429) {
return { kind: "hint", message: serverError ?? "今日报告生成次数已达上限,请明天再试。" };
}
if (status === 401) {
return { kind: "hint", message: "登录状态已失效,请重新登录。" };
}
if (status === 400) {
return { kind: "hint", message: serverError ?? "报告请求格式不正确,请刷新后重试。" };
}
if (status >= 500) {
return { kind: "hint", message: serverError ?? "报告服务暂时不可用,请稍后重试。" };
}
return { kind: "hint", message: serverError ?? "报告创建失败,请稍后重试。" };
}
export interface GeneratePersonalReportButtonProps {
/** Real chat session id (uuid) or null when no session exists. */
sessionId: string | null;
/**
* "ready": the active conversation carries an in-memory workflow receipt.
* "unknown": the client cannot reliably tell (e.g. reloaded session), so the
* copy says the server will verify — never a fabricated boolean.
*/
evidenceState: "ready" | "unknown";
}
export function GeneratePersonalReportButton({
sessionId,
evidenceState,
}: GeneratePersonalReportButtonProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const inFlight = useRef(false);
async function handleGenerate() {
if (inFlight.current || submitting) {
return;
}
inFlight.current = true;
setSubmitting(true);
setNotice(null);
try {
const requestId = createReportRequestId();
if (requestId === null) {
setNotice("请使用支持安全请求标识的现代浏览器。");
return;
}
const body = buildPersonalReportCreateRequest(requestId, sessionId);
const response = await fetch("/api/reports", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
let json: unknown = null;
try {
json = await response.json();
} catch {
json = null;
}
const outcome = classifyCreateResponse(response.status, json);
if (outcome.kind === "navigate") {
router.push(`/reports/${encodeURIComponent(outcome.reportId)}`);
return;
}
setNotice(outcome.message);
} catch {
setNotice("网络异常,请检查连接后重试。");
} finally {
inFlight.current = false;
setSubmitting(false);
}
}
const title = evidenceState === "ready"
? "本次对话包含工作流证据记录,可尝试生成个人报告;最终以服务端校验为准。"
: "生成个人报告;服务端将校验本次咨询是否存在可用证据,无证据时会提示。";
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted disabled:pointer-events-none disabled:opacity-50"
onClick={() => void handleGenerate()}
disabled={submitting}
title={title}
aria-describedby="personal-report-entry-note"
>
<FileText aria-hidden="true" className="size-4" />
{submitting ? "正在生成…" : "生成个人报告"}
</button>
{notice !== null ? (
<span id="personal-report-entry-note" role="status" className="max-w-56 text-xs text-warning">
{notice}
</span>
) : evidenceState === "unknown" ? (
<span id="personal-report-entry-note" className="max-w-56 text-xs text-ink-tertiary">
</span>
) : (
<span id="personal-report-entry-note" className="sr-only">
</span>
)}
</div>
);
}
@@ -0,0 +1,496 @@
/**
* Renders a schema-validated ReportDocument v1 with a FIXED user-visible order:
*
* 1. 封面与资料状态
* 2. D1 命盘 SVG 与基础信息(仅真实数据存在时)
* 3. 核心摘要
* 4. 主题叙事 / 可执行建议 / 待核验问题
* 5. 证据附录(Technique Audit、冲突、blockedresearch 默认展开,default 默认折叠)
* 6. 声明
*
* Technique Audit lives inside the appendix and is never placed before the
* summary. All text comes from the validated document; no inner-HTML injection
* APIs, no remote resources, no arbitrary URLs (evidenceRefs only become
* in-page anchors when the referenced id exists inside this document's
* appendix).
*/
"use client";
import { useMemo, useState } from "react";
import { findChart, VedicChartSvg } from "./vedic-chart-svg";
import type {
ClaimStatus,
EvidenceAppendix,
ReportDocumentV1,
ThematicSectionV1,
} from "@/lib/personal-report-contract";
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
multi_system_consensus: "多系统一致",
single_system_inference: "单系统推断",
parameter_sensitive: "参数敏感",
unclosed_divisional_chart: "分盘未闭环",
user_history_verification_required: "需用户历史核验",
blocked: "阻塞",
};
const BIRTH_TIME_STATUS_LABELS: Record<string, string> = {
reported: "用户申报时间",
candidate: "候选时间(未确认)",
accepted: "已接受时间",
confirmed: "已确认时间",
};
const AUDIT_STATUS_LABELS: Record<string, string> = {
verified: "已核验",
partial: "部分",
blocked: "阻塞",
};
const CONFLICT_STATUS_LABELS: Record<string, string> = {
unresolved: "未解决",
partial: "部分解决",
resolved: "已解决",
};
function claimStatusLabel(status: string): string {
return CLAIM_STATUS_LABELS[status as ClaimStatus] ?? status;
}
function badgeClass(status: string): string {
switch (status) {
case "multi_system_consensus":
return "bg-success-muted text-success border border-success/30";
case "blocked":
return "bg-danger-muted text-danger border border-danger/30";
case "parameter_sensitive":
case "unclosed_divisional_chart":
case "user_history_verification_required":
return "bg-[#f6efdd] text-warning border border-warning/30";
default:
return "bg-canvas-muted text-ink-secondary border border-border";
}
}
function ClaimBadge({ status }: { status: string }) {
return (
<span className={`personal-report-avoid-break inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(status)}`}>
{claimStatusLabel(status)}
</span>
);
}
function splitParagraphs(text: string): string[] {
return String(text ?? "")
.split(/\n{2,}/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
function NarrativeText({ text }: { text: string }) {
const paragraphs = splitParagraphs(text);
if (paragraphs.length === 0) {
return null;
}
return (
<>
{paragraphs.map((paragraph, index) => (
<p key={index} className="mb-3 last:mb-0 leading-relaxed text-ink-strong">
{paragraph}
</p>
))}
</>
);
}
function ChartSection({ document }: { document: ReportDocumentV1 }) {
const d1 = findChart(document.charts, "D1");
const d9 = findChart(document.charts, "D9");
const d10 = findChart(document.charts, "D10");
const renderableCharts = [d1, d9, d10].filter(
(chart): chart is NonNullable<typeof chart> => chart !== undefined,
);
if (renderableCharts.length === 0) {
return null;
}
return (
<section aria-labelledby="report-charts" className="personal-report-section mb-10">
<h2 id="report-charts" className="mb-4 text-xl font-semibold text-ink">
</h2>
<div className="grid gap-6 lg:grid-cols-2">
{renderableCharts.map((chart) => (
<article
key={chart.id}
aria-label={chart.title}
className="personal-report-avoid-break rounded-xl border border-border bg-canvas-soft p-4"
>
<header className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-base font-semibold text-ink">{chart.title}</h3>
<ClaimBadge status={chart.claimStatus} />
</header>
<VedicChartSvg
chart={chart}
ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`}
/>
{chart.planets && chart.planets.length > 0 && (
<table className="personal-report-planet-table mt-4 w-full text-left text-sm">
<caption className="sr-only">{chart.title}</caption>
<thead>
<tr className="border-b border-border text-xs text-ink-secondary">
<th className="py-1 pr-2 font-medium"></th>
<th className="py-1 pr-2 font-medium"></th>
<th className="py-1 pr-2 font-medium"></th>
<th className="py-1 pr-2 font-medium"></th>
<th className="py-1 font-medium"></th>
</tr>
</thead>
<tbody>
{chart.planets.map((planet) => (
<tr key={planet.name} className="personal-report-avoid-break-row border-b border-border/60 last:border-b-0">
<td className="py-1 pr-2">{planet.name}</td>
<td className="py-1 pr-2">{planet.sign}</td>
<td className="py-1 pr-2 tabular-nums">{planet.longitudeDegrees.toFixed(1)}°</td>
<td className="py-1 pr-2">{planet.houseNumber}</td>
<td className="py-1">{planet.retrograde ? "是" : "否"}</td>
</tr>
))}
</tbody>
</table>
)}
</article>
))}
</div>
</section>
);
}
function ExecutiveSummarySection({ document }: { document: ReportDocumentV1 }) {
const summary = document.executiveSummary;
return (
<section aria-labelledby="report-summary" className="personal-report-section mb-10">
<h2 id="report-summary" className="mb-3 text-xl font-semibold text-ink">
</h2>
<div className="personal-report-avoid-break rounded-xl border border-border bg-canvas-soft p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="text-base font-semibold text-ink">{summary.headline}</h3>
<ClaimBadge status={summary.overallClaimStatus} />
</div>
<NarrativeText text={summary.summary} />
{summary.priorities.length > 0 && (
<>
<h4 className="mb-2 mt-4 text-sm font-semibold text-ink"></h4>
<ol className="list-decimal space-y-1 pl-5 text-sm text-ink-strong">
{summary.priorities.map((priority, index) => (
<li key={index}>{priority}</li>
))}
</ol>
</>
)}
</div>
</section>
);
}
function EvidenceChips({ section, knownEvidenceIds }: { section: ThematicSectionV1; knownEvidenceIds: Set<string> }) {
const refs = section.evidenceRefs ?? [];
if (refs.length === 0) {
return null;
}
return (
<ul className="mt-3 flex flex-wrap gap-2">
{refs.map((ref) =>
knownEvidenceIds.has(ref) ? (
<li key={ref}>
<a
href={`#evidence-${ref}`}
className="personal-report-avoid-break inline-block rounded-full border border-border bg-canvas-muted px-2.5 py-0.5 text-xs text-ink-secondary hover:border-ring hover:text-ink"
>
{ref}
</a>
</li>
) : (
<li key={ref} className="personal-report-avoid-break inline-block rounded-full border border-border bg-canvas-muted px-2.5 py-0.5 text-xs text-ink-tertiary">
{ref}
</li>
),
)}
</ul>
);
}
function ThematicNarrativeSection({ document }: { document: ReportDocumentV1 }) {
const knownEvidenceIds = useMemo(() => {
const ids = new Set<string>();
const appendix = document.evidenceAppendix;
for (const row of appendix.techniqueAudit ?? []) ids.add(row.id);
for (const row of appendix.conflicts ?? []) ids.add(row.id);
for (const row of appendix.calculationEvidence ?? []) ids.add(row.id);
return ids;
}, [document]);
if (document.thematicNarrative.length === 0) {
return null;
}
return (
<section aria-labelledby="report-themes" className="personal-report-section mb-10">
<h2 id="report-themes" className="mb-4 text-xl font-semibold text-ink">
</h2>
<div className="space-y-6">
{document.thematicNarrative.map((section) => (
<article
key={section.id}
aria-labelledby={`theme-${section.id}`}
className="personal-report-theme rounded-xl border border-border p-4"
>
<header className="mb-3 flex items-start justify-between gap-3">
<h3 id={`theme-${section.id}`} className="text-lg font-semibold text-ink">
{section.title}
</h3>
<ClaimBadge status={section.claimStatus} />
</header>
<NarrativeText text={section.narrative} />
{section.actions.length > 0 && (
<>
<h4 className="mb-2 mt-4 text-sm font-semibold text-ink"></h4>
<ul className="list-disc space-y-1 pl-5 text-sm text-ink-strong">
{section.actions.map((action, index) => (
<li key={index}>{action}</li>
))}
</ul>
</>
)}
{section.caveats.length > 0 && (
<>
<h4 className="mb-2 mt-4 text-sm font-semibold text-ink"></h4>
<ul className="list-disc space-y-1 pl-5 text-sm text-ink-secondary">
{section.caveats.map((caveat, index) => (
<li key={index}>{caveat}</li>
))}
</ul>
</>
)}
<EvidenceChips section={section} knownEvidenceIds={knownEvidenceIds} />
</article>
))}
</div>
</section>
);
}
function AuditTable({ appendix }: { appendix: EvidenceAppendix }) {
if ((appendix.techniqueAudit ?? []).length === 0) {
return null;
}
return (
<div className="overflow-x-auto">
<table className="personal-report-audit-table w-full min-w-[560px] text-left text-sm">
<caption className="sr-only">Technique Audit Table</caption>
<thead>
<tr className="border-b border-border text-xs text-ink-secondary">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium">使</th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{appendix.techniqueAudit.map((row) => (
<tr
key={row.id}
id={`evidence-${row.id}`}
className="personal-report-avoid-break-row border-b border-border/60 align-top last:border-b-0"
>
<td className="py-1.5 pr-3">
<span className="font-medium text-ink">{row.techniqueName}</span>
<span className="block text-xs text-ink-tertiary">{row.techniqueId}</span>
</td>
<td className="py-1.5 pr-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(row.status)}`}>
{AUDIT_STATUS_LABELS[row.status] ?? row.status}
</span>
</td>
<td className="py-1.5 pr-3">{row.used ? "已使用" : "未使用"}</td>
<td className="py-1.5 text-ink-secondary">{row.notes ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EvidenceAppendixSection({ document }: { document: ReportDocumentV1 }) {
const appendix = document.evidenceAppendix;
const expandedByDefault = appendix.expandedByDefault === true
|| document.presentationMode === "research";
const [expanded, setExpanded] = useState<boolean>(expandedByDefault);
const hasContent = (appendix.techniqueAudit ?? []).length > 0
|| (appendix.conflicts ?? []).length > 0
|| (appendix.calculationEvidence ?? []).length > 0
|| (appendix.blockedTechniques ?? []).length > 0;
return (
<section aria-labelledby="report-appendix" className="personal-report-section mb-10">
<h2 id="report-appendix" className="mb-3 text-xl font-semibold text-ink">
</h2>
<div className="rounded-xl border border-border bg-canvas-soft p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-ink-secondary">
Technique Audit blocked
</p>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls="report-appendix-body"
className="personal-report-screen-only rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted"
>
{expanded ? "收起证据附录" : "展开证据附录"}
</button>
</div>
{hasContent && (
<div
id="report-appendix-body"
className={expanded ? "personal-report-print-always block" : "personal-report-print-always hidden"}
>
<h3 className="mb-2 mt-2 text-base font-semibold text-ink">Technique Audit Table</h3>
<AuditTable appendix={appendix} />
{(appendix.conflicts ?? []).length > 0 && (
<>
<h3 className="mb-2 mt-6 text-base font-semibold text-ink"></h3>
<ul className="space-y-2">
{appendix.conflicts.map((conflict) => (
<li key={conflict.id} id={`evidence-${conflict.id}`} className="personal-report-avoid-break rounded-lg border border-border bg-canvas p-3">
<p className="text-sm font-medium text-ink">{conflict.description}</p>
<p className="mt-1 text-sm text-ink-secondary">{conflict.impact}</p>
<p className="mt-1 text-xs text-ink-tertiary">
{CONFLICT_STATUS_LABELS[conflict.status] ?? conflict.status}
</p>
</li>
))}
</ul>
</>
)}
{(appendix.calculationEvidence ?? []).length > 0 && (
<>
<h3 className="mb-2 mt-6 text-base font-semibold text-ink"></h3>
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] text-left text-sm">
<caption className="sr-only"></caption>
<thead>
<tr className="border-b border-border text-xs text-ink-secondary">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{appendix.calculationEvidence.map((row) => (
<tr
key={row.id}
id={`evidence-${row.id}`}
className="personal-report-avoid-break-row border-b border-border/60 align-top last:border-b-0"
>
<td className="py-1.5 pr-3 font-medium text-ink">{row.label}</td>
<td className="py-1.5 pr-3 text-ink-strong">{row.value}</td>
<td className="py-1.5 text-ink-secondary">{row.source}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{(appendix.blockedTechniques ?? []).length > 0 && (
<>
<h3 className="mb-2 mt-6 text-base font-semibold text-ink">Blocked </h3>
<ul className="flex flex-wrap gap-2">
{appendix.blockedTechniques.map((name, index) => (
<li key={`${name}-${index}`} className="personal-report-avoid-break inline-block rounded-full border border-danger/30 bg-danger-muted px-2.5 py-0.5 text-xs text-danger">
{name}
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
</section>
);
}
function DisclaimerSection({ document }: { document: ReportDocumentV1 }) {
return (
<section aria-labelledby="report-disclaimer" className="personal-report-section mb-6">
<h2 id="report-disclaimer" className="mb-3 text-xl font-semibold text-ink">
</h2>
<div className="personal-report-avoid-break rounded-xl border border-border bg-canvas-muted p-4 text-sm leading-relaxed text-ink-secondary">
{document.disclaimer}
</div>
</section>
);
}
export function PersonalReportDocumentView({ document }: { document: ReportDocumentV1 }) {
return (
<div className="personal-report-document mx-auto max-w-4xl">
<header className="personal-report-section mb-8">
<h1 className="text-2xl font-semibold text-ink"></h1>
<div className="personal-report-avoid-break mt-4 rounded-xl border border-border bg-canvas-soft p-4">
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
<div className="flex items-center gap-2">
<dt className="text-ink-tertiary"></dt>
<dd className="font-medium text-ink">{document.subject.displayName}</dd>
</div>
<div className="flex items-center gap-2">
<dt className="text-ink-tertiary"></dt>
<dd className="text-ink-strong">{document.subject.birthPlaceLabel}</dd>
</div>
<div className="flex items-center gap-2">
<dt className="text-ink-tertiary"></dt>
<dd>
<span className="inline-block rounded-full border border-border bg-canvas-muted px-2 py-0.5 text-xs font-medium text-ink-secondary">
{BIRTH_TIME_STATUS_LABELS[document.subject.birthTimeStatus] ?? document.subject.birthTimeStatus}
</span>
</dd>
</div>
<div className="flex items-center gap-2">
<dt className="text-ink-tertiary"></dt>
<dd>
<span className="inline-block rounded-full border border-border bg-canvas-muted px-2 py-0.5 text-xs font-medium text-ink-secondary">
{document.presentationMode === "research" ? "研究模式" : "标准模式"}
</span>
</dd>
</div>
<div className="flex items-center gap-2 sm:col-span-2">
<dt className="text-ink-tertiary"></dt>
<dd className="font-mono text-xs text-ink-secondary">{document.reportId}</dd>
</div>
<div className="flex items-center gap-2 sm:col-span-2">
<dt className="text-ink-tertiary"></dt>
<dd className="text-ink-secondary">{document.generatedAt}</dd>
</div>
</dl>
</div>
</header>
<ChartSection document={document} />
<ExecutiveSummarySection document={document} />
<ThematicNarrativeSection document={document} />
<EvidenceAppendixSection document={document} />
<DisclaimerSection document={document} />
</div>
);
}
@@ -0,0 +1,255 @@
/**
* Client loader for /reports/[reportId].
*
* Fetches GET /api/reports/:id (same-origin, cookies included) and maps the
* envelope to explicit UI states: loading / unauthorized / not-found /
* generating (with polling) / failed / invalid (schema guard rejected) /
* ready. The print button is only enabled in the ready state.
*
* The GET envelope is classified here by status discriminant only; the ready
* reportDocument payload itself is validated by the canonical
* safeParseReportDocument from @/lib/personal-report-contract.
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { LoaderCircle, TriangleAlert } from "lucide-react";
import { ReportActions } from "./report-actions";
import { PersonalReportDocumentView } from "./personal-report-document-view";
import { safeParseReportDocument } from "@/lib/personal-report-contract";
import type { ReportDocumentV1 } from "@/lib/personal-report-contract";
import { Button } from "@/components/ui/button";
export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
| { phase: "not-found" }
| { phase: "generating" }
| { phase: "failed"; failureCode: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "ready"; document: ReportDocumentV1 };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
export interface ReportEnvelopeView {
id: string;
requestId: string;
reportType: string;
presentationMode: string;
status: string;
failureCode: string | null;
createdAt: string;
completedAt: string | null;
}
/**
* Status discriminant for the GET /api/reports/:id envelope, aligned to the
* actual route response: `{ report: { status, failureCode, ... }, reportDocument? }`
* with 401/404/403/5xx error envelopes. Does NOT validate reportDocument here;
* ready payloads are passed to the canonical safeParseReportDocument from
* @/lib/personal-report-contract.
*/
export function classifyReportEnvelope(statusCode: number, json: unknown): ReportLoadState {
if (statusCode === 401) {
return { phase: "unauthorized" };
}
if (statusCode === 404) {
return { phase: "not-found" };
}
if (statusCode === 403) {
return { phase: "invalid", message: "无权访问该报告。" };
}
if (statusCode >= 500) {
const code = isRecord(json) && typeof json.code === "string" ? json.code : null;
return { phase: "failed", failureCode: code };
}
if (!isRecord(json) || !isRecord(json.report)) {
return { phase: "invalid", message: "报告接口返回了无法识别的数据。" };
}
const view = json.report as Partial<ReportEnvelopeView>;
switch (view.status) {
case "ready": {
if (!("reportDocument" in json)) {
return { phase: "invalid", message: "报告接口缺少报告正文。" };
}
const parsed = safeParseReportDocument(json.reportDocument);
if (!parsed.ok) {
return { phase: "invalid", message: parsed.errors.map((error) => `${error.path}: ${error.message}`).join("; ") };
}
return { phase: "ready", document: parsed.document };
}
case "generating":
return { phase: "generating" };
case "failed": {
const code = typeof view.failureCode === "string" && view.failureCode.length > 0
? view.failureCode
: null;
return { phase: "failed", failureCode: code };
}
default:
return { phase: "invalid", message: "报告状态无法识别。" };
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const POLL_INTERVAL_MS = 3000;
const MAX_POLLS = 40;
export function PersonalReportPage({ reportId }: { reportId: string }) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [polls, setPolls] = useState(0);
const cancelledRef = useRef(false);
const load = useCallback(() => {
fetch(`/api/reports/${encodeURIComponent(reportId)}`, {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" },
})
.then((response) =>
response.json().catch(() => null).then((json) => ({ response, json })),
)
.then(({ response, json }) => {
if (cancelledRef.current) {
return;
}
const next = classifyReportEnvelope(response.status, json);
if (next.phase === "generating") {
setPolls((count) => count + 1);
} else {
setPolls(0);
}
setState(next);
})
.catch(() => {
if (!cancelledRef.current) {
setState({ phase: "network-error" });
}
});
}, [reportId]);
const reload = useCallback(() => {
setState({ phase: "loading" });
void load();
}, [load]);
useEffect(() => {
cancelledRef.current = false;
void load();
return () => {
cancelledRef.current = true;
};
}, [load]);
useEffect(() => {
if (state.phase !== "generating" || polls >= MAX_POLLS) {
return;
}
const timer = setInterval(() => {
void load();
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [state.phase, polls, load]);
if (state.phase === "loading" || state.phase === "generating") {
const generating = state.phase === "generating";
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
<p className="text-ink" role="status">
{generating ? "报告正在生成中,请稍候…" : "正在加载报告…"}
</p>
{generating && (
<p className="max-w-md text-sm text-ink-secondary">
</p>
)}
</main>
);
}
if (state.phase === "unauthorized") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button render={<Link href="/login" />} variant="default">
</Button>
</main>
);
}
if (state.phase === "not-found") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button render={<Link href="/" />} variant="outline">
</Button>
</main>
);
}
if (state.phase === "failed") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1 className="text-xl font-semibold text-ink"></h1>
{state.failureCode && (
<p className="font-mono text-xs text-ink-tertiary">{state.failureCode}</p>
)}
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button type="button" variant="outline" onClick={() => void reload()}>
</Button>
</main>
);
}
if (state.phase === "invalid" || state.phase === "network-error") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
{state.phase === "invalid"
? `报告数据未通过校验(${state.message}),已停止渲染。`
: "网络连接失败,请检查网络后重试。"}
</p>
<Button type="button" variant="outline" onClick={() => void reload()}>
</Button>
</main>
);
}
const document = state.document;
return (
<main className="min-h-svh bg-canvas pb-12">
<ReportActions
reportId={document.reportId}
ready
reportTitle={`${document.subject.displayName} · 个人报告`}
/>
<div className="px-4 pt-6">
<PersonalReportDocumentView document={document} />
</div>
</main>
);
}
@@ -0,0 +1,91 @@
/**
* Header action bar for the personal report page.
*
* The print button stays disabled until the report document is ready; printing
* goes through client-report-export (window.print only, no server PDF).
* Environments that cannot print reliably (WeChat in-app browser) get a
* friendly hint instead of a broken printout.
*/
"use client";
import { useState } from "react";
import Link from "next/link";
import { Printer, ArrowLeft, TriangleAlert } from "lucide-react";
import {
detectPrintRestriction,
isPrintSupported,
printPersonalReport,
safeReportFilename,
} from "@/lib/client-report-export";
import { Button } from "@/components/ui/button";
interface ReportActionsProps {
reportId: string;
ready: boolean;
reportTitle?: string;
}
export function ReportActions({ reportId, ready, reportTitle }: ReportActionsProps) {
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const restriction = detectPrintRestriction();
const disabled = !ready || busy || !isPrintSupported();
async function handlePrint() {
if (disabled) {
return;
}
if (restriction.restricted) {
setNotice(restriction.message ?? null);
return;
}
setBusy(true);
setNotice(null);
try {
await printPersonalReport({
title: reportTitle?.trim() || safeReportFilename(reportId),
});
} finally {
setBusy(false);
}
}
return (
<nav
aria-label="报告操作"
className="personal-report-screen-only sticky top-0 z-10 border-b border-border bg-canvas/95 px-4 py-3 backdrop-blur"
>
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm text-ink-secondary transition-colors hover:text-ink"
>
<ArrowLeft aria-hidden="true" className="size-4" />
</Link>
<div className="flex flex-wrap items-center gap-3">
{notice !== null && (
<span className="inline-flex items-center gap-1.5 text-sm text-warning" role="status">
<TriangleAlert aria-hidden="true" className="size-4" />
{notice}
</span>
)}
<Button type="button" variant="default" size="sm" onClick={handlePrint} disabled={disabled}>
<Printer aria-hidden="true" />
{busy ? "正在准备打印…" : "打印 / 保存为 PDF"}
</Button>
</div>
</div>
{!ready && (
<p className="mx-auto mt-2 max-w-4xl text-xs text-ink-tertiary">
</p>
)}
</nav>
);
}
@@ -0,0 +1,160 @@
/**
* Pure React SVG North Indian (Rasi) chart for the personal report.
*
* - All text comes from the schema-validated report document (house sign,
* occupants, retrograde markers from the real planets array).
* - No canvas, no base64, no remote images/fonts, no inner-HTML injection APIs.
* - Uses a viewBox so print output stays sharp at any size.
* - Fixed house cells follow the standard North Indian layout:
*
* 12 | 11 | 10 | 9
* 1 | | | 8
* 2 | | | 7
* 3 | 4 | 5 | 6
*/
import type { ChartV1 } from "@/lib/personal-report-contract";
type HouseV1 = ChartV1["houses"][number];
export const CHART_VIEWBOX_WIDTH = 400;
export const CHART_VIEWBOX_HEIGHT = 400;
const CELL = 100;
const HOUSE_CELLS: Record<number, { x: number; y: number }> = {
1: { x: 0, y: CELL },
2: { x: 0, y: CELL * 2 },
3: { x: 0, y: CELL * 3 },
4: { x: CELL, y: CELL * 3 },
5: { x: CELL * 2, y: CELL * 3 },
6: { x: CELL * 3, y: CELL * 3 },
7: { x: CELL * 3, y: CELL * 2 },
8: { x: CELL * 3, y: CELL },
9: { x: CELL * 3, y: 0 },
10: { x: CELL * 2, y: 0 },
11: { x: CELL, y: 0 },
12: { x: 0, y: 0 },
};
const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
interface VedicChartSvgProps {
chart: ChartV1;
ariaLabel: string;
}
function buildRetrogradeSet(chart: ChartV1): Set<string> {
const retrograde = new Set<string>();
for (const planet of chart.planets ?? []) {
if (planet.retrograde) {
retrograde.add(planet.name);
}
}
return retrograde;
}
/** Combine house occupants with real retrograde markers from chart.planets. */
function occupantLines(house: HouseV1, chart: ChartV1): string[] {
const retrograde = buildRetrogradeSet(chart);
return house.occupants.map((name) => (retrograde.has(name) ? `${name}` : name));
}
function cellTextSize(occupantCount: number): number {
if (occupantCount <= 3) return 13;
if (occupantCount <= 6) return 11;
return 9.5;
}
function PlanetList({ lines, fontSize }: { lines: string[]; fontSize: number }) {
const y0 = 40;
const step = fontSize + 4;
return (
<>
{lines.map((line, index) => (
<text
key={`${line}-${index}`}
x={6}
y={y0 + index * step}
fontSize={fontSize}
fill="#1d1d1f"
>
{line}
</text>
))}
</>
);
}
export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
const linesByHouse = new Map<number, string[]>();
for (const house of chart.houses) {
linesByHouse.set(house.houseNumber, occupantLines(house, chart));
}
const maxOccupants = Math.max(0, ...chart.houses.map((house) => house.occupants.length));
const hasRetrogradeMarker = chart.houses.some((house) =>
house.occupants.some((name) => (chart.planets ?? []).some((p) => p.retrograde && p.name === name)),
);
const fontSize = cellTextSize(maxOccupants);
return (
<svg
viewBox={`0 0 ${CHART_VIEWBOX_WIDTH} ${CHART_VIEWBOX_HEIGHT}`}
width="100%"
height="auto"
role="img"
aria-label={ariaLabel}
className="personal-report-chart-svg"
>
{HOUSE_NUMBERS.map((houseNumber) => {
const cell = HOUSE_CELLS[houseNumber];
const house = chart.houses.find((h) => h.houseNumber === houseNumber);
const lines = house ? linesByHouse.get(houseNumber) ?? [] : [];
return (
<g key={houseNumber}>
<rect
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
fill="none"
stroke="#32322f"
strokeWidth={1.25}
/>
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} fill="#6a6963">
{houseNumber}
</text>
{house && (
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600} fill="#32322f">
{house.sign}
</text>
)}
<PlanetList lines={lines} fontSize={fontSize} />
</g>
);
})}
<rect
x={CELL}
y={CELL}
width={CELL * 2}
height={CELL * 2}
fill="#f3f2ee"
stroke="#32322f"
strokeWidth={1.25}
/>
{hasRetrogradeMarker && (
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} fill="#6a6963">
=
</text>
)}
</svg>
);
}
export function hasRealChartData(chart: ChartV1): boolean {
return Array.isArray(chart.houses) && chart.houses.length > 0
&& chart.houses.some((house) => Array.isArray(house.occupants) && house.occupants.length > 0);
}
export function findChart(charts: ChartV1[], id: string): ChartV1 | undefined {
return charts.find((chart) => chart.id === id && hasRealChartData(chart));
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Client-side print export for the personal report.
*
* Deliberately print-only: `await document.fonts.ready` then `window.print()`.
* It never requests a server-side PDF endpoint, never launches a headless
* browser, and never rasterizes pixels: the user's own browser and OS render
* the PDF.
*/
export interface PrintPersonalReportOptions {
/** Title used as the browser's default PDF filename (document.title). */
title?: string;
}
/** Sanitize a report id into a safe PDF filename fragment (no path/query chars). */
export function safeReportFilename(reportId: string): string {
const cleaned = String(reportId ?? "")
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return cleaned.length > 0 ? `jyotisha-report-${cleaned}` : "jyotisha-report";
}
export function isPrintSupported(): boolean {
return typeof window !== "undefined" && typeof window.print === "function";
}
/**
* Detect environments whose built-in browser cannot print reliably.
* WeChat's in-app browser is the known case; it should ask users to open the
* page in the system browser instead of producing a broken printout.
*/
export function detectPrintRestriction(userAgent?: string): {
restricted: boolean;
message?: string;
} {
const ua = typeof userAgent === "string" ? userAgent : typeof navigator !== "undefined" ? navigator.userAgent : "";
if (/MicroMessenger/i.test(ua)) {
return {
restricted: true,
message: "微信内置浏览器可能无法完整打印,请在系统浏览器中打开本页后打印。",
};
}
return { restricted: false };
}
/**
* Wait for web fonts, then open the browser print dialog.
* The page/PDF layout is computed on the user's device; nothing is uploaded.
*/
export async function printPersonalReport(options: PrintPersonalReportOptions = {}): Promise<void> {
if (typeof window === "undefined" || typeof window.print !== "function") {
return;
}
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
if (fonts && typeof fonts.ready?.then === "function") {
await fonts.ready;
}
const previousTitle = typeof document !== "undefined" ? document.title : "";
const requested = typeof options.title === "string" ? options.title.trim() : "";
if (typeof document !== "undefined" && requested.length > 0) {
// Browsers derive the default PDF filename from document.title.
document.title = requested;
}
try {
window.print();
} finally {
if (typeof document !== "undefined") {
document.title = previousTitle;
}
}
}