feat: add global asynchronous report center
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 ---- */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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">
|
||||
“逆”=逆行
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
|
||||
@@ -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']) == {
|
||||
|
||||
Reference in New Issue
Block a user