From d89073d296279d30617a2f6bcfc0ad2a74ac194a Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 12:44:36 +0800 Subject: [PATCH] feat(report): add browser-rendered report and PDF print --- docs/BUG_HISTORY.md | 15 + frontend/src/app/globals.css | 52 ++ frontend/src/app/page.tsx | 28 + frontend/src/app/reports/[reportId]/error.tsx | 33 ++ .../src/app/reports/[reportId]/loading.tsx | 12 + .../src/app/reports/[reportId]/not-found.tsx | 19 + frontend/src/app/reports/[reportId]/page.tsx | 19 + .../generate-personal-report-button.tsx | 233 ++++++++ .../personal-report-document-view.tsx | 496 ++++++++++++++++++ .../personal-report/personal-report-page.tsx | 255 +++++++++ .../personal-report/report-actions.tsx | 91 ++++ .../personal-report/vedic-chart-svg.tsx | 160 ++++++ frontend/src/lib/client-report-export.ts | 72 +++ frontend/tests/personal-report-entry.test.ts | 243 +++++++++ frontend/tests/personal-report-export.test.ts | 80 +++ frontend/tests/personal-report-view.test.ts | 239 +++++++++ 16 files changed, 2047 insertions(+) create mode 100644 frontend/src/app/reports/[reportId]/error.tsx create mode 100644 frontend/src/app/reports/[reportId]/loading.tsx create mode 100644 frontend/src/app/reports/[reportId]/not-found.tsx create mode 100644 frontend/src/app/reports/[reportId]/page.tsx create mode 100644 frontend/src/components/personal-report/generate-personal-report-button.tsx create mode 100644 frontend/src/components/personal-report/personal-report-document-view.tsx create mode 100644 frontend/src/components/personal-report/personal-report-page.tsx create mode 100644 frontend/src/components/personal-report/report-actions.tsx create mode 100644 frontend/src/components/personal-report/vedic-chart-svg.tsx create mode 100644 frontend/src/lib/client-report-export.ts create mode 100644 frontend/tests/personal-report-entry.test.ts create mode 100644 frontend/tests/personal-report-export.test.ts create mode 100644 frontend/tests/personal-report-view.test.ts 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 ? (