/** * SSE 订阅层 —— apps/web 唯一一条服务端推送连接(编码规范 §9.2)。 * * 「SSE 事件到达 → **按信封目标**失效对应 TanStack Query 缓存,由订阅层统一做; * 组件禁止自行轮询或手写缓存。」 * * 推送不带业务载荷:它只说「哪个目标动了」,画面照旧由服务端投影重建 —— 前端零过程状态。 * 断线由 `EventSource` 自行重连;重连后整体失效一次,补上断线期间错过的变更。 */ import type { QueryClient } from '@tanstack/react-query'; import { queryKeys } from './query.ts'; type Touch = { kind: 'task' | 'workflow' | 'staff' | 'system'; id: string; reason: string }; export function connectStream(queryClient: QueryClient): () => void { const source = new EventSource('/api/stream'); source.addEventListener('touch', (event) => { const touch = JSON.parse((event as MessageEvent).data) as Touch; 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] }); 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'] }); break; case 'staff': void queryClient.invalidateQueries({ queryKey: queryKeys.messages }); void queryClient.invalidateQueries({ queryKey: ['gov', 'staff'] }); break; case 'system': void queryClient.invalidateQueries({ queryKey: ['gov'] }); break; } }); // 重连即补课:断线期间的推送收不到,回来先整体失效一次 source.addEventListener('open', () => { void queryClient.invalidateQueries(); }); return () => source.close(); }