fix(ux): surface hidden notices, bound report waits, add root boundaries
Independent Staging Quality Gate / validate (push) Successful in 12m8s
Independent Staging Quality Gate / publish (push) Successful in 14m29s

Framework-level UX fixes found while auditing staging (BUG-216..220).

- chat: route 44 previously discarded composer notices to sonner with
  dedupe, so recovery, cancel and archive feedback is actually visible
  (BUG-216)
- chat: anchor stream auto-scroll to bottom proximity and add a
  jump-to-latest control, so reading history is no longer interrupted
  on every token (BUG-218)
- reports: replace the silent 120s poll cutoff with an explicit
  timed-out state, an 8m budget, stepped backoff and an elapsed
  counter (BUG-217)
- reports: pause polling while the tab is hidden, via a shared hook
- app: add root error, global-error and not-found boundaries (BUG-219)
- admin: add antd SSR style extraction and the React 19 render adapter,
  and move admin-only css out of the global stylesheet (BUG-220)
- membership: run bootstrap fetches concurrently and pause payment
  polling while hidden
- build: configure optimizePackageImports

Verified on top of 2d370f2e: tsc, eslint, next build, and the related
frontend contract suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 12:52:24 +08:00
parent 2d370f2e9d
commit 9c296f1e3f
23 changed files with 1144 additions and 88 deletions
+70
View File
@@ -3618,3 +3618,73 @@
- 防复发:被多处断言复用的测试辅助函数必须有自己的回归,不得以副本形式散落在各测试文件。以字符串匹配近似 CSS 语义时,必须按规则解析并覆盖同名选择器的全部声明;`indexOf` 式首个匹配不可用于可能重复出现的选择器。
- 相关记录:BUG-214
- 修复版本:本地未提交候选
## BUG-216 | 咨询中断、取消、归档等 44 条提示文案计算后从未显示
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/` 主对话页的断线恢复、取消回答、模型下线切换、会话重命名与删除失败等全部提示通路。
- 用户现象:网络中断、刷新后恢复、点击停止、删除会话失败时,界面只有一个转圈或静默无反应,用户无法知道回答仍在后台生成、是否已取消、失败原因是什么。
- 根因:`frontend/src/app/page.tsx` 将提示状态声明为 `const [, setComposerNotice] = useState("")`,解构时丢弃了状态值,JSX 中也没有任何渲染点;44 处 `setComposerNotice(...)` 调用的文案全部写入一个永不读取的 state。恢复轮询逻辑本身正确,缺的只是出口。
- 修复:新增 `frontend/src/lib/chat-notice.ts`,把提示按语义分派到既有 sonner `toast.success` / `toast.error` / `toast`;根布局早已挂载 `<Toaster />`,无需改动。以固定 `id` 复用同一条 toast,避免 1750ms 恢复轮询把同一句话堆成几十条;空字符串走 dismiss 而不弹空 toast。44 处调用点与全部中文文案逐字未改。
- 验证:新增 `frontend/tests/chat-notice-and-scroll-contract.test.ts` 锁定状态不再被丢弃、提示进入 toast、空串不弹窗、轮询防刷屏与分级语义;`tsc --noEmit``eslint` 清洁;与改动文件相关的 43 个测试文件共 386 条断言全绿。
- 防复发:禁止以 `const [, setX]` 形式声明用户可见文案状态;任何面向用户的提示必须有可断言的渲染出口,合同测试需覆盖“文案确实可达 UI”而不仅是“文案存在”。
- 相关记录:BUG-211
- 修复版本:本地未提交候选
## BUG-217 | 个人报告页轮询 120 秒后静默停止,界面仍显示“生成完成后页面会自动显示”
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/reports/[reportId]` 报告详情页的生成等待态。
- 用户现象:报告生成超过两分钟后,页面永远停在“报告正在生成中,请稍候…”的转圈上,即使报告已经在后台完成也不会刷新;页面同时声称“生成完成后页面会自动显示”,与实际行为矛盾。
- 根因:`personal-report-page.tsx``MAX_POLLS = 40` × `POLL_INTERVAL_MS = 3000` 限制轮询次数,达到上限后 effect 直接 `return` 停止轮询,但 `state.phase` 仍保持 `"generating"`,UI 继续渲染等待分支,没有任何超时态或失败态承接。
- 修复:在 `ReportLoadState` 中新增客户端专用的 `timed-out` 相位(`classifyReportEnvelope` 不产出该值,服务端语义未变)。轮询改为 8 分钟墙钟预算配阶梯退避(首分钟 3s,之后 6s/10s/15s,总请求数由 160 降到约 48)。预算耗尽后进入 `timed-out` 屏:说明生成仍在后台继续,提供“继续等待”重置时钟并立即重取,保留“返回报告中心”。等待中与超时后均显示“已等待 X 分 Y 秒”。
- 验证:新增 `frontend/tests/report-polling-contract.test.ts`,除文本合同外还对导出的 `pollIntervalForElapsed` / `formatWaitedDuration` 做真实单元断言(退避边界、单调性、请求数上限);既有 `personal-report-*` 测试全部通过。
- 防复发:任何有次数或时间上限的轮询,达到上限时必须切换到显式终态并给出用户可执行的下一步;等待文案承诺“自动刷新”时,必须由测试保证该承诺在整个等待窗口内成立。
- 相关记录:BUG-217 无前序同类记录
- 修复版本:本地未提交候选
## BUG-218 | 流式回答期间强制滚到底部,用户无法向上翻阅历史
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/` 主对话页的会话滚动容器。
- 用户现象:长回答生成过程中向上滚动查看此前内容,会被立即拽回底部,无法停留;也没有任何“回到最新”的入口。
- 根因:`page.tsx` 的自动滚动 effect 依赖数组包含 `activeStreamingText`,每个流式 token 都会触发一次无条件 `scrollTo(scrollHeight)`,未判断用户当前是否已在底部附近。
- 修复:新增 `frontend/src/hooks/use-conversation-scroll-anchor.ts`,以 rAF 合并的 passive 滚动监听维护锚定状态:用户向上越过约 96px 阈值即解除锚定,回到阈值内自动恢复;仅在锚定时执行自动滚动。切换会话与用户自己发送消息仍强制滚到底部。解除锚定时显示“跳到最新”按钮(真实 `<button>``aria-label`、可见焦点环、44×44 触控区),点击后滚到底并恢复锚定。沿用既有 `prefers-reduced-motion` 处理。
- 验证:同 BUG-216 的合同测试覆盖锚定守卫、有意跳转与无障碍跳转控件;`tsc``eslint` 清洁。浏览器内的视觉位置未经人工目视确认。
- 防复发:聊天类自动滚动必须做底部锚定判断,禁止把流式文本直接作为无条件滚动的依赖项。
- 相关记录:BUG-218 无前序同类记录
- 修复版本:本地未提交候选
## BUG-219 | 应用根级缺少错误与 404 边界,渲染崩溃时落到 Next.js 默认页
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/``/membership``/login``/admin/*` 等除 `/reports/[reportId]` 外的全部路由,以及所有未知 URL。
- 用户现象:主对话页等发生渲染异常时,用户看到的是 Next.js 默认错误界面,无中文说明、无重试入口、无返回路径;访问不存在的地址得到默认 404,与产品界面割裂。
- 根因:`src/app/` 下从未创建 `error.tsx``global-error.tsx``not-found.tsx`,全应用唯一的错误边界位于 `src/app/reports/[reportId]/`
- 修复:新增三个根级边界。`error.tsx` 为客户端边界,提供“重试”调用 `reset()` 与返回入口,并以克制方式展示 `error.digest` 供用户报障引用,不暴露堆栈。`global-error.tsx` 自带 `<html lang="zh-CN">``<body>`,零依赖并使用内联样式配 `var(--color-*, 字面回退)`,因为它替换根布局时 `globals.css` 不可达。`not-found.tsx` 为服务端组件,文案兼容 `login/page.tsx` 在未识别域名下主动 `notFound()` 的既有行为。
- 验证:新增 `frontend/tests/root-error-boundaries-contract.test.ts` 6 条断言,覆盖文件存在、客户端指令、`global-error` 自带文档骨架、`role="alert"`、标题层级与中文文案;`tsc``eslint` 清洁。未做浏览器目视验证。
- 防复发:新增顶层路由段时必须同步确认错误与未找到边界覆盖;`global-error` 不得依赖根布局引入的全局样式。
- 相关记录:BUG-219 无前序同类记录
- 修复版本:本地未提交候选
## BUG-220 | 管理端 antd 缺少 SSR 样式提取与 React 19 适配,首屏闪烁无样式内容
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/admin/**` 全部 18 个后台页面;以及所有用户端路由的首屏 CSS 体积。
- 用户现象:进入后台页面时先闪现一屏无样式内容再套上 antd 样式;同时聊天、登录、会员等用户端页面也要下载解析只有后台会用到的 antd 覆盖样式。
- 根因:其一,admin 是 `"use client"` 子树并使用 antd v5 CSS-in-JS,但项目从未接入 `AntdRegistry` / `@ant-design/cssinjs` 服务端样式提取,也未应用 antd v5 + React 19 的官方渲染适配,服务端输出 0 字节 antd 样式。其二,`globals.css` 由根布局引入,却在尾部包含 37 行 `.admin-app-shell .ant-*` 后台专用规则。
- 修复:新增 `frontend/src/components/admin/admin-antd-registry.tsx`,在 admin 子树内完成按请求的样式提取(`createCache` + `extractStyle` + `useServerInsertedHTML`,输出 `data-rc-order="prepend"` 保证服务端样式先于客户端注入),并用 antd 5.29.3 公开导出的 `unstableSetRender` 完成 React 19 适配,不新增 registry / patch 包。仅显式声明已随 antd 安装的 `@ant-design/cssinjs@^1.24.0`(版本未变、锁文件仅增 1 行)。后台 37 行样式移入 `frontend/src/app/admin/admin.css` 由 admin 布局引入;移除与 antd reset 重复的 refine reset。另在 `next.config.ts` 增加 `experimental.optimizePackageImports`,既有 `outputFileTracingIncludes` 等设置逐项保留。
- 验证:`npx next build` 退出码 0;构建产物显示用户端 CSS chunk 184,968 字节且不含任何后台规则,后台规则独立成 5,358 字节 chunk 且仅被 `.next/server/app/admin/` 下 18 个 client-reference-manifest 引用。以 `react-dom/server``ServerInsertedHTMLContext` 实测 registry 服务端输出 90,132 字节含主题色 `#85432f``<style id="antd-cssinjs">`(修复前为 0)。`npm ci --dry-run` 报告锁文件同步。standalone 产物仍包含 Python skill 资产。未能以真实管理员会话做端到端 HTTP 验证(需 Postgres 与登录态)。
- 防复发:引入 CSS-in-JS UI 库时必须同时接入 SSR 样式提取;路由段专用样式不得写入根布局引入的全局样式表。
- 相关记录:BUG-213
- 修复版本:本地未提交候选
+11 -1
View File
@@ -5,7 +5,17 @@ const repositoryRoot = path.join(process.cwd(), "..");
const nextConfig: NextConfig = {
devIndicators: false,
experimental: { authInterrupts: true },
experimental: {
authInterrupts: true,
optimizePackageImports: [
"@ant-design/icons",
"@refinedev/antd",
"@refinedev/core",
"antd",
"date-fns",
"lucide-react",
],
},
output: "standalone",
turbopack: { root: repositoryRoot },
outputFileTracingRoot: repositoryRoot,
+1
View File
@@ -37,6 +37,7 @@
"tsx": "^4.23.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76",
"@ant-design/cssinjs": "^1.24.0",
"@ant-design/icons": "^6.3.2",
"@refinedev/antd": "^6.0.3",
"@refinedev/core": "^5.0.12",
+1
View File
@@ -16,6 +16,7 @@
"data:china": "node scripts/pull-china-locations.mjs"
},
"dependencies": {
"@ant-design/cssinjs": "^1.24.0",
"@ant-design/icons": "^6.3.2",
"@base-ui/react": "^1.6.0",
"@gsap/react": "^2.1.2",
+35
View File
@@ -0,0 +1,35 @@
.admin-app-shell {
min-height: 100dvh;
background: #f3f2ee;
color: #1d1d1f;
font-family: var(--font-body);
}
.admin-app-shell .ant-layout { background: #f3f2ee; }
.admin-app-shell .ant-layout-sider { border-right: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-header { border-bottom: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-content { padding: 22px; }
.admin-app-shell .ant-menu { border-inline-end: 0 !important; background: transparent !important; }
.admin-app-shell .ant-menu-item, .admin-app-shell .ant-menu-submenu-title { margin-inline: 8px; width: calc(100% - 16px); }
.admin-app-shell .ant-card { border-color: #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-card-head { border-bottom-color: #d8d6cf; }
.admin-app-shell .ant-card .ant-card { border-radius: 0; }
.admin-app-shell .ant-card .ant-card:not(:last-child) { border-bottom: 0; }
.admin-app-shell .ant-list-bordered { border-color: #d8d6cf; border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table { background: #fbfaf7; }
.admin-app-shell .ant-table-wrapper .ant-table-container { border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table-thead > tr > th { font-size: 12px; font-weight: 650; }
.admin-app-shell .ant-table-wrapper .ant-table-cell { line-height: 1.45; }
.admin-app-shell .ant-tag { margin-inline-end: 4px; border-radius: 4px; font-weight: 550; }
.admin-app-shell .ant-alert { border-radius: 6px; box-shadow: none; }
.admin-app-shell .ant-statistic-title { color: #5f5f59; font-size: 12px; }
.admin-app-shell .ant-statistic-content { font-size: 22px; font-weight: 650; }
.admin-app-shell .ant-typography h1, .admin-app-shell h1.ant-typography { font-family: var(--font-body); font-size: 26px; font-weight: 680; letter-spacing: -.025em; }
.admin-app-shell .ant-typography h2, .admin-app-shell h2.ant-typography { font-family: var(--font-body); font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.admin-app-shell .ant-typography h3, .admin-app-shell h3.ant-typography { font-family: var(--font-body); font-size: 17px; font-weight: 650; }
.admin-app-shell code { font-family: var(--font-mono); }
.admin-text-list { max-width: 680px; color: #5f5f59; line-height: 1.65; white-space: normal; overflow-wrap: anywhere; }
.admin-loading { min-height: 100dvh; display: grid; place-content: center; justify-items: center; gap: 12px; background: #f3f2ee; color: #5f5f59; }
@media (max-width: 767px) {
.admin-app-shell .ant-layout-content { padding: 14px; }
}
+7 -2
View File
@@ -1,8 +1,9 @@
import "@refinedev/antd/dist/reset.css";
import "antd/dist/reset.css";
import "./admin.css";
import type { ReactNode } from "react";
import { forbidden, redirect } from "next/navigation";
import { AdminAntdRegistry } from "@/components/admin/admin-antd-registry";
import { AdminApp } from "@/components/admin/admin-app";
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
import { resolveAdminPageAccessFailure } from "@/lib/admin/page-access";
@@ -21,5 +22,9 @@ export default async function AdminLayout({ children }: { children: ReactNode })
}
throw error;
}
return <AdminApp>{children}</AdminApp>;
return (
<AdminAntdRegistry>
<AdminApp>{children}</AdminApp>
</AdminAntdRegistry>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function RootError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("app root error", error.digest ?? error.message);
}, [error]);
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-destructive" />
<div className="flex flex-col items-center gap-3" role="alert">
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="max-w-md text-sm text-muted-foreground">
</p>
</div>
{error.digest ? (
<p className="max-w-md text-xs text-muted-foreground">
<code className="font-mono">{error.digest}</code>
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center justify-center gap-3">
<Button type="button" onClick={() => reset()}>
</Button>
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
</Button>
</div>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
const canvas = "var(--color-canvas, #fbfaf7)";
const ink = "var(--color-ink, #1d1d1f)";
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
const danger = "var(--color-danger, #9a2f2f)";
const border = "var(--color-border, #d8d6cf)";
const action = "var(--color-action, #85432f)";
const focus = "var(--color-focus, #85432f)";
const actionStyle = {
alignItems: "center",
borderRadius: "12px",
display: "inline-flex",
fontSize: "14px",
fontWeight: 500,
justifyContent: "center",
minHeight: "44px",
minWidth: "88px",
padding: "0 20px",
} as const;
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="zh-CN">
<body
style={{
alignItems: "center",
background: canvas,
color: ink,
display: "flex",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
justifyContent: "center",
margin: 0,
minHeight: "100vh",
padding: "24px",
}}
>
<style>{`
.global-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
`}</style>
<main style={{ maxWidth: "32rem", textAlign: "center" }}>
<div role="alert">
<h1 style={{ color: danger, fontSize: "20px", fontWeight: 600, margin: "0 0 12px" }}>
</h1>
<p style={{ color: inkSecondary, fontSize: "14px", lineHeight: 1.7, margin: 0 }}>
</p>
</div>
{error.digest ? (
<p style={{ color: inkSecondary, fontSize: "12px", lineHeight: 1.7, margin: "12px 0 0" }}>
{error.digest}
</p>
) : null}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "12px",
justifyContent: "center",
marginTop: "24px",
}}
>
<button
className="global-error-action"
onClick={() => reset()}
style={{
...actionStyle,
background: action,
border: "1px solid transparent",
color: canvas,
cursor: "pointer",
}}
type="button"
>
</button>
<button
className="global-error-action"
onClick={() => window.location.assign("/")}
style={{
...actionStyle,
background: canvas,
border: `1px solid ${border}`,
color: ink,
cursor: "pointer",
}}
type="button"
>
</button>
</div>
</main>
</body>
</html>
);
}
-37
View File
@@ -2175,40 +2175,3 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-activity-receipt__toggle svg { transition: none; }
}
/* Admin: quiet operational surface. */
.admin-app-shell {
min-height: 100dvh;
background: #f3f2ee;
color: #1d1d1f;
font-family: var(--font-body);
}
.admin-app-shell .ant-layout { background: #f3f2ee; }
.admin-app-shell .ant-layout-sider { border-right: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-header { border-bottom: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-content { padding: 22px; }
.admin-app-shell .ant-menu { border-inline-end: 0 !important; background: transparent !important; }
.admin-app-shell .ant-menu-item, .admin-app-shell .ant-menu-submenu-title { margin-inline: 8px; width: calc(100% - 16px); }
.admin-app-shell .ant-card { border-color: #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-card-head { border-bottom-color: #d8d6cf; }
.admin-app-shell .ant-card .ant-card { border-radius: 0; }
.admin-app-shell .ant-card .ant-card:not(:last-child) { border-bottom: 0; }
.admin-app-shell .ant-list-bordered { border-color: #d8d6cf; border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table { background: #fbfaf7; }
.admin-app-shell .ant-table-wrapper .ant-table-container { border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table-thead > tr > th { font-size: 12px; font-weight: 650; }
.admin-app-shell .ant-table-wrapper .ant-table-cell { line-height: 1.45; }
.admin-app-shell .ant-tag { margin-inline-end: 4px; border-radius: 4px; font-weight: 550; }
.admin-app-shell .ant-alert { border-radius: 6px; box-shadow: none; }
.admin-app-shell .ant-statistic-title { color: #5f5f59; font-size: 12px; }
.admin-app-shell .ant-statistic-content { font-size: 22px; font-weight: 650; }
.admin-app-shell .ant-typography h1, .admin-app-shell h1.ant-typography { font-family: var(--font-body); font-size: 26px; font-weight: 680; letter-spacing: -.025em; }
.admin-app-shell .ant-typography h2, .admin-app-shell h2.ant-typography { font-family: var(--font-body); font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.admin-app-shell .ant-typography h3, .admin-app-shell h3.ant-typography { font-family: var(--font-body); font-size: 17px; font-weight: 650; }
.admin-app-shell code { font-family: var(--font-mono); }
.admin-text-list { max-width: 680px; color: #5f5f59; line-height: 1.65; white-space: normal; overflow-wrap: anywhere; }
.admin-loading { min-height: 100dvh; display: grid; place-content: center; justify-items: center; gap: 12px; background: #f3f2ee; color: #5f5f59; }
@media (max-width: 767px) {
.admin-app-shell .ant-layout-content { padding: 14px; }
}
+41 -21
View File
@@ -130,8 +130,7 @@ function MembershipContent() {
useEffect(() => {
void (async () => {
await fetchAccountData();
await fetchPackages();
await Promise.allSettled([fetchAccountData(), fetchPackages()]);
})();
}, [fetchAccountData, fetchPackages]);
@@ -246,25 +245,46 @@ function MembershipContent() {
useEffect(() => {
if (!paymentOrder || paymentOrder.status !== "pending") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
const failed = (typeof payload.status === "string"
&& ["failed", "closed", "cancelled"].includes(payload.status))
|| payload.grantStatus === "failed";
const paid = payload.status === "paid" && !failed;
setPaymentOrder((current) => current ? {
...current,
status: paid ? "paid" : failed ? "failed" : "pending",
} : current);
if (paid) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
}
});
}, 3000);
return () => window.clearInterval(timer);
const checkPaymentStatus = async () => {
const response = await fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok) return;
const failed = (typeof payload.status === "string"
&& ["failed", "closed", "cancelled"].includes(payload.status))
|| payload.grantStatus === "failed";
const paid = payload.status === "paid" && !failed;
setPaymentOrder((current) => current ? {
...current,
status: paid ? "paid" : failed ? "failed" : "pending",
} : current);
if (paid) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
}
};
let timer = 0;
const stopPolling = () => {
if (timer) window.clearInterval(timer);
timer = 0;
};
const startPolling = () => {
stopPolling();
timer = window.setInterval(() => void checkPaymentStatus(), 3000);
};
const onVisibilityChange = () => {
if (document.hidden) {
stopPolling();
return;
}
void checkPaymentStatus();
startPolling();
};
if (!document.hidden) startPolling();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [fetchAccountData, paymentOrder]);
const plans = selectMembershipPlans(paymentPackages);
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function RootNotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<p className="text-sm font-medium tracking-[0.12em] text-muted-foreground">404</p>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="max-w-md text-sm text-muted-foreground">
线
</p>
<div className="mt-2">
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
</Button>
</div>
</main>
);
}
+28 -3
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { ArrowDown, ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
@@ -68,6 +68,8 @@ import {
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import {
requestBirthTimeAssessment,
type JourneyClientResponse,
@@ -1020,7 +1022,6 @@ export default function Home() {
const [draft, setDraft] = useState("");
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
const [, setComposerNotice] = useState("");
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
@@ -1259,6 +1260,15 @@ export default function Home() {
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const daypartGreeting = greetingForHour(new Date().getHours());
const conversationAnchor = useConversationScrollAnchor(
conversation,
!rectificationSurfaceOpen && !starterHomeVisible,
activeSessionId,
);
const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible
&& !conversationAnchor.anchored
&& Boolean(activeSession?.messages.length);
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
if (pendingConsultation.current) return;
@@ -1725,9 +1735,10 @@ export default function Home() {
if (starterHomeVisible) return;
const container = conversation.current;
if (!container) return;
if (!conversationAnchor.anchored) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({ top: container.scrollHeight, behavior: isLoading || reduceMotion ? "auto" : "smooth" });
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, conversationAnchor.anchored, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
useEffect(() => {
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
@@ -2875,6 +2886,7 @@ export default function Home() {
};
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
@@ -3440,6 +3452,19 @@ export default function Home() {
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
{jumpToLatestVisible && (
<div className="pointer-events-none sticky bottom-3 z-10 flex h-0 items-end justify-center">
<button
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
type="button"
aria-label="跳到最新"
onClick={conversationAnchor.anchorToLatest}
>
<ArrowDown aria-hidden="true" className="size-4" />
</button>
</div>
)}
</div>
)}
@@ -0,0 +1,39 @@
"use client";
import { StyleProvider, createCache, extractStyle } from "@ant-design/cssinjs";
import { unstableSetRender } from "antd";
import { useServerInsertedHTML } from "next/navigation";
import { useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
type ReactRootContainer = (Element | DocumentFragment) & { _reactRoot?: Root };
unstableSetRender((node, container) => {
const target = container as ReactRootContainer;
target._reactRoot ??= createRoot(target);
const root = target._reactRoot;
root.render(node);
return async () => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
root.unmount();
};
});
export function AdminAntdRegistry({ children }: { children: ReactNode }) {
const [cache] = useState(() => createCache());
useServerInsertedHTML(() => {
const styleText = extractStyle(cache, { plain: true, once: true });
if (styleText.includes('.data-ant-cssinjs-cache-path{content:"";}')) return null;
return (
<style
id="antd-cssinjs"
data-rc-order="prepend"
data-rc-priority="-1000"
dangerouslySetInnerHTML={{ __html: styleText }}
/>
);
});
return <StyleProvider cache={cache}>{children}</StyleProvider>;
}
@@ -6,6 +6,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
const LIST_POLL_INTERVAL_MS = 3000;
type ReportListItem = Readonly<{
id: string;
@@ -118,11 +121,12 @@ export function PersonalReportCenter() {
}, [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 refresh = useCallback(() => void load(), [load]);
useVisibilityAwarePoll({
enabled: hasGenerating,
intervalMs: LIST_POLL_INTERVAL_MS,
onPoll: refresh,
});
const latestReady = useMemo(
() => state.reports.find((report) => report.status === "ready") ?? null,
@@ -3,8 +3,9 @@
*
* 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 action is mounted only after a validated ready document exists.
* generating (with polling) / timed-out (poll budget exhausted, generation
* continues server-side) / failed / invalid (schema guard rejected) / 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
@@ -17,19 +18,21 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { LoaderCircle, TriangleAlert } from "lucide-react";
import { Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
import { ReportActions } from "./report-actions";
import { PersonalReportDocumentView } from "./personal-report-document-view";
import { safeParseReportDocument } from "@/lib/personal-report-contract";
import type { ReportDocument } from "@/lib/personal-report-contract";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
| { phase: "not-found" }
| { phase: "generating" }
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
@@ -52,7 +55,8 @@ export interface ReportEnvelopeView {
* actual route response: `{ report: { status, failureCode, ... }, reportDocument? }`
* with 401/404/403/5xx error envelopes. Does NOT validate reportDocument here;
* ready payloads are passed to the canonical safeParseReportDocument from
* @/lib/personal-report-contract.
* @/lib/personal-report-contract. The timed-out phase is client-only: the
* server never reports it, it is reached when the local poll budget runs out.
*/
export function classifyReportEnvelope(statusCode: number, json: unknown): ReportLoadState {
if (statusCode === 401) {
@@ -100,12 +104,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const POLL_INTERVAL_MS = 3000;
const MAX_POLLS = 40;
/** Wall-clock budget for one uninterrupted wait: 8 minutes, then we stop and say so. */
export const POLL_BUDGET_MS = 8 * 60 * 1000;
const POLL_TICK_MS = 1000;
/** Step backoff: 3s for the first minute, then 6s, 10s and 15s. */
export function pollIntervalForElapsed(elapsedMs: number): number {
if (elapsedMs < 60_000) return 3000;
if (elapsedMs < 180_000) return 6000;
if (elapsedMs < 360_000) return 10_000;
return 15_000;
}
export function formatWaitedDuration(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}${seconds}` : `${seconds}`;
}
export function PersonalReportPage({ reportId }: { reportId: string }) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [polls, setPolls] = useState(0);
const [waitStartedAt, setWaitStartedAt] = useState<number | null>(null);
const [waitedMs, setWaitedMs] = useState(0);
const cancelledRef = useRef(false);
const load = useCallback(() => {
@@ -123,9 +144,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
}
const next = classifyReportEnvelope(response.status, json);
if (next.phase === "generating") {
setPolls((count) => count + 1);
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
} else {
setPolls(0);
setWaitStartedAt(null);
setWaitedMs(0);
}
setState(next);
})
@@ -141,6 +163,24 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
void load();
}, [load]);
const keepWaiting = useCallback(() => {
setWaitStartedAt(Date.now());
setWaitedMs(0);
setState({ phase: "generating" });
void load();
}, [load]);
const tick = useCallback(() => {
if (waitStartedAt === null) {
return;
}
const elapsed = Date.now() - waitStartedAt;
setWaitedMs(elapsed);
if (elapsed >= POLL_BUDGET_MS) {
setState((current) => (current.phase === "generating" ? { phase: "timed-out" } : current));
}
}, [waitStartedAt]);
useEffect(() => {
cancelledRef.current = false;
void load();
@@ -149,18 +189,21 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
};
}, [load]);
useEffect(() => {
if (state.phase !== "generating" || polls >= MAX_POLLS) {
return;
}
const timer = setInterval(() => {
void load();
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [state.phase, polls, load]);
const generating = state.phase === "generating";
useVisibilityAwarePoll({
enabled: generating,
intervalMs: pollIntervalForElapsed(waitedMs),
onPoll: load,
});
useVisibilityAwarePoll({
enabled: generating && waitStartedAt !== null,
intervalMs: POLL_TICK_MS,
onPoll: tick,
});
if (state.phase === "loading" || state.phase === "generating") {
const generating = state.phase === "generating";
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
@@ -169,6 +212,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
</p>
{generating && (
<>
<p className="text-sm text-ink-tertiary"> {formatWaitedDuration(waitedMs)}</p>
<p className="max-w-md text-sm text-ink-secondary">
</p>
@@ -179,6 +223,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
);
}
if (state.phase === "timed-out") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
<h1 className="text-xl font-semibold text-ink"></h1>
<p className="text-sm text-ink-tertiary"> {formatWaitedDuration(waitedMs)}</p>
<p className="max-w-md text-sm text-ink-secondary">
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button type="button" variant="default" onClick={() => keepWaiting()}>
</Button>
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
</Button>
</div>
</main>
);
}
if (state.phase === "unauthorized") {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
export const conversationAnchorThreshold = 96;
type AnchorState = {
readonly key: string;
readonly anchored: boolean;
};
type ConversationScrollAnchor = {
readonly anchored: boolean;
readonly anchorToLatest: () => void;
};
export function conversationDistanceFromBottom(container: HTMLElement) {
return container.scrollHeight - container.scrollTop - container.clientHeight;
}
export function nextAnchorState(anchored: boolean, distanceFromBottom: number, scrolledUp: boolean) {
if (distanceFromBottom <= conversationAnchorThreshold) return true;
return scrolledUp ? false : anchored;
}
export function useConversationScrollAnchor(
container: RefObject<HTMLDivElement | null>,
active: boolean,
resetKey: string,
): ConversationScrollAnchor {
const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: true });
const lastScrollTop = useRef(0);
const anchored = anchor.key === resetKey ? anchor.anchored : true;
useEffect(() => {
const element = container.current;
if (!active || !element) return;
lastScrollTop.current = element.scrollTop;
let frame = 0;
const measure = () => {
frame = 0;
const distance = conversationDistanceFromBottom(element);
const scrolledUp = element.scrollTop < lastScrollTop.current;
lastScrollTop.current = element.scrollTop;
setAnchor((current) => {
const currentAnchored = current.key === resetKey ? current.anchored : true;
const next = nextAnchorState(currentAnchored, distance, scrolledUp);
return next === currentAnchored && current.key === resetKey ? current : { key: resetKey, anchored: next };
});
};
const onScroll = () => {
if (frame) return;
frame = window.requestAnimationFrame(measure);
};
element.addEventListener("scroll", onScroll, { passive: true });
frame = window.requestAnimationFrame(measure);
return () => {
if (frame) window.cancelAnimationFrame(frame);
element.removeEventListener("scroll", onScroll);
};
}, [active, container, resetKey]);
return {
anchored,
anchorToLatest: () => {
const element = container.current;
if (element) {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" });
}
setAnchor({ key: resetKey, anchored: true });
},
};
}
@@ -0,0 +1,71 @@
/**
* Interval polling that stops while the tab is hidden.
*
* While `document.hidden` is true the interval is torn down entirely, so a
* backgrounded tab or a locked phone issues no requests. Becoming visible
* again fires one immediate poll (unless `refreshOnVisible` is false) and then
* restarts the interval, so a returning user sees fresh state without waiting
* out a full tick. The callback is held in a ref, so a caller may pass an
* inline closure without restarting the interval on every render.
*/
"use client";
import { useEffect, useRef } from "react";
export type VisibilityAwarePollOptions = {
readonly enabled: boolean;
readonly intervalMs: number;
readonly onPoll: () => void;
readonly refreshOnVisible?: boolean;
};
export function useVisibilityAwarePoll(options: VisibilityAwarePollOptions): void {
const { enabled, intervalMs, onPoll, refreshOnVisible = true } = options;
const pollRef = useRef(onPoll);
useEffect(() => {
pollRef.current = onPoll;
}, [onPoll]);
useEffect(() => {
if (!enabled || typeof document === "undefined" || intervalMs <= 0) {
return;
}
let timer: number | null = null;
const stop = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
const start = () => {
stop();
timer = window.setInterval(() => pollRef.current(), intervalMs);
};
const handleVisibilityChange = () => {
if (document.hidden) {
stop();
return;
}
if (refreshOnVisible) {
pollRef.current();
}
start();
};
if (!document.hidden) {
start();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [enabled, intervalMs, refreshOnVisible]);
}
+39
View File
@@ -0,0 +1,39 @@
import { toast } from "sonner";
export const chatNoticeToastId = "chat-notice";
export type NoticeTone = "info" | "success" | "error";
const ongoingNotice = /正在|请稍候|请先|联网后/;
const failedNotice = /失败|无法|不可用|未找到/;
const settledNotice = /^已|^回答已恢复/;
export function noticeTone(message: string): NoticeTone {
if (ongoingNotice.test(message)) return "info";
if (failedNotice.test(message)) return "error";
if (settledNotice.test(message)) return "success";
return "info";
}
let lastNotice = "";
export function showChatNotice(message: string) {
if (!message.trim()) {
if (!lastNotice) return;
lastNotice = "";
toast.dismiss(chatNoticeToastId);
return;
}
if (lastNotice === message) return;
lastNotice = message;
const tone = noticeTone(message);
if (tone === "success") {
toast.success(message, { id: chatNoticeToastId });
return;
}
if (tone === "error") {
toast.error(message, { id: chatNoticeToastId });
return;
}
toast(message, { id: chatNoticeToastId });
}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const registry = readFileSync(new URL("../src/components/admin/admin-antd-registry.tsx", import.meta.url), "utf8");
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
const rootLayout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8");
const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
dependencies: Record<string, string>;
};
test("admin antd styles are extracted during server rendering", () => {
assert.match(registry, /^"use client";/);
assert.match(registry, /import \{ StyleProvider, createCache, extractStyle \} from "@ant-design\/cssinjs";/);
assert.match(registry, /useServerInsertedHTML\(\(\) => \{/);
assert.match(registry, /extractStyle\(cache, \{ plain: true, once: true \}\)/);
assert.match(registry, /<StyleProvider cache=\{cache\}>\{children\}<\/StyleProvider>/);
assert.match(registry, /id="antd-cssinjs"/);
assert.match(registry, /data-rc-order="prepend"/);
assert.ok("@ant-design/cssinjs" in packageJson.dependencies, "cssinjs must be a declared dependency");
});
test("the antd registry wraps the admin subtree only", () => {
assert.match(adminLayout, /import \{ AdminAntdRegistry \} from "@\/components\/admin\/admin-antd-registry";/);
assert.match(adminLayout, /<AdminAntdRegistry>\s*<AdminApp>\{children\}<\/AdminApp>\s*<\/AdminAntdRegistry>/);
assert.doesNotMatch(rootLayout, /AdminAntdRegistry|cssinjs|antd/);
});
test("antd runs with the React 19 render patch inside admin", () => {
assert.match(registry, /import \{ unstableSetRender \} from "antd";/);
assert.match(registry, /import \{ createRoot, type Root \} from "react-dom\/client";/);
assert.match(registry, /unstableSetRender\(\(node, container\) => \{/);
assert.match(registry, /target\._reactRoot \?\?= createRoot\(target\);/);
assert.match(registry, /root\.render\(node\);/);
assert.match(registry, /root\.unmount\(\);/);
});
test("admin antd overrides ship only on admin routes", () => {
assert.doesNotMatch(globalsCss, /\.admin-app-shell \.ant-layout-content/);
assert.doesNotMatch(globalsCss, /\.admin-app-shell \.ant-table-wrapper/);
assert.doesNotMatch(globalsCss, /\.admin-text-list|\.admin-loading/);
assert.match(adminCss, /\.admin-app-shell \.ant-layout-content \{ padding: 22px; \}/);
assert.match(adminCss, /\.admin-app-shell \.ant-table-wrapper \.ant-table \{ background: #fbfaf7; \}/);
assert.match(adminCss, /\.admin-text-list \{ max-width: 680px;/);
assert.match(adminCss, /\.admin-loading \{ min-height: 100dvh;/);
assert.match(adminLayout, /import "\.\/admin\.css";/);
assert.doesNotMatch(rootLayout, /admin\.css/);
});
test("the admin shell keeps one antd reset stylesheet", () => {
assert.match(adminLayout, /import "antd\/dist\/reset\.css";/);
assert.doesNotMatch(adminLayout, /@refinedev\/antd\/dist\/reset\.css/);
});
test("barrel-heavy packages are optimized at build time", () => {
assert.match(nextConfig, /optimizePackageImports: \[/);
for (const pkg of ["@ant-design/icons", "@refinedev/antd", "@refinedev/core", "antd", "date-fns", "lucide-react"]) {
assert.ok(nextConfig.includes(`"${pkg}"`), `${pkg} must be listed in optimizePackageImports`);
assert.ok(pkg in packageJson.dependencies, `${pkg} must be a declared dependency`);
}
assert.match(nextConfig, /authInterrupts: true/);
assert.match(nextConfig, /outputFileTracingIncludes: \{/);
assert.match(nextConfig, /"\/api\/consult": \[/);
assert.match(nextConfig, /turbopack: \{ root: repositoryRoot \}/);
assert.match(nextConfig, /output: "standalone"/);
});
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { noticeTone } from "../src/lib/chat-notice.ts";
import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
return source.slice(start, end);
}
test("routes composer notices to the user instead of discarding them", () => {
// Given: the page no longer keeps the notice in write-only state.
assert.doesNotMatch(pageSource, /const \[, setComposerNotice\] = useState/);
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
// Then: every notice reaches the mounted sonner toaster.
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
assert.match(noticeSource, /toast\.success\(message, \{ id: chatNoticeToastId \}\)/);
assert.match(noticeSource, /toast\.error\(message, \{ id: chatNoticeToastId \}\)/);
assert.match(noticeSource, /toast\(message, \{ id: chatNoticeToastId \}\)/);
});
test("clearing a notice dismisses instead of showing an empty toast", () => {
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message) return;");
assert.match(clearBranch, /toast\.dismiss\(chatNoticeToastId\)/);
assert.doesNotMatch(clearBranch, /toast\(|toast\.success|toast\.error/);
assert.match(pageSource, /setComposerNotice\(""\)/);
});
test("keeps the recovery poll from stacking repeated notices", () => {
// Given: the recovery loop repeats the same message every 1750ms.
assert.match(pageSource, /timer = window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
// Then: a stable toast id plus last-message dedupe replaces instead of accumulating.
assert.match(noticeSource, /export const chatNoticeToastId = "chat-notice"/);
assert.match(noticeSource, /if \(lastNotice === message\) return;/);
});
test("assigns notice severity by message intent", () => {
assert.equal(noticeTone("网络已断开,回答仍在后台生成;联网后会自动恢复。"), "info");
assert.equal(noticeTone("回答仍在后台生成,正在自动恢复。"), "info");
assert.equal(noticeTone("正在确认本次咨询请求是否已开始…"), "info");
assert.equal(noticeTone("此前选择的模型已下线,已切换为默认模型。"), "info");
assert.equal(noticeTone("回答已取消;问题仍保留在聊天记录中,可重新发送。"), "info");
assert.equal(noticeTone("回答已恢复。"), "success");
assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success");
assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success");
assert.equal(noticeTone("删除失败:网络异常"), "error");
assert.equal(noticeTone("重命名同步失败"), "error");
assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
assert.equal(noticeTone("后台未找到本次咨询请求,已停止恢复,请重新发送。"), "error");
});
test("anchors the streaming scroll instead of following every token", () => {
const autoScrollEffect = sourceBetween(
pageSource,
"useEffect(() => {\n if (starterHomeVisible) return;",
"profileComplete, starterHomeVisible]);",
);
// Then: streamed tokens only move the viewport while the reader stays anchored.
assert.match(autoScrollEffect, /if \(!conversationAnchor\.anchored\) return/);
const scrollGuard = autoScrollEffect.indexOf("if (!conversationAnchor.anchored) return");
const scrollCall = autoScrollEffect.indexOf("container.scrollTo(");
assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
});
test("scrolls to the newest turn on intentional jumps", () => {
// Given: switching sessions resets the anchor through the hook reset key.
assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/);
assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/);
// And: sending a question re-anchors before the optimistic turn renders.
const sendBlock = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
});
test("offers an accessible jump-to-latest control while reading history", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</div>\n )}");
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
assert.match(jumpControl, /type="button"/);
assert.match(jumpControl, /aria-label="跳到最新"/);
assert.match(jumpControl, /focus-visible:ring-3/);
assert.match(jumpControl, /min-h-11/);
assert.match(jumpControl, /min-w-11/);
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
});
test("keeps the scroll listener passive and reduced-motion aware", () => {
assert.match(anchorSource, /addEventListener\("scroll", onScroll, \{ passive: true \}\)/);
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(measure\)/);
assert.match(anchorSource, /window\.matchMedia\("\(prefers-reduced-motion: reduce\)"\)\.matches/);
assert.match(anchorSource, /behavior: reduceMotion \? "auto" : "smooth"/);
});
test("re-anchors once the reader returns to the newest turn", () => {
// Given: the reader is following the stream and scrolls up mid-answer.
assert.equal(nextAnchorState(true, 0, false), true);
assert.equal(nextAnchorState(true, 600, true), false);
// Then: growing content alone never re-anchors, but scrolling back does.
assert.equal(nextAnchorState(false, 600, false), false);
assert.equal(nextAnchorState(false, 40, false), true);
});
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const membershipPage = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8");
const ordersPage = readFileSync(new URL("../src/app/membership/orders/page.tsx", import.meta.url), "utf8");
function countMatches(source: string, pattern: RegExp) {
return source.match(pattern)?.length ?? 0;
}
test("account and packages are requested concurrently on mount", () => {
assert.match(membershipPage, /await Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\);/);
assert.doesNotMatch(membershipPage, /await fetchAccountData\(\);\s*await fetchPackages\(\)/);
assert.doesNotMatch(membershipPage, /Promise\.all\(\[/);
});
test("a failing bootstrap request cannot hide the other section", () => {
assert.match(membershipPage, /setAccountError\(caught instanceof Error/);
assert.match(membershipPage, /setPackagesError\("套餐支付暂时不可用,请稍后重试"\)/);
assert.match(membershipPage, /\{account \? `\$\{account\.credits\} 点` : accountError \|\| "正在读取…"\}/);
assert.match(membershipPage, /\{packagesError && <p className="form-error" role="alert">\{packagesError\}<\/p>\}/);
});
test("membership and orders navigate through next/link instead of a document reload", () => {
assert.match(membershipPage, /import Link from "next\/link"/);
assert.match(membershipPage, /<Link className="membership-orders-entry" href="\/membership\/orders">/);
assert.match(ordersPage, /import Link from "next\/link"/);
assert.match(ordersPage, /<Link className="membership-back" href="\/membership" replace/);
assert.doesNotMatch(membershipPage, /window\.location\.assign\("\/membership/);
assert.doesNotMatch(ordersPage, /window\.location\.assign\("\/membership/);
});
test("401 redirects stay hard navigations so no client state survives the sign-out", () => {
assert.match(membershipPage, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/);
assert.match(ordersPage, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/);
assert.equal(countMatches(membershipPage, /window\.location\.assign\("\/login"\)/g), 3);
assert.equal(countMatches(ordersPage, /window\.location\.assign\("\/login"\)/g), 1);
});
test("browser back and the external cashier keep their native behaviour", () => {
assert.match(membershipPage, /if \(window\.history\.length > 1\) window\.history\.back\(\);/);
assert.match(membershipPage, /else window\.location\.assign\("\/"\);/);
assert.match(membershipPage, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
});
test("the hard navigation inventory does not grow", () => {
assert.equal(countMatches(membershipPage, /window\.location\./g), 4);
assert.equal(countMatches(ordersPage, /window\.location\./g), 1);
});
test("payment polling pauses on a hidden tab and refreshes when it returns", () => {
assert.match(membershipPage, /if \(!document\.hidden\) startPolling\(\);/);
assert.match(membershipPage, /document\.addEventListener\("visibilitychange", onVisibilityChange\);/);
assert.match(membershipPage, /if \(document\.hidden\) \{\s*stopPolling\(\);\s*return;\s*\}/);
assert.match(membershipPage, /void checkPaymentStatus\(\);\s*startPolling\(\);/);
assert.match(membershipPage, /window\.setInterval\(\(\) => void checkPaymentStatus\(\), 3000\);/);
});
test("polling cleanup releases both the timer and the visibility listener", () => {
assert.match(membershipPage, /if \(timer\) window\.clearInterval\(timer\);/);
assert.match(membershipPage, /stopPolling\(\);\s*document\.removeEventListener\("visibilitychange", onVisibilityChange\);/);
});
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
formatWaitedDuration,
POLL_BUDGET_MS,
pollIntervalForElapsed,
} from "../src/components/personal-report/personal-report-page.tsx";
const pageSource = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
);
const centerSource = readFileSync(
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
"utf8",
);
const hookSource = readFileSync(
new URL("../src/hooks/use-visibility-aware-poll.ts", import.meta.url),
"utf8",
);
test("an exhausted poll budget becomes an explicit state instead of an endless spinner", () => {
assert.match(pageSource, /\| \{ phase: "timed-out" \}/, "ReportLoadState carries an explicit timed-out phase");
assert.match(pageSource, /setState\(\(current\) => \(current\.phase === "generating" \? \{ phase: "timed-out" \} : current\)\)/);
assert.match(pageSource, /if \(state\.phase === "timed-out"\)/, "the timed-out phase has its own render branch");
// The old silent give-up must be gone.
assert.doesNotMatch(pageSource, /MAX_POLLS/);
assert.doesNotMatch(pageSource, /polls >= /);
});
test("the timed-out branch is honest and offers both resume and the report center", () => {
const branchAt = pageSource.indexOf('if (state.phase === "timed-out")');
assert.ok(branchAt >= 0, "timed-out branch must exist");
const branchEnd = pageSource.indexOf('if (state.phase === "unauthorized")');
assert.ok(branchEnd > branchAt, "the timed-out branch must be self-contained");
const branch = pageSource.slice(branchAt, branchEnd);
assert.match(branch, /生成时间超出预期/);
assert.match(branch, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
assert.match(branch, /页面已暂停自动刷新。/, "we must say polling stopped rather than imply it continues");
assert.match(branch, /报告仍在后台生成/);
assert.match(branch, /继续等待/);
assert.match(branch, /onClick=\{\(\) => keepWaiting\(\)\}/);
assert.match(branch, /href="\/reports"/);
assert.match(branch, /返回报告中心/);
assert.doesNotMatch(branch, /[!]/, "copy carries no exclamation marks");
});
test("keepWaiting restarts the wait clock and immediately refetches", () => {
const resumeAt = pageSource.indexOf("const keepWaiting = useCallback(");
assert.ok(resumeAt >= 0);
const resume = pageSource.slice(resumeAt, resumeAt + 320);
assert.match(resume, /setWaitStartedAt\(Date\.now\(\)\)/);
assert.match(resume, /setWaitedMs\(0\)/);
assert.match(resume, /setState\(\{ phase: "generating" \}\)/);
assert.match(resume, /void load\(\)/);
});
test("the wait budget is an explicit wall clock well beyond the old 120s", () => {
assert.equal(POLL_BUDGET_MS, 8 * 60 * 1000);
assert.ok(POLL_BUDGET_MS > 120_000, "120s was too short for report generation");
assert.match(pageSource, /export const POLL_BUDGET_MS = 8 \* 60 \* 1000;/);
});
test("polling backs off without slowing the first minute", () => {
assert.equal(pollIntervalForElapsed(0), 3000);
assert.equal(pollIntervalForElapsed(59_999), 3000);
assert.equal(pollIntervalForElapsed(60_000), 6000);
assert.equal(pollIntervalForElapsed(179_999), 6000);
assert.equal(pollIntervalForElapsed(180_000), 10_000);
assert.equal(pollIntervalForElapsed(360_000), 15_000);
assert.equal(pollIntervalForElapsed(POLL_BUDGET_MS), 15_000);
let previous = 0;
for (let elapsed = 0; elapsed <= POLL_BUDGET_MS; elapsed += 1000) {
const interval = pollIntervalForElapsed(elapsed);
assert.ok(interval >= previous, `interval must never shrink at ${elapsed}ms`);
assert.ok(interval >= 3000 && interval <= 15_000, `interval stays within 3s..15s at ${elapsed}ms`);
previous = interval;
}
// A full budget must cost far fewer requests than a flat 3s interval would.
let requests = 0;
for (let elapsed = 0; elapsed < POLL_BUDGET_MS; elapsed += pollIntervalForElapsed(elapsed)) {
requests += 1;
}
assert.ok(requests < POLL_BUDGET_MS / 3000, "backoff must reduce the request count");
assert.ok(requests < 80, `a full wait should stay under 80 requests, got ${requests}`);
assert.match(pageSource, /intervalMs: pollIntervalForElapsed\(waitedMs\)/);
});
test("elapsed wait is rendered in Simplified Chinese minutes and seconds", () => {
assert.equal(formatWaitedDuration(0), "0 秒");
assert.equal(formatWaitedDuration(9_400), "9 秒");
assert.equal(formatWaitedDuration(60_000), "1 分 0 秒");
assert.equal(formatWaitedDuration(130_000), "2 分 10 秒");
assert.equal(formatWaitedDuration(-5), "0 秒");
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
const generatingAt = pageSource.indexOf('{generating ? "报告正在生成中,请稍候…"');
assert.ok(generatingAt >= 0, "the generating spinner still exists");
assert.match(pageSource.slice(generatingAt, generatingAt + 700), /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
});
test("the shared hook pauses on hidden, refreshes on visible and always cleans up", () => {
assert.match(hookSource, /export function useVisibilityAwarePoll/);
assert.match(hookSource, /document\.hidden/);
assert.match(hookSource, /document\.addEventListener\("visibilitychange", handleVisibilityChange\)/);
assert.match(hookSource, /document\.removeEventListener\("visibilitychange", handleVisibilityChange\)/);
assert.match(hookSource, /if \(document\.hidden\) \{\s*stop\(\);\s*return;\s*\}/);
assert.match(hookSource, /if \(refreshOnVisible\) \{\s*pollRef\.current\(\);\s*\}/);
assert.match(hookSource, /window\.clearInterval\(timer\)/);
// The callback lives in a ref so an inline closure cannot restart the interval every render.
assert.match(hookSource, /const pollRef = useRef\(onPoll\)/);
assert.match(hookSource, /pollRef\.current = onPoll;/);
assert.match(hookSource, /\}, \[enabled, intervalMs, refreshOnVisible\]\);/);
// Reusable: no report-specific imports.
assert.doesNotMatch(hookSource, /personal-report|\/api\/reports/);
});
test("both report surfaces poll through the visibility-aware hook and nothing else", () => {
for (const [name, source] of [["page", pageSource], ["center", centerSource]] as const) {
assert.match(
source,
/import \{ useVisibilityAwarePoll \} from "@\/hooks\/use-visibility-aware-poll";/,
`${name} imports the shared hook`,
);
assert.match(source, /useVisibilityAwarePoll\(\{/, `${name} calls the shared hook`);
assert.doesNotMatch(source, /setInterval\(/, `${name} must not hand-roll an interval poll`);
assert.doesNotMatch(source, /addEventListener\("visibilitychange"/, `${name} must not duplicate the listener`);
}
assert.match(centerSource, /enabled: hasGenerating,/);
assert.match(centerSource, /intervalMs: LIST_POLL_INTERVAL_MS,/);
assert.match(centerSource, /const LIST_POLL_INTERVAL_MS = 3000;/);
assert.match(pageSource, /enabled: generating,/);
});
test("cancellation guards survive the rewrite and no poll runs outside the generating phase", () => {
assert.match(pageSource, /const cancelledRef = useRef\(false\)/);
assert.match(pageSource, /if \(cancelledRef\.current\) \{\s*return;\s*\}/);
assert.match(pageSource, /cancelledRef\.current = true;/);
assert.match(centerSource, /const cancelled = useRef\(false\)/);
assert.match(centerSource, /if \(cancelled\.current\) return;/);
assert.match(centerSource, /cancelled\.current = true;/);
assert.match(pageSource, /const generating = state\.phase === "generating";/);
});
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), "utf8");
const error = read("../src/app/error.tsx");
const globalError = read("../src/app/global-error.tsx");
const notFound = read("../src/app/not-found.tsx");
const chinese = /[\u4e00-\u9fff]/;
test("the app root ships error, global-error and not-found boundaries", () => {
for (const source of [error, globalError, notFound]) {
assert.ok(source.length > 0);
assert.match(source, /export default function \w+\(/);
}
});
test("root error boundary is a client component that can reset and route home", () => {
assert.match(error, /^"use client";/);
assert.match(error, /reset,\s*\}: \{[\s\S]*reset: \(\) => void;/);
assert.match(error, /onClick=\{\(\) => reset\(\)\}/);
assert.match(error, /href="\/"/);
assert.match(error, /error\.digest/);
assert.doesNotMatch(error, /error\.stack/);
});
test("global error boundary replaces the root layout without external styling", () => {
assert.match(globalError, /^"use client";/);
assert.match(globalError, /<html lang="zh-CN">/);
assert.match(globalError, /<body/);
assert.match(globalError, /<\/body>/);
assert.match(globalError, /<\/html>/);
assert.doesNotMatch(globalError, /^import /m);
assert.doesNotMatch(globalError, /className="[a-z-]*(flex|text-|bg-|min-h-)/);
assert.match(globalError, /minHeight: "44px"/);
assert.match(globalError, /onClick=\{\(\) => reset\(\)\}/);
assert.match(globalError, /window\.location\.assign\("\/"\)/);
});
test("root not found stays a server component and links back to the chat home", () => {
assert.doesNotMatch(notFound, /"use client"/);
assert.doesNotMatch(notFound, /useState|useEffect|onClick/);
assert.match(notFound, /404/);
assert.match(notFound, /render=\{<Link href="\/" \/>\}/);
});
test("every boundary announces itself accessibly with one heading", () => {
for (const source of [error, globalError]) {
assert.match(source, /role="alert"/);
}
for (const source of [error, globalError, notFound]) {
assert.equal(source.match(/<h1/g)?.length, 1);
assert.doesNotMatch(source, /<h[3-6]/);
}
});
test("boundary copy is simplified chinese in the product voice", () => {
for (const source of [error, globalError, notFound]) {
const copy = source.match(/>\s*([^<>{}]*[\u4e00-\u9fff][^<>{}]*)\s*</g) ?? [];
assert.ok(copy.length >= 2);
for (const line of copy) {
assert.match(line, chinese);
assert.doesNotMatch(line, /[!]/);
assert.doesNotMatch(line, /\p{Extended_Pictographic}/u);
}
}
});