Archived
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);内存路由栈只是原型期形态,正式系统必须可深链
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user