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

This commit is contained in:
Jesse
2026-08-06 12:52:05 +08:00
parent 47e829e971
commit d89073d296
16 changed files with 2047 additions and 0 deletions
+52
View File
@@ -1787,3 +1787,55 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; }
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
.payment-qr-badge svg { display: block; width: 100%; height: 100%; }
/* ============================================================
personal-report: unique block — report reader + A4 print
Owned by the report UI worker. Only used by /reports/[reportId].
Screen layout uses Tailwind utilities; this block only adds
print-critical and report-specific rules.
============================================================ */
@page {
size: A4;
margin: 14mm 12mm;
}
/* Small cards / short tables / charts avoid page breaks; long themes
(personal-report-theme) intentionally paginate. */
.personal-report-avoid-break {
break-inside: avoid-page;
}
.personal-report-avoid-break-row {
break-inside: avoid;
}
@media print {
html,
body {
background: #fff !important;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Navigation, disclosure toggle and other screen-only chrome. */
.personal-report-screen-only {
display: none !important;
}
/* Collapsed appendix is printed in full (product decision). */
.personal-report-print-always {
display: block !important;
}
.personal-report-document {
max-width: none !important;
padding: 0 !important;
}
.personal-report-chart-svg {
max-width: 120mm;
}
}
+28
View File
@@ -16,6 +16,7 @@ import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { GeneratePersonalReportButton } from "@/components/personal-report/generate-personal-report-button";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
import {
@@ -35,6 +36,7 @@ import {
birthTimePersistenceValues,
declaredBirthInputChanged,
describeBirthTimeDraft,
isBirthTimeReadyForConsultation,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
normalizePersistedBirthDate,
@@ -1126,6 +1128,26 @@ export default function Home() {
const profileComplete = isProfileComplete(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
// Client-side display hint only: an in-memory workflow receipt on the latest
// assistant answer. After a reload receipts are gone, so the state falls back
// to "unknown" (the button copy says the server will verify) instead of
// fabricating an evidence boolean. The server owns real evidence validation.
const latestAssistantMessage = [...(activeSession?.messages ?? [])]
.reverse()
.find((message) => message.role === "assistant");
const reportEvidenceState: "ready" | "unknown" = latestAssistantMessage?.workflowReceipt
? "ready"
: "unknown";
// Mirror the server-side birth-time gate (accepted/confirmed + usable active
// time): reported/candidate users must not see the entry, since the API would
// reject them with 422. profileComplete alone is not enough.
const reportBirthTimeUsable = isBirthTimeReadyForConsultation(profile);
const reportEntryVisible = !rectificationSurfaceOpen
&& profileComplete
&& reportBirthTimeUsable
&& activeSession?.sessionType === "consultation"
&& activeSession.messages.length > 0;
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
const onboardingPending = profileComplete && !onboarding && !onboardingError;
@@ -2819,6 +2841,12 @@ export default function Home() {
: personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}</span>
</div>
<div className="chat-header-actions">
{reportEntryVisible && activeSession && (
<GeneratePersonalReportButton
sessionId={activeSession.id}
evidenceState={reportEvidenceState}
/>
)}
{account.isAdmin && account.adminUrl ? (
<Link className="admin-button" href={account.adminUrl} aria-label="后台管理" title="后台管理">
<ShieldCheck aria-hidden="true" />
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function ReportError({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
useEffect(() => {
// Error boundary must log; digest matches server-side logs without leaking details.
console.error("report page error", error.digest ?? error.message);
}, [error]);
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button type="button" variant="outline" onClick={() => unstable_retry()}>
</Button>
</main>
);
}
@@ -0,0 +1,12 @@
import { LoaderCircle } from "lucide-react";
export default function ReportLoading() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
<p className="text-ink" role="status">
</p>
</main>
);
}
@@ -0,0 +1,19 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function ReportNotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<Button render={<Link href="/" />} variant="outline">
</Button>
</main>
);
}
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "个人报告 · Jyotisha",
robots: { index: false, follow: false },
};
export default async function ReportPage({
params,
}: {
params: Promise<{ reportId: string }>;
}) {
const { reportId } = await params;
return <PersonalReportPage reportId={reportId} />;
}