手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
85 lines
4.3 KiB
TypeScript
85 lines
4.3 KiB
TypeScript
/**
|
|
* 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<string>).data) as Touch);
|
|
});
|
|
|
|
// 重连即补课:断线期间的推送收不到,回来先整体失效一次
|
|
source.addEventListener('open', () => {
|
|
void queryClient.invalidateQueries();
|
|
});
|
|
|
|
return () => source.close();
|
|
}
|