Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
13 KiB
TASK · 星盘页骨架盘等待态 + 三个「永远还没拿到」死角(2026-09-24)
基线
origin/staging = 8902e484(fix(chat): honor new-chat intent from secondary pages)。- staging 线上
deployment.gitCommit = 018b2b48,8902e484尚未部署(门禁状态待产品在 Gitea 核对)。本单不依赖它。 - 执行分支:
codex/chart-page-skeleton-wait-20260924,worktree.worktrees/chart-page-skeleton-wait-20260924。 - 同期无人改
frontend/src/components/chart-page/**、frontend/src/hooks/use-chart-page.ts;TASK-console-noise-20260916.md(待领取)里 BUG-906 动的是vedic-chart-svg.tsx的height=auto,若两单同期开工,本单后合,合前 rebase。
事故实证
产品真机:进入 /chart 看到一整块空白区域中间一句「这一张盘还没拿到。」,分不清是在算还是已经失败。
代码链(按 origin/staging 8902e484):
- 文案在
frontend/src/lib/chart-view-labels.tsCHART_VIEW_COPY:waitingChart「这一张盘还没拿到。」、waitingLayer「这一栏还没拿到。」、waitingVarga「这一分盘还没拿到。」;失败句另有busy/unavailable/rateLimited/unauthenticated等。 frontend/src/components/chart-page/chart-page-view.tsxChartPageView:<SecondaryPageShell waiting={view == null ? CHART_VIEW_COPY.waitingChart : null}>;SecondaryPageShell(secondary-page-shell.tsx)把waiting渲染成居中一句<p className="secondary-page-waiting">。view === null是这句话唯一触发条件。frontend/src/hooks/use-chart-page.tsuseChartPage:useState<ChartViewResponse | null>(() => viewFromSnapshot(peekChartPage()));挂载后refreshChartPage()。状态只有null | view,没有 loading / failed 的显式态。frontend/src/lib/secondary-page-data.tsChartPageSnapshot = {kind:"view"} | {kind:"unauthenticated"},缺失即null;错误被编码成status !== "ok"的 view。frontend/src/lib/chart-view-client.tsfetchChartView接受signal,但useChartPage两处调用都没传;服务端/api/chart-viewmaxDuration = 20,引擎AbortSignal.timeout(10000)(chart-view-engine.ts),但auth.getUser与profiles读库无超时。frontend/src/components/chart-page/chart-vedic-tab.tsxChartVedicTab:const waiting = vargaId !== "D1" && !selected→{waiting || !varga ? <p>{waitingVarga}</p> : …};只看有没有数据,不看pendingLayers。useChartPage.requestLayer的.catch只warnClientFailure("chart_page_layer_failed"),.then里status !== "ok"时写缓存但 D1 之外的分盘不会因此有selected;结果分盘失败后永远停在「这一分盘还没拿到。」。
三个死角(同一句话、无出口):
| 死角 | 触发 | 现状 |
|---|---|---|
| A 请求不回 | 客户端无超时;服务端 20s 上限之外的读库无超时 | 等待句永远挂着 |
| B 未登录 | 401 → window.location.assign("/login") 之前 |
等待句挂到跳转 |
| C 分盘层失败 / 429 | /api/varga_full、/api/western、/api/qizheng 在 HEAVY_COMPUTE_PATHS(并发 2,饱和 429) |
「这一分盘还没拿到。」永远挂着;西洋 / 七政的 pendingLayers 清掉后也回不到失败句 |
另一个小口:useChartPage 首次请求 .catch 里 if (peekChartPage()?.kind === "view") return; —— 缓存里已有 view 但组件 state 仍 null 时不 setView,等待句留住。
根因
- 等待与失败的状态模型缺失:只有
null | view,"在等"、"等不到"、"等到了但坏了"没有各自的态;分盘层没有任何失败态。 - 视觉上,等待态只有一句静态文案,整块内容区空白,用户无法判断进度;这是 2026-09-18 产品选定的「方案一」(BUG-966:去掉中间态、不放 spinner),现在产品要推翻。
决策记录
- D1(推翻既有红线,产品 2026-09-24 拍板):星盘页主盘与分盘未到时显示骨架盘 + 呼吸动效。这条推翻:
frontend/DESIGN.md§9「等待与加载」的"揭幕后不得再出现任何阻塞等待或组件级 spinner"对星盘页的适用;frontend/DESIGN.md§15「只读星盘页 · 等待态」"揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」";AGENTS.md§6 "第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外)"——本单在该句后追加例外「星盘页盘位骨架盘除外(BUG-1016 决策)」;- BUG-716 / BUG-966 的防复发条款中"不得再上骨架"的部分。
- 范围只限星盘页会出现盘轮的位置:D1 主盘、非 D1 分盘、西洋盘。首页、侧栏、星历页、报告页、大运 / 七政的表格区不适用,仍用静态句。
- D2 骨架盘不是新造一套图形:复用
frontend/src/components/personal-report/vedic-chart-svg.tsx的VedicChartSvg几何(北印度盘外框 + 十二宫菱形格),以空盘模型(无行星、无度数)渲染,加data-skeleton="true",颜色用现有中性 token;西洋盘骨架复用西洋盘 SVG 组件的空模型。不得复制一份路径数据到新组件。 - D3 呼吸动效:
opacity0.35 ↔ 0.6,周期 2.4s,ease-in-out infinite;@media (prefers-reduced-motion: reduce)下静止在 0.5。不用 shimmer 渐变扫光、不用旋转。 - D4 骨架盘下方保留原静态句(「这一张盘还没拿到。」/「这一分盘还没拿到。」),文案不改;
aria-busy="true"加在骨架容器上。 - D5 三个死角的出口:
- A:
useChartPage首次请求与requestLayer都传AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS),常量 = 25 000(比服务端 20s 上限长,避免客户端先于服务端放弃)。超时后主盘显示新文案timedOut:「这张盘等了太久没有回来,再打开一次试试。」(不承诺速度、不写"正在",对照VOICE.md)。 - B:缓存或响应为
unauthenticated时,先把view置为status: "unauthenticated"的失败体(文案已存在:「请先登录后再看星盘。」),再location.assign("/login")。 - C:
useChartPage新增按层的失败态layerFailures: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;requestLayer的非 ok 响应与 catch 都写入(429 →engine_busy,超时 →timedOut,其它 →unavailable);ChartVedicTab/ 西洋 / 七政 / 大运各 Tab 在pending为假且该层有失败态时渲染对应失败句,而不是等待句。点击 Tab 再次请求时清掉该层失败态。 - 小口:首次
.catch里若缓存已有 view,setView(cached.view)而不是 return。
- A:
- D6 文案新增只有一句
timedOut;其余全部复用CHART_VIEW_COPY既有条目。 - D7 不改的:
/api/chart-view路由、引擎超时、HEAVY_COMPUTE_PATHS、并发数(BUG-717 红线);SecondaryPageShell对星历 / 报告的行为;/chart保持○ Static。
硬红线
- 骨架盘只在星盘页;不得把骨架 / 呼吸动效带进首页、侧栏、星历、报告列表(这些页的"不放骨架"合同不动)。
- 不得新增第二份盘轮几何;骨架必须由
VedicChartSvg(及西洋盘既有组件)以空模型渲染。 - 不得为了"解决" 429 扩
HEAVY_COMPUTE_PATHS或调并发(BUG-717)。 - 429 必须独立一档(BUG-715 / 723):分盘层 429 显示
busy句,不得并入unavailable。 /chart必须保持○ Static;useChartPage不得引入服务端读取。- 既有断言改动逐条写"原值 / 新值 / 原因";测试总数不低于开工实测。
- 不改
jyotish_api_server.py;不顺手修 warning;不升级依赖。
任务分解
T1 · 状态模型(use-chart-page.ts、secondary-page-data.ts)
useChartPage返回值增加layerFailures;首次请求与requestLayer传超时 signal;401 先写失败体再跳转;catch 小口改setView(cached.view)。- 超时常量
CHART_VIEW_CLIENT_TIMEOUT_MS = 25_000与文案timedOut放在chart-view-labels.ts/chart-view-failure.ts现有映射表里(FAILURE_COPY/FAILURE_STATUS加一档client_timeout)。
验收:frontend/tests/chart-page-hook.test.ts(新)≥5 条纯函数 / 模拟 fetch 测试:主盘超时 → view.status === "client_timeout" 且文案是 timedOut;分盘 429 → layerFailures.get(layer) === "engine_busy";分盘 catch → unavailable;再次 requestLayer 清失败态并重新 pending;缓存已有 view 时 catch 不留 null。
T2 · 骨架盘(chart-page-view.tsx、chart-vedic-tab.tsx、vedic-chart-svg.tsx、globals.css)
vedic-chart-svg.tsx导出EMPTY_NORTH_INDIAN_CHART(或等价空模型工厂)与skeleton?: booleanprop(加data-skeleton、去掉行星层)。ChartPageView:view == null时不再走SecondaryPageShell.waiting,而是正常渲染 body,主盘位置放<VedicChartSvg skeleton>+ 等待句;Tab 条可见但禁用。ChartVedicTab:waiting且该层无失败态 → 骨架盘 + 等待句;有失败态 → 失败句;西洋 Tab 同理用西洋盘骨架;大运 / 七政表格区维持静态句(D1 范围)。- CSS:
.chart-page-skeleton { animation: chart-skeleton-breathe 2.4s ease-in-out infinite }+prefers-reduced-motion静止;class 名与 keyframe 只此一处。
验收:frontend/tests/chart-page-view.test.tsx 既有五条里涉及"不得出现骨架 / spinner"的断言按三栏改成"出现 data-skeleton 且不出现 InlineSpinner / 「正在加载」";新增:分盘失败态渲染失败句不渲染骨架;prefers-reduced-motion 规则存在。frontend/tests/secondary-page-entry.test.ts 三条:星历 / 报告的"不得骨架"断言不变,星盘那条改为允许 data-skeleton(三栏)。grep -rn "chart-skeleton-breathe" frontend/src = 恰好 CSS 一处。
T3 · 规则文件
frontend/DESIGN.md§9 表格加一行"盘位骨架(仅星盘页)→VedicChartSvg skeleton",§15 等待态改写并更新表(主盘未到 → 骨架盘 + 句;分盘失败 → 失败句;超时 →timedOut句);写明推翻 09-18 方案一的决策日期。AGENTS.md§6 那一句追加例外(见 D1)。frontend/docs/VOICE.md星盘段加timedOut一句。
T4 · 记录
docs/BUG_HISTORY.md:BUG-1016 主盘等待句吞掉超时 / 401 / 缓存小口(死角 A、B、小口);BUG-1017 分盘层失败永远显示「这一分盘还没拿到」(死角 C)。两条resolved各带针对性测试;关联 BUG-715、716、717、723、966、996;BUG-1016 的"防复发"里写明 BUG-716 / 966 的"不得骨架"条款被 D1 有限推翻。CHANGELOG.md:星盘页等待改骨架盘 + 呼吸;分盘失败会说明原因;等太久有出口。docs/tasks/PROGRESS-chart-page-skeleton-wait-20260924.md;docs/testing/chart-page-skeleton-20260924.md真机清单:① 冷进/chart看到骨架盘呼吸 + 句,主盘到后无闪烁;② 点 D9 看到分盘骨架;③ 连点西洋 / 七政 / D9 / D10 触发 429 → 显示「算盘的服务正忙…」而非等待句,再点恢复;④ 断网后进页 → 25 秒内出现timedOut句;⑤ 系统开"减弱动态效果"→ 骨架静止;⑥ 星历 / 报告页无骨架。
让步顺序
- 西洋盘骨架若其 SVG 组件无法以空模型渲染,允许西洋 Tab 沿用静态句 + 同一呼吸容器包一个空
VedicChartSvg,写进进度记录——不得新画一套西洋盘轮廓。 layerFailures若让useChartPage复杂度超出,允许把失败态并入pendingLayers的同一个 Map(ReadonlyMap<layer, "pending" | failureStatus>),但每层必须能区分 pending / failed。- 不得让步:D1 范围只限星盘页;红线 2(不复制几何);红线 4(429 独立档)。
开工前置命令
git fetch origin --prune
git worktree add -b codex/chart-page-skeleton-wait-20260924 .worktrees/chart-page-skeleton-wait-20260924 origin/staging
cd .worktrees/chart-page-skeleton-wait-20260924/frontend
./node_modules/.bin/tsc --noEmit && npm run lint
npm test 2>&1 | tail -5 # 记开工总数与失败清单
npx tsx --test tests/chart-page-view.test.tsx tests/secondary-page-entry.test.ts tests/chart-profile-update-consistency.test.ts tests/chart-view-engine.test.ts tests/chart-view-route.test.ts
完成后:tsc 0 错、lint 0 error、npm test 失败清单与基线逐条一致、next build 后 / 与 /chart 仍 ○ Static、首屏 gzip ±2%(/chart 首屏多带的骨架不计入 /)。纯前端,不要求 pre_work_check.py。
BUG 编号起点
写单时最大号 BUG-1015;本单用 BUG-1016 / 1017,开工时核对,被占用则顺延并写进进度记录。