feat(web): 工作台任务界面给 SSE 配 10 秒兜底轮询 —— 只此一处,由壳层起停

`app/fallback-poll.ts`:任务界面开着时每 10 秒把 `desk` 下**在用的**查询(清单 / 这一单 / 角标,
`type:'active'`)各重取一次,推送漏了画面最多旧 10 秒。**不取消在途**(`cancelRefetch:false` ——
慢接口不被兜底打成永远取不回)、页签藏起不打、回前台先补一趟;壳层 `useEffect` 起停,
切模块 / 卸载即 `stop()`(定时器与可见性监听一起摘)。`doc` 可注入,4 测钉死
(周期 / 停止 / 可见性 / 在途去重,第四条做过变异验证)。
`queryKeys.desk` 新增作整支前缀,订阅层的裸 `['desk']` 改用它。
编码规范 §9.2「组件禁止自行轮询」照旧,补一句:兜底只此一处、由壳层起停。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-28 01:13:07 +08:00
co-authored by Claude Fable 5
parent f77c6325fe
commit 282584d26e
7 changed files with 215 additions and 3 deletions
@@ -0,0 +1,135 @@
/**
* SSE 的兜底轮询 —— **推送丢了,画面最多旧 10 秒**,不必等人按刷新。
*
* 这里钉四条:①每个周期把这一屏**在用的**查询重取一遍(挂着观察者的才算在用;
* 早已离开的屏留在缓存里的键不碰);②`stop()` 之后定时器真的停了 —— 离开界面不许继续打接口;
* ③页签藏起来时不打,回到前台先补一趟;④正在飞的请求不被兜底那一下取消重发(在途去重)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryObserver } from '@tanstack/react-query';
import { startFallbackPolling } from '../fallback-poll.ts';
type Visibility = {
hidden: boolean;
fire(): void;
addEventListener(type: string, handler: () => void): void;
removeEventListener(type: string, handler: () => void): void;
readonly listeners: number;
};
/** 假的 document:只有 hidden 与 visibilitychange 这两样,测试自己拨 */
function fakeDoc(): Visibility {
const handlers = new Set<() => void>();
return {
hidden: false,
fire: () => handlers.forEach((h) => h()),
addEventListener: (_type, h) => void handlers.add(h),
removeEventListener: (_type, h) => void handlers.delete(h),
get listeners() {
return handlers.size;
},
};
}
/** 一把「在用的」查询:挂一个观察者,像组件 mount 了一样;回报它被取了几次(`delayOf` 按第几次给时延) */
function mounted(qc: QueryClient, key: readonly unknown[], delayOf: (call: number) => number = () => 0) {
let calls = 0;
const observer = new QueryObserver(qc, {
queryKey: key,
queryFn: async () => {
calls += 1;
const delayMs = delayOf(calls);
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
return { n: calls };
},
});
const unsubscribe = observer.subscribe(() => undefined);
return { calls: () => calls, unsubscribe };
}
const flush = async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(0);
};
describe('startFallbackPolling', () => {
let qc: QueryClient;
beforeEach(() => {
vi.useFakeTimers();
qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 5_000 } } });
});
afterEach(() => {
qc.clear();
vi.useRealTimers();
});
it('每 10 秒把 desk 下在用的查询各重取一次;别的前缀与没人看的键不碰', async () => {
const tasks = mounted(qc, ['desk', 'tasks']);
const detail = mounted(qc, ['desk', 'task', 'T1']);
const gov = mounted(qc, ['gov', 'tasks']);
// 早已离开的屏:缓存里有,没人看
qc.setQueryData(['desk', 'task', 'T0'], { stale: true });
await flush();
expect([tasks.calls(), detail.calls(), gov.calls()]).toEqual([1, 1, 1]); // 首取
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc: fakeDoc() });
await vi.advanceTimersByTimeAsync(10_000);
expect([tasks.calls(), detail.calls()]).toEqual([2, 2]);
await vi.advanceTimersByTimeAsync(10_000);
expect([tasks.calls(), detail.calls()]).toEqual([3, 3]);
expect(gov.calls()).toBe(1);
expect(qc.getQueryState(['desk', 'task', 'T0'])?.fetchStatus).toBe('idle');
stop();
});
it('stop() 之后定时器停了,监听也摘了 —— 离开界面不再打接口', async () => {
const tasks = mounted(qc, ['desk', 'tasks']);
const doc = fakeDoc();
await flush();
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc });
expect(doc.listeners).toBe(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(tasks.calls()).toBe(2);
stop();
expect(doc.listeners).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(tasks.calls()).toBe(2);
});
it('页签藏起来时不打;回到前台先补一趟,周期照旧', async () => {
const tasks = mounted(qc, ['desk', 'tasks']);
const doc = fakeDoc();
await flush();
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc });
doc.hidden = true;
doc.fire();
await vi.advanceTimersByTimeAsync(30_000);
expect(tasks.calls()).toBe(1);
doc.hidden = false;
doc.fire();
await flush();
expect(tasks.calls()).toBe(2); // 回前台立刻补一趟
await vi.advanceTimersByTimeAsync(10_000);
expect(tasks.calls()).toBe(3);
stop();
});
it('上一趟还在飞,兜底这一下不取消它重发 —— 慢接口不会被兜底打成永远取不回', async () => {
/*
* 首取要先回来:TanStack 只对**已有数据**的查询做 cancelRefetch(首取飞着时谁都不掐),
* 所以「第二趟慢」才是有分辨力的局面 —— 第一版拿首取当慢趟,true/false 都过。
*/
const slow = mounted(qc, ['desk', 'tasks'], (call) => (call === 1 ? 0 : 25_000));
await flush();
expect(slow.calls()).toBe(1);
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc: fakeDoc() });
await vi.advanceTimersByTimeAsync(10_000);
expect(slow.calls()).toBe(2); // 第二趟起飞,要 25 秒
await vi.advanceTimersByTimeAsync(10_000);
// 第二趟还没回来:这一个兜底周期不该把它取消再发(cancelRefetch:true 会让这里变成 3)
expect(slow.calls()).toBe(2);
stop();
});
});
+18 -1
View File
@@ -6,7 +6,7 @@
*
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
*/
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
import {
@@ -28,6 +28,7 @@ import { call } from '@web-shared/typed-fetch.ts';
import { logout as kcLogout } from '@web-shared/auth.ts';
import { useChatWidth } from '@web-shared/useChatWidth.ts';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { startFallbackPolling } from './fallback-poll.ts';
import { queryKeys } from './query.ts';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -40,6 +41,9 @@ const NAV_MODULES: ReadonlyArray<{ key: ModuleKey; label: string; icon: string }
{ key: 'report', label: '汇报', icon: 'bar-chart' },
];
/** 兜底轮询周期 —— 用户定 10 秒:比这密就成了轮询,比这疏推送漏了人等得出来 */
const FALLBACK_POLL_MS = 10_000;
export function AppShell() {
const queryClient = useQueryClient();
const [module, setModule] = useState<ModuleKey>('tasks');
@@ -109,6 +113,19 @@ export function AppShell() {
*/
const detailPending = current !== '' && detail.isPending;
/*
* SSE 的兜底:任务界面开着的时候每 10 秒把 `desk` 下在用的查询(清单 / 这一单 / 角标)
* 各重取一次 —— 推送漏了画面最多旧 10 秒,不必等人按刷新(2026-08-28 用户定)。
* 离开这一屏(切模块 / 壳层卸载)effect 清理即 `stop()`,定时器与可见性监听一起摘。
*/
useEffect(
() =>
module === 'tasks'
? startFallbackPolling(queryClient, { intervalMs: FALLBACK_POLL_MS, prefix: queryKeys.desk })
: undefined,
[module, queryClient],
);
+57
View File
@@ -0,0 +1,57 @@
/**
* SSE 的兜底轮询 —— **推送丢了,画面最多旧 10 秒**(2026-08-28 用户定)。
*
* 刷新的正路仍是订阅层(`subscribe.ts`:推送到了 → 按键失效 → 重取);这里只是兜底:
* 断线那几秒漏掉的推送、桥没接上的那一台、任何一处忘了广播的写路由 —— 从前都得靠人按刷新。
* 编码规范 §9.2「组件禁止自行轮询」照旧算数:兜底只此一处、由壳层起停,组件不许各自再起定时器。
*
* 三条约束:
* · 只重取**在用的**查询(`type: 'active'`,挂着观察者的才算)—— 早已离开的屏留在缓存里的键不碰;
* · **不取消在途请求**(`cancelRefetch: false`):慢接口正飞着,兜底那一下只是等它,不把它掐掉重发;
* · 页签藏起来不打(后台页签打接口是纯浪费),回到前台先补一趟。
*
* 连接是 I/O,节奏是逻辑:`doc` 可注入,测试里拨 hidden 与 visibilitychange 就能钉死起停。
* 返回 `stop()` —— 离开界面必须调它,定时器与监听一起摘。
*/
import type { QueryClient } from '@tanstack/react-query';
export type VisibilitySource = {
readonly hidden: boolean;
addEventListener(type: 'visibilitychange', handler: () => void): void;
removeEventListener(type: 'visibilitychange', handler: () => void): void;
};
export type FallbackPollingOptions = {
/** 周期(毫秒);工作台任务界面定 10 秒 */
readonly intervalMs: number;
/** 重取哪一支查询(按键前缀) */
readonly prefix: readonly unknown[];
/** 可见性来源;不传用 `document` */
readonly doc?: VisibilitySource;
};
export function startFallbackPolling(queryClient: QueryClient, options: FallbackPollingOptions): () => void {
const doc: VisibilitySource = options.doc ?? document;
let stopped = false;
const sweep = (): void => {
if (stopped || doc.hidden) return;
// 取不回来不算兜底的错:那一把查询自己会带着 error 状态,画面照实摆
// `cancelRefetch` 是第二参(RefetchOptions),写进过滤器会被无视、实际走默认 true —— 测试逮过一次
void queryClient
.refetchQueries({ queryKey: options.prefix, type: 'active' }, { cancelRefetch: false })
.catch(() => undefined);
};
const onVisibility = (): void => {
if (!doc.hidden) sweep();
};
const timer = setInterval(sweep, options.intervalMs);
doc.addEventListener('visibilitychange', onVisibility);
return () => {
stopped = true;
clearInterval(timer);
doc.removeEventListener('visibilitychange', onVisibility);
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ export { queryKeys };
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 缓存失效由 SSE 订阅层驱动,不靠组件轮询
// 缓存失效由 SSE 订阅层驱动,不靠组件轮询;推送漏了有壳层的 10 秒兜底(fallback-poll.ts),组件不另起定时器
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5_000,
+1 -1
View File
@@ -69,7 +69,7 @@ export function invalidateFor(queryClient: QueryClient, touch: Touch): void {
* 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。
*/
void queryClient.invalidateQueries({ queryKey: ['gov'] });
void queryClient.invalidateQueries({ queryKey: ['desk'] });
void queryClient.invalidateQueries({ queryKey: queryKeys.desk });
break;
}
}
+2
View File
@@ -6,6 +6,8 @@
export const queryKeys = {
/** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */
me: ['me'] as const,
/** 工作台整支 —— 失效 / 兜底轮询按这个前缀扫,下面四把都在它名下 */
desk: ['desk'] as const,
tasks: ['desk', 'tasks'] as const,
task: (id: string) => ['desk', 'task', id] as const,
messages: ['desk', 'messages'] as const,
+1
View File
@@ -390,6 +390,7 @@ S-FEEL 子集(OS 03 定义域)的解析与求值,纯函数。被 `process`(边
- 视图区容器渲染活动推送的 HTML:**沙箱 iframe(`srcdoc` + `sandbox` 属性)或净化后注入,二选一由 `ui-kit` 的 `ViewContainer` 统一实现,feature 不得自行 `dangerouslySetInnerHTML`**;无脚本执行权(AM 07)
- 卡片表单由 `@contracts/card.ts` schema 驱动渲染与校验,与后端回传校验同 schema
- SSE 事件到达 → 按信封目标(任务/工作流/要素)**失效对应 TanStack Query 缓存**,由订阅层统一做;组件禁止自行轮询或手写缓存
- 推送的兜底**只此一处**:`app/fallback-poll.ts`,工作台任务界面开着时每 10 秒把 `desk` 下在用的查询重取一遍(不取消在途、页签藏起不打、离开即停),由壳层起停 —— 组件仍不许各自起定时器(2026-08-28)
- 路由状态即应用状态:当前区/页签/下钻栈可从 URL 完整还原(TanStack Router 类型化 search params);内存路由栈只是原型期形态,正式系统必须可深链
---