diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index ea97b5ab..5118d926 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -2161,3 +2161,18 @@
- 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder、RPC 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。套餐与易支付配置契约必须显式拒绝 Supabase builder/RPC 并锁定参数化 SQL、404、原子函数写入和安全错误响应;支付配置必须默认折叠,后台必须拥有独立滚动容器且不得放宽聊天的全局 `overflow:hidden`。易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。
- 相关记录:BUG-122、BUG-123
- 修复版本:`d44a414`(权限迁移),staging 部署 `1f44892a2cf210797e7dc74f49721a8f10c8849d`
+
+## BUG-125 | 个人报告入口对不可用出生时间状态错误开放
+
+- 状态:resolved(local,pending staging deployment)
+- 首次发现:2026-08-06
+- 最近更新:2026-08-06
+- 影响面:首页个人报告 CTA、`POST /api/reports` 出生时间门槛
+- 用户现象:资料流程已经完成、但出生时间仍为 `reported` 或 `candidate` 的用户会看到“生成个人报告”,点击后服务端必然返回 `422 birth_time_not_usable`。
+- 触发条件:用户有咨询会话和消息,`profileComplete=true`,但当前排盘时间尚未被用户采用或引擎确认。
+- 根因:首页只用资料完整度判断入口可见性,没有镜像报告 API 的 `accepted/confirmed + 有效 active time` 门槛;UI 与服务端各自正确但组合后形成误导入口。
+- 修复:首页复用既有 `isBirthTimeReadyForConsultation(profile)`,只有 `accepted` 或 `confirmed` 且当前排盘时间有效时才显示个人报告入口;服务端门槛保持不变,不把候选范围或填报时间伪装成已采用时间。
+- 验证:`frontend/tests/personal-report-entry.test.ts` 15/15 通过,新增回归直接覆盖 `reported=false`、`candidate=false`、`accepted=true`、`confirmed=true` 及缺失 active time 为 false;目标 TypeScript、ESLint 和 `git diff --check` 通过。
+- 防复发:任何报告出生时间状态扩展必须同时更新服务端事实门槛和客户端可见性测试;客户端不得仅以资料表单完成度推导报告可生成。
+- 相关记录:BUG-117、BUG-119
+- 修复版本:本次个人报告 staging 发布提交
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 92ff7adc..6b369c65 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -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;
+ }
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index c1b4b62b..499a4453 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -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 ? "基于星盘证据回答" : "回答一般占星知识"}
+ {reportEntryVisible && activeSession && (
+
+ )}
{account.isAdmin && account.adminUrl ? (
diff --git a/frontend/src/app/reports/[reportId]/error.tsx b/frontend/src/app/reports/[reportId]/error.tsx
new file mode 100644
index 00000000..c5097ea5
--- /dev/null
+++ b/frontend/src/app/reports/[reportId]/error.tsx
@@ -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 (
+
+
+ 报告页面加载出错
+
+ 页面渲染时发生异常,未展示任何报告内容。
+
+
+
+ );
+}
diff --git a/frontend/src/app/reports/[reportId]/loading.tsx b/frontend/src/app/reports/[reportId]/loading.tsx
new file mode 100644
index 00000000..fcaa7295
--- /dev/null
+++ b/frontend/src/app/reports/[reportId]/loading.tsx
@@ -0,0 +1,12 @@
+import { LoaderCircle } from "lucide-react";
+
+export default function ReportLoading() {
+ return (
+
+
+
+ 正在加载报告…
+
+
+ );
+}
diff --git a/frontend/src/app/reports/[reportId]/not-found.tsx b/frontend/src/app/reports/[reportId]/not-found.tsx
new file mode 100644
index 00000000..b004872d
--- /dev/null
+++ b/frontend/src/app/reports/[reportId]/not-found.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import Link from "next/link";
+
+import { Button } from "@/components/ui/button";
+
+export default function ReportNotFound() {
+ return (
+
+ 报告不存在
+
+ 该报告不存在、已删除,或不属于当前账号。
+
+ } variant="outline">
+ 返回对话
+
+
+ );
+}
diff --git a/frontend/src/app/reports/[reportId]/page.tsx b/frontend/src/app/reports/[reportId]/page.tsx
new file mode 100644
index 00000000..c99c91b5
--- /dev/null
+++ b/frontend/src/app/reports/[reportId]/page.tsx
@@ -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
;
+}
diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx
new file mode 100644
index 00000000..b3dba1fe
--- /dev/null
+++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx
@@ -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/
; 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 {
+ 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(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 (
+
+
+ {notice !== null ? (
+
+ {notice}
+
+ ) : evidenceState === "unknown" ? (
+
+ 服务端将校验本次咨询证据。
+
+ ) : (
+
+ 本次对话包含工作流证据记录,最终以服务端校验为准。
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/personal-report/personal-report-document-view.tsx b/frontend/src/components/personal-report/personal-report-document-view.tsx
new file mode 100644
index 00000000..bf8e5e77
--- /dev/null
+++ b/frontend/src/components/personal-report/personal-report-document-view.tsx
@@ -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、冲突、blocked;research 默认展开,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 = {
+ multi_system_consensus: "多系统一致",
+ single_system_inference: "单系统推断",
+ parameter_sensitive: "参数敏感",
+ unclosed_divisional_chart: "分盘未闭环",
+ user_history_verification_required: "需用户历史核验",
+ blocked: "阻塞",
+};
+
+const BIRTH_TIME_STATUS_LABELS: Record = {
+ reported: "用户申报时间",
+ candidate: "候选时间(未确认)",
+ accepted: "已接受时间",
+ confirmed: "已确认时间",
+};
+
+const AUDIT_STATUS_LABELS: Record = {
+ verified: "已核验",
+ partial: "部分",
+ blocked: "阻塞",
+};
+
+const CONFLICT_STATUS_LABELS: Record = {
+ 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 (
+
+ {claimStatusLabel(status)}
+
+ );
+}
+
+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) => (
+
+ {paragraph}
+
+ ))}
+ >
+ );
+}
+
+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 => chart !== undefined,
+ );
+
+ if (renderableCharts.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ 命盘与基础信息
+
+
+ {renderableCharts.map((chart) => (
+
+
+
+ {chart.planets && chart.planets.length > 0 && (
+
+ {chart.title}行星信息
+
+
+ | 行星 |
+ 星座 |
+ 黄经 |
+ 宫位 |
+ 逆行 |
+
+
+
+ {chart.planets.map((planet) => (
+
+ | {planet.name} |
+ {planet.sign} |
+ {planet.longitudeDegrees.toFixed(1)}° |
+ 第{planet.houseNumber}宫 |
+ {planet.retrograde ? "是" : "否"} |
+
+ ))}
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function ExecutiveSummarySection({ document }: { document: ReportDocumentV1 }) {
+ const summary = document.executiveSummary;
+ return (
+
+
+ 核心摘要
+
+
+
+
{summary.headline}
+
+
+
+ {summary.priorities.length > 0 && (
+ <>
+
优先事项
+
+ {summary.priorities.map((priority, index) => (
+ - {priority}
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function EvidenceChips({ section, knownEvidenceIds }: { section: ThematicSectionV1; knownEvidenceIds: Set }) {
+ const refs = section.evidenceRefs ?? [];
+ if (refs.length === 0) {
+ return null;
+ }
+ return (
+
+ {refs.map((ref) =>
+ knownEvidenceIds.has(ref) ? (
+ -
+
+ 证据:{ref}
+
+
+ ) : (
+ -
+ 证据:{ref}
+
+ ),
+ )}
+
+ );
+}
+
+function ThematicNarrativeSection({ document }: { document: ReportDocumentV1 }) {
+ const knownEvidenceIds = useMemo(() => {
+ const ids = new Set();
+ 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 (
+
+
+ 主题解读、建议与待核验问题
+
+
+ {document.thematicNarrative.map((section) => (
+
+
+
+ {section.title}
+
+
+
+
+ {section.actions.length > 0 && (
+ <>
+ 可执行建议
+
+ {section.actions.map((action, index) => (
+ - {action}
+ ))}
+
+ >
+ )}
+ {section.caveats.length > 0 && (
+ <>
+ 待核验问题与边界
+
+ {section.caveats.map((caveat, index) => (
+ - {caveat}
+ ))}
+
+ >
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+function AuditTable({ appendix }: { appendix: EvidenceAppendix }) {
+ if ((appendix.techniqueAudit ?? []).length === 0) {
+ return null;
+ }
+ return (
+
+
+ Technique Audit Table
+
+
+ | 技法 |
+ 状态 |
+ 使用 |
+ 说明 |
+
+
+
+ {appendix.techniqueAudit.map((row) => (
+
+ |
+ {row.techniqueName}
+ {row.techniqueId}
+ |
+
+
+ {AUDIT_STATUS_LABELS[row.status] ?? row.status}
+
+ |
+ {row.used ? "已使用" : "未使用"} |
+ {row.notes ?? ""} |
+
+ ))}
+
+
+
+ );
+}
+
+function EvidenceAppendixSection({ document }: { document: ReportDocumentV1 }) {
+ const appendix = document.evidenceAppendix;
+ const expandedByDefault = appendix.expandedByDefault === true
+ || document.presentationMode === "research";
+ const [expanded, setExpanded] = useState(expandedByDefault);
+
+ const hasContent = (appendix.techniqueAudit ?? []).length > 0
+ || (appendix.conflicts ?? []).length > 0
+ || (appendix.calculationEvidence ?? []).length > 0
+ || (appendix.blockedTechniques ?? []).length > 0;
+
+ return (
+
+
+ 证据附录
+
+
+
+
+ Technique Audit、冲突、计算证据与 blocked 技法。研究模式默认展开;默认模式折叠。
+
+
+
+ {hasContent && (
+
+
Technique Audit Table
+
+ {(appendix.conflicts ?? []).length > 0 && (
+ <>
+
冲突与降级
+
+ >
+ )}
+ {(appendix.calculationEvidence ?? []).length > 0 && (
+ <>
+
计算证据
+
+
+ 计算证据
+
+
+ | 条目 |
+ 内容 |
+ 来源 |
+
+
+
+ {appendix.calculationEvidence.map((row) => (
+
+ | {row.label} |
+ {row.value} |
+ {row.source} |
+
+ ))}
+
+
+
+ >
+ )}
+ {(appendix.blockedTechniques ?? []).length > 0 && (
+ <>
+
Blocked 技法
+
+ {appendix.blockedTechniques.map((name, index) => (
+ -
+ {name}
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
+
+function DisclaimerSection({ document }: { document: ReportDocumentV1 }) {
+ return (
+
+
+ 声明
+
+
+ {document.disclaimer}
+
+
+ );
+}
+
+export function PersonalReportDocumentView({ document }: { document: ReportDocumentV1 }) {
+ return (
+
+
+ 个人报告
+
+
+
+
- 报告对象
+ - {document.subject.displayName}
+
+
+
- 出生地
+ - {document.subject.birthPlaceLabel}
+
+
+
- 生时状态
+ -
+
+ {BIRTH_TIME_STATUS_LABELS[document.subject.birthTimeStatus] ?? document.subject.birthTimeStatus}
+
+
+
+
+
- 呈现模式
+ -
+
+ {document.presentationMode === "research" ? "研究模式" : "标准模式"}
+
+
+
+
+
- 报告编号
+ - {document.reportId}
+
+
+
- 生成时间
+ - {document.generatedAt}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx
new file mode 100644
index 00000000..fcb64d2e
--- /dev/null
+++ b/frontend/src/components/personal-report/personal-report-page.tsx
@@ -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;
+ 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 {
+ 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({ 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 (
+
+
+
+ {generating ? "报告正在生成中,请稍候…" : "正在加载报告…"}
+
+ {generating && (
+
+ 生成完成后页面会自动显示。生成期间打印按钮保持禁用。
+
+ )}
+
+ );
+ }
+
+ if (state.phase === "unauthorized") {
+ return (
+
+ 请先登录
+
+ 个人报告仅对登录用户开放。请登录后重试。
+
+ } variant="default">
+ 去登录
+
+
+ );
+ }
+
+ if (state.phase === "not-found") {
+ return (
+
+ 报告不存在
+
+ 该报告不存在、已删除,或不属于当前账号。
+
+ } variant="outline">
+ 返回对话
+
+
+ );
+ }
+
+ if (state.phase === "failed") {
+ return (
+
+
+ 报告生成失败
+ {state.failureCode && (
+ 错误码:{state.failureCode}
+ )}
+
+ 生成过程中出现问题,未产出可用报告。请稍后重试。
+
+
+
+ );
+ }
+
+ if (state.phase === "invalid" || state.phase === "network-error") {
+ return (
+
+
+ 报告暂时无法显示
+
+ {state.phase === "invalid"
+ ? `报告数据未通过校验(${state.message}),已停止渲染。`
+ : "网络连接失败,请检查网络后重试。"}
+
+
+
+ );
+ }
+
+ const document = state.document;
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/personal-report/report-actions.tsx b/frontend/src/components/personal-report/report-actions.tsx
new file mode 100644
index 00000000..1ca7fd89
--- /dev/null
+++ b/frontend/src/components/personal-report/report-actions.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx
new file mode 100644
index 00000000..0aa2659a
--- /dev/null
+++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx
@@ -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 = {
+ 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 {
+ const retrograde = new Set();
+ 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) => (
+
+ {line}
+
+ ))}
+ >
+ );
+}
+
+export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
+ const linesByHouse = new Map();
+ 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 (
+
+ );
+}
+
+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));
+}
diff --git a/frontend/src/lib/client-report-export.ts b/frontend/src/lib/client-report-export.ts
new file mode 100644
index 00000000..5c0e52ed
--- /dev/null
+++ b/frontend/src/lib/client-report-export.ts
@@ -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 {
+ 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;
+ }
+ }
+}
diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts
new file mode 100644
index 00000000..b153fde8
--- /dev/null
+++ b/frontend/tests/personal-report-entry.test.ts
@@ -0,0 +1,243 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import {
+ buildPersonalReportCreateRequest,
+ classifyCreateResponse,
+ createReportRequestId,
+ DEFAULT_REPORT_THEMES,
+} from "../src/components/personal-report/generate-personal-report-button.tsx";
+import { isBirthTimeReadyForConsultation } from "../src/lib/birth-time-intake-model.ts";
+import {
+ consultationReportMarkdown,
+ downloadMarkdownReport,
+} from "../src/lib/consultation-report-export.ts";
+
+const componentSource = readFileSync(
+ new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url),
+ "utf8",
+);
+const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+
+test("request shape: POST /api/reports body carries only report identity fields", () => {
+ const body = buildPersonalReportCreateRequest("request-id-1", "session-id-1");
+ assert.deepEqual(Object.keys(body).sort(), ["presentationMode", "reportType", "requestId", "sessionId", "themes"]);
+ assert.equal(body.requestId, "request-id-1");
+ assert.equal(body.sessionId, "session-id-1");
+ assert.equal(body.reportType, "personal_full");
+ assert.equal(body.presentationMode, "default");
+ assert.deepEqual(body.themes, ["career", "marriage", "wealth", "timing"]);
+ assert.deepEqual(DEFAULT_REPORT_THEMES, ["career", "marriage", "wealth", "timing"]);
+ // No birth data, no chat text, no profile payload.
+ assert.doesNotMatch(JSON.stringify(body), /birth|birthDate|latitude|longitude|timezone|profile|message|text/i);
+});
+
+test("request shape: sessionId is null when no real session exists", () => {
+ const body = buildPersonalReportCreateRequest("request-id-2", null);
+ assert.equal(body.sessionId, null);
+});
+
+test("requestId is a uuid v4", () => {
+ assert.match(createReportRequestId() ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
+ const cryptoAny = globalThis.crypto as unknown as { randomUUID: () => string };
+ const original = cryptoAny.randomUUID;
+ cryptoAny.randomUUID = () => "fixed-uuid-value";
+ assert.equal(createReportRequestId(), "fixed-uuid-value");
+ cryptoAny.randomUUID = original;
+});
+
+test("without crypto.randomUUID the request id is null and no request is sent", () => {
+ // Simulate an environment without a secure request identifier.
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
+ Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true });
+ try {
+ assert.equal(createReportRequestId(), null);
+ } finally {
+ if (descriptor) {
+ Object.defineProperty(globalThis, "crypto", descriptor);
+ }
+ }
+ // The handler must abort before fetch and show a browser hint instead.
+ assert.match(componentSource, /if \(requestId === null\)/);
+ assert.match(componentSource, /请使用支持安全请求标识的现代浏览器/);
+ const abortCheck = componentSource.indexOf("if (requestId === null)");
+ const fetchCall = componentSource.indexOf('fetch("/api/reports"');
+ assert.ok(abortCheck >= 0 && fetchCall > abortCheck, "null requestId must abort before fetch");
+ // No Math.random fallback may ever mint a weak request id.
+ assert.doesNotMatch(componentSource, /Math\.random/);
+ assert.doesNotMatch(componentSource, /xxxxxxxx-xxxx-4xxx/);
+});
+
+test("201 created and 200 ready replay navigate to the report reader", () => {
+ const report = {
+ id: "report-id-1",
+ requestId: "req",
+ reportType: "personal_full",
+ presentationMode: "default",
+ status: "ready",
+ failureCode: null,
+ createdAt: "2026-08-06T00:00:00Z",
+ completedAt: null,
+ };
+ const created = classifyCreateResponse(201, { report });
+ assert.deepEqual(created, { kind: "navigate", reportId: "report-id-1" });
+ const replayed = classifyCreateResponse(200, { report, reportDocument: { schemaVersion: "report_document.v1" } });
+ assert.deepEqual(replayed, { kind: "navigate", reportId: "report-id-1" });
+});
+
+test("failed replay never navigates", () => {
+ const outcome = classifyCreateResponse(200, {
+ report: {
+ id: "report-id-2",
+ status: "failed",
+ failureCode: "model_unavailable",
+ },
+ });
+ assert.equal(outcome.kind, "hint");
+ assert.ok(outcome.kind === "hint" && /未成功|重试/.test(outcome.message));
+});
+
+test("409 generating/conflict, 422 profile, 403, 429, 400, 5xx all become friendly hints", () => {
+ const cases: Array<[number, unknown, RegExp]> = [
+ [409, { error: "已有报告正在生成中", code: "report_generation_in_progress" }, /生成中/],
+ [409, { error: "请求内容与已有记录不一致", code: "report_request_conflict" }, /不一致/],
+ [409, {}, /生成中|不一致/],
+ [422, { error: "出生时间尚未达到可用状态", code: "birth_time_not_usable" }, /出生|资料/],
+ [422, {}, /出生资料/],
+ [403, { error: "个人报告功能暂未开放", code: "report_export_disabled" }, /暂未开放/],
+ [403, {}, /会话校验|无法生成/],
+ [429, { error: "今日报告生成次数已达上限", code: "report_rate_limited" }, /上限/],
+ [400, {}, /格式/],
+ [502, { error: "报告模型暂不可用", code: "model_unavailable" }, /不可用/],
+ [503, {}, /稍后重试/],
+ [401, {}, /登录/],
+ ];
+ for (const [status, json, pattern] of cases) {
+ const outcome = classifyCreateResponse(status, json);
+ assert.equal(outcome.kind, "hint", `status ${status}`);
+ assert.ok(outcome.kind === "hint" && pattern.test(outcome.message), `status ${status}: ${outcome.message}`);
+ }
+});
+
+test("409/403 branches key on the server's real stable codes", () => {
+ // Code-only bodies (no server error text) prove the branch is code-driven.
+ const conflict = classifyCreateResponse(409, { code: "report_request_conflict" });
+ assert.ok(conflict.kind === "hint" && /不一致/.test(conflict.message));
+ const inProgress = classifyCreateResponse(409, { code: "report_generation_in_progress" });
+ assert.ok(inProgress.kind === "hint" && /生成中/.test(inProgress.message));
+ const disabled = classifyCreateResponse(403, { code: "report_export_disabled" });
+ assert.ok(disabled.kind === "hint" && /暂未开放/.test(disabled.message));
+ const notOwned = classifyCreateResponse(403, { code: "report_resource_forbidden" });
+ assert.ok(notOwned.kind === "hint" && /会话校验|无法生成/.test(notOwned.message));
+
+ // The comparisons use the stable codes from REPORT_STABLE_CODES, not short aliases.
+ assert.match(componentSource, /code === "report_request_conflict"/);
+ assert.match(componentSource, /code === "report_export_disabled"/);
+ assert.doesNotMatch(componentSource, /code === "request_conflict"/);
+ assert.doesNotMatch(componentSource, /code === "export_disabled"/);
+});
+
+test("repeat clicks are guarded while a request is in flight", () => {
+ assert.match(componentSource, /if \(inFlight\.current \|\| submitting\)/);
+ assert.match(componentSource, /inFlight\.current = true/);
+ assert.match(componentSource, /disabled=\{submitting\}/);
+ assert.match(componentSource, /正在生成/);
+});
+
+test("entry never requests a server PDF and never prints directly", () => {
+ assert.doesNotMatch(componentSource, /api\/report_artifact/);
+ assert.doesNotMatch(componentSource, /window\.print/);
+ assert.doesNotMatch(componentSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i);
+ assert.doesNotMatch(componentSource, /getContext\s*\(|toDataURL|base64/i);
+});
+
+test("no birth data or chat text is sent from the entry component", () => {
+ // The request body is built only by buildPersonalReportCreateRequest, whose
+ // keys are exactly the report identity fields (covered above). The component
+ // posts that body as-is.
+ assert.match(componentSource, /const body = buildPersonalReportCreateRequest\(requestId, sessionId\)/);
+ assert.match(componentSource, /body: JSON\.stringify\(body\)/);
+ assert.doesNotMatch(componentSource, /const body = \{[\s\S]*?latitude/);
+});
+
+test("evidence honesty: ready vs unknown copy, never a fabricated boolean", () => {
+ assert.match(componentSource, /服务端将校验本次咨询是否存在可用证据/);
+ assert.match(componentSource, /最终以服务端校验为准/);
+ assert.match(pageSource, /workflowReceipt\s*\?\s*"ready"\s*:\s*"unknown"/);
+ assert.doesNotMatch(pageSource, /reportEvidenceState\s*=\s*true/);
+});
+
+test("entry is gated on authenticated chat surface with usable profile", () => {
+ assert.match(pageSource, /reportEntryVisible = !rectificationSurfaceOpen/);
+ assert.match(pageSource, /profileComplete/);
+ assert.match(pageSource, /sessionType === "consultation"/);
+ assert.match(pageSource, /GeneratePersonalReportButton/);
+ assert.match(pageSource, /sessionId=\{activeSession\.id\}/);
+ assert.match(pageSource, /evidenceState=\{reportEvidenceState\}/);
+});
+
+test("entry hides for reported/candidate birth time: client gate mirrors the API", () => {
+ // The API only accepts accepted/confirmed + usable active time (422 otherwise).
+ // The page must mirror that gate so reported/candidate users never see the CTA.
+ assert.match(pageSource, /isBirthTimeReadyForConsultation\(profile\)/);
+ assert.match(pageSource, /reportBirthTimeUsable/);
+ assert.match(pageSource, /&& reportBirthTimeUsable/);
+ assert.doesNotMatch(pageSource, /reportEntryVisible = [^;]*?birthTimeStatus === "reported"/);
+ assert.doesNotMatch(pageSource, /birthTimeStatus === "candidate"/);
+
+ const draftBase = {
+ date: "1990-01-01",
+ time: "12:00",
+ reportedTime: "12:00",
+ birthTimeSource: "family_exact" as const,
+ birthTimePeriod: "morning" as const,
+ birthTimeClue: "",
+ uncertaintyBeforeMinutes: 10,
+ uncertaintyAfterMinutes: 10,
+ };
+ // reported/candidate must never be treated as usable (no fake candidate boundary).
+ assert.equal(
+ isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "reported" }),
+ false,
+ "reported time must not show the entry",
+ );
+ assert.equal(
+ isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "candidate" }),
+ false,
+ "candidate time must not show the entry",
+ );
+ assert.equal(
+ isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "accepted" }),
+ true,
+ );
+ assert.equal(
+ isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "confirmed" }),
+ true,
+ );
+ // A missing active time is not usable even when the status is accepted.
+ assert.equal(
+ isBirthTimeReadyForConsultation({ ...draftBase, time: "", birthTimeStatus: "accepted" }),
+ false,
+ );
+});
+
+test("legacy consultation Markdown export is untouched and still works", () => {
+ const markdown = consultationReportMarkdown({
+ title: "事业咨询",
+ messages: [
+ { role: "user", text: "未来一年事业如何?" },
+ {
+ role: "assistant",
+ text: "先看阶段,不承诺具体日期。",
+ techniqueTruth: "partial",
+ workflowReceipt: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: ["MEVG"] },
+ },
+ ],
+ });
+ assert.match(markdown, /# 事业咨询/);
+ assert.match(markdown, /workflow_route: career/);
+ assert.match(markdown, /precise_timing: blocked/);
+ assert.equal(typeof downloadMarkdownReport, "function");
+ assert.match(pageSource, /consultation-report-export/);
+ assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/);
+});
diff --git a/frontend/tests/personal-report-export.test.ts b/frontend/tests/personal-report-export.test.ts
new file mode 100644
index 00000000..614bf03f
--- /dev/null
+++ b/frontend/tests/personal-report-export.test.ts
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import {
+ detectPrintRestriction,
+ isPrintSupported,
+ printPersonalReport,
+ safeReportFilename,
+} from "../src/lib/client-report-export.ts";
+
+const exportSource = readFileSync(
+ new URL("../src/lib/client-report-export.ts", import.meta.url),
+ "utf8",
+);
+
+test("safeReportFilename strips path, query and control characters", () => {
+ assert.equal(safeReportFilename("../../etc/passwd?x=1&y=2"), "jyotisha-report-etc-passwd-x-1-y-2");
+ assert.equal(safeReportFilename("a bd|e:f"), "jyotisha-report-a-b-c-d-e-f");
+ assert.equal(safeReportFilename(""), "jyotisha-report");
+ assert.equal(safeReportFilename(null as unknown as string), "jyotisha-report");
+ const cleaned = safeReportFilename("abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ-extra");
+ assert.ok(cleaned.startsWith("jyotisha-report-"));
+ assert.ok(cleaned.length <= "jyotisha-report-".length + 64);
+ assert.doesNotMatch(cleaned, /[^a-zA-Z0-9_-]/);
+});
+
+test("detectPrintRestriction flags WeChat in-app browser and nothing else", () => {
+ assert.equal(detectPrintRestriction("Mozilla/5.0 MicroMessenger/8.0.49").restricted, true);
+ assert.equal(detectPrintRestriction("Mozilla/5.0 (iPhone) Safari").restricted, false);
+ assert.equal(detectPrintRestriction(undefined).restricted, false);
+ assert.match(detectPrintRestriction("MicroMessenger").message ?? "", /系统浏览器/);
+});
+
+test("isPrintSupported requires a usable window.print", () => {
+ assert.equal(isPrintSupported(), false);
+ (globalThis as Record).window = { print: () => undefined };
+ assert.equal(isPrintSupported(), true);
+ delete (globalThis as Record).window;
+});
+
+test("printPersonalReport waits for fonts, then calls window.print exactly once", async () => {
+ const calls: string[] = [];
+ let resolveFonts: (() => void) | undefined;
+ const fontsReady = new Promise((resolve) => {
+ resolveFonts = resolve;
+ });
+ const fakeDocument = { fonts: { ready: fontsReady }, title: "original-title" };
+ (globalThis as Record).window = {
+ print: () => {
+ calls.push("print");
+ // Browsers derive the PDF filename from document.title at print time.
+ assert.equal(fakeDocument.title, "safe-filename-title");
+ },
+ };
+ (globalThis as Record).document = fakeDocument;
+ const printPromise = printPersonalReport({ title: "safe-filename-title" });
+ // Fonts not ready yet: print must not have fired.
+ assert.deepEqual(calls, []);
+ resolveFonts?.();
+ await printPromise;
+ assert.deepEqual(calls, ["print"]);
+ // Original title restored afterwards.
+ assert.equal(fakeDocument.title, "original-title");
+ delete (globalThis as Record).window;
+ delete (globalThis as Record).document;
+});
+
+test("printPersonalReport is a no-op without window", async () => {
+ // window is already deleted; must not throw.
+ await printPersonalReport({ title: "x" });
+});
+
+test("client export never touches a server PDF pipeline", () => {
+ assert.doesNotMatch(exportSource, /api\/report_artifact/);
+ assert.doesNotMatch(exportSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i);
+ assert.doesNotMatch(exportSource, /canvas|base64/i);
+ assert.doesNotMatch(exportSource, /\bfetch\s*\(/);
+ assert.match(exportSource, /window\.print/);
+ assert.match(exportSource, /document\.fonts/);
+});
diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts
new file mode 100644
index 00000000..a6cbd95d
--- /dev/null
+++ b/frontend/tests/personal-report-view.test.ts
@@ -0,0 +1,239 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { PersonalReportDocumentView } from "../src/components/personal-report/personal-report-document-view.tsx";
+import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
+import { safeParseReportDocument } from "../src/lib/personal-report-contract.ts";
+import type { ReportDocumentV1 } from "../src/lib/personal-report-contract.ts";
+
+Object.assign(globalThis, { React });
+
+const fixturePath = new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url);
+const canonicalFixture = JSON.parse(readFileSync(fixturePath, "utf8")) as ReportDocumentV1;
+
+function render(document: ReportDocumentV1): string {
+ return renderToStaticMarkup(React.createElement(PersonalReportDocumentView, { document }));
+}
+
+function withPresentationMode(document: ReportDocumentV1, presentationMode: "default" | "research"): ReportDocumentV1 {
+ return { ...structuredClone(document), presentationMode };
+}
+
+function withCharts(document: ReportDocumentV1, charts: ReportDocumentV1["charts"]): ReportDocumentV1 {
+ return { ...structuredClone(document), charts };
+}
+
+test("canonical fixture passes the canonical contract parse", () => {
+ const parsed = safeParseReportDocument(canonicalFixture);
+ assert.equal(parsed.ok, true);
+ if (parsed.ok) {
+ assert.equal(parsed.document.reportId, canonicalFixture.reportId);
+ }
+});
+
+test("renders sections in the fixed order: cover -> D1 -> summary -> themes -> appendix -> disclaimer", () => {
+ const markup = render(canonicalFixture);
+ const positions: Record = {
+ subject: markup.indexOf(canonicalFixture.subject.displayName),
+ d1: markup.indexOf(canonicalFixture.charts[0].title),
+ summary: markup.indexOf("核心摘要"),
+ theme: markup.indexOf(canonicalFixture.thematicNarrative[0].title),
+ appendix: markup.indexOf("证据附录"),
+ disclaimer: markup.indexOf("声明"),
+ };
+ for (const key of Object.keys(positions)) {
+ assert.ok(positions[key] >= 0, `${key} must be rendered`);
+ }
+ assert.ok(positions.subject < positions.d1, "cover comes before D1 chart");
+ assert.ok(positions.d1 < positions.summary, "D1 chart comes before the summary");
+ assert.ok(positions.summary < positions.theme, "summary comes before themes");
+ assert.ok(positions.theme < positions.appendix, "themes come before the appendix");
+ assert.ok(positions.appendix < positions.disclaimer, "appendix comes before the disclaimer");
+});
+
+test("Technique Audit Table is never placed before the summary", () => {
+ const markup = render(canonicalFixture);
+ const summaryAt = markup.indexOf("核心摘要");
+ const auditAt = markup.indexOf("Technique Audit Table");
+ assert.ok(summaryAt >= 0 && auditAt >= 0, "both summary and audit table render");
+ assert.ok(auditAt > summaryAt, "audit table must appear after the summary");
+});
+
+test("evidence appendix is collapsed by default in default mode and expanded in research mode", () => {
+ const auditText = "MEVG / Global Web Evidence";
+ const collapsed = render(canonicalFixture);
+ assert.match(collapsed, /personal-report-print-always hidden/, "default mode starts collapsed");
+ assert.doesNotMatch(collapsed, /personal-report-print-always block/, "default mode must not render expanded");
+ assert.ok(collapsed.indexOf(auditText) >= 0, "content exists in the DOM but is visually hidden");
+
+ const expanded = render(withPresentationMode(canonicalFixture, "research"));
+ assert.match(expanded, /personal-report-print-always block/, "research mode starts expanded");
+ assert.ok(expanded.indexOf(auditText) >= 0, "research mode renders appendix content");
+});
+
+test("expandedByDefault=true expands the appendix even in default mode", () => {
+ const document = structuredClone(canonicalFixture);
+ document.evidenceAppendix.expandedByDefault = true;
+ const markup = render(document);
+ assert.match(markup, /personal-report-print-always block/, "expandedByDefault forces the expanded state");
+});
+
+test("D1 SVG renders when real houses exist; D9/D10 are never fabricated", () => {
+ const svgCount = (markup: string) => (markup.match(/