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)。
|
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
|
||||||
*/
|
*/
|
||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
|
import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,7 @@ import { call } from '@web-shared/typed-fetch.ts';
|
|||||||
import { logout as kcLogout } from '@web-shared/auth.ts';
|
import { logout as kcLogout } from '@web-shared/auth.ts';
|
||||||
import { useChatWidth } from '@web-shared/useChatWidth.ts';
|
import { useChatWidth } from '@web-shared/useChatWidth.ts';
|
||||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||||
|
import { startFallbackPolling } from './fallback-poll.ts';
|
||||||
import { queryKeys } from './query.ts';
|
import { queryKeys } from './query.ts';
|
||||||
|
|
||||||
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
|
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' },
|
{ key: 'report', label: '汇报', icon: 'bar-chart' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 兜底轮询周期 —— 用户定 10 秒:比这密就成了轮询,比这疏推送漏了人等得出来 */
|
||||||
|
const FALLBACK_POLL_MS = 10_000;
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [module, setModule] = useState<ModuleKey>('tasks');
|
const [module, setModule] = useState<ModuleKey>('tasks');
|
||||||
@@ -109,6 +113,19 @@ export function AppShell() {
|
|||||||
*/
|
*/
|
||||||
const detailPending = current !== '' && detail.isPending;
|
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({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
// 缓存失效由 SSE 订阅层驱动,不靠组件轮询
|
// 缓存失效由 SSE 订阅层驱动,不靠组件轮询;推送漏了有壳层的 10 秒兜底(fallback-poll.ts),组件不另起定时器
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function invalidateFor(queryClient: QueryClient, touch: Touch): void {
|
|||||||
* 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。
|
* 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。
|
||||||
*/
|
*/
|
||||||
void queryClient.invalidateQueries({ queryKey: ['gov'] });
|
void queryClient.invalidateQueries({ queryKey: ['gov'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['desk'] });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.desk });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
/** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */
|
/** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */
|
||||||
me: ['me'] as const,
|
me: ['me'] as const,
|
||||||
|
/** 工作台整支 —— 失效 / 兜底轮询按这个前缀扫,下面四把都在它名下 */
|
||||||
|
desk: ['desk'] as const,
|
||||||
tasks: ['desk', 'tasks'] as const,
|
tasks: ['desk', 'tasks'] as const,
|
||||||
task: (id: string) => ['desk', 'task', id] as const,
|
task: (id: string) => ['desk', 'task', id] as const,
|
||||||
messages: ['desk', 'messages'] 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)
|
- 视图区容器渲染活动推送的 HTML:**沙箱 iframe(`srcdoc` + `sandbox` 属性)或净化后注入,二选一由 `ui-kit` 的 `ViewContainer` 统一实现,feature 不得自行 `dangerouslySetInnerHTML`**;无脚本执行权(AM 07)
|
||||||
- 卡片表单由 `@contracts/card.ts` schema 驱动渲染与校验,与后端回传校验同 schema
|
- 卡片表单由 `@contracts/card.ts` schema 驱动渲染与校验,与后端回传校验同 schema
|
||||||
- SSE 事件到达 → 按信封目标(任务/工作流/要素)**失效对应 TanStack Query 缓存**,由订阅层统一做;组件禁止自行轮询或手写缓存
|
- SSE 事件到达 → 按信封目标(任务/工作流/要素)**失效对应 TanStack Query 缓存**,由订阅层统一做;组件禁止自行轮询或手写缓存
|
||||||
|
- 推送的兜底**只此一处**:`app/fallback-poll.ts`,工作台任务界面开着时每 10 秒把 `desk` 下在用的查询重取一遍(不取消在途、页签藏起不打、离开即停),由壳层起停 —— 组件仍不许各自起定时器(2026-08-28)
|
||||||
- 路由状态即应用状态:当前区/页签/下钻栈可从 URL 完整还原(TanStack Router 类型化 search params);内存路由栈只是原型期形态,正式系统必须可深链
|
- 路由状态即应用状态:当前区/页签/下钻栈可从 URL 完整还原(TanStack Router 类型化 search params);内存路由栈只是原型期形态,正式系统必须可深链
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user