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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user