fix(ux): surface hidden notices, bound report waits, add root boundaries
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:
@@ -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
@@ -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,
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user