feat(report): add browser-rendered report and PDF print
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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、冲突、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<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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user