fix(web): 次级页稳定外壳与缓存预取,跨部署导航自愈(BUG-966/967)

This commit is contained in:
jesse-ux
2026-09-18 18:45:48 +08:00
parent dccedf372e
commit 9ccb65b71f
25 changed files with 1066 additions and 160 deletions
+4
View File
@@ -12,6 +12,10 @@
回答里的清单重新画出圆点和序号,不再只是往右缩进的孤行。口语段落里的冒号不会再被改成列表。思考条和正文之间的空隙收成一档。Skill 版本不变。
## 2026-09-18 — 次级页进入不再推挤;跨部署旧标签页下一次跳转会整页重载
从侧栏进星盘、星历、我的报告时,标题和等待句不再把整页从矮块推成高块。第二次进入会直接显示上次的内容。如果这个标签页开着的时候网站刚更新过,下一次点这些入口会整页打开新版本,而不是点了没反应。Skill 版本不变。
## 2026-09-18 — 申报时段咨询重新能回答
只知道一段出生范围时,问事业 / 运势会在几十毫秒内失败,提示「未完成必要的方法与计算步骤」,从八月下旬起一直如此。根因是这条路线的助手装了方法校验、却没把方法写进系统提示,模型一次都没被叫到。现在方法已经装上;没有跑完计算但模型写过字时,会把那段留下并注明「没跑完星盘计算」,不再整轮空白。Skill 版本不变。
+1
View File
@@ -15,6 +15,7 @@ ARG NEXT_DEPLOYMENT_ID
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID} \
NEXT_PUBLIC_GIT_COMMIT=${NEXT_DEPLOYMENT_ID} \
NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
COPY SKILL.md /app/SKILL.md
+43 -7
View File
@@ -12533,7 +12533,7 @@
## BUG-965 | 侧栏「星盘 / 星历 / 我的报告」点了不跳转,账户入口也没反应
- 状态:investigating(触发条件已由产品复现确认,修复未做)
- 状态:resolved(由 BUG-967 修复)
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`AppSidebar` 的 `NAV_PAGES` 链接与页脚账户入口;staging `877128ce`
@@ -12545,12 +12545,48 @@
3. 链接是真链接:三项导航是 `SidebarMenuLink` → `next/link` 的 `<Link href>`,`onClick={leaveChat}` 只做 `persistLoginSessionReturn()` 与关抽屉,**没有 `preventDefault`**。
4. 没有覆盖桌面侧栏的 `pointer-events: none` 或 `inert`:`inert` 只出现在移动端抽屉、时间轴折叠体、`SidebarInset`(`insetInert = modalOpen`,只盖正文区不盖侧栏)。
5. 次级页页脚是 `Link href="/"`(账户菜单只在 `/`),因此「在次级页点头像」应当跳回首页而不是没反应。
- 待取证据:控制台报错原文与 Network 面板是否有 404 的 `/_next/static/**`;点击时所在页面的 URL;该标签页是否在 17:12 部署之前就已打开;硬刷新(Cmd+Shift+R)后是否恢复。
- 根因(按复现结论):标签页跨过部署后,客户端导航要拉的构建产物已被新镜像替换(构建 id 变了,旧 chunk / RSC 负载不再存在),路由切换静默失败;已经加载好的对话页因为代码都在内存里,照常工作。Next 16 自带的构建不匹配回退在这次没有把用户带到整页重载,具体是被哪一层吞掉(404 形状、Caddy、还是 RSC 负载不匹配的判定)需要在修复单里查证。
- 修复方向:见 `docs/tasks/TASK-secondary-page-entry-20260918.md` BUG-967——客户端用编译期注入的 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 的 `deployment.gitCommit` 比对,发现自己是旧版本就在下一次导航时整页重载,而不是静默失败。
- 关联记录:BUG-744~746(侧栏统一为 `Link` 跳转)、BUG-926~929(会话列表单一数据源与外壳搬家)、BUG-936(首屏 bundle 未执行)
- 复发自:待确认
- 修复版本:待发布
- 根因:见 BUG-967。标签页跨过部署后,客户端导航要拉的构建产物已被新镜像替换;Next 16.3.1 自带的构建不匹配回退在自托管 + 预取缓存这条路上没有把用户带到整页重载,点击表现为静默无反应。已经加载好的对话页因为代码都在内存里,照常工作。
- 修复:BUG-967 的版本比对自愈。旧标签页在下一次站内跳转走 `window.location.assign`。
- 验证:见 BUG-967。
- 防复发:见 BUG-967。不得再把「刷新就好」当成侧栏无响应的结案。
- 关联记录:BUG-967、BUG-204、BUG-744~746、BUG-926~929、BUG-936
- 复发自:BUG-204 同类(跨发布客户端导航),那次修的是 `deploymentId` / `?dpl=`,在 Vercel 上靠部署路由 404 触发 MPA;本仓是自托管单镜像,那条路径不成立。
- 修复版本:本轮提交
## BUG-966 | 星盘 / 星历 / 我的报告进入时矮等待块推挤成高正文
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`chart-page-view.tsx`、`ephemeris-page.tsx`、`personal-report-center.tsx`、`use-chart-page.ts`、`SecondaryHeader`
- 用户现象:从侧栏进「星盘 / 星历 / 我的报告」有一瞬间抖动。
- 触发条件:进入这三页,尤其是同一会话里每次进入(没有首屏缓存)。
- 根因:三页各写各的等待布局——先渲染 46px 标题加一行短等待文案,数据到达后整块换成高正文。`SecondaryHeader` 的 `note` 未到时不渲染,标题区二次填充。`use-chart-page` 进页才 fetch,零缓存,每次都重放中间态。产品 2026-09-18 选定方案一:消灭中间态,不加 spinner,红线不动。
- 修复:三页共用 `SecondaryPageShell`(标题行高度固定、note 槽占位不塌、内容区撑满剩余视口,等待文案居中在该区)。星盘 / 星历 / 报告首屏进模块级缓存,同一会话再进直接用上次结果并后台静默刷新。侧栏三项在 `pointerenter` / `pointerdown` 预热数据。等待句仍是静态句(「这一张盘还没拿到。」等),没有 spinner / 骨架 / 「正在加载」。
- 验证:`frontend/tests/secondary-page-entry.test.ts`(三页 import 共享外壳、暖缓存第二次不经 null 等待、预取命中同一 inflight、CSS 内容区 min-height:0 且等待居中);既有 `chart-page-view` / `ephemeris-page` / sidebar 合同。浏览器量高度见 `docs/testing/secondary-page-entry-20260918.md`(无 Chrome)。
- 防复发:次级页进入不得再各写一套等待布局。揭幕后不得加 spinner / 骨架 / 「正在加载」。缓存必须模块级,组件 unmount 不能丢掉。
- 关联记录:BUG-716、BUG-717、BUG-745
- 复发自:无
- 修复版本:本轮提交
## BUG-967 | 跨部署旧标签页的客户端导航静默失效
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`AppLink`、`app-navigation.ts`、`StaleBuildGuard`、侧栏 `NAV_PAGES` 与次级页账户入口、Web 镜像 `NEXT_PUBLIC_GIT_COMMIT`
- 用户现象:见 BUG-965。
- 触发条件:标签页在一次部署之前打开,部署后不刷新,点站内次级页或账户入口。
- 根因(查证 Next 16.3.1 源码,不得跳过):
1. 不匹配回退写在 `fetchServerResponse`:RSC 不是 `text/x-component`、或非 2xx、或 `x-nextjs-deployment-id` / `flightResponse.b` ≠ `getNavigationBuildId()` 时才 `doMpaNavigation`。`<Link>` 进页时已经把 `/chart` `/ephemeris` `/reports` 的 Flight 预取进内存;点击走缓存、不再 fetch,这条检查根本不跑。
2. 自托管只有当前镜像。Caddy 对 `/_next/static` 没有改写,只是 `reverse_proxy web:3000`。`?dpl=` 是 Vercel 按部署分流的参数,在本机被忽略。目标路由在新镜像上仍 200 返回新的 Flight,`!res.ok` 也不成立。
3. `handleHardNavError` / `useNavFailureHandler` 编译期开关 `__NEXT_APP_NAV_FAIL_HANDLING`,standalone 默认关。旧 chunk 的 `ChunkLoadError` 若被 router reducer 吃掉,既有 `StaleClientRecovery`(只听 `window.error` / `unhandledrejection`)也看不见。客户端导航于是表现为点击无反应;挂起的 `useTransition` 还能让同一页的账户入口一起没反应。
- 修复:编译期 `NEXT_PUBLIC_GIT_COMMIT`(Docker 构建时与 `NEXT_DEPLOYMENT_ID` 同值写入)对照 `/api/health` `.deployment.gitCommit`。页面重新可见时拉一次 health,导航前读这份快照。不一致则 `window.location.assign`,一致则继续客户端路由。health 失败不强制刷新。无轮询、无定时重载。
- 验证:合同测试——版本不一致走 `assign`,一致走传入的 `router.push` / 不 preventDefault;health 失败或 commit 为 `unknown` 不强制跳。真机跨部署见 `docs/testing/secondary-page-entry-20260918.md`。
- 防复发:站内跨布局跳转走 `AppLink` / `navigateAppPath`,不得假定 Next 的 mismatch fallback 在自托管上会救场。不得用「每次导航都整页重载」或定时刷新糊过去。
- 关联记录:BUG-965、BUG-204
- 复发自:BUG-204
- 修复版本:本轮提交
## BUG-955 | 输入处理器 abort 与「合同未完成」同码,窗口装配失败藏了四周
@@ -0,0 +1,65 @@
# PROGRESS · 次级页进入时的等待态与抖动(2026-09-18)
分支:`codex/secondary-page-entry-20260918`
基线:`origin/staging` @ `1061514f`(任务书写的 `41902067` 是写单时的 head;本 worktree 按开工指令跟当前 `origin/staging`)
方案:产品 2026-09-18 选定方案一。方案 B 未做。
## BUG-967 调查:Next 16.3.1 的 mismatch fallback 为什么没开火
对照 `next@16.3.1` 源码(GitHub tag `v16.3.1`),不是猜的。
### Next 自己会在什么时候整页跳
`packages/next/src/client/components/router-reducer/fetch-server-response.ts`:
1. RSC 响应 `Content-Type` 不是 `text/x-component`(export 模式下还接受 `text/plain`),或 `!res.ok`,或没有 body → `doMpaNavigation`。
2. 解出 Flight 之后:`(res.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? flightResponse.b) !== getNavigationBuildId()` → 同样 MPA。
3. `app-router.tsx` 看到 `pushRef.mpaNavigation` 才 `location.assign` / `replace`,并 `throw unresolvedThenable` 卡住当前树。
另有 `nav-failure-handler.ts` 的 `handleHardNavError`:导航过程中未捕获错误则 `window.location.href = window.next.__pendingUrl`。**整份文件包在 `process.env.__NEXT_APP_NAV_FAIL_HANDLING` 里**,standalone 默认不打开。
既有 `StaleClientRecovery` 只在 `window.error` / `unhandledrejection` 文本像 chunk 失败、且 `sessionStorage` 还没记过一次时 `reload()`。router reducer 如果把 rejection 吃掉,它看不见。
### 为什么这次三条都不触发
1. **预取缓存让检查根本不跑。** 侧栏三项是 `<Link>`。标签页在部署前已经打开对话页时,Next 已经把 `/chart` `/ephemeris` `/reports` 的 Flight 预取进内存。点击走这段缓存,不再 `fetchServerResponse`。构建 id 比对、非 2xx、非 Flight Content-Type,全部不执行。
2. **自托管没有 Vercel 那种 `?dpl=` 分流。** `next.config.ts` 写了 `deploymentId: process.env.NEXT_DEPLOYMENT_ID`,静态资源带 `?dpl=`。在 Vercel 上这个 query 把请求打到对应部署,旧部署不在了就是 404,于是走条件 1。本仓 Caddy(`deploy/Caddyfile.staging` / `Caddyfile.production.selfhosted`)对 `/_next/static` **没有任何特殊 404 改写**,只 `reverse_proxy web:3000`。主机上只有当前镜像。旧 tab 就算真的去 fetch `/chart`,新服务器仍对这条路径返回 **200 + 新的 Flight**。条件 1 的 `!res.ok` 不成立。BUG-204 当年靠 `deploymentId` 修好的是 Vercel 形状,不是这台 VPS。
3. **错误回退开关是关的。** 旧 chunk 404 变成 `ChunkLoadError` 时,默认 standalone 不会把它升级成 MPA。点击看起来像没反应。`app-router.tsx` 若已经丢出 `unresolvedThenable` 或 `startTransition` 一直 pending,同一页上的账户按钮(本就不是 Link)也会一起没反应——这与「对话还能发」不矛盾:对话代码在内存里,路由切换被卡住。
Caddy 不是元凶:它没有把 404 变成 200 HTML。问题是 **200 的新 Flight** 加上 **根本不再 fetch 的预取缓存**。
### 自愈怎么做(才没有废掉正常客户端导航)
- 客户端 commit:构建期 `NEXT_PUBLIC_GIT_COMMIT`(Dockerfile 里与 `NEXT_DEPLOYMENT_ID` 同值写入,不改 workflow)。
- 服务端 commit:已有的 `GET /api/health` → `.deployment.gitCommit`(运行时 `GITHUB_SHA`)。
- `StaleBuildGuard` 在挂载和 `visibilitychange → visible` 时拉一次 health。不轮询、不定时 reload。
- 导航前读这份快照。不一致:`window.location.assign`。一致:原样走 `Link` / `router.push`。health 失败或任一侧是空/`unknown`:不强制跳。
## BUG-966
三页改为 `SecondaryPageShell`。`.secondary-page` 是 chat-panel 第二行(`minmax(0,1fr)`),等待与正文同一格子;等待句居中。`SecondaryHeader reserveNote` 让出生行晚到时标题行不塌。模块级缓存 + 侧栏 `pointerenter`/`pointerdown` 预取。报告列表首屏去掉 `InlineSpinner`「正在读取报告…」,改静态句「报告列表还没拿到。」刷新按钮上的 spinner 仍是用户点的动作,DESIGN 允许。
未做方案 B(延迟揭幕)。
## 测试
| 命令 | 结果 |
| --- | --- |
| `npx tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(既有 120 warning,本轮文件未新增) |
| 定向:`secondary-page-entry` / `chart-page-view` / `ephemeris-page` / `sidebar-contract` / `stale-client-recovery` / `personal-report-entry` / `chat-navigation-a11y-contract` / `class-name-definition-contract` | 114 / 114 |
| `personal-report-view.test.ts` | 通过(详情页仍用 `SecondaryHeader`) |
| `next build` | compile + tsc 过;收集 `/api/daily-starlanguage` 时 Windows `EPERM` 无法 symlink skill runtime。环境缺口,与本轮无关。`/` Static 与 gzip 未在本机量到。 |
无 Chrome:高度真机清单在 `docs/testing/secondary-page-entry-20260918.md`。无登录态:缓存第二次进入与跨部署自愈的浏览器步骤同样写在那里。
改过的既有断言:
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `sidebar-contract` 只读行 / 页脚 | `<Link className="session-main">`、`<Link className="profile-trigger" href="/">` | `AppLink`,class 与 href 不变 | BUG-967 跨部署自愈;只读行仍是链接、页脚仍去 `/` |
## 偏离
- 任务书写 `app/(secondary)/layout.tsx`,当前树是 `app/(app)/layout.tsx`(会话列表单源之后)。外壳挂在这里,没有把路由组改回去。
- 报告详情 `/reports/[reportId]` 仍用 `SecondaryHeader` 各阶段一份,不进三页进入抖动的范围;loading.tsx 里的 spinner 本轮不动。
+1 -1
View File
@@ -142,7 +142,7 @@
| `TASK-contract-degraded-pass4-20260918.md` | `PROGRESS-contract-degraded-pass4-20260918.md` | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待验收 | `e07d5190` |
| `TASK-window-consult-contract-20260918.md` §5 | `PROGRESS-window-consult-precompute-20260918.md` | BUG-957:申报时段计算改为服务端预跑并走同请求缓存。产品确认 BUG-954 已在 staging 验证,本轮单独做。不得 `toolChoice: "required"`,本命不预跑 | 待验收 | `efd40d43` |
| `TASK-chat-markdown-list-20260918.md` | `PROGRESS-chat-markdown-list-20260918.md` | 真机排版反馈:聊天正文列表**没有项目符号**(Tailwind v4 preflight 清了 `list-style`,`.markdown-list` 没恢复,BUG-962);`promoteDefinitionLists` 把四标题口语体的散文误判成并列项,三段正文被改写成列表(BUG-963,判据太松,收紧而非删除——BUG-356 的场景要留);思考条与正文之间实测 56px=报告 grid gap 24 + 首标题 margin-top 32,后者是被特指度压掉的「首元素清零」规则(BUG-964)。基线 `ea0280c1` | 待验收 | `8b102d52` |
| `TASK-secondary-page-entry-20260918.md` | — | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待领取 | — |
| `TASK-secondary-page-entry-20260918.md` | `PROGRESS-secondary-page-entry-20260918.md` | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待验收 | `4c69a3dc` |
| `TASK-account-dialog-inert-20260918.md` | — | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56` 把 `SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968,P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — |
| `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — |
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
@@ -0,0 +1,75 @@
# 真机清单 · 次级页进入不再推挤 + 跨部署导航自愈(2026-09-18)
对应 `TASK-secondary-page-entry-20260918.md`、`PROGRESS-secondary-page-entry-20260918.md`、BUG-965 / 966 / 967。
自动化做不到的部分:需要**浏览器 + 已登录账号**。执行环境无 Chrome、无登录态,因此这些是环境缺口,不是「已通过」。
准备:桌面 Chrome,宽度 ≥ 1024px,已登录。DevTools → Network 勾上 `Preserve log`。
## A · 进入三页时外壳高度不变(BUG-966)
1. 停在 `/`。Console 里先贴:
```js
(() => {
const measure = () => {
const node = document.querySelector(".secondary-page");
if (!node) return null;
const r = node.getBoundingClientRect();
return { h: Math.round(r.height), top: Math.round(r.top) };
};
const log = [];
const obs = new ResizeObserver(() => log.push({ t: performance.now(), ...measure() }));
const start = () => {
const node = document.querySelector(".secondary-page");
if (!node) { requestAnimationFrame(start); return; }
obs.observe(node);
log.push({ t: performance.now(), ...measure(), event: "first" });
};
window.__secondaryPageLog = log;
window.__secondaryPageStop = () => { obs.disconnect(); console.table(log); };
document.addEventListener("click", (event) => {
const a = event.target.closest("a[href='/chart'], a[href='/ephemeris'], a[href='/reports']");
if (a) requestAnimationFrame(start);
}, true);
console.log("ready: click 星盘 / 星历 / 我的报告, then __secondaryPageStop()");
})();
```
2. 点侧栏「星盘」。内容出现后立刻在 Console 执行 `__secondaryPageStop()`。
- **期望**:表格里所有 `h` 相同(等待态与内容态同高),`top` 不变。
- **不期望**:高度从一行文案跳到整页正文;出现转圈、骨架、「正在加载」。
3. 对「星历」「我的报告」各做一次。三页的等待句可以不同(盘还没拿到 / 星历还没拿到 / 报告列表还没拿到),但都必须写在 `.secondary-page` 里居中,不能是矮块。
## B · 第二次进入没有等待句(BUG-966 缓存)
1. 打开过「星盘」并等到盘出现。
2. 点「新建对话」回 `/`(不要刷新)。
3. Network Clear,再点「星盘」。
- **期望**:第一帧就是盘(或失败句),**没有**「这一张盘还没拿到。」。Network 里可以有一条后台 `/api/chart-view`(静默刷新)。
4. 「星历」「我的报告」同样:第二次不得再出现各自的等待句。
## C · 悬停会预取数据(BUG-966 预取)
1. 刷新 `/`,Network Clear,筛选 `chart-view|ephemeris|reports`。
2. 鼠标移到侧栏「星盘」上,**先不要点**。
- **期望**:出现 `GET /api/chart-view`。对「星历」「我的报告」分别是 `/api/ephemeris`、`/api/reports`。
3. 再点进去。
- **期望**:若预取已完成,进页不再先闪等待句。
## D · 跨部署旧标签页会在下一次跳转整页重载(BUG-965 / 967)
这一条必须跨一次真实部署,属环境依赖。
1. 在 staging 打开 `/` 并停留在对话页(不要关标签)。确认对话还能发、还能收。
2. 让负责人发一版新的 staging(或等下一轮门禁部署)。记下新 SHA。
3. **不要刷新**这个旧标签。切到别的应用再切回来(触发 `visibilitychange`)。
4. Network Clear。点侧栏「星盘」。
- **期望**:出现 Type = `document` 的 `/chart` 请求(整页重载),页面打开;不是点击无反应。
- 地址栏最终是 `/chart`,之后再点「星历」应走客户端导航(没有新的 document 请求),因为这时已经是新构建。
5. 另开一个**部署之后**才打开的新标签,点「星盘」。
- **期望**:没有 document 请求(版本一致,仍是客户端路由)。
6. 若 health 接口人为断掉(DevTools Offline 只挡 `/api/health` 不挡其它):
- **期望**:当前标签继续能点、不强制刷新。
失败时记下:点击时的 URL、Console 全文、Network 里 `/_next/static` 与 `/chart?_rsc` 的状态码。不要把用户资料贴进仓库。
+21 -13
View File
@@ -395,14 +395,21 @@ shell. Each used to own a full-screen layout — a `*-shell` root, a `*-topbar`
with one 「返回对话」 link, and a `*-hero` with a page-sized h1 — three copies of
one skeleton, none of them carrying the sidebar.
- **Composition:** `app/(secondary)/layout.tsx` = `SidebarProvider` →
`AppSidebar` (read-only) → `SidebarInset.chat-panel.secondary-panel`. The
route group changes no URL; what it changes is that the four routes share
**one** shell instance, so moving between them neither remounts the sidebar
nor re-reads the session list. Each page contributes only `SecondaryHeader`:
the same 46px row the chat page uses — sidebar trigger, page name, an optional
quiet note chip, and up to two right-aligned actions. The body is the only
thing that scrolls, and it owns the page floor (`--color-canvas-soft`).
- **Composition:** `app/(app)/layout.tsx` = `SessionListProvider` →
`SidebarProvider` → `AppSidebar` → `SidebarInset.chat-panel.secondary-panel`
(home registers its own inset class). `/chart`, `/ephemeris` and `/reports`
share `SecondaryPageShell`: the same 46px `SecondaryHeader` (sidebar trigger,
page name, a reserved note slot so a late birth line cannot collapse the row,
up to two actions) and a `.secondary-page` body that always fills the
remaining viewport. Waiting copy is centered in that cell; swapping in the
tall content does not change the shell height. No spinner, skeleton, or
「正在加载」. First-screen data for the three pages lives in a module-level
cache; a second visit paints the last result immediately and refreshes in
the background. Sidebar `pointerenter` / `pointerdown` prefetches that data
(`<Link>` only prefetches JS). A tab that outlived a deploy compares
`NEXT_PUBLIC_GIT_COMMIT` with `/api/health` `.deployment.gitCommit` on
visibility and before navigation; a mismatch uses `window.location.assign`,
a match keeps the client router.
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
product. `/` passes `controls`; the secondary layout does not, and without it
the same markup renders read-only. There used to be a second component for
@@ -419,10 +426,11 @@ one skeleton, none of them carrying the sidebar.
deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback
layer, which is far more than these four routes need.
- **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are
`<Link>` on every page, including `/`, where they used to be a full document
`AppLink` on every page, including `/`, where they used to be a full document
load that threw away the React tree, the session list and the account. Only
`/login` stays a hard exit, and `persistLoginSessionReturn()` still stashes the
`?c=` before any of them.
`?c=` before any of them. After a deploy, a stale tab's next `AppLink` is the
hard exit: `window.location.assign`, not a silent client-router no-op.
- **Loading:** the sidebar renders its nav immediately and fills the list when
`/api/sessions` returns. While that is in flight the list area carries static
copy — never a skeleton, per the unified-loading ruling. A list read in the
@@ -446,7 +454,7 @@ one skeleton, none of them carrying the sidebar.
- **Surface:** `--color-canvas-soft` floor; rows use `--color-canvas`, a warm hairline, and `--radius-lg` on the list as a whole. No drop shadow.
- **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut.
- **Actions:** “生成完整报告” is the one filled action. A ready row keeps “查看报告” as the primary document action with “导出报告(.md)” quiet beside it. The export downloads the same longform Markdown the detail page renders. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never starts a second writing flow. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
- **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert.
### Personal report reader
@@ -828,7 +836,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
### 等待态
星盘页是独立文档。外壳揭幕(标题、返回)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。
星盘页是独立文档。`SecondaryPageShell` 揭幕(标题、保留的 note 槽、撑满剩余视口的内容区)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。同一会话再次进入直接用模块缓存,不重放等待句。
| 时刻 | 文案 |
| --- | --- |
@@ -850,6 +858,6 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
- **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。首屏未到时 `SecondaryPageShell` 写「这一天的星历还没拿到。」,居中在已撑满的内容区里。未登录是静态「请先登录」加去登录,不是转圈。同一会话再次进入直接用模块缓存。
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。
+20 -1
View File
@@ -2094,6 +2094,24 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.secondary-page {
display: grid;
align-content: start;
min-height: 0;
}
.secondary-page:has(> .secondary-page-waiting) {
align-content: center;
justify-items: center;
}
.secondary-page-waiting {
max-width: 44ch;
margin: 0;
padding: 0 var(--space-6);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.6;
text-align: center;
}
.chart-page-body,
.ephemeris-body,
.report-center-body,
@@ -2138,7 +2156,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.report-center-body,
.chart-page-message,
.ephemeris-message,
.report-center-message { padding-inline: var(--space-4); }
.report-center-message,
.secondary-page-waiting { padding-inline: var(--space-4); }
}
/* Starter home: greeting, composer, entry pills */
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleBuildGuard } from "@/components/stale-build-guard";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
import { themePreferenceBootScript } from "@/lib/theme-preference";
@@ -71,6 +72,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<body>
<StaleClientRecovery />
<ViewportScrollLock />
<StaleBuildGuard />
{children}
<Toaster />
</body>
+33
View File
@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { forwardRef, type ComponentProps } from "react";
import { hrefFromLinkTarget, interceptAppNavigation } from "@/lib/app-navigation";
export type AppLinkProps = ComponentProps<typeof Link>;
/**
* In-app link that stays on the client router when this tab's build matches
* the running image, and does a document load when it does not.
*
* Next 16.3.1's own mismatch fallback did not fire on this host after a
* deploy (see BUG-967); wrapping the destinations that actually no-op'd is
* the self-heal.
*/
export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(function AppLink(
{ onClick, href, ...props },
ref,
) {
return (
<Link
ref={ref}
href={href}
onClick={(event) => {
onClick?.(event);
interceptAppNavigation(event, hrefFromLinkTarget(href));
}}
{...props}
/>
);
});
+6 -3
View File
@@ -15,11 +15,12 @@ import {
UserRound,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import { AppLink } from "@/components/app-link";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
import {
Sidebar,
SidebarContent,
@@ -261,6 +262,8 @@ export function AppSidebar({
tooltip={label}
isActive={pathname === href || pathname.startsWith(`${href}/`)}
href={href}
onPointerEnter={() => prefetchSecondaryPage(href)}
onPointerDown={() => prefetchSecondaryPage(href)}
onClick={leaveChat}
>
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
@@ -389,7 +392,7 @@ export function AppSidebar({
/* Read-only footer: the same 56px identity block, minus the chevron
and the menu. The account menu and its dialogs live on `/`, so this
goes there rather than growing a second copy of them. */
<Link
<AppLink
className="profile-trigger"
href="/"
aria-label={account ? `${account.name},打开账户` : "打开账户"}
@@ -399,7 +402,7 @@ export function AppSidebar({
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
{showExpandedContent && account ? <span><b>{account.name}</b></span> : null}
</Link>
</AppLink>
)}
</SidebarFooter>
<SidebarRail />
@@ -2,7 +2,7 @@
import { useState } from "react";
import { SecondaryHeader } from "@/components/secondary-header";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { useChartPage } from "@/hooks/use-chart-page";
import {
CHART_VIEW_TABS,
@@ -63,13 +63,12 @@ export function ChartPageView({
: null;
return (
<>
<SecondaryHeader title="星盘" note={birthline} />
{view == null ? (
<section className="chart-page-message">
<p>{CHART_VIEW_COPY.waitingChart}</p>
</section>
) : view.status !== "ok" ? (
<SecondaryPageShell
title="星盘"
note={birthline}
waiting={view == null ? CHART_VIEW_COPY.waitingChart : null}
>
{view == null ? null : view.status !== "ok" ? (
<section className="chart-page-message">
<p>{view.message}</p>
</section>
@@ -118,6 +117,6 @@ export function ChartPageView({
</section>
</div>
)}
</>
</SecondaryPageShell>
);
}
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
import { AppLink } from "@/components/app-link";
import { useCallback, useEffect, useRef, useState } from "react";
import { CalendarIcon } from "lucide-react";
@@ -8,9 +8,15 @@ import { zhCN } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SecondaryHeader } from "@/components/secondary-header";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { setComposerDraft } from "@/lib/composer-draft";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
import {
EPHEMERIS_WAITING_COPY,
peekEphemerisPage,
refreshEphemerisPage,
writeEphemerisPage,
} from "@/lib/secondary-page-data";
import {
EPHEMERIS_COPY,
ephemerisAskDraft,
@@ -28,10 +34,22 @@ type PageState =
| { phase: "unauthorized" }
| { phase: "open" };
function snapshotToState(): PageState {
const cached = peekEphemerisPage();
if (cached?.kind === "unauthorized") return { phase: "unauthorized" };
if (cached?.kind === "ready") return { phase: "ready", payload: cached.payload };
return { phase: "open" };
}
export function EphemerisPage() {
const [date, setDate] = useState<string | null>(null);
const [state, setState] = useState<PageState>({ phase: "open" });
const cache = useRef(new Map<string, EphemerisOkResponse>());
const cachedFirst = peekEphemerisPage();
const [date, setDate] = useState<string | null>(
cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null,
);
const [state, setState] = useState<PageState>(snapshotToState);
const cache = useRef(new Map<string, EphemerisOkResponse>(
cachedFirst?.kind === "ready" ? [[cachedFirst.payload.date, cachedFirst.payload]] : [],
));
const load = useCallback(async (nextDate: string | null, signal: AbortSignal) => {
const query = nextDate ? `?date=${encodeURIComponent(nextDate)}` : "";
@@ -42,6 +60,7 @@ export function EphemerisPage() {
});
if (signal.aborted) return;
if (response.status === 401) {
writeEphemerisPage({ kind: "unauthorized" });
setState({ phase: "unauthorized" });
return;
}
@@ -49,12 +68,38 @@ export function EphemerisPage() {
const payload = parseEphemerisOkResponse(json);
if (!payload) return;
cache.current.set(payload.date, payload);
writeEphemerisPage({ kind: "ready", payload });
setDate(payload.date);
setState({ phase: "ready", payload });
}, []);
useEffect(() => {
if (date && cache.current.has(date)) return;
let cancelled = false;
void refreshEphemerisPage()
.then((snapshot) => {
if (cancelled) return;
if (snapshot.kind === "unauthorized") {
setState({ phase: "unauthorized" });
return;
}
cache.current.set(snapshot.payload.date, snapshot.payload);
setDate((current) => current ?? snapshot.payload.date);
setState((current) => {
if (current.phase === "ready" && current.payload.date !== snapshot.payload.date) return current;
return { phase: "ready", payload: snapshot.payload };
});
})
.catch((error: unknown) => {
if (cancelled) return;
console.warn("ephemeris_page_fetch_failed", error);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!date || cache.current.has(date)) return;
const controller = new AbortController();
void load(date, controller.signal).catch((error: unknown) => {
if (controller.signal.aborted) return;
@@ -65,26 +110,27 @@ export function EphemerisPage() {
if (state.phase === "unauthorized") {
return (
<>
<SecondaryHeader title={EPHEMERIS_COPY.title} />
<SecondaryPageShell title={EPHEMERIS_COPY.title}>
<section className="ephemeris-message">
<h2>{EPHEMERIS_COPY.loginTitle}</h2>
<p>{EPHEMERIS_COPY.loginBody}</p>
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
<Button render={<AppLink href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
</section>
</>
</SecondaryPageShell>
);
}
const payload = state.phase === "ready" ? state.payload : null;
const activeDate = date ?? payload?.date ?? payload?.today ?? "";
const today = payload?.today ?? activeDate;
if (!payload) {
return <SecondaryPageShell title={EPHEMERIS_COPY.title} waiting={EPHEMERIS_WAITING_COPY} />;
}
const activeDate = date ?? payload.date ?? payload.today ?? "";
const today = payload.today ?? activeDate;
function changeDate(next: string) {
setDate(next);
const cached = cache.current.get(next);
if (cached) setState({ phase: "ready", payload: cached });
else setState({ phase: "open" });
}
function askAboutDate() {
@@ -127,15 +173,14 @@ export function EphemerisView(props: {
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
return (
<>
<SecondaryHeader
title={EPHEMERIS_COPY.title}
actions={(
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
{EPHEMERIS_COPY.ask}
</Button>
)}
/>
<SecondaryPageShell
title={EPHEMERIS_COPY.title}
actions={(
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
{EPHEMERIS_COPY.ask}
</Button>
)}
>
<div className="ephemeris-body">
<section className="ephemeris-hero">
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles.
@@ -273,6 +318,6 @@ export function EphemerisView(props: {
)}
</section>
</div>
</>
</SecondaryPageShell>
);
}
@@ -16,6 +16,7 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { navigateAppPath } from "@/lib/app-navigation";
import { FileText } from "lucide-react";
import { toast } from "sonner";
@@ -178,7 +179,10 @@ export function GeneratePersonalReportButton({
if (onCreated) {
onCreated(outcome.reportId);
} else {
router.push(`/reports/${encodeURIComponent(outcome.reportId)}`);
navigateAppPath(
`/reports/${encodeURIComponent(outcome.reportId)}`,
(href) => router.push(href),
);
}
return;
}
@@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { AppLink } from "@/components/app-link";
import { Clock3, FileText, RefreshCw } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SecondaryHeader } from "@/components/secondary-header";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -14,23 +14,17 @@ import {
PERSONAL_REPORT_GENERATING_COPY,
} from "@/lib/personal-report-longform-copy";
import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download";
import {
peekReportsPage,
refreshReportsPage,
REPORTS_WAITING_COPY,
writeReportsPage,
type ReportListItem,
type ReportsPageSnapshot,
} from "@/lib/secondary-page-data";
const LIST_POLL_INTERVAL_MS = 3000;
type ReportListItem = Readonly<{
id: string;
reportType: string;
presentationMode: string;
depth: "concise" | "standard" | "deep" | "research";
themes: readonly string[];
status: "generating" | "ready" | "failed";
failureCode: string | null;
failureSummary?: string | null;
createdAt: string;
completedAt: string | null;
cardSummary?: string | null;
}>;
type CenterState =
| { phase: "loading"; reports: readonly ReportListItem[] }
| { phase: "ready"; reports: readonly ReportListItem[] }
@@ -43,36 +37,11 @@ const STATUS_COPY = {
failed: { label: "未完成", description: "本次生成没有产出可用报告,可重新生成。" },
} as const;
function readReports(value: unknown): ReportListItem[] {
if (!value || typeof value !== "object" || !("reports" in value)) return [];
const reports = (value as { reports?: unknown }).reports;
if (!Array.isArray(reports)) return [];
return reports.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const row = entry as Record<string, unknown>;
if (typeof row.id !== "string" || !["generating", "ready", "failed"].includes(String(row.status))) {
return [];
}
return [{
id: row.id,
reportType: typeof row.reportType === "string" ? row.reportType : "personal_full",
presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default",
depth: ["concise", "standard", "deep", "research"].includes(String(row.depth))
? row.depth as ReportListItem["depth"]
: "standard",
themes: Array.isArray(row.themes)
? row.themes.filter((theme): theme is string => typeof theme === "string")
: [],
status: row.status as ReportListItem["status"],
failureCode: typeof row.failureCode === "string" ? row.failureCode : null,
failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null,
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
completedAt: row.completedAt == null ? null : String(row.completedAt),
cardSummary: typeof row.cardSummary === "string" && row.cardSummary.trim()
? row.cardSummary
: null,
}];
});
function snapshotToState(snapshot: ReportsPageSnapshot | null): CenterState {
if (snapshot?.kind === "ready") return { phase: "ready", reports: snapshot.reports };
if (snapshot?.kind === "unauthorized") return { phase: "unauthorized", reports: [] };
if (snapshot?.kind === "error") return { phase: "error", reports: snapshot.reports };
return { phase: "loading", reports: [] };
}
function formatDate(value: string): string {
@@ -98,37 +67,40 @@ function rowMeta(report: ReportListItem): string {
}
export function PersonalReportCenter() {
const [state, setState] = useState<CenterState>({ phase: "loading", reports: [] });
const [state, setState] = useState<CenterState>(() => snapshotToState(peekReportsPage()));
const [exportingReportId, setExportingReportId] = useState<string | null>(null);
const [exportError, setExportError] = useState<{ reportId: string; message: string } | null>(null);
const cancelled = useRef(false);
const applySnapshot = useCallback((snapshot: ReportsPageSnapshot) => {
if (snapshot.kind === "unauthorized") {
setState({ phase: "unauthorized", reports: [] });
return;
}
if (snapshot.kind === "error") {
setState({ phase: "error", reports: snapshot.reports });
return;
}
setState({ phase: "ready", reports: snapshot.reports });
}, []);
const load = useCallback(async (showLoading = false) => {
if (showLoading) {
setState((current) => ({ phase: "loading", reports: current.reports }));
}
try {
const response = await fetch("/api/reports", {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const json: unknown = await response.json().catch(() => null);
const snapshot = await refreshReportsPage();
if (cancelled.current) return;
if (response.status === 401) {
setState({ phase: "unauthorized", reports: [] });
return;
}
if (!response.ok) {
setState((current) => ({ phase: "error", reports: current.reports }));
return;
}
setState({ phase: "ready", reports: readReports(json) });
applySnapshot(snapshot);
} catch {
if (!cancelled.current) {
setState((current) => ({ phase: "error", reports: current.reports }));
}
if (cancelled.current) return;
const cached = peekReportsPage();
const reports = cached && "reports" in cached ? cached.reports : [];
const fallback: ReportsPageSnapshot = { kind: "error", reports };
writeReportsPage(fallback);
applySnapshot(fallback);
}
}, []);
}, [applySnapshot]);
useEffect(() => {
cancelled.current = false;
@@ -171,24 +143,26 @@ export function PersonalReportCenter() {
if (state.phase === "unauthorized") {
return (
<>
<SecondaryHeader title="我的报告" />
<SecondaryPageShell title="我的报告">
<section className="report-center-message">
<FileText aria-hidden="true" className="size-8 text-primary" />
<h2>请先登录</h2>
<p>登录后即可生成和查看你的个人报告。</p>
<Button render={<Link href="/login" />} nativeButton={false}>去登录</Button>
<Button render={<AppLink href="/login" />} nativeButton={false}>去登录</Button>
</section>
</>
</SecondaryPageShell>
);
}
if (state.phase === "loading" && state.reports.length === 0) {
return <SecondaryPageShell title="我的报告" waiting={REPORTS_WAITING_COPY} />;
}
return (
<>
<SecondaryHeader
title="我的报告"
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
/>
<SecondaryPageShell
title="我的报告"
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
>
<div className="report-center-body">
<section className="report-center-hero">
<div>
@@ -211,11 +185,7 @@ export function PersonalReportCenter() {
<p className="report-center-alert" role="alert">列表读取失败,请检查网络后重试。</p>
)}
{state.phase === "loading" && state.reports.length === 0 ? (
<div className="report-center-empty" role="status">
<InlineSpinner size={24} />正在读取报告…
</div>
) : state.reports.length === 0 ? (
{state.reports.length === 0 ? (
<div className="report-center-empty">
<Clock3 aria-hidden="true" className="size-7" />
<h3>还没有个人报告</h3>
@@ -257,7 +227,7 @@ export function PersonalReportCenter() {
{exportingReportId === report.id ? <InlineSpinner size={14} /> : null}
{PERSONAL_REPORT_EXPORT_LABEL}
</Button>
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
查看报告
</Button>
{exportError?.reportId === report.id ? (
@@ -268,7 +238,7 @@ export function PersonalReportCenter() {
</div>
) : report.status === "generating" ? (
<div className="report-center-row-actions">
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
查看进度
</Button>
</div>
@@ -280,6 +250,6 @@ export function PersonalReportCenter() {
)}
</section>
</div>
</>
</SecondaryPageShell>
);
}
+9 -2
View File
@@ -25,15 +25,22 @@ export type SecondaryHeaderProps = {
readonly actions?: ReactNode;
/** A quiet chip beside the title — birth line, report date. */
readonly note?: ReactNode;
/** Keep the note slot so a late birth line cannot collapse the 46px row. */
readonly reserveNote?: boolean;
};
export function SecondaryHeader({ title, actions, note }: SecondaryHeaderProps) {
export function SecondaryHeader({ title, actions, note, reserveNote = false }: SecondaryHeaderProps) {
const showNote = Boolean(note) || reserveNote;
return (
<header className="chat-header has-title">
<SidebarTrigger placement="inset" />
<div className="chat-header-title">
<strong>{title}</strong>
{note ? <span className="chat-header-chart">{note}</span> : null}
{showNote ? (
<span className="chat-header-chart" aria-hidden={note ? undefined : true}>
{note ?? "\u00a0"}
</span>
) : null}
</div>
{actions ? <div className="chat-header-actions">{actions}</div> : null}
</header>
@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { SecondaryHeader } from "@/components/secondary-header";
export type SecondaryPageShellProps = {
readonly title: string;
readonly note?: ReactNode;
readonly actions?: ReactNode;
readonly waiting?: string | null;
readonly children?: ReactNode;
};
/**
* Shared chrome for /chart, /ephemeris and /reports.
*
* Header and body stay siblings of `.chat-panel` so the existing 46px + 1fr
* grid still owns height. The body cell (`.secondary-page`) always fills the
* remaining viewport; waiting copy is centered in that cell, so swapping in
* the tall content cannot push the shell.
*/
export function SecondaryPageShell({
title,
note,
actions,
waiting,
children,
}: SecondaryPageShellProps) {
return (
<>
<SecondaryHeader title={title} note={note} reserveNote actions={actions} />
<div className="secondary-page">
{waiting ? <p className="secondary-page-waiting">{waiting}</p> : children}
</div>
</>
);
}
@@ -11,8 +11,8 @@ import {
StarOff,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { forwardRef } from "react";
import { AppLink } from "@/components/app-link";
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
@@ -98,7 +98,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
if (props.href !== undefined) {
return (
<div className="session-row" data-readonly="true">
<Link
<AppLink
className="session-main"
data-sidebar="menu-button"
data-slot="sidebar-menu-button"
@@ -108,7 +108,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
onClick={props.onNavigate}
>
<SessionRowBody session={session} opening={opening} />
</Link>
</AppLink>
{error ? <p className="session-open-error" role="alert">{error}</p> : null}
</div>
);
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { syncBuildCommitFromHealth } from "@/lib/app-navigation";
/**
* Remember the running image's git commit when the tab becomes visible again.
*
* Navigation itself reads that snapshot: a stale tab does a document load on
* the next in-app jump, a current tab keeps the client router. No polling.
*/
export function StaleBuildGuard() {
useEffect(() => {
void syncBuildCommitFromHealth();
const onVisibility = () => {
if (document.visibilityState === "visible") void syncBuildCommitFromHealth();
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
return null;
}
+4 -3
View File
@@ -2,7 +2,6 @@
import { Tooltip } from "@base-ui/react/tooltip";
import { PanelLeft } from "lucide-react";
import Link from "next/link";
import {
createContext,
forwardRef,
@@ -15,6 +14,8 @@ import {
type ForwardedRef,
} from "react";
import { AppLink } from "@/components/app-link";
import { useSidebarViewport } from "@/hooks/use-sidebar-viewport";
import {
defaultSidebarOpen,
@@ -61,7 +62,7 @@ type SidebarMenuButtonProps = ComponentProps<"button"> & {
readonly tooltip?: string;
};
type SidebarMenuLinkProps = ComponentProps<typeof Link> & {
type SidebarMenuLinkProps = ComponentProps<typeof AppLink> & {
readonly isActive?: boolean;
readonly tooltip?: string;
};
@@ -251,7 +252,7 @@ export function SidebarMenuButton({ className, isActive = false, tooltip, ...pro
*/
export function SidebarMenuLink({ className, isActive = false, tooltip, ...props }: SidebarMenuLinkProps) {
const { isMobile, state } = useSidebar();
const link = <Link data-sidebar="menu-button" data-slot="sidebar-menu-button" data-active={isActive} className={cn("min-h-11 w-full", className)} {...props} />;
const link = <AppLink data-sidebar="menu-button" data-slot="sidebar-menu-button" data-active={isActive} className={cn("min-h-11 w-full", className)} {...props} />;
if (tooltip === undefined || state !== "collapsed" || isMobile) return link;
return <Tooltip.Root><Tooltip.Trigger render={link} /><Tooltip.Portal><Tooltip.Positioner side="right" sideOffset={8}><Tooltip.Popup>{tooltip}</Tooltip.Popup></Tooltip.Positioner></Tooltip.Portal></Tooltip.Root>;
}
+31 -10
View File
@@ -6,6 +6,12 @@ import { fetchChartView } from "@/lib/chart-view-client";
import type { ChartViewResponse } from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
import { CHART_VIEW_COPY } from "@/lib/chart-view-labels";
import {
peekChartPage,
refreshChartPage,
writeChartPage,
type ChartPageSnapshot,
} from "@/lib/secondary-page-data";
function unavailableBody(): ChartViewResponse {
return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable };
@@ -19,29 +25,43 @@ function warnClientFailure(label: string, error: unknown, extra?: Record<string,
});
}
function viewFromSnapshot(snapshot: ChartPageSnapshot | null): ChartViewResponse | null {
return snapshot?.kind === "view" ? snapshot.view : null;
}
export function useChartPage() {
const [view, setView] = useState<ChartViewResponse | null>(null);
const [view, setView] = useState<ChartViewResponse | null>(() => viewFromSnapshot(peekChartPage()));
const [pendingLayers, setPendingLayers] = useState<ReadonlySet<ChartViewLayer>>(new Set());
const loadedLayers = useRef(new Set<ChartViewLayer>());
const inflightLayers = useRef(new Set<ChartViewLayer>());
useEffect(() => {
const controller = new AbortController();
void fetchChartView({ signal: controller.signal })
.then((result) => {
if (controller.signal.aborted) return;
if (result.httpStatus === 401) {
const cached = peekChartPage();
if (cached?.kind === "unauthenticated") {
window.location.assign("/login");
return;
}
let cancelled = false;
void refreshChartPage()
.then((snapshot) => {
if (cancelled) return;
if (snapshot.kind === "unauthenticated") {
window.location.assign("/login");
return;
}
setView(result.body ?? unavailableBody());
setView(snapshot.view);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
if (cancelled) return;
warnClientFailure("chart_page_fetch_failed", error);
setView(unavailableBody());
if (peekChartPage()?.kind === "view") return;
const fallback = unavailableBody();
writeChartPage({ kind: "view", view: fallback });
setView(fallback);
});
return () => controller.abort();
return () => {
cancelled = true;
};
}, []);
const requestLayer = useCallback((layer: ChartViewLayer) => {
@@ -57,6 +77,7 @@ export function useChartPage() {
.then((result) => {
if (result.body?.status === "ok") {
loadedLayers.current.add(layer);
writeChartPage({ kind: "view", view: result.body });
setView(result.body);
}
})
+143
View File
@@ -0,0 +1,143 @@
export type AppNavigationHost = {
assign: (href: string) => void;
};
export type AppNavigationClick = {
button: number;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
defaultPrevented: boolean;
preventDefault: () => void;
};
const UNKNOWN = "unknown";
let clientCommitOverride: string | null = null;
let knownServerCommit: string | null = null;
let host: AppNavigationHost = {
assign: (href) => {
window.location.assign(href);
},
};
export function clientBuildCommit(): string {
if (clientCommitOverride !== null) return clientCommitOverride;
const value = process.env.NEXT_PUBLIC_GIT_COMMIT;
return typeof value === "string" ? value.trim() : "";
}
export function knownServerBuildCommit(): string | null {
return knownServerCommit;
}
export function rememberServerBuildCommit(commit: string | null): void {
if (typeof commit !== "string") {
knownServerCommit = null;
return;
}
const trimmed = commit.trim();
knownServerCommit = trimmed ? trimmed : null;
}
export function isUsableBuildCommit(value: string | null | undefined): value is string {
return typeof value === "string" && value.length > 0 && value !== UNKNOWN;
}
export function isStaleClientBuild(
client = clientBuildCommit(),
server = knownServerCommit,
): boolean {
if (!isUsableBuildCommit(client) || !isUsableBuildCommit(server)) return false;
return client !== server;
}
export function readHealthGitCommit(payload: unknown): string | null {
if (!payload || typeof payload !== "object" || !("deployment" in payload)) return null;
const deployment = (payload as { deployment?: unknown }).deployment;
if (!deployment || typeof deployment !== "object" || !("gitCommit" in deployment)) return null;
const commit = (deployment as { gitCommit?: unknown }).gitCommit;
return typeof commit === "string" && commit.trim() ? commit.trim() : null;
}
export async function syncBuildCommitFromHealth(
fetchImpl: typeof fetch = fetch,
): Promise<void> {
try {
const response = await fetchImpl("/api/health", { cache: "no-store" });
if (!response.ok) return;
const payload: unknown = await response.json().catch(() => null);
const commit = readHealthGitCommit(payload);
if (commit) rememberServerBuildCommit(commit);
} catch {
// Health is unavailable: keep the last known commit and do not force a reload.
}
}
export function navigateAppPath(
href: string,
clientNavigate?: (href: string) => void,
): void {
if (isStaleClientBuild()) {
host.assign(href);
return;
}
if (clientNavigate) {
clientNavigate(href);
return;
}
host.assign(href);
}
function isModifiedClick(event: AppNavigationClick): boolean {
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}
export function interceptAppNavigation(event: AppNavigationClick, href: string): void {
if (event.defaultPrevented || isModifiedClick(event)) return;
if (!href.startsWith("/") || href.startsWith("//")) return;
if (!isStaleClientBuild()) return;
event.preventDefault();
host.assign(href);
}
export function hrefFromLinkTarget(href: unknown): string {
if (typeof href === "string") return href;
if (!href || typeof href !== "object") return "";
const record = href as { pathname?: unknown; search?: unknown; hash?: unknown; query?: unknown };
const pathname = typeof record.pathname === "string" ? record.pathname : "";
let search = typeof record.search === "string" ? record.search : "";
if (!search && record.query && typeof record.query === "object") {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(record.query as Record<string, unknown>)) {
if (typeof value === "string") params.set(key, value);
}
const serialized = params.toString();
search = serialized ? `?${serialized}` : "";
}
const hash = typeof record.hash === "string" ? record.hash : "";
return `${pathname}${search}${hash}`;
}
export function setAppNavigationHostForTests(next: AppNavigationHost | null): void {
host = next ?? {
assign: (href) => {
window.location.assign(href);
},
};
}
export function setClientBuildCommitForTests(commit: string | null): void {
clientCommitOverride = commit;
}
export function resetAppNavigationForTests(): void {
clientCommitOverride = null;
knownServerCommit = null;
host = {
assign: (href) => {
window.location.assign(href);
},
};
}
+192
View File
@@ -0,0 +1,192 @@
import { fetchChartView } from "@/lib/chart-view-client";
import type { ChartViewResponse } from "@/lib/chart-view-contract";
import { CHART_VIEW_COPY } from "@/lib/chart-view-labels";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
export const REPORTS_WAITING_COPY = "报告列表还没拿到。";
export const EPHEMERIS_WAITING_COPY = "这一天的星历还没拿到。";
export type ChartPageSnapshot =
| { kind: "view"; view: ChartViewResponse }
| { kind: "unauthenticated" };
export type EphemerisPageSnapshot =
| { kind: "ready"; payload: EphemerisOkResponse }
| { kind: "unauthorized" };
export type ReportListItem = Readonly<{
id: string;
reportType: string;
presentationMode: string;
depth: "concise" | "standard" | "deep" | "research";
themes: readonly string[];
status: "generating" | "ready" | "failed";
failureCode: string | null;
failureSummary?: string | null;
createdAt: string;
completedAt: string | null;
cardSummary?: string | null;
}>;
export type ReportsPageSnapshot =
| { kind: "ready"; reports: readonly ReportListItem[] }
| { kind: "unauthorized" }
| { kind: "error"; reports: readonly ReportListItem[] };
type MemoryCache<T> = {
peek: () => T | null;
write: (value: T) => T;
refresh: () => Promise<T>;
prefetch: () => void;
reset: () => void;
};
function createMemoryCache<T>(load: () => Promise<T>): MemoryCache<T> {
let value: T | null = null;
let inflight: Promise<T> | null = null;
const refresh = (): Promise<T> => {
if (inflight) return inflight;
const request = load()
.then((next) => {
value = next;
return next;
})
.finally(() => {
if (inflight === request) inflight = null;
});
inflight = request;
return request;
};
return {
peek: () => value,
write: (next) => {
value = next;
return next;
},
refresh,
prefetch: () => {
void refresh();
},
reset: () => {
value = null;
inflight = null;
},
};
}
function unavailableChart(): ChartViewResponse {
return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable };
}
const chartCache = createMemoryCache<ChartPageSnapshot>(async () => {
const result = await fetchChartView({});
if (result.httpStatus === 401) return { kind: "unauthenticated" };
return { kind: "view", view: result.body ?? unavailableChart() };
});
const ephemerisCache = createMemoryCache<EphemerisPageSnapshot>(async () => {
const response = await fetch("/api/ephemeris", {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (response.status === 401) return { kind: "unauthorized" };
const json: unknown = await response.json().catch(() => null);
const payload = parseEphemerisOkResponse(json);
if (!payload) throw new Error("ephemeris_page_bad_payload");
return { kind: "ready", payload };
});
export function readReports(value: unknown): ReportListItem[] {
if (!value || typeof value !== "object" || !("reports" in value)) return [];
const reports = (value as { reports?: unknown }).reports;
if (!Array.isArray(reports)) return [];
return reports.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const row = entry as Record<string, unknown>;
if (typeof row.id !== "string" || !["generating", "ready", "failed"].includes(String(row.status))) {
return [];
}
return [{
id: row.id,
reportType: typeof row.reportType === "string" ? row.reportType : "personal_full",
presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default",
depth: ["concise", "standard", "deep", "research"].includes(String(row.depth))
? row.depth as ReportListItem["depth"]
: "standard",
themes: Array.isArray(row.themes)
? row.themes.filter((theme): theme is string => typeof theme === "string")
: [],
status: row.status as ReportListItem["status"],
failureCode: typeof row.failureCode === "string" ? row.failureCode : null,
failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null,
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
completedAt: row.completedAt == null ? null : String(row.completedAt),
cardSummary: typeof row.cardSummary === "string" && row.cardSummary.trim()
? row.cardSummary
: null,
}];
});
}
const reportsCache = createMemoryCache<ReportsPageSnapshot>(async () => {
const response = await fetch("/api/reports", {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
});
const json: unknown = await response.json().catch(() => null);
if (response.status === 401) return { kind: "unauthorized" };
if (!response.ok) return { kind: "error", reports: [] };
return { kind: "ready", reports: readReports(json) };
});
export function peekChartPage(): ChartPageSnapshot | null {
return chartCache.peek();
}
export function writeChartPage(snapshot: ChartPageSnapshot): ChartPageSnapshot {
return chartCache.write(snapshot);
}
export function refreshChartPage(): Promise<ChartPageSnapshot> {
return chartCache.refresh();
}
export function peekEphemerisPage(): EphemerisPageSnapshot | null {
return ephemerisCache.peek();
}
export function writeEphemerisPage(snapshot: EphemerisPageSnapshot): EphemerisPageSnapshot {
return ephemerisCache.write(snapshot);
}
export function refreshEphemerisPage(): Promise<EphemerisPageSnapshot> {
return ephemerisCache.refresh();
}
export function peekReportsPage(): ReportsPageSnapshot | null {
return reportsCache.peek();
}
export function writeReportsPage(snapshot: ReportsPageSnapshot): ReportsPageSnapshot {
return reportsCache.write(snapshot);
}
export function refreshReportsPage(): Promise<ReportsPageSnapshot> {
return reportsCache.refresh();
}
export function prefetchSecondaryPage(href: string): void {
if (href === "/chart" || href.startsWith("/chart?")) chartCache.prefetch();
else if (href === "/ephemeris" || href.startsWith("/ephemeris?")) ephemerisCache.prefetch();
else if (href === "/reports" || href.startsWith("/reports?")) reportsCache.prefetch();
}
export function resetSecondaryPageDataForTests(): void {
chartCache.reset();
ephemerisCache.reset();
reportsCache.reset();
}
+212
View File
@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
hrefFromLinkTarget,
interceptAppNavigation,
isStaleClientBuild,
navigateAppPath,
readHealthGitCommit,
rememberServerBuildCommit,
resetAppNavigationForTests,
setAppNavigationHostForTests,
setClientBuildCommitForTests,
} from "../src/lib/app-navigation.ts";
import {
peekChartPage,
peekEphemerisPage,
peekReportsPage,
prefetchSecondaryPage,
refreshChartPage,
refreshEphemerisPage,
refreshReportsPage,
resetSecondaryPageDataForTests,
writeChartPage,
} from "../src/lib/secondary-page-data.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
const chartView = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8");
const ephemerisPage = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8");
const reportsCenter = readFileSync(new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url), "utf8");
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const dockerfile = readFileSync(new URL("../../deploy/railway-web.Dockerfile", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const chartHook = readFileSync(new URL("../src/hooks/use-chart-page.ts", import.meta.url), "utf8");
function clickEvent(overrides: Partial<{
button: number;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
defaultPrevented: boolean;
}> = {}) {
let defaultPrevented = overrides.defaultPrevented ?? false;
return {
button: overrides.button ?? 0,
metaKey: overrides.metaKey ?? false,
ctrlKey: overrides.ctrlKey ?? false,
shiftKey: overrides.shiftKey ?? false,
altKey: overrides.altKey ?? false,
get defaultPrevented() {
return defaultPrevented;
},
preventDefault() {
defaultPrevented = true;
},
};
}
test("the three entry pages import the shared SecondaryPageShell", () => {
for (const source of [chartView, ephemerisPage, reportsCenter]) {
assert.match(source, /from "@\/components\/secondary-page-shell"/);
assert.match(source, /<SecondaryPageShell/);
}
assert.doesNotMatch(chartView, /<SecondaryHeader title="星盘"/);
assert.doesNotMatch(ephemerisPage, /<SecondaryHeader/);
assert.doesNotMatch(reportsCenter, /<SecondaryHeader/);
assert.doesNotMatch(chartView, /InlineSpinner|正在加载|skeleton/i);
assert.doesNotMatch(ephemerisPage, /InlineSpinner|正在加载|skeleton/i);
assert.doesNotMatch(reportsCenter, /正在读取报告|正在加载/);
});
test("the secondary-page body is a stable remaining-viewport cell", () => {
const page = cssDeclarations(".secondary-page", globalStyles);
assert.match(page, /display:\s*grid/);
assert.match(page, /min-height:\s*0/);
const waitingParent = cssDeclarations(".secondary-page:has(> .secondary-page-waiting)", globalStyles);
assert.match(waitingParent, /align-content:\s*center/);
const waiting = cssDeclarations(".secondary-page-waiting", globalStyles);
assert.match(waiting, /text-align:\s*center/);
assert.doesNotMatch(waiting, /spinner|skeleton/i);
});
test("a warm chart cache does not go through a null waiting state", async () => {
resetSecondaryPageDataForTests();
assert.equal(peekChartPage(), null);
writeChartPage({
kind: "view",
view: { status: "chart_unavailable", billed: false, message: "这一张盘还没拿到。" },
});
const warm = peekChartPage();
assert.equal(warm?.kind, "view");
assert.notEqual(warm, null);
const secondMountView = peekChartPage()?.kind === "view" ? peekChartPage() : null;
assert.equal(secondMountView?.kind, "view");
assert.match(chartHook, /useState<ChartViewResponse \| null>\(\(\) => viewFromSnapshot\(peekChartPage\(\)\)\)/);
});
test("chart / ephemeris / reports caches share one inflight fetch and keep the last result", async () => {
resetSecondaryPageDataForTests();
const originalFetch = globalThis.fetch;
let chartCalls = 0;
let ephemerisCalls = 0;
let reportsCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/chart-view")) {
chartCalls += 1;
return new Response("{}", { status: 401 });
}
if (url.includes("/api/ephemeris")) {
ephemerisCalls += 1;
return new Response("{}", { status: 401 });
}
if (url.includes("/api/reports")) {
reportsCalls += 1;
return new Response("{}", { status: 401 });
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
try {
prefetchSecondaryPage("/chart");
prefetchSecondaryPage("/ephemeris");
prefetchSecondaryPage("/reports");
const [chart, ephemeris, reports] = await Promise.all([
refreshChartPage(),
refreshEphemerisPage(),
refreshReportsPage(),
]);
assert.equal(chart.kind, "unauthenticated");
assert.equal(ephemeris.kind, "unauthorized");
assert.equal(reports.kind, "unauthorized");
assert.equal(chartCalls, 1);
assert.equal(ephemerisCalls, 1);
assert.equal(reportsCalls, 1);
assert.equal(peekChartPage()?.kind, "unauthenticated");
assert.equal(peekEphemerisPage()?.kind, "unauthorized");
assert.equal(peekReportsPage()?.kind, "unauthorized");
} finally {
globalThis.fetch = originalFetch;
resetSecondaryPageDataForTests();
}
});
test("sidebar prefetches the three entries on pointerenter and pointerdown", () => {
assert.match(sidebar, /prefetchSecondaryPage\(href\)/);
assert.match(sidebar, /onPointerEnter=\{\(\) => prefetchSecondaryPage\(href\)\}/);
assert.match(sidebar, /onPointerDown=\{\(\) => prefetchSecondaryPage\(href\)\}/);
assert.match(sidebar, /from "@\/lib\/secondary-page-data"/);
});
test("version mismatch navigates with location.assign; a match keeps the client router", () => {
resetAppNavigationForTests();
const assigned: string[] = [];
setAppNavigationHostForTests({ assign: (href) => assigned.push(href) });
setClientBuildCommitForTests("old-sha");
rememberServerBuildCommit("new-sha");
assert.equal(isStaleClientBuild(), true);
const staleClick = clickEvent();
interceptAppNavigation(staleClick, "/chart");
assert.equal(staleClick.defaultPrevented, true);
assert.deepEqual(assigned, ["/chart"]);
assigned.length = 0;
navigateAppPath("/ephemeris", (href) => assigned.push(`client:${href}`));
assert.deepEqual(assigned, ["/ephemeris"]);
assigned.length = 0;
rememberServerBuildCommit("old-sha");
assert.equal(isStaleClientBuild(), false);
const freshClick = clickEvent();
interceptAppNavigation(freshClick, "/reports");
assert.equal(freshClick.defaultPrevented, false);
assert.equal(assigned.length, 0);
navigateAppPath("/reports", (href) => assigned.push(`client:${href}`));
assert.deepEqual(assigned, ["client:/reports"]);
assigned.length = 0;
rememberServerBuildCommit(null);
interceptAppNavigation(clickEvent(), "/chart");
assert.equal(assigned.length, 0);
resetAppNavigationForTests();
});
test("modified clicks and health-shaped payloads stay out of the hard-nav path", () => {
resetAppNavigationForTests();
const assigned: string[] = [];
setAppNavigationHostForTests({ assign: (href) => assigned.push(href) });
setClientBuildCommitForTests("old-sha");
rememberServerBuildCommit("new-sha");
interceptAppNavigation(clickEvent({ metaKey: true }), "/chart");
interceptAppNavigation(clickEvent({ button: 1 }), "/chart");
interceptAppNavigation(clickEvent(), "https://example.com");
assert.equal(assigned.length, 0);
assert.equal(readHealthGitCommit({ deployment: { gitCommit: "abc" } }), "abc");
assert.equal(readHealthGitCommit({ status: "ok" }), null);
assert.equal(hrefFromLinkTarget("/chart"), "/chart");
assert.equal(hrefFromLinkTarget({ pathname: "/reports", search: "?x=1" }), "/reports?x=1");
resetAppNavigationForTests();
});
test("the root layout checks health on visibility, not on a timer", () => {
const guard = readFileSync(new URL("../src/components/stale-build-guard.tsx", import.meta.url), "utf8");
assert.match(layout, /StaleBuildGuard/);
assert.match(guard, /visibilitychange/);
assert.match(guard, /syncBuildCommitFromHealth/);
assert.doesNotMatch(guard, /setInterval|setTimeout/);
assert.match(dockerfile, /NEXT_PUBLIC_GIT_COMMIT=\$\{NEXT_DEPLOYMENT_ID\}/);
});
+7 -2
View File
@@ -228,13 +228,18 @@ test("the same component renders read-only when `/` is not the one mounting it",
// `.session-main` — and drop only the menu trigger and its reserved column.
const sessionRow = readProjectFile("src/components/sidebar-session-row.tsx");
assert.match(sessionRow, /<div className="session-row" data-readonly="true">/);
assert.match(sessionRow, /<Link\n\s*className="session-main"/);
// 原值:只读行是 `<Link className="session-main">`,页脚是 `<Link className="profile-trigger" href="/">`
// 新值:两者都改成 `AppLink`,属性与 href 一字未改
// 原因:BUG-967。跨部署旧标签页上 Next 的客户端导航会静默失败;AppLink 在版本不一致时
// 走 `window.location.assign`,一致时仍是客户端路由。断言主语(只读行仍是
// `.session-main` 链接、页脚仍去 `/`、没有菜单按钮)没变。
assert.match(sessionRow, /<AppLink\n\s*className="session-main"/);
assert.match(cssBlock('.session-row[data-readonly="true"]'), /grid-template-columns:\s*minmax\(0,\s*1fr\)/);
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
assert.match(appSidebar, /<Link\n\s*className="profile-trigger"\n\s*href="\/"/);
assert.match(appSidebar, /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/);
// And nothing in read-only mode can write: the write callbacks only exist
// inside `controls`, which the secondary layout never passes.