From 65bcef3001a2724704ac9f1e345e6b458756fbd7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 20:21:55 -0700 Subject: [PATCH] feat: add global asynchronous report center --- docs/BUG_HISTORY.md | 32 + frontend/src/app/api/reports/route.ts | 85 ++- frontend/src/app/globals.css | 271 ++++++-- frontend/src/app/page.tsx | 28 +- frontend/src/app/reports/page.tsx | 14 + frontend/src/components/app-sidebar.tsx | 19 + .../generate-personal-report-button.tsx | 50 +- .../personal-report-center.tsx | 220 ++++++ .../personal-report-document-view.tsx | 645 ++++++++---------- .../personal-report/personal-report-page.tsx | 26 +- .../personal-report/report-actions.tsx | 69 +- .../personal-report/vedic-chart-svg.tsx | 63 +- .../src/lib/personal-report-route-core.ts | 231 ++++--- frontend/tests/personal-report-api.test.ts | 31 + frontend/tests/personal-report-entry.test.ts | 54 +- frontend/tests/personal-report-view.test.ts | 66 +- tests/test_api_server_security.py | 1 + 17 files changed, 1257 insertions(+), 648 deletions(-) create mode 100644 frontend/src/app/reports/page.tsx create mode 100644 frontend/src/components/personal-report/personal-report-center.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index fb949440..014602c7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2600,3 +2600,35 @@ - 相关记录:无 - 复发自:无 - 修复版本:本地候选提交(未 push / deploy) + +## BUG-153 | 个人报告页面无法下滑且命盘与打印版式失真 + +- 状态:resolved(local candidate,待 review/deployment) +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:`/reports/[reportId]` 网页阅读、D1/D9/D10 命盘 SVG、浏览器打印/保存 PDF;聊天页保留既有全局滚动锁。 +- 用户现象:报告页只能通过缩小浏览器查看下方内容;打印或保存 PDF 时长文本、表格和分页缺少稳定版式;命盘内多个宫位的星体文字叠在左上宫格;当前版本还隐藏了已存在的 PDF 操作入口。 +- 触发条件:报告页运行在全局 `html, body { overflow: hidden }` 的聊天壳层中但自身没有纵向滚动边界;命盘为每个宫位绘制 occupants 时重复使用固定的局部坐标却未应用宫格偏移;打印样式继续保留屏幕表格的 `min-width`/横向滚动合同,并把可能超过一页的长摘要整体设为避免分页。 +- 根因:报告页面复用了聊天应用的根滚动策略,却没有为报告建立独立 `100dvh + overflow-y:auto` 阅读容器。`PlanetList` 的调用没有 `translate(cell.x, cell.y)`,因此十二宫共享同一绘制原点,且未对 schema 允许的密集 occupants 做行数上限和裁切。打印 CSS 只有 A4 页面与少量 `break-inside` 规则,没有清理祖先高度/overflow、表格最小宽度、长单词换行与长叙事分页。PDF 操作此前被功能隐藏提交移除,并非底层打印 helper 缺失。 +- 修复:为 ready 报告建立独立 `.personal-report-reader` 滚动容器;将报告重构为 answer-first 的研究备忘录版式(封面快照、核心判断、D1 与关键证据双栏、主题解读、证据附录),使用既有中文正文字体、暖象牙纸张色和细分隔线,不引入卡片阴影或渐变。SVG 逐宫应用 transform 与 clipPath,密集星体最多显示七项加 `+N 项`。打印时恢复祖先自动高度和可见 overflow,重置表格 `min-width`/wrapper overflow,启用 fixed layout 与强制换行,并允许长主题自然分页;A4 `@page` 只随 ready 报告 route 注入,不再污染全站打印。恢复“打印 / 保存为 PDF”操作并保留不支持打印浏览器的能力保护;附录改用原生 `details/summary`,即使客户端 hydration 延迟也可展开,打印时始终显示内容。 +- 验证:个人报告聚焦测试覆盖 answer-first 顺序、十二宫坐标/裁切/密集数据、独立滚动边界、打印表格与分页、原生附录 disclosure、PDF 操作能力保护;浏览器在 1363×936 CSS viewport 实测报告容器 `clientHeight=936`、`scrollHeight=3466`,滚动后 `scrollTop=980` 并可到达底部,附录展开后高度增至 5219;参考图与实现图完成同画布视觉比对。聚焦 ESLint、生产构建和 `git diff --check` 作为最终门禁重新执行。 +- 防复发:聊天根滚动锁与报告阅读滚动必须分层;报告回归测试不得删除 `100dvh/overflow-y:auto` 合同。任何命盘 occupants 改动必须验证至少两个不同宫位 transform、clipPath 与 12 项密集输入。打印回归必须覆盖长摘要、长主题、最小宽度表格和无滚动容器的 A4 输出;PDF 入口的隐藏/恢复必须同步更新明确的能力与 ready-state 合同。 +- 相关记录:BUG-126 +- 复发自:无 +- 修复版本:本地候选(未 push / deploy) + +## BUG-154 | 个人报告错误绑定会话且创建请求同步阻塞 + +- 状态:resolved(local candidate,待 staging gate/deployment) +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:个人报告入口、`/reports` 信息架构、`POST/GET /api/reports`、报告生成等待体验;报告证据合同、用户归属与每日限额不放宽。 +- 用户现象:只有进入一条已有消息的咨询 session 后才看得到“生成个人报告”,但实际报告使用的是账户出生资料而非该次对话;点击后 HTTP 请求会等待完整排盘与模型生成,用户必须停留并感知长时间阻塞,也没有集中查看历史报告的位置。 +- 触发条件:聊天页用 active session、消息数和临时 workflow receipt 控制报告 CTA;`POST /api/reports` 在插入 `generating` 记录后继续同步等待计算、模型生成、校验和 ready 写入。 +- 根因:产品入口沿用了最初的会话内 MVP,但生成主链实际构造 `entryMode=direct_chart` 并只读取认证用户的 canonical profile,会话只充当无意义的入口门槛。API 状态表已经支持 `generating/ready/failed`,却没有用响应后任务执行,也没有元数据列表端点与全局报告中心消费这些状态。 +- 修复:从聊天 header 删除 session-bound CTA,在全局侧栏加入“我的报告”并新增 `/reports` 报告中心;创建请求不再发送 `sessionId`,只携带报告身份字段。`GET /api/reports` 通过认证客户端/RLS 返回最近 20 条元数据,明确不返回正文与证据 hash。生产 POST 持久化 `generating` 后使用 Next.js 16 官方 `after()` 在响应后运行原有生成链并立即返回 202;完成/失败继续走原有 owner-scoped service 写入,意外异常也会落到稳定失败态。进程重启等中断留下的生成记录超过 15 分钟后会在下一次显式创建前安全回收为失败,避免永久占用单用户生成锁。中心与详情页每 3 秒轮询,用户可离开页面,现有 ready 报告可直接打开。 +- 验证:新增可执行回归证明 deferred 模式先返回 202 且行状态为 `generating`,执行任务后才转为 `ready`;个人报告、侧栏相关测试 185/185 通过;聚焦 ESLint、Next.js 生产构建(含 TypeScript、57 个静态页面、`/reports` 动态路由)和 `git diff --check` 通过。 +- 防复发:个人完整报告入口不得依赖 active session、消息数量或临时 workflow receipt;创建请求不得携带 chat text、出生明文或 sessionId。生产适配必须保留 response-after 调度与 202 合同,列表端点禁止返回 `report_document`、calculation/evidence hash。报告中心必须持续覆盖空、生成中、完成、失败和未登录状态。 +- 相关记录:BUG-126、BUG-153 +- 复发自:无 +- 修复版本:本地候选(待 staging push/gate) diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 341b1a87..4acdeb78 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { after, NextResponse } from "next/server"; import { runConsultationWorkflow } from "@/mastra"; import { createPersonalReportAgent } from "@/mastra/personal-report"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; @@ -26,6 +26,19 @@ import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; export const maxDuration = 120; +const STALE_GENERATION_MS = 15 * 60 * 1000; + +const REPORT_LIST_COLUMNS = [ + "id", + "request_id", + "report_type", + "presentation_mode", + "requested_themes", + "status", + "failure_code", + "created_at", + "completed_at", +].join(","); function sanitizedErrorCode(error: unknown): string { if (error instanceof Error) return error.name; @@ -36,6 +49,56 @@ function toNextResponse(response: { status: number; body: Record : {}; + return { + id: typeof row.id === "string" ? row.id : "", + requestId: typeof row.request_id === "string" ? row.request_id : "", + reportType: typeof row.report_type === "string" ? row.report_type : "personal_full", + presentationMode: typeof row.presentation_mode === "string" ? row.presentation_mode : "default", + themes: Array.isArray(row.requested_themes) + ? row.requested_themes.filter((theme): theme is string => typeof theme === "string") + : [], + status: typeof row.status === "string" ? row.status : "failed", + failureCode: typeof row.failure_code === "string" ? row.failure_code : null, + createdAt: typeof row.created_at === "string" ? row.created_at : "", + completedAt: typeof row.completed_at === "string" ? row.completed_at : null, + }; +} + +/** Lightweight report-centre index. Report bodies never leave this endpoint. */ +export async function GET() { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const { data, error } = await supabase + .from("personal_reports") + .select(REPORT_LIST_COLUMNS) + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(20); + if (error) throw error; + return NextResponse.json({ + reports: Array.isArray(data) ? data.map(listReportView).filter((report) => report.id) : [], + }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] list failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} + export async function POST(request: Request) { try { // Authenticated client: auth, profile, session/chart-profile owner reads. @@ -50,6 +113,25 @@ export async function POST(request: Request) { const persistence: PersonalReportService = createSupabasePersonalReportService(admin); const adminDataClient = createPersonalReportDataClient(admin); + // `after()` is bounded by the route duration and can be interrupted by a + // process restart. Reclaim only rows far beyond that bound so a killed + // task never blocks the user's next explicit generation forever. + if (userId) { + const now = new Date(); + const staleBefore = new Date(now.getTime() - STALE_GENERATION_MS).toISOString(); + const { error } = await admin + .from("personal_reports") + .update({ + status: "failed", + failure_code: REPORT_STABLE_CODES.calculationUnavailable, + updated_at: now.toISOString(), + }) + .eq("user_id", userId) + .eq("status", "generating") + .lt("updated_at", staleBefore); + if (error) throw error; + } + let profile: unknown = null; let profileError: unknown = null; if (userId) { @@ -126,6 +208,7 @@ export async function POST(request: Request) { runWorkflow: (input) => runConsultationWorkflow(input), createAgent: (model) => createPersonalReportAgent(model as Parameters[0]), skillSnapshot: resolveSkillSnapshot(), + deferGeneration: (task) => after(task), }; const response = await resolveReportCreate(deps); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 719e05d0..6d625950 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -314,7 +314,9 @@ button:disabled { cursor: default; opacity: .45; } .brand-row strong { font-weight: 400; } .brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } .auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } -.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: var(--sidebar-primary); color: var(--sidebar-primary-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-6); border-radius: var(--radius-md); font-weight: 500; } +.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: var(--sidebar-primary); color: var(--sidebar-primary-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-2); border-radius: var(--radius-md); font-weight: 500; } +.report-nav-button { width: 100%; min-height: 44px; justify-content: flex-start; gap: var(--space-2); margin-bottom: var(--space-4); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); color: var(--sidebar-foreground); } +.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } .session-nav-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); } .sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } .sidebar-label:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: 2px; border-radius: var(--radius-sm); } @@ -1826,56 +1828,239 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled .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%; } +/* Global report centre — editorial index, independent from chat sessions. */ +.report-center-shell { width: 100%; min-height: 100dvh; overflow-x: hidden; padding: 0 clamp(20px, 5vw, 72px) 72px; background: var(--color-canvas-soft); color: var(--color-ink); } +.report-center-topbar { max-width: 1180px; min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 0 auto; border-bottom: 1px solid var(--color-border); color: var(--color-ink-tertiary); font-size: 12px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; } +.report-center-back { min-height: 44px; display: inline-flex; align-items: center; gap: 7px; color: var(--color-ink-secondary); font-size: 14px; font-weight: 500; letter-spacing: 0; text-decoration: none; text-transform: none; } +.report-center-back:hover { color: var(--color-action); } +.report-center-back svg, .report-center-section-heading button svg { width: 16px; height: 16px; } +.report-center-hero { max-width: 1180px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 48px; margin: 0 auto; padding: clamp(56px, 9vw, 104px) 0 52px; } +.report-center-hero > div { max-width: 760px; } +.report-center-kicker { margin: 0 0 12px; color: var(--color-action); font-size: 11px; font-weight: 700; letter-spacing: .14em; } +.report-center-hero h1 { margin: 0; font-size: clamp(48px, 8vw, 88px); font-weight: 720; letter-spacing: -.065em; line-height: .98; } +.report-center-hero > div > p:last-child { max-width: 680px; margin: 24px 0 0; color: var(--color-ink-secondary); font-size: 16px; line-height: 1.75; text-wrap: pretty; } +.report-center-hero > button { min-height: 46px; padding-inline: 18px; border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); } +.report-center-summary { max-width: 1180px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 auto 64px; border-top: 1px solid var(--color-border-strong); border-bottom: 1px solid var(--color-border); } +.report-center-summary > div { min-width: 0; display: grid; gap: 6px; padding: 22px 0; } +.report-center-summary > div + div { padding-left: 28px; border-left: 1px solid var(--color-border); } +.report-center-summary strong { font-size: 22px; font-weight: 680; } +.report-center-summary span { color: var(--color-ink-tertiary); font-size: 12px; } +.report-center-section { max-width: 1180px; margin: 0 auto; } +.report-center-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 20px; } +.report-center-section-heading h2 { margin: 0; font-size: 26px; letter-spacing: -.03em; } +.report-center-section-heading button { min-height: 40px; display: inline-flex; align-items: center; gap: 7px; padding: 0 10px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; } +.report-center-section-heading button:hover { color: var(--color-action); } +.report-center-list { border-top: 1px solid var(--color-border-strong); } +.report-center-card { min-height: 150px; display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 24px; padding: 26px 0; border-bottom: 1px solid var(--color-border); } +.report-center-card-index { align-self: start; padding-top: 4px; color: var(--color-ink-tertiary); font-size: 12px; font-variant-numeric: tabular-nums; } +.report-center-card-body { min-width: 0; } +.report-center-status { width: fit-content; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--color-ink-secondary); font-size: 12px; font-weight: 650; } +.report-center-status.is-ready { color: var(--color-success); } +.report-center-status.is-generating { color: var(--color-action); } +.report-center-status.is-failed, .report-center-failure { color: var(--color-danger); } +.report-center-card h3 { margin: 0; font-size: 20px; letter-spacing: -.02em; } +.report-center-card p { margin: 7px 0; color: var(--color-ink-secondary); font-size: 14px; line-height: 1.55; } +.report-center-card small { color: var(--color-ink-tertiary); font-size: 12px; } +.report-center-failure { max-width: 180px; font-family: ui-monospace, monospace; font-size: 11px; overflow-wrap: anywhere; } +.report-center-empty { min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; border-top: 1px solid var(--color-border-strong); border-bottom: 1px solid var(--color-border); color: var(--color-ink-tertiary); text-align: center; } +.report-center-empty h3 { margin: 8px 0 0; color: var(--color-ink); font-size: 18px; } +.report-center-empty p { max-width: 460px; margin: 0; font-size: 14px; line-height: 1.6; } +.report-center-alert { padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--color-danger) 36%, var(--color-border)); background: var(--color-danger-muted); color: var(--color-danger); } +.report-center-message { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; text-align: center; } +.report-center-message h1, .report-center-message p { margin: 0; } + +@media (max-width: 720px) { + .report-center-topbar > span { display: none; } + .report-center-hero { grid-template-columns: 1fr; align-items: start; gap: 28px; padding-block: 48px 38px; } + .report-center-hero > button { width: 100%; } + .report-center-summary { grid-template-columns: 1fr; margin-bottom: 48px; } + .report-center-summary > div { grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 15px 0; } + .report-center-summary > div + div { padding-left: 0; border-top: 1px solid var(--color-border); border-left: 0; } + .report-center-summary span { grid-column: 1; grid-row: 1; } + .report-center-summary strong { grid-column: 2; grid-row: 1; font-size: 17px; } + .report-center-card { grid-template-columns: 32px minmax(0, 1fr); gap: 12px; } + .report-center-card > a, .report-center-card > button, .report-center-failure { grid-column: 2; justify-self: start; } + .report-center-section-heading { align-items: center; } +} + /* ============================================================ - 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. + personal-report: unique block — editorial reader + A4 print + Only /reports/[reportId] uses these semantic classes. The report owns its + scroll boundary because the chat shell intentionally locks html/body. ============================================================ */ -@page { - size: A4; - margin: 14mm 12mm; +.personal-report-reader { + --report-accent: #85432f; + --report-paper: #f8f5ee; + --report-rule: #c9c2b7; + width: 100%; + height: 100dvh; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + background: var(--report-paper); } +.personal-report-reader-body { width: 100%; padding: 0 24px 64px; } +.personal-report-actions { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid color-mix(in srgb, var(--report-rule) 78%, transparent); background: color-mix(in srgb, var(--report-paper) 94%, transparent); backdrop-filter: blur(16px); } +.personal-report-actions > div { width: min(1040px, 100%); min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 auto; padding: 8px 24px; } +.personal-report-back { min-height: 38px; display: inline-flex; align-items: center; gap: 6px; color: var(--color-ink-secondary); font-size: 14px; text-decoration: none; } +.personal-report-back:hover { color: var(--color-ink); } +.personal-report-back svg, .personal-report-action-end svg { width: 16px; height: 16px; } +.personal-report-action-end { display: flex; align-items: center; justify-content: flex-end; gap: 12px; } +.personal-report-action-end > span { max-width: 440px; display: inline-flex; align-items: center; gap: 6px; color: var(--color-warning); font-size: 13px; line-height: 1.45; } -/* 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-document { width: min(1040px, 100%); margin: 0 auto; padding: 54px 52px 38px; color: var(--color-ink); font-family: var(--font-body); font-size: 15px; line-height: 1.78; } +.personal-report-section { margin: 0 0 58px; } +.personal-report-cover-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 34%); gap: 72px; align-items: end; } +.personal-report-eyebrow { margin: 0 0 14px; color: var(--report-accent); font-size: 13px; font-weight: 650; letter-spacing: .08em; } +.personal-report-cover h1 { margin: 0; font-family: var(--font-body); font-size: clamp(44px, 6vw, 66px); font-weight: 700; letter-spacing: -.055em; line-height: 1.05; } +.personal-report-deck { max-width: 620px; margin: 18px 0 0; color: var(--color-ink-secondary); font-size: 16px; line-height: 1.65; text-wrap: pretty; } +.personal-report-metadata { display: grid; gap: 9px; margin: 0; } +.personal-report-metadata > div { min-width: 0; display: grid; grid-template-columns: 68px minmax(0, 1fr); gap: 10px; } +.personal-report-metadata dt { color: var(--color-ink-tertiary); } +.personal-report-metadata dd { min-width: 0; margin: 0; color: var(--color-ink); overflow-wrap: anywhere; } +.personal-report-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 38px 0 0; padding: 27px 0 24px; border-top: 1px solid var(--report-rule); border-bottom: 1px solid var(--report-rule); } +.personal-report-facts > div { min-width: 0; padding: 0 22px; text-align: center; } +.personal-report-facts > div + div { border-left: 1px solid var(--report-rule); } +.personal-report-facts dd { margin: 0; color: var(--color-ink); font-size: 21px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; } +.personal-report-facts dt { margin-top: 6px; color: var(--color-ink-tertiary); font-size: 13px; } -.personal-report-avoid-break-row { - break-inside: avoid; +.personal-report-section-heading { margin-bottom: 20px; } +.personal-report-section-heading > p { margin: 0 0 4px; color: var(--report-accent); font-size: 11px; font-weight: 650; letter-spacing: .12em; text-transform: uppercase; } +.personal-report-section-heading h2 { margin: 0; font-family: var(--font-body); font-size: 27px; font-weight: 700; letter-spacing: -.025em; line-height: 1.3; } +.personal-report-narrative p { margin: 0 0 14px; color: var(--color-ink-strong); font-size: 15px; line-height: 1.88; text-wrap: pretty; } +.personal-report-narrative p:last-child { margin-bottom: 0; } +.personal-report-summary { max-width: 920px; } +.personal-report-callout { margin: 26px 0 0; padding: 14px 18px; border-left: 4px solid var(--report-accent); background: color-mix(in srgb, var(--report-accent) 5%, transparent); color: var(--color-ink-strong); } +.personal-report-callout strong { color: var(--report-accent); } +.personal-report-priorities { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 30px; margin: 22px 0 0; padding: 0; list-style: none; } +.personal-report-priorities li { display: grid; grid-template-columns: 26px minmax(0, 1fr); gap: 9px; padding: 10px 0; border-top: 1px solid color-mix(in srgb, var(--report-rule) 72%, transparent); color: var(--color-ink-secondary); font-size: 14px; } +.personal-report-priorities span { color: var(--report-accent); font-size: 11px; font-weight: 700; letter-spacing: .08em; } + +.personal-report-lead-chart { padding-top: 2px; } +.personal-report-lead-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr); gap: 40px; align-items: start; } +.personal-report-subheading { min-height: 38px; display: flex; align-items: baseline; gap: 10px; padding-bottom: 9px; border-bottom: 1px solid var(--report-rule); } +.personal-report-subheading h2, .personal-report-subheading h3 { margin: 0; font-family: var(--font-body); font-size: 20px; font-weight: 700; line-height: 1.4; } +.personal-report-subheading span { color: var(--color-ink-secondary); font-size: 13px; } +.personal-report-chart-svg { display: block; width: 100%; height: auto; margin-top: 14px; } +.personal-report-chart-figure { min-width: 0; margin: 0; } +.personal-report-chart-figure figcaption { margin-top: 10px; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.55; } +.personal-report-key-evidence { min-width: 0; } +.personal-report-key-evidence table, .personal-report-table-wrap table { width: 100%; min-width: 0; border-collapse: collapse; table-layout: fixed; text-align: left; } +.personal-report-key-evidence th, .personal-report-key-evidence td, .personal-report-table-wrap th, .personal-report-table-wrap td { padding: 9px 8px; border-bottom: 1px solid color-mix(in srgb, var(--report-rule) 68%, transparent); vertical-align: top; overflow-wrap: anywhere; } +.personal-report-key-evidence thead th, .personal-report-table-wrap thead th { color: var(--color-ink-secondary); font-size: 11px; font-weight: 650; letter-spacing: .04em; } +.personal-report-key-evidence tbody th { width: 30%; color: var(--color-ink); font-size: 13px; font-weight: 550; } +.personal-report-key-evidence tbody td { color: var(--color-ink-strong); font-size: 13px; } + +.personal-report-chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 38px; } +.personal-report-chart-grid.is-single { grid-template-columns: minmax(0, 520px); } +.personal-report-chart-card { min-width: 0; padding-top: 16px; border-top: 2px solid var(--color-ink); } +.personal-report-chart-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.personal-report-chart-card h3 { margin: 0; font-size: 18px; } +.personal-report-claim-badge { display: inline-flex; flex: 0 0 auto; align-items: center; padding: 2px 7px; border: 1px solid var(--report-rule); border-radius: 999px; color: var(--color-ink-secondary); background: transparent; font-size: 10px; font-weight: 650; line-height: 1.5; } +.personal-report-claim-badge.is-success { border-color: color-mix(in srgb, var(--color-success) 45%, transparent); color: var(--color-success); } +.personal-report-claim-badge.is-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); color: #866018; } +.personal-report-claim-badge.is-danger { border-color: color-mix(in srgb, var(--color-danger) 42%, transparent); color: var(--color-danger); } +.personal-report-table-wrap { max-width: 100%; overflow-x: auto; } +.personal-report-planet-table { margin-top: 18px; font-size: 12px; } +.personal-report-planet-table th, .personal-report-planet-table td { padding-inline: 5px; } + +.personal-report-themes { border-top: 1px solid var(--report-rule); padding-top: 34px; } +.personal-report-theme { padding: 28px 0 32px; border-top: 1px solid color-mix(in srgb, var(--report-rule) 68%, transparent); } +.personal-report-theme > header { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; align-items: start; margin-bottom: 16px; } +.personal-report-theme > header > span { padding-top: 5px; color: var(--report-accent); font-size: 11px; font-weight: 700; letter-spacing: .08em; } +.personal-report-theme h3 { margin: 0; font-family: var(--font-body); font-size: 21px; line-height: 1.45; } +.personal-report-theme .personal-report-narrative { padding-left: 44px; } +.personal-report-theme-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px; margin: 22px 0 0 44px; padding: 17px 0; border-top: 1px solid color-mix(in srgb, var(--report-rule) 68%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--report-rule) 68%, transparent); } +.personal-report-theme-details h4 { margin: 0 0 7px; color: var(--color-ink); font-size: 13px; } +.personal-report-theme-details ul { margin: 0; padding-left: 18px; color: var(--color-ink-secondary); font-size: 13px; line-height: 1.7; } +.personal-report-theme-details li + li { margin-top: 4px; } +.personal-report-evidence-links { display: flex; flex-wrap: wrap; gap: 7px 16px; margin: 16px 0 0 44px; padding: 0; list-style: none; color: var(--color-ink-tertiary); font-size: 11px; } +.personal-report-evidence-links a { color: var(--report-accent); text-decoration-color: color-mix(in srgb, var(--report-accent) 35%, transparent); text-underline-offset: 3px; } + +.personal-report-appendix { position: relative; padding-top: 34px; border-top: 1px solid var(--report-rule); } +.personal-report-appendix > .personal-report-section-heading { margin-bottom: 0; } +.personal-report-appendix-details > summary { position: absolute; top: 34px; right: 0; display: inline-flex; align-items: center; } +.personal-report-disclosure { min-height: 38px; padding: 0 12px; border: 1px solid var(--report-rule); border-radius: 6px; background: transparent; color: var(--color-ink); cursor: pointer; font-size: 13px; list-style: none; } +.personal-report-disclosure::-webkit-details-marker { display: none; } +.personal-report-disclosure:hover { border-color: var(--report-accent); color: var(--report-accent); } +.personal-report-appendix-intro { margin: 12px 0 24px; color: var(--color-ink-secondary); font-size: 13px; } +.personal-report-appendix h3 { margin: 28px 0 10px; font-size: 16px; } +.personal-report-audit-table { font-size: 12px; } +.personal-report-audit-table th:nth-child(1) { width: 24%; } +.personal-report-audit-table th:nth-child(2), .personal-report-audit-table th:nth-child(3) { width: 12%; } +.personal-report-audit-table td strong, .personal-report-audit-table td small { display: block; } +.personal-report-audit-table td small { color: var(--color-ink-tertiary); font-size: 10px; } +.personal-report-conflicts { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; } +.personal-report-conflicts li { display: grid; gap: 3px; padding: 12px 0; border-bottom: 1px solid color-mix(in srgb, var(--report-rule) 68%, transparent); } +.personal-report-conflicts span { color: var(--color-ink-secondary); font-size: 13px; } +.personal-report-conflicts small { color: var(--color-ink-tertiary); } +.personal-report-blocked-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; } +.personal-report-blocked-list li { padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent); border-radius: 999px; color: var(--color-danger); font-size: 11px; } +.personal-report-disclaimer { padding-top: 26px; border-top: 1px solid var(--report-rule); } +.personal-report-disclaimer h2 { margin: 0 0 8px; font-size: 16px; } +.personal-report-disclaimer p { margin: 0; color: var(--color-ink-secondary); font-size: 12px; line-height: 1.75; } +.personal-report-footer { display: flex; justify-content: space-between; gap: 20px; padding-top: 20px; border-top: 1px solid var(--report-rule); color: var(--color-ink-tertiary); font-size: 10px; } +.personal-report-footer span:last-child { overflow-wrap: anywhere; text-align: right; } +.personal-report-avoid-break { break-inside: avoid-page; } +.personal-report-avoid-break-row { break-inside: avoid; } + +@media (max-width: 760px) { + .personal-report-reader-body { padding: 0 16px 48px; } + .personal-report-actions > div { min-height: 50px; padding: 6px 16px; } + .personal-report-action-end > span { display: none; } + .personal-report-document { padding: 34px 0 24px; font-size: 14px; } + .personal-report-section { margin-bottom: 44px; } + .personal-report-cover-grid { grid-template-columns: 1fr; gap: 26px; } + .personal-report-cover h1 { font-size: clamp(40px, 13vw, 54px); } + .personal-report-metadata { gap: 7px; } + .personal-report-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 0; } + .personal-report-facts > div { padding: 17px 8px; } + .personal-report-facts > div + div { border-left: 0; } + .personal-report-facts > div:nth-child(even) { border-left: 1px solid var(--report-rule); } + .personal-report-facts > div:nth-child(n + 3) { border-top: 1px solid var(--report-rule); } + .personal-report-facts dd { font-size: 17px; } + .personal-report-priorities { grid-template-columns: 1fr; } + .personal-report-lead-grid, .personal-report-chart-grid, .personal-report-chart-grid.is-single { grid-template-columns: 1fr; gap: 34px; } + .personal-report-theme > header { grid-template-columns: 28px minmax(0, 1fr); } + .personal-report-theme > header .personal-report-claim-badge { grid-column: 2; justify-self: start; } + .personal-report-theme .personal-report-narrative, .personal-report-theme-details, .personal-report-evidence-links { margin-left: 0; padding-left: 0; } + .personal-report-theme-details { grid-template-columns: 1fr; gap: 18px; } + .personal-report-footer { display: grid; } + .personal-report-footer span:last-child { text-align: left; } } @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; - } + html, body { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; } + * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } + .personal-report-screen-only { display: none !important; } + .personal-report-print-always { display: block !important; } + .personal-report-appendix-details:not([open]) > .personal-report-print-always { display: block !important; } + .personal-report-reader { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; } + .personal-report-reader-body { width: auto !important; padding: 0 !important; } + .personal-report-document { width: auto !important; max-width: none !important; margin: 0 !important; padding: 0 !important; background: #fff !important; font-size: 9.6pt; line-height: 1.66; } + .personal-report-section { margin-bottom: 9mm; } + .personal-report-cover-grid { grid-template-columns: minmax(0, 1fr) 66mm; gap: 12mm; } + .personal-report-cover h1 { font-size: 30pt; } + .personal-report-deck { font-size: 10pt; } + .personal-report-facts { margin-top: 8mm; padding: 5mm 0; } + .personal-report-facts dd { font-size: 13pt; } + .personal-report-section-heading h2 { font-size: 18pt; } + .personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; } + .personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; } + .personal-report-chart-svg { max-width: none; margin-top: 3mm; } + .personal-report-chart-grid, .personal-report-chart-grid.is-single { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .personal-report-chart-grid.is-single { max-width: 118mm; } + .personal-report-table-wrap { max-width: none !important; overflow: visible !important; } + .personal-report-table-wrap table, .personal-report-key-evidence table { width: 100% !important; min-width: 0 !important; table-layout: fixed !important; } + .personal-report-table-wrap th, .personal-report-table-wrap td, .personal-report-key-evidence th, .personal-report-key-evidence td { overflow-wrap: anywhere !important; word-break: break-word; } + .personal-report-theme { padding: 6mm 0; } + .personal-report-theme h3, .personal-report-subheading h2, .personal-report-subheading h3, .personal-report-appendix h3 { break-after: avoid-page; } + .personal-report-theme-details { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .personal-report-footer { margin-top: 8mm; } + .personal-report-avoid-break { break-inside: avoid-page; } + .personal-report-avoid-break-row { break-inside: avoid; } } /* ---- Membership page ---- */ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 59ead308..aa4df977 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -17,7 +17,6 @@ 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 { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; @@ -44,7 +43,6 @@ import { birthTimePersistenceValues, declaredBirthInputChanged, describeBirthTimeDraft, - isBirthTimeReadyForConsultation, isDeclaredBirthProfileComplete, isBirthTimeDraftReady, normalizePersistedBirthDate, @@ -1198,25 +1196,6 @@ export default function Home() { 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; @@ -3099,6 +3078,7 @@ export default function Home() { }} onAccountMenuOpenChange={setAccountMenuOpen} onNewChat={() => void startNewChat()} + onOpenReports={() => window.location.assign("/reports")} onSelectSession={selectSession} onOpenProfile={() => openAccountDialog("profile")} onOpenRedeem={() => window.location.assign(membershipHref("account-menu", { redeem: true }))} @@ -3127,12 +3107,6 @@ export default function Home() { {activeSession?.title || "新对话"}
- {reportEntryVisible && activeSession && ( - - )} ); } diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx new file mode 100644 index 00000000..681a39b8 --- /dev/null +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -0,0 +1,220 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, Check, Clock3, FileText, LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { GeneratePersonalReportButton } from "./generate-personal-report-button"; +import { Button } from "@/components/ui/button"; + +type ReportListItem = Readonly<{ + id: string; + reportType: string; + presentationMode: string; + themes: readonly string[]; + status: "generating" | "ready" | "failed"; + failureCode: string | null; + createdAt: string; + completedAt: string | null; +}>; + +type CenterState = + | { phase: "loading"; reports: readonly ReportListItem[] } + | { phase: "ready"; reports: readonly ReportListItem[] } + | { phase: "unauthorized"; reports: readonly ReportListItem[] } + | { phase: "error"; reports: readonly ReportListItem[] }; + +const STATUS_COPY = { + generating: { label: "生成中", description: "已在后台处理,你可以离开此页面。" }, + ready: { label: "已完成", description: "报告已生成,可随时查看或保存为 PDF。" }, + failed: { label: "未完成", description: "本次生成没有产出可用报告,可重新生成。" }, +} as const; + +function readReports(value: unknown): ReportListItem[] { + if (!value || typeof value !== "object" || !("reports" in value)) return []; + const reports = (value as { reports?: unknown }).reports; + if (!Array.isArray(reports)) return []; + return reports.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const row = entry as Record; + if (typeof row.id !== "string" || !["generating", "ready", "failed"].includes(String(row.status))) { + return []; + } + return [{ + id: row.id, + reportType: typeof row.reportType === "string" ? row.reportType : "personal_full", + presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default", + themes: Array.isArray(row.themes) + ? row.themes.filter((theme): theme is string => typeof theme === "string") + : [], + status: row.status as ReportListItem["status"], + failureCode: typeof row.failureCode === "string" ? row.failureCode : null, + createdAt: typeof row.createdAt === "string" ? row.createdAt : "", + completedAt: typeof row.completedAt === "string" ? row.completedAt : null, + }]; + }); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "时间未知"; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function StatusIcon({ status }: { status: ReportListItem["status"] }) { + if (status === "generating") return