/** * SSE 订阅层 —— apps/web 唯一一条服务端推送连接(编码规范 §9.2)。 * * 「SSE 事件到达 → **按信封目标**失效对应 TanStack Query 缓存,由订阅层统一做; * 组件禁止自行轮询或手写缓存。」 * * 推送不带业务载荷:它只说「哪个目标动了」,画面照旧由服务端投影重建 —— 前端零过程状态。 * 断线由 `EventSource` 自行重连;重连后整体失效一次,补上断线期间错过的变更。 */ import type { QueryClient } from '@tanstack/react-query'; import { queryKeys } from './query.ts'; export type Touch = { kind: 'task' | 'workflow' | 'staff' | 'system'; id: string; reason: string }; /** * 一次推送该失效哪些键 —— **单独导出是为了让它可测**。 * * 这段映射曾经整段失效(键前缀写成 `['tasks']`,而工作台的键是 `['desk','tasks']`)—— * 推送照发、画面纹丝不动,而且不报错。这类错只能靠断言「推送到了哪把查询会重取」来守, * 所以把它从 EventSource 里剥出来:连接是 I/O,映射是逻辑。 */ export function invalidateFor(queryClient: QueryClient, touch: Touch): void { switch (touch.kind) { /* * **键名一律取自 `queryKeys`**,不手写字面量:TanStack 的失效按**前缀**匹配, * 而工作台的键都以 `desk` 打头(`['desk','tasks']`)。从前这里写的是 `['tasks']` —— * 前缀对不上,一条也匹配不到:推送照发,画面纹丝不动 * (实测:任务办完了,列表不挪窝、「已完成」也不长出来)。 */ case 'task': // 工作台三处 + 治理台任务面都以任务为轴,一起失效 void queryClient.invalidateQueries({ queryKey: queryKeys.task(touch.id) }); void queryClient.invalidateQueries({ queryKey: queryKeys.tasks }); void queryClient.invalidateQueries({ queryKey: queryKeys.approvals }); void queryClient.invalidateQueries({ queryKey: ['gov', 'tasks'] }); void queryClient.invalidateQueries({ queryKey: ['gov', 'tasklog', touch.id] }); /* * **测试台也要跟着动。** 它的键自成一支(`gov.testbed.*`),不在上面几条的前缀里 —— * 从前只靠自己 2 秒一轮的轮询,而轮询到终态就停:一单跑完之后, * 那一屏就此定格,连「当前在跑的是哪一单」都不再问(实测:跑完了旋钮还锁着)。 * 推送到了就失效,轮询退回成兜底。 */ void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] }); break; case 'workflow': // 流动了,自己名下的任务与审批都可能变 —— 工作台一并失效 void queryClient.invalidateQueries({ queryKey: queryKeys.tasks }); void queryClient.invalidateQueries({ queryKey: queryKeys.approvals }); void queryClient.invalidateQueries({ queryKey: ['gov', 'workflow', touch.id] }); void queryClient.invalidateQueries({ queryKey: ['gov', 'tasks'] }); void queryClient.invalidateQueries({ queryKey: ['gov', 'approvals'] }); void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] }); break; case 'staff': void queryClient.invalidateQueries({ queryKey: queryKeys.messages }); void queryClient.invalidateQueries({ queryKey: ['gov', 'staff'] }); break; case 'system': /* * 全局变更(配置改了 / 试跑单被销毁 / 台账落了新事实)——**两面一起失效**。 * * 只失效 `gov` 是不够的:销毁试跑单拔掉的是**真任务**,它这一刻还挂在 * 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。 */ void queryClient.invalidateQueries({ queryKey: ['gov'] }); void queryClient.invalidateQueries({ queryKey: ['desk'] }); break; } } export function connectStream(queryClient: QueryClient): () => void { const source = new EventSource('/api/stream'); source.addEventListener('touch', (event) => { invalidateFor(queryClient, JSON.parse((event as MessageEvent).data) as Touch); }); // 重连即补课:断线期间的推送收不到,回来先整体失效一次 source.addEventListener('open', () => { void queryClient.invalidateQueries(); }); return () => source.close(); }