feat: add global asynchronous report center
Staging Backend Quality Gate / validate (push) Has been cancelled
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Codex
2026-08-09 20:24:09 -07:00
parent a17ff258d8
commit 65bcef3001
17 changed files with 1257 additions and 648 deletions
+32
View File
@@ -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)
+84 -1
View File
@@ -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<string, unknown
return NextResponse.json(response.body, { status: response.status });
}
function listReportView(value: unknown) {
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
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<typeof createPersonalReportAgent>[0]),
skillSnapshot: resolveSkillSnapshot(),
deferGeneration: (task) => after(task),
};
const response = await resolveReportCreate(deps);
+228 -43
View File
@@ -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 ---- */
+1 -27
View File
@@ -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() {
<strong>{activeSession?.title || "新对话"}</strong>
</div>
<div className="chat-header-actions">
{reportEntryVisible && activeSession && (
<GeneratePersonalReportButton
sessionId={activeSession.id}
evidenceState={reportEvidenceState}
/>
)}
<button className="credit-button" type="button" onClick={() => window.location.assign(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "个人报告中心 · Jyotisha",
robots: { index: false, follow: false },
};
export default function ReportsPage() {
return <PersonalReportCenter />;
}
+19
View File
@@ -3,6 +3,7 @@
import { Menu } from "@base-ui/react/menu";
import {
ChevronRight,
FileText,
Gift,
LogOut,
MessageSquareText,
@@ -52,6 +53,7 @@ export type AppSidebarProps = {
sessionControls: SidebarSessionControls;
onAccountMenuOpenChange: (open: boolean) => void;
onNewChat: () => void;
onOpenReports: () => void;
onSelectSession: (sessionId: string) => void;
onOpenProfile: () => void;
onOpenRedeem: () => void;
@@ -69,6 +71,7 @@ export function AppSidebar({
sessionControls,
onAccountMenuOpenChange,
onNewChat,
onOpenReports,
onSelectSession,
onOpenProfile,
onOpenRedeem,
@@ -94,6 +97,11 @@ export function AppSidebar({
if (isMobile) setOpenMobile(false);
}
function handleOpenReports() {
onOpenReports();
if (isMobile) setOpenMobile(false);
}
function handleExpandHistory() {
setOpen(true);
window.requestAnimationFrame(() => {
@@ -121,6 +129,17 @@ export function AppSidebar({
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新对话"}</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip="我的报告"
onClick={handleOpenReports}
>
<FileText aria-hidden="true" />
{showExpandedContent ? <span>我的报告</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
@@ -1,14 +1,11 @@
/**
* Home/chat entry point for generating a personal report.
* Global 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
* - Strictly client-side and never sends birth data, chat text, or a session
* identifier. The server uses the authenticated user's canonical profile.
* - POST /api/reports with crypto.randomUUID requestId, reportType
* personal_full, presentationMode default, and default themes. 202/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.
@@ -27,7 +24,6 @@ export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing"]
export interface PersonalReportCreateRequest {
requestId: string;
sessionId: string | null;
reportType: "personal_full";
presentationMode: "default";
themes: readonly string[];
@@ -50,11 +46,9 @@ export function createReportRequestId(): string | 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],
@@ -88,12 +82,12 @@ function readServerCode(json: unknown): string | null {
}
/**
* Classify POST /api/reports responses (route: 201 created, 200 idempotent
* Classify POST /api/reports responses (route: 202 accepted, 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 (status === 202 || status === 201 || status === 200) {
if (isRecord(json) && isRecord(json.report) && json.report.status === "failed") {
return { kind: "hint", message: "上次生成未成功,请稍后重试。" };
}
@@ -140,19 +134,11 @@ export function classifyCreateResponse(status: number, json: unknown): CreateRep
}
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";
onCreated?: (reportId: string) => void;
}
export function GeneratePersonalReportButton({
sessionId,
evidenceState,
onCreated,
}: GeneratePersonalReportButtonProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
@@ -170,7 +156,7 @@ export function GeneratePersonalReportButton({
toast.error("请使用支持安全请求标识的现代浏览器。");
return;
}
const body = buildPersonalReportCreateRequest(requestId, sessionId);
const body = buildPersonalReportCreateRequest(requestId);
const response = await fetch("/api/reports", {
method: "POST",
credentials: "same-origin",
@@ -185,7 +171,11 @@ export function GeneratePersonalReportButton({
}
const outcome = classifyCreateResponse(response.status, json);
if (outcome.kind === "navigate") {
router.push(`/reports/${encodeURIComponent(outcome.reportId)}`);
if (onCreated) {
onCreated(outcome.reportId);
} else {
router.push(`/reports/${encodeURIComponent(outcome.reportId)}`);
}
return;
}
toast.error(outcome.message);
@@ -197,20 +187,16 @@ export function GeneratePersonalReportButton({
}
}
const title = evidenceState === "ready"
? "本次对话包含工作流证据记录,可尝试生成个人报告;最终以服务端校验为准。"
: "生成个人报告;服务端将校验本次咨询是否存在可用证据,无证据时会提示。";
return (
<button
type="button"
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap 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}
title="根据已确认的个人出生资料生成完整报告"
>
<FileText aria-hidden="true" className="size-4" />
{submitting ? "正在生成…" : "生成个人报告"}
{submitting ? "正在创建…" : "生成完整报告"}
</button>
);
}
@@ -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<string, unknown>;
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 <LoaderCircle aria-hidden="true" className="size-4 animate-spin" />;
if (status === "ready") return <Check aria-hidden="true" className="size-4" />;
return <TriangleAlert aria-hidden="true" className="size-4" />;
}
export function PersonalReportCenter() {
const [state, setState] = useState<CenterState>({ phase: "loading", reports: [] });
const cancelled = useRef(false);
const load = useCallback(async (showLoading = false) => {
if (showLoading) {
setState((current) => ({ phase: "loading", reports: current.reports }));
}
try {
const response = await fetch("/api/reports", {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const json: unknown = await response.json().catch(() => null);
if (cancelled.current) return;
if (response.status === 401) {
setState({ phase: "unauthorized", reports: [] });
return;
}
if (!response.ok) {
setState((current) => ({ phase: "error", reports: current.reports }));
return;
}
setState({ phase: "ready", reports: readReports(json) });
} catch {
if (!cancelled.current) {
setState((current) => ({ phase: "error", reports: current.reports }));
}
}
}, []);
useEffect(() => {
cancelled.current = false;
const timer = window.setTimeout(() => void load(), 0);
return () => {
window.clearTimeout(timer);
cancelled.current = true;
};
}, [load]);
const hasGenerating = state.reports.some((report) => report.status === "generating");
useEffect(() => {
if (!hasGenerating) return;
const timer = window.setInterval(() => void load(), 3000);
return () => window.clearInterval(timer);
}, [hasGenerating, load]);
const latestReady = useMemo(
() => state.reports.find((report) => report.status === "ready") ?? null,
[state.reports],
);
if (state.phase === "unauthorized") {
return (
<main className="report-center-shell report-center-message">
<FileText aria-hidden="true" className="size-8 text-primary" />
<h1>请先登录</h1>
<p>登录后即可生成和查看你的个人报告。</p>
<Button render={<Link href="/login" />}>去登录</Button>
</main>
);
}
return (
<main className="report-center-shell">
<header className="report-center-topbar">
<Link href="/" className="report-center-back"><ArrowLeft aria-hidden="true" />返回对话</Link>
<span>Jyotisha Reports</span>
</header>
<section className="report-center-hero">
<div>
<p className="report-center-kicker">PERSONAL INTELLIGENCE REPORT</p>
<h1>个人报告</h1>
<p>基于你已确认的出生资料生成,与某一次对话无关。创建后会在后台继续处理,你无需停留等待。</p>
</div>
<GeneratePersonalReportButton onCreated={() => void load()} />
</section>
<section className="report-center-summary" aria-label="报告概览">
<div><strong>{state.reports.length}</strong><span>全部报告</span></div>
<div><strong>{state.reports.filter((report) => report.status === "ready").length}</strong><span>已完成</span></div>
<div><strong>{hasGenerating ? "进行中" : latestReady ? "可查看" : "待生成"}</strong><span>当前状态</span></div>
</section>
<section className="report-center-section">
<div className="report-center-section-heading">
<div>
<p className="report-center-kicker">REPORT ARCHIVE</p>
<h2>报告记录</h2>
</div>
<button type="button" onClick={() => void load(true)} aria-label="刷新报告列表">
<RefreshCw aria-hidden="true" className={state.phase === "loading" ? "animate-spin" : ""} />刷新
</button>
</div>
{state.phase === "error" && (
<p className="report-center-alert" role="alert">列表读取失败,请检查网络后重试。</p>
)}
{state.phase === "loading" && state.reports.length === 0 ? (
<div className="report-center-empty" role="status">
<LoaderCircle aria-hidden="true" className="size-6 animate-spin" />正在读取报告…
</div>
) : state.reports.length === 0 ? (
<div className="report-center-empty">
<Clock3 aria-hidden="true" className="size-7" />
<h3>还没有个人报告</h3>
<p>点击“生成完整报告”,创建后即可离开;完成时这里会自动更新。</p>
</div>
) : (
<div className="report-center-list">
{state.reports.map((report, index) => {
const copy = STATUS_COPY[report.status];
return (
<article className="report-center-card" key={report.id}>
<div className="report-center-card-index">{String(index + 1).padStart(2, "0")}</div>
<div className="report-center-card-body">
<div className={`report-center-status is-${report.status}`}>
<StatusIcon status={report.status} />{copy.label}
</div>
<h3>{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}</h3>
<p>{copy.description}</p>
<small>{formatDate(report.createdAt)} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} variant="outline">
查看报告
</Button>
) : report.status === "generating" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} variant="ghost">
查看进度
</Button>
) : (
<span className="report-center-failure">{report.failureCode ?? "生成失败"}</span>
)}
</article>
);
})}
</div>
)}
</section>
</main>
);
}
@@ -1,26 +1,24 @@
/**
* Renders a schema-validated ReportDocument v1 with a FIXED user-visible order:
* Editorial personal-report reader inspired by a research memo:
*
* 1. 封面与资料状态
* 2. D1 命盘 SVG 与基础信息(仅真实数据存在时)
* 3. 核心摘要
* 4. 主题叙事 / 可执行建议 / 待核验问题
* 5. 证据附录(Technique Audit、冲突、blocked;research 默认展开,default 默认折叠)
* 6. 声明
* 1. Subject, metadata and four fast facts
* 2. Executive judgement and priorities
* 3. D1 chart with a compact evidence table
* 4. Additional charts and thematic narratives
* 5. Evidence appendix and disclaimer
*
* 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).
* The visual order is deliberately answer-first. All content still comes from
* the validated ReportDocument v1; no report text, chart or evidence is
* fabricated in the reader.
*/
"use client";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { findChart, VedicChartSvg } from "./vedic-chart-svg";
import type {
ChartV1,
ClaimStatus,
EvidenceAppendix,
ReportDocumentV1,
@@ -32,7 +30,7 @@ const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
single_system_inference: "单系统推断",
parameter_sensitive: "参数敏感",
unclosed_divisional_chart: "分盘未闭环",
user_history_verification_required: "需用户历史核验",
user_history_verification_required: "需历史核验",
blocked: "阻塞",
};
@@ -55,6 +53,11 @@ const CONFLICT_STATUS_LABELS: Record<string, string> = {
resolved: "已解决",
};
const PLANET_ALIASES = {
sun: ["太阳", "Sun", "Surya"],
moon: ["月亮", "月球", "Moon", "Chandra"],
} as const;
function claimStatusLabel(status: string): string {
return CLAIM_STATUS_LABELS[status as ClaimStatus] ?? status;
}
@@ -62,21 +65,21 @@ function claimStatusLabel(status: string): string {
function badgeClass(status: string): string {
switch (status) {
case "multi_system_consensus":
return "bg-success-muted text-success border border-success/30";
return "is-success";
case "blocked":
return "bg-danger-muted text-danger border border-danger/30";
return "is-danger";
case "parameter_sensitive":
case "unclosed_divisional_chart":
case "user_history_verification_required":
return "bg-[#f6efdd] text-warning border border-warning/30";
return "is-warning";
default:
return "bg-canvas-muted text-ink-secondary border border-border";
return "is-neutral";
}
}
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)}`}>
<span className={`personal-report-claim-badge personal-report-avoid-break ${badgeClass(status)}`}>
{claimStatusLabel(status)}
</span>
);
@@ -91,193 +94,186 @@ function splitParagraphs(text: string): string[] {
function NarrativeText({ text }: { text: string }) {
const paragraphs = splitParagraphs(text);
if (paragraphs.length === 0) {
return null;
}
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>
))}
</>
<div className="personal-report-narrative">
{paragraphs.map((paragraph, index) => <p key={index}>{paragraph}</p>)}
</div>
);
}
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,
);
function formatGeneratedAt(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
}
if (renderableCharts.length === 0) {
return null;
}
function findPlanet(chart: ChartV1 | undefined, aliases: readonly string[]) {
if (!chart?.planets) return undefined;
return chart.planets.find((planet) => aliases.some((alias) => planet.name.toLowerCase() === alias.toLowerCase()));
}
function planetPlacement(chart: ChartV1 | undefined, aliases: readonly string[]): string {
const planet = findPlanet(chart, aliases);
return planet ? `${planet.sign} · 第${planet.houseNumber}宫` : "暂无数据";
}
function ReportCover({ document, d1 }: { document: ReportDocumentV1; d1: ChartV1 | undefined }) {
const risingSign = d1?.houses.find((house) => house.houseNumber === 1)?.sign ?? "暂无数据";
const facts = [
{ value: risingSign, label: "上升星座" },
{ value: planetPlacement(d1, PLANET_ALIASES.sun), label: "太阳落点" },
{ value: planetPlacement(d1, PLANET_ALIASES.moon), label: "月亮落点" },
{ value: claimStatusLabel(document.executiveSummary.overallClaimStatus), label: "证据状态" },
];
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>
))}
<header className="personal-report-cover personal-report-section">
<div className="personal-report-cover-grid">
<div>
<p className="personal-report-eyebrow">
个人命盘研读 · {document.presentationMode === "research" ? "研究模式" : "标准模式"}
</p>
<h1>{document.subject.displayName}</h1>
<p className="personal-report-deck">{document.executiveSummary.headline}</p>
</div>
<dl className="personal-report-metadata">
<div><dt>出生地</dt><dd>{document.subject.birthPlaceLabel}</dd></div>
<div><dt>生时状态</dt><dd>{BIRTH_TIME_STATUS_LABELS[document.subject.birthTimeStatus] ?? document.subject.birthTimeStatus}</dd></div>
<div><dt>呈现模式</dt><dd>{document.presentationMode === "research" ? "研究模式" : "标准模式"}</dd></div>
<div><dt>生成日期</dt><dd>{formatGeneratedAt(document.generatedAt)}</dd></div>
</dl>
</div>
</section>
<dl className="personal-report-facts personal-report-avoid-break">
{facts.map((fact) => (
<div key={fact.label}>
<dd>{fact.value}</dd>
<dt>{fact.label}</dt>
</div>
))}
</dl>
</header>
);
}
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>
<section aria-labelledby="report-summary" className="personal-report-section personal-report-summary">
<div className="personal-report-section-heading">
<p>Executive judgement</p>
<h2 id="report-summary">核心判断</h2>
</div>
<NarrativeText text={summary.summary} />
{summary.priorities.length > 0 && (
<>
<p className="personal-report-callout personal-report-avoid-break">
<strong>首要关注:</strong>{summary.priorities[0]}
</p>
{summary.priorities.length > 1 && (
<ol className="personal-report-priorities">
{summary.priorities.slice(1).map((priority, index) => (
<li key={index}><span>{String(index + 2).padStart(2, "0")}</span>{priority}</li>
))}
</ol>
</>
)}
)}
</>
)}
</section>
);
}
function D1EvidenceTable({ chart }: { chart: ChartV1 }) {
const risingSign = chart.houses.find((house) => house.houseNumber === 1)?.sign ?? "暂无数据";
const rows = [
{ label: "上升", value: risingSign },
...(chart.planets ?? []).map((planet) => ({
label: planet.name,
value: `${planet.sign} · 第${planet.houseNumber}宫 · ${planet.longitudeDegrees.toFixed(1)}°${planet.retrograde ? " · 逆行" : ""}`,
})),
];
return (
<div className="personal-report-key-evidence">
<div className="personal-report-subheading">
<h3>关键落点</h3>
<span>证据摘要</span>
</div>
<table>
<caption className="sr-only">本命盘关键落点</caption>
<thead><tr><th>项目</th><th>落点 / 说明</th></tr></thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.label}-${index}`} className="personal-report-avoid-break-row">
<th scope="row">{row.label}</th><td>{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function LeadChartSection({ chart }: { chart: ChartV1 | undefined }) {
if (!chart) return null;
return (
<section aria-labelledby="report-charts" className="personal-report-section personal-report-lead-chart">
<div className="personal-report-lead-grid">
<figure className="personal-report-chart-figure personal-report-avoid-break">
<div className="personal-report-subheading">
<h2 id="report-charts">本命盘</h2>
<span>{chart.title}</span>
</div>
<VedicChartSvg chart={chart} ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`} />
<figcaption>图 1 · {chart.title}。数字为宫位编号,宫内列出星座与主要落点。</figcaption>
</figure>
<D1EvidenceTable chart={chart} />
</div>
</section>
);
}
function EvidenceChips({ section, knownEvidenceIds }: { section: ThematicSectionV1; knownEvidenceIds: Set<string> }) {
const refs = section.evidenceRefs ?? [];
if (refs.length === 0) {
return null;
}
function PlanetTable({ chart }: { chart: ChartV1 }) {
if (!chart.planets || chart.planets.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>
<div className="personal-report-table-wrap">
<table className="personal-report-planet-table">
<caption className="sr-only">{chart.title}行星信息</caption>
<thead><tr><th>行星</th><th>星座</th><th>黄经</th><th>宫位</th><th>逆行</th></tr></thead>
<tbody>
{chart.planets.map((planet) => (
<tr key={planet.name} className="personal-report-avoid-break-row">
<td>{planet.name}</td><td>{planet.sign}</td><td>{planet.longitudeDegrees.toFixed(1)}°</td>
<td>第{planet.houseNumber}宫</td><td>{planet.retrograde ? "是" : "否"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
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;
}
function AdditionalChartsSection({ charts }: { charts: ChartV1[] }) {
if (charts.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} />
<section aria-labelledby="report-additional-charts" className="personal-report-section personal-report-additional-charts">
<div className="personal-report-section-heading">
<p>Supporting charts</p>
<h2 id="report-additional-charts">分盘证据</h2>
</div>
<div className={charts.length === 1 ? "personal-report-chart-grid is-single" : "personal-report-chart-grid"}>
{charts.map((chart) => (
<article key={chart.id} className="personal-report-chart-card">
<header><h3>{chart.title}</h3><ClaimBadge status={chart.claimStatus} /></header>
<VedicChartSvg chart={chart} ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`} />
<PlanetTable chart={chart} />
</article>
))}
</div>
@@ -285,40 +281,69 @@ function ThematicNarrativeSection({ document }: { document: ReportDocumentV1 })
);
}
function AuditTable({ appendix }: { appendix: EvidenceAppendix }) {
if ((appendix.techniqueAudit ?? []).length === 0) {
return null;
}
function EvidenceChips({ section, knownEvidenceIds }: { section: ThematicSectionV1; knownEvidenceIds: Set<string> }) {
if (section.evidenceRefs.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">
<ul className="personal-report-evidence-links">
{section.evidenceRefs.map((ref) => knownEvidenceIds.has(ref) ? (
<li key={ref}><a href={`#evidence-${ref}`}>证据 · {ref}</a></li>
) : <li key={ref}>证据 · {ref}</li>)}
</ul>
);
}
function ThematicNarrativeSection({ document }: { document: ReportDocumentV1 }) {
const knownEvidenceIds = useMemo(() => new Set([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]), [document]);
if (document.thematicNarrative.length === 0) return null;
return (
<section aria-labelledby="report-themes" className="personal-report-section personal-report-themes">
<div className="personal-report-section-heading">
<p>Interpretation</p>
<h2 id="report-themes">主题解读与行动建议</h2>
</div>
{document.thematicNarrative.map((section, index) => (
<article key={section.id} aria-labelledby={`theme-${section.id}`} className="personal-report-theme">
<header>
<span>{String(index + 1).padStart(2, "0")}</span>
<h3 id={`theme-${section.id}`}>{section.title}</h3>
<ClaimBadge status={section.claimStatus} />
</header>
<NarrativeText text={section.narrative} />
{(section.actions.length > 0 || section.caveats.length > 0) && (
<div className="personal-report-theme-details">
{section.actions.length > 0 && (
<div><h4>可执行建议</h4><ul>{section.actions.map((action, actionIndex) => <li key={actionIndex}>{action}</li>)}</ul></div>
)}
{section.caveats.length > 0 && (
<div><h4>待核验与边界</h4><ul>{section.caveats.map((caveat, caveatIndex) => <li key={caveatIndex}>{caveat}</li>)}</ul></div>
)}
</div>
)}
<EvidenceChips section={section} knownEvidenceIds={knownEvidenceIds} />
</article>
))}
</section>
);
}
function AuditTable({ appendix }: { appendix: EvidenceAppendix }) {
if (appendix.techniqueAudit.length === 0) return null;
return (
<div className="personal-report-table-wrap">
<table className="personal-report-audit-table">
<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>
<thead><tr><th>技法</th><th>状态</th><th>使用</th><th>说明</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 key={row.id} id={`evidence-${row.id}`} className="personal-report-avoid-break-row">
<td><strong>{row.techniqueName}</strong><small>{row.techniqueId}</small></td>
<td>{AUDIT_STATUS_LABELS[row.status] ?? row.status}</td>
<td>{row.used ? "已使用" : "未使用"}</td><td>{row.notes ?? ""}</td>
</tr>
))}
</tbody>
@@ -329,168 +354,80 @@ function AuditTable({ appendix }: { appendix: EvidenceAppendix }) {
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;
const expandedByDefault = appendix.expandedByDefault || document.presentationMode === "research";
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>
)}
<section aria-labelledby="report-appendix" className="personal-report-section personal-report-appendix">
<div className="personal-report-section-heading">
<p>Method & evidence</p><h2 id="report-appendix">证据附录</h2>
</div>
<p className="personal-report-appendix-intro">Technique Audit、冲突、计算证据与 blocked 技法。打印时始终展开。</p>
{hasContent && (
<details className="personal-report-appendix-details" open={expandedByDefault}>
<summary className="personal-report-screen-only personal-report-disclosure">展开 / 收起附录</summary>
<div id="report-appendix-body" className="personal-report-print-always">
<h3>Technique Audit Table</h3><AuditTable appendix={appendix} />
{appendix.conflicts.length > 0 && (
<><h3>冲突与降级</h3><ul className="personal-report-conflicts">
{appendix.conflicts.map((conflict) => (
<li key={conflict.id} id={`evidence-${conflict.id}`}>
<strong>{conflict.description}</strong><span>影响:{conflict.impact}</span>
<small>{CONFLICT_STATUS_LABELS[conflict.status] ?? conflict.status}</small>
</li>
))}
</ul></>
)}
{appendix.calculationEvidence.length > 0 && (
<><h3>计算证据</h3><div className="personal-report-table-wrap"><table>
<caption className="sr-only">计算证据</caption>
<thead><tr><th>条目</th><th>内容</th><th>来源</th></tr></thead>
<tbody>{appendix.calculationEvidence.map((row) => (
<tr key={row.id} id={`evidence-${row.id}`} className="personal-report-avoid-break-row">
<th scope="row">{row.label}</th><td>{row.value}</td><td>{row.source}</td>
</tr>
))}</tbody>
</table></div></>
)}
{appendix.blockedTechniques.length > 0 && (
<><h3>Blocked 技法</h3><ul className="personal-report-blocked-list">
{appendix.blockedTechniques.map((name, index) => <li key={`${name}-${index}`}>{name}</li>)}
</ul></>
)}
</div>
</details>
)}
</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 aria-labelledby="report-disclaimer" className="personal-report-section personal-report-disclaimer">
<h2 id="report-disclaimer">声明</h2><p>{document.disclaimer}</p>
</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>
const d1 = findChart(document.charts, "D1");
const additionalCharts = [findChart(document.charts, "D9"), findChart(document.charts, "D10")]
.filter((chart): chart is ChartV1 => chart !== undefined);
<ChartSection document={document} />
return (
<article className="personal-report-document">
<ReportCover document={document} d1={d1} />
<ExecutiveSummarySection document={document} />
<LeadChartSection chart={d1} />
<AdditionalChartsSection charts={additionalCharts} />
<ThematicNarrativeSection document={document} />
<EvidenceAppendixSection document={document} />
<DisclaimerSection document={document} />
</div>
<footer className="personal-report-footer">
<span>个人命盘研读 · {document.presentationMode === "research" ? "研究模式" : "标准模式"}</span>
<span>报告编号 {document.reportId}</span>
</footer>
</article>
);
}
@@ -4,7 +4,7 @@
* 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.
* ready. The print action is mounted only after a validated ready document exists.
*
* The GET envelope is classified here by status discriminant only; the ready
* reportDocument payload itself is validated by the canonical
@@ -168,9 +168,12 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
{generating ? "报告正在生成中,请稍候…" : "正在加载报告…"}
</p>
{generating && (
<p className="max-w-md text-sm text-ink-secondary">
生成完成后页面会自动显示。
</p>
<>
<p className="max-w-md text-sm text-ink-secondary">
生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。
</p>
<Button render={<Link href="/reports" />} variant="ghost">返回报告中心</Button>
</>
)}
</main>
);
@@ -197,8 +200,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
<p className="max-w-md text-sm text-ink-secondary">
该报告不存在、已删除,或不属于当前账号。
</p>
<Button render={<Link href="/" />} variant="outline">
返回对话
<Button render={<Link href="/reports" />} variant="outline">
返回报告中心
</Button>
</main>
);
@@ -215,8 +218,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
<p className="max-w-md text-sm text-ink-secondary">
生成过程中出现问题,未产出可用报告。请稍后重试。
</p>
<Button type="button" variant="outline" onClick={() => void reload()}>
重试
<Button render={<Link href="/reports" />} variant="outline">
返回报告中心重新生成
</Button>
</main>
);
@@ -241,9 +244,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const document = state.document;
return (
<main className="min-h-svh bg-canvas pb-12">
<ReportActions />
<div className="px-4 pt-6">
<main className="personal-report-reader">
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
<ReportActions reportId={document.reportId} reportTitle={`${document.subject.displayName} · 个人报告`} />
<div className="personal-report-reader-body">
<PersonalReportDocumentView document={document} />
</div>
</main>
@@ -1,23 +1,64 @@
/** Header action bar for the personal report page. */
/** Sticky screen-only actions for a validated personal report. */
"use client";
import { useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Printer, TriangleAlert } from "lucide-react";
import {
detectPrintRestriction,
isPrintSupported,
printPersonalReport,
safeReportFilename,
} from "@/lib/client-report-export";
import { Button } from "@/components/ui/button";
interface ReportActionsProps {
reportId: string;
reportTitle?: string;
}
function subscribePrintCapability(onStoreChange: () => void): () => void {
queueMicrotask(onStoreChange);
return () => undefined;
}
export function ReportActions({ reportId, reportTitle }: ReportActionsProps) {
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const printSupported = useSyncExternalStore(subscribePrintCapability, isPrintSupported, () => false);
async function handlePrint() {
if (busy || !printSupported) return;
const restriction = detectPrintRestriction();
if (restriction.restricted) {
setNotice(restriction.message ?? "当前浏览器无法可靠打印,请在系统浏览器中打开本页。");
return;
}
setBusy(true);
setNotice(null);
try {
await printPersonalReport({ title: reportTitle?.trim() || safeReportFilename(reportId) });
} finally {
setBusy(false);
}
}
export function ReportActions() {
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 items-center">
<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" />
返回对话
<nav aria-label="报告操作" className="personal-report-actions personal-report-screen-only">
<div>
<Link href="/reports" className="personal-report-back">
<ArrowLeft aria-hidden="true" />返回报告中心
</Link>
<div className="personal-report-action-end">
{notice && <span role="status"><TriangleAlert aria-hidden="true" />{notice}</span>}
<Button type="button" size="sm" onClick={() => void handlePrint()} disabled={busy || !printSupported}>
<Printer aria-hidden="true" />{busy ? "正在准备…" : "打印 / 保存为 PDF"}
</Button>
</div>
</div>
</nav>
);
@@ -13,6 +13,8 @@
* 3 | 4 | 5 | 6
*/
import { useId } from "react";
import type { ChartV1 } from "@/lib/personal-report-contract";
type HouseV1 = ChartV1["houses"][number];
@@ -59,42 +61,55 @@ function occupantLines(house: HouseV1, chart: ChartV1): string[] {
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;
const MAX_RENDERED_OCCUPANTS = 8;
function truncateSvgLabel(value: string, maxCharacters: number): string {
const characters = Array.from(value);
if (characters.length <= maxCharacters) return value;
return `${characters.slice(0, Math.max(1, maxCharacters - 1)).join("")}…`;
}
function PlanetList({ lines, fontSize }: { lines: string[]; fontSize: number }) {
const y0 = 40;
const step = fontSize + 4;
function visibleOccupantLines(lines: string[]): string[] {
if (lines.length <= MAX_RENDERED_OCCUPANTS) return lines;
return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)} 项`];
}
function PlanetList({ lines }: { lines: string[] }) {
const visible = visibleOccupantLines(lines);
const twoColumns = visible.length > 4;
const fontSize = twoColumns ? 8.5 : visible.length > 2 ? 10 : 11;
const rowStep = twoColumns ? 13 : 15;
const maxCharacters = twoColumns ? 5 : 8;
return (
<>
{lines.map((line, index) => (
{visible.map((line, index) => {
const column = twoColumns ? Math.floor(index / 4) : 0;
const row = twoColumns ? index % 4 : index;
return (
<text
key={`${line}-${index}`}
x={6}
y={y0 + index * step}
x={6 + column * 49}
y={45 + row * rowStep}
fontSize={fontSize}
fill="#1d1d1f"
>
{line}
{truncateSvgLabel(line, maxCharacters)}
</text>
))}
);
})}
</>
);
}
export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
const chartId = useId().replace(/:/g, "");
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
@@ -105,6 +120,16 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
aria-label={ariaLabel}
className="personal-report-chart-svg"
>
<defs>
{HOUSE_NUMBERS.map((houseNumber) => {
const cell = HOUSE_CELLS[houseNumber];
return (
<clipPath key={houseNumber} id={`${chartId}-house-${houseNumber}`}>
<rect x={cell.x + 2} y={cell.y + 2} width={CELL - 4} height={CELL - 4} />
</clipPath>
);
})}
</defs>
{HOUSE_NUMBERS.map((houseNumber) => {
const cell = HOUSE_CELLS[houseNumber];
const house = chart.houses.find((h) => h.houseNumber === houseNumber);
@@ -128,7 +153,9 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
{house.sign}
</text>
)}
<PlanetList lines={lines} fontSize={fontSize} />
<g transform={`translate(${cell.x} ${cell.y})`} clipPath={`url(#${chartId}-house-${houseNumber})`}>
<PlanetList lines={lines} />
</g>
</g>
);
})}
@@ -141,6 +168,12 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
stroke="#32322f"
strokeWidth={1.25}
/>
<path
d={`M ${CELL * 2} ${CELL} L ${CELL * 3} ${CELL * 2} L ${CELL * 2} ${CELL * 3} L ${CELL} ${CELL * 2} Z`}
fill="none"
stroke="#32322f"
strokeWidth={1.25}
/>
{hasRetrogradeMarker && (
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} fill="#6a6963">
“逆”=逆行
+131 -100
View File
@@ -161,6 +161,12 @@ export type ReportCreateCoreDeps = Readonly<{
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
skillSnapshot: SkillSnapshot;
/**
* Production may schedule the expensive workflow after the HTTP response.
* Unit tests omit this hook and execute synchronously so the complete
* generation contract remains deterministic and directly testable.
*/
deferGeneration?: (task: () => Promise<void>) => void;
now?: () => Date;
}>;
@@ -302,115 +308,140 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
}
const row = begun.record;
// Real workflow evidence (main chain), never mock/example/random data.
if (!deps.model) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable);
return {
status: 502,
body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable },
};
}
const workflowTheme = payload.reportType === "personal_thematic"
? payload.themes[0]
: "general";
const workflowInput: ConsultationInput = {
year: birthDate.year,
month: birthDate.month,
day: birthDate.day,
hour: birthClock.hour,
minute: birthClock.minute,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
question: `请生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")})`,
theme: workflowTheme,
entryMode: "direct_chart",
};
let workflow: unknown;
try {
workflow = await deps.runWorkflow(workflowInput);
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
const workflowRecord = record(workflow);
if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
let packet: ReportEvidencePacket;
try {
packet = buildReportEvidencePacket({
workflow,
subject: {
displayName,
birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted",
birthPlaceLabel,
},
requestedThemes: payload.themes,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
candidateRange: birthTimeStatus === "accepted"
? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" }
: null,
skillSnapshot: deps.skillSnapshot,
});
} catch (error) {
// Real evidence could not support an honest report: fail closed, never
// generate an empty or sample-backed report.
if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
const finishGeneration = async (): Promise<ReportRouteResponse> => {
// Real workflow evidence (main chain), never mock/example/random data.
if (!deps.model) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable);
return {
status: 422,
body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable },
status: 502,
body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable },
};
}
throw error;
}
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
packet,
agent: deps.createAgent(deps.model),
now: deps.now,
});
const workflowTheme = payload.reportType === "personal_thematic"
? payload.themes[0]
: "general";
const workflowInput: ConsultationInput = {
year: birthDate.year,
month: birthDate.month,
day: birthDate.day,
hour: birthClock.hour,
minute: birthClock.minute,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
question: `请生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")})`,
theme: workflowTheme,
entryMode: "direct_chart",
};
if (result.status === "failed") {
await deps.persistence.markFailed(userId, row.id, result.failureCode);
let workflow: unknown;
try {
workflow = await deps.runWorkflow(workflowInput);
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
const workflowRecord = record(workflow);
if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
let packet: ReportEvidencePacket;
try {
packet = buildReportEvidencePacket({
workflow,
subject: {
displayName,
birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted",
birthPlaceLabel,
},
requestedThemes: payload.themes,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
candidateRange: birthTimeStatus === "accepted"
? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" }
: null,
skillSnapshot: deps.skillSnapshot,
});
} catch (error) {
// Real evidence could not support an honest report: fail closed, never
// generate an empty or sample-backed report.
if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 422,
body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
throw error;
}
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
packet,
agent: deps.createAgent(deps.model),
now: deps.now,
});
if (result.status === "failed") {
await deps.persistence.markFailed(userId, row.id, result.failureCode);
return {
status: 422,
body: {
error: result.failureCode === REPORT_STABLE_CODES.guardRejected
? "报告未通过确定性校验"
: "报告内容未通过结构校验",
code: result.failureCode,
},
};
}
try {
const readyRow = await deps.persistence.completeReady(userId, row.id, result.document);
return {
status: 201,
body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 422,
body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid },
};
}
};
if (deps.deferGeneration) {
deps.deferGeneration(async () => {
try {
await finishGeneration();
} catch {
// Unexpected model/adapter exceptions must not leave a permanent
// generating row. The stable schema failure is deliberately generic;
// report text, profile data and exception details are never logged.
try {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
} catch {
// A concurrent terminal transition already won; keep that state.
}
}
});
return {
status: 422,
body: {
error: result.failureCode === REPORT_STABLE_CODES.guardRejected
? "报告未通过确定性校验"
: "报告内容未通过结构校验",
code: result.failureCode,
},
status: 202,
body: { report: reportView(row) },
};
}
try {
const readyRow = await deps.persistence.completeReady(userId, row.id, result.document);
return {
status: 201,
body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 422,
body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid },
};
}
return finishGeneration();
}
export type ReportReadCoreDeps = Readonly<{
@@ -512,6 +512,23 @@ test("core create: 201 ready with a document on the happy path", async () => {
assert.ok(row?.reportDocument);
});
test("core create: production deferral returns 202 before background generation completes", async () => {
const persistence = new MemoryPersistence();
let scheduled: (() => Promise<void>) | null = null;
const response = await resolveReportCreate(baseDeps({
persistence,
deferGeneration: (task) => {
scheduled = task;
},
}));
assert.equal(response.status, 202);
assert.equal((response.body.report as { status?: string }).status, "generating");
assert.equal(persistence.rows.get(REPORT_ID)?.status, "generating");
assert.ok(scheduled);
await scheduled();
assert.equal(persistence.rows.get(REPORT_ID)?.status, "ready");
});
test("core create: 422 report_guard_rejected when the agent output violates the guard", async () => {
const persistence = new MemoryPersistence();
const violatingAgent: ReportAgentPort = {
@@ -787,6 +804,20 @@ test("POST route never generates HTML/PDF/base64 or local paths", () => {
assert.doesNotMatch(createRoute, /sendFile|createWriteStream|\/opt\/|\/var\/|\/Users\//);
});
test("POST defers expensive work and GET lists metadata without report bodies", () => {
assert.match(createRoute, /import \{ after, NextResponse \} from "next\/server"/);
assert.match(createRoute, /deferGeneration: \(task\) => after\(task\)/);
assert.match(createRoute, /export async function GET\(\)/);
assert.match(createRoute, /REPORT_LIST_COLUMNS/);
const listColumns = createRoute.slice(
createRoute.indexOf("const REPORT_LIST_COLUMNS"),
createRoute.indexOf("function sanitizedErrorCode"),
);
assert.doesNotMatch(listColumns, /report_document|calculation_hash|evidence_hash/);
assert.match(createRoute, /STALE_GENERATION_MS = 15 \* 60 \* 1000/);
assert.match(createRoute, /\.lt\("updated_at", staleBefore\)/);
});
test("stable error codes live in the dependency-free codes module", () => {
for (const code of [
"profile_incomplete",
+25 -29
View File
@@ -18,13 +18,17 @@ const componentSource = readFileSync(
"utf8",
);
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const reportCenterSource = readFileSync(
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
"utf8",
);
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("request shape: POST /api/reports body carries only report identity fields", () => {
const body = buildPersonalReportCreateRequest("request-id-1", "session-id-1");
assert.deepEqual(Object.keys(body).sort(), ["presentationMode", "reportType", "requestId", "sessionId", "themes"]);
const body = buildPersonalReportCreateRequest("request-id-1");
assert.deepEqual(Object.keys(body).sort(), ["presentationMode", "reportType", "requestId", "themes"]);
assert.equal(body.requestId, "request-id-1");
assert.equal(body.sessionId, "session-id-1");
assert.equal(body.reportType, "personal_full");
assert.equal(body.presentationMode, "default");
assert.deepEqual(body.themes, ["career", "marriage", "wealth", "timing"]);
@@ -33,9 +37,9 @@ test("request shape: POST /api/reports body carries only report identity fields"
assert.doesNotMatch(JSON.stringify(body), /birth|birthDate|latitude|longitude|timezone|profile|message|text/i);
});
test("request shape: sessionId is null when no real session exists", () => {
const body = buildPersonalReportCreateRequest("request-id-2", null);
assert.equal(body.sessionId, null);
test("request shape: global reports never carry a session identifier", () => {
const body = buildPersonalReportCreateRequest("request-id-2");
assert.equal("sessionId" in body, false);
});
test("requestId is a uuid v4", () => {
@@ -69,7 +73,7 @@ test("without crypto.randomUUID the request id is null and no request is sent",
assert.doesNotMatch(componentSource, /xxxxxxxx-xxxx-4xxx/);
});
test("201 created and 200 ready replay navigate to the report reader", () => {
test("202 accepted, 201 created and 200 ready replay navigate to the report reader", () => {
const report = {
id: "report-id-1",
requestId: "req",
@@ -82,6 +86,8 @@ test("201 created and 200 ready replay navigate to the report reader", () => {
};
const created = classifyCreateResponse(201, { report });
assert.deepEqual(created, { kind: "navigate", reportId: "report-id-1" });
const accepted = classifyCreateResponse(202, { report: { ...report, status: "generating" } });
assert.deepEqual(accepted, { kind: "navigate", reportId: "report-id-1" });
const replayed = classifyCreateResponse(200, { report, reportDocument: { schemaVersion: "report_document.v1" } });
assert.deepEqual(replayed, { kind: "navigate", reportId: "report-id-1" });
});
@@ -166,36 +172,26 @@ test("no birth data or chat text is sent from the entry component", () => {
// The request body is built only by buildPersonalReportCreateRequest, whose
// keys are exactly the report identity fields (covered above). The component
// posts that body as-is.
assert.match(componentSource, /const body = buildPersonalReportCreateRequest\(requestId, sessionId\)/);
assert.match(componentSource, /const body = buildPersonalReportCreateRequest\(requestId\)/);
assert.match(componentSource, /body: JSON\.stringify\(body\)/);
assert.doesNotMatch(componentSource, /const body = \{[\s\S]*?latitude/);
});
test("evidence honesty: ready vs unknown copy, never a fabricated boolean", () => {
assert.match(componentSource, /服务端将校验本次咨询是否存在可用证据/);
assert.match(componentSource, /最终以服务端校验为准/);
assert.match(pageSource, /workflowReceipt\s*\?\s*"ready"\s*:\s*"unknown"/);
assert.doesNotMatch(pageSource, /reportEvidenceState\s*=\s*true/);
test("global report copy uses the canonical profile and makes background work explicit", () => {
assert.match(componentSource, /authenticated user's canonical profile/);
assert.match(reportCenterSource, /与某一次对话无关/);
assert.match(reportCenterSource, /后台继续处理/);
assert.doesNotMatch(componentSource, /evidenceState|workflowReceipt/);
});
test("entry is gated on authenticated chat surface with usable profile", () => {
assert.match(pageSource, /reportEntryVisible = !rectificationSurfaceOpen/);
assert.match(pageSource, /profileComplete/);
assert.match(pageSource, /sessionType === "consultation"/);
assert.match(pageSource, /GeneratePersonalReportButton/);
assert.match(pageSource, /sessionId=\{activeSession\.id\}/);
assert.match(pageSource, /evidenceState=\{reportEvidenceState\}/);
test("entry is global in the sidebar and absent from the active session header", () => {
assert.match(sidebarSource, /我的报告/);
assert.match(sidebarSource, /onOpenReports/);
assert.match(pageSource, /onOpenReports=\{\(\) => window\.location\.assign\("\/reports"\)\}/);
assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/);
});
test("entry hides for reported/candidate birth time: client gate mirrors the API", () => {
// The API only accepts accepted/confirmed + usable active time (422 otherwise).
// The page must mirror that gate so reported/candidate users never see the CTA.
assert.match(pageSource, /isBirthTimeReadyForConsultation\(profile\)/);
assert.match(pageSource, /reportBirthTimeUsable/);
assert.match(pageSource, /&& reportBirthTimeUsable/);
assert.doesNotMatch(pageSource, /reportEntryVisible = [^;]*?birthTimeStatus === "reported"/);
assert.doesNotMatch(pageSource, /birthTimeStatus === "candidate"/);
test("reported/candidate birth times remain unusable for server-side report creation", () => {
const draftBase = {
date: "1990-01-01",
time: "12:00",
+44 -22
View File
@@ -34,29 +34,29 @@ test("canonical fixture passes the canonical contract parse", () => {
}
});
test("renders sections in the fixed order: cover -> D1 -> summary -> themes -> appendix -> disclaimer", () => {
test("renders the selected answer-first order: cover -> summary -> D1 -> themes -> appendix -> disclaimer", () => {
const markup = render(canonicalFixture);
const positions: Record<string, number> = {
subject: markup.indexOf(canonicalFixture.subject.displayName),
d1: markup.indexOf(canonicalFixture.charts[0].title),
summary: markup.indexOf("核心摘要"),
theme: markup.indexOf(canonicalFixture.thematicNarrative[0].title),
appendix: markup.indexOf("证据附录"),
disclaimer: markup.indexOf("声明"),
d1: markup.indexOf('id="report-charts"'),
summary: markup.indexOf('id="report-summary"'),
theme: markup.indexOf('id="report-themes"'),
appendix: markup.indexOf('id="report-appendix"'),
disclaimer: markup.indexOf('id="report-disclaimer"'),
};
for (const key of Object.keys(positions)) {
assert.ok(positions[key] >= 0, `${key} must be rendered`);
}
assert.ok(positions.subject < positions.d1, "cover comes before D1 chart");
assert.ok(positions.d1 < positions.summary, "D1 chart comes before the summary");
assert.ok(positions.summary < positions.theme, "summary comes before themes");
assert.ok(positions.subject < positions.summary, "cover comes before the summary");
assert.ok(positions.summary < positions.d1, "answer-first summary comes before the D1 chart");
assert.ok(positions.d1 < positions.theme, "D1 chart comes before themes");
assert.ok(positions.theme < positions.appendix, "themes come before the appendix");
assert.ok(positions.appendix < positions.disclaimer, "appendix comes before the disclaimer");
});
test("Technique Audit Table is never placed before the summary", () => {
const markup = render(canonicalFixture);
const summaryAt = markup.indexOf("核心摘要");
const summaryAt = markup.indexOf("核心判断");
const auditAt = markup.indexOf("Technique Audit Table");
assert.ok(summaryAt >= 0 && auditAt >= 0, "both summary and audit table render");
assert.ok(auditAt > summaryAt, "audit table must appear after the summary");
@@ -65,12 +65,12 @@ test("Technique Audit Table is never placed before the summary", () => {
test("evidence appendix is collapsed by default in default mode and expanded in research mode", () => {
const auditText = "MEVG / Global Web Evidence";
const collapsed = render(canonicalFixture);
assert.match(collapsed, /personal-report-print-always hidden/, "default mode starts collapsed");
assert.doesNotMatch(collapsed, /personal-report-print-always block/, "default mode must not render expanded");
assert.ok(collapsed.indexOf(auditText) >= 0, "content exists in the DOM but is visually hidden");
assert.match(collapsed, /<details class="personal-report-appendix-details">/, "default mode starts collapsed");
assert.match(collapsed, /<summary[^>]*>展开 \/ 收起附录<\/summary>/, "native disclosure remains usable without hydration");
assert.ok(collapsed.indexOf(auditText) >= 0, "content exists in the closed details element");
const expanded = render(withPresentationMode(canonicalFixture, "research"));
assert.match(expanded, /personal-report-print-always block/, "research mode starts expanded");
assert.match(expanded, /<details class="personal-report-appendix-details" open="">/, "research mode starts expanded");
assert.ok(expanded.indexOf(auditText) >= 0, "research mode renders appendix content");
});
@@ -78,7 +78,7 @@ test("expandedByDefault=true expands the appendix even in default mode", () => {
const document = structuredClone(canonicalFixture);
document.evidenceAppendix.expandedByDefault = true;
const markup = render(document);
assert.match(markup, /personal-report-print-always block/, "expandedByDefault forces the expanded state");
assert.match(markup, /<details class="personal-report-appendix-details" open="">/, "expandedByDefault forces the expanded state");
});
test("D1 SVG renders when real houses exist; D9/D10 are never fabricated", () => {
@@ -112,7 +112,17 @@ test("D1 SVG renders when real houses exist; D9/D10 are never fabricated", () =>
// No charts at all -> no SVG, no fabricated chart section.
const noCharts = render(withCharts(canonicalFixture, []));
assert.equal(svgCount(noCharts), 0);
assert.equal(noCharts.indexOf("命盘与基础信息"), -1);
assert.doesNotMatch(noCharts, /personal-report-chart-figure/);
});
test("each house owns its occupant coordinates and dense houses stay clipped and bounded", () => {
const dense = structuredClone(canonicalFixture);
dense.charts[0].houses[0].occupants = Array.from({ length: 12 }, (_, index) => `占星体${index + 1}`);
const markup = render(dense);
assert.match(markup, /transform="translate\(0 100\)"/, "house 1 uses its own cell offset");
assert.match(markup, /transform="translate\(300 0\)"/, "house 9 uses its own cell offset");
assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own cell");
assert.match(markup, /\+5 项/, "dense houses summarize overflow instead of painting twelve overlapping lines");
});
test("evidenceRefs only produce in-page anchors for ids present in the appendix", () => {
@@ -149,13 +159,22 @@ test("long theme sections are not forced to avoid page breaks; only small elemen
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const printBlock = globals.slice(globals.indexOf("personal-report: unique block"));
assert.match(printBlock, /@page\s*\{[^}]*size:\s*A4/);
assert.doesNotMatch(printBlock, /@page\s*\{/, "report page size must not leak into global print CSS");
const pageSource = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
);
assert.match(pageSource, /<style media="print">\{"@page \{ size: A4; margin: 13mm 12mm 14mm; \}"\}<\/style>/);
assert.match(printBlock, /@media print/);
assert.match(printBlock, /\.personal-report-screen-only\s*\{[\s\S]*?display:\s*none\s*!important/);
assert.match(printBlock, /print-color-adjust:\s*exact\s*!important/);
assert.match(printBlock, /\.personal-report-avoid-break\s*\{[\s\S]*?break-inside:\s*avoid-page/);
assert.match(printBlock, /\.personal-report-avoid-break-row\s*\{[\s\S]*?break-inside:\s*avoid/);
assert.match(printBlock, /\.personal-report-print-always\s*\{[\s\S]*?display:\s*block\s*!important/);
assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?height:\s*100dvh[\s\S]*?overflow-y:\s*auto/);
assert.match(printBlock, /html, body\s*\{[^}]*height:\s*auto\s*!important[^}]*overflow:\s*visible\s*!important/);
assert.match(printBlock, /\.personal-report-table-wrap\s*\{[^}]*overflow:\s*visible\s*!important/);
assert.match(printBlock, /table-layout:\s*fixed\s*!important/);
});
test("GET envelope classification: 401/404/ready/generating/failed/invalid (real API shape)", () => {
@@ -227,13 +246,16 @@ test("canonical contract rejects malformed documents", () => {
assert.equal(safeParseReportDocument(badRefs).ok, false, "dangling evidenceRefs must fail guards");
});
test("PDF action is hidden while the export format is unavailable", () => {
test("ready reports expose the browser print/PDF action with capability and hydration guards", () => {
const actionsSource = readFileSync(
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
"utf8",
);
assert.match(actionsSource, /返回对话/);
assert.doesNotMatch(actionsSource, /打印 \/ 保存为 PDF/);
assert.doesNotMatch(actionsSource, /printPersonalReport/);
assert.doesNotMatch(actionsSource, /Printer/);
assert.match(actionsSource, /href="\/reports"/);
assert.match(actionsSource, /返回报告中心/);
assert.match(actionsSource, /打印 \/ 保存为 PDF/);
assert.match(actionsSource, /printPersonalReport/);
assert.match(actionsSource, /Printer/);
assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/);
assert.match(actionsSource, /disabled=\{busy \|\| !printSupported\}/);
});
+1
View File
@@ -1590,6 +1590,7 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
'login',
'membership',
'membership/orders',
'reports',
'reports/[reportId]',
]
assert set(audit['surfaces']['app_visible_topics']) == {