E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排

活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
+4
View File
@@ -14,3 +14,7 @@ coverage/
# html-one-pager 技能的评测工作区(skill-creator 产物,不入库) # html-one-pager 技能的评测工作区(skill-creator 产物,不入库)
.claude/skills/html-one-pager-workspace/ .claude/skills/html-one-pager-workspace/
# 浏览器走查的截图与日志(本地取证用,不入库)
.playwright-mcp/
/*.png
+268
View File
@@ -1026,6 +1026,274 @@
归档的 .xlsx 落盘后 `file` 认作 **Microsoft Excel 2007+**、python `zipfile` 读得出 归档的 .xlsx 落盘后 `file` 认作 **Microsoft Excel 2007+**、python `zipfile` 读得出
整张对照表。新测 8 条(存哪些不存哪些 + 主键拼法);`reset-runs` 一并清这张表。 整张对照表。新测 8 条(存哪些不存哪些 + 主键拼法);`reset-runs` 一并清这张表。
- **两个后端连同一个库,把同一张单推坏了**(2026-08-24 柴浩实测:E04 一单五拍熔断;
用户中途提示「本机和局域网另一台机器的同一个后端都连着这个库」——最后一块拼图):
症状是**两堵墙对着撞** —— `finish S2` 被拒「从没给它发过卡」,而重发那张卡又被拒
「这件事刚问过,人也答过了」。查下来:卡确实发过(11:17:53 发、11:18:01 人答的),
可局面里 **8 个计划项的 `worldTouched` 全是空**;同一步 S1 被收了两遍
(两条 rationale 只差一个标点 —— 是两次独立采样,不是重放)。
根因:两边的推进器各自 2 秒扫一次「running 的单」,**同时推同一张**,
两拍并发写同一份 `sys.loop_state`,**后写的整份覆盖先写的** —— 发过卡这个事实就此消失。
而互斥只在进程内(`task-lock.ts` 开头自己写着「MVP 单进程…多进程部署时再叠一层」)。
**修**:`workers/task-claim.ts` —— 库里一列 `advancing_until`(迁移 0030)当跨进程租约,
推之前一条 SQL 判+占(判与占分两句写就是经典竞态),推完就还,带 5 分钟过期
(进程崩了不锁死);推进器与派发器两条推进路径都接上。
实测:两条真连接对着真库抢 20 轮,**每轮恰好一个赢**;租约过期后另一方接得上。
**还欠**(记 debt):`loop_state` 本身没有乐观并发(版本号 / CAS)——
租约挡住了主路,但绕过它的第三条路仍会**悄无声息**地整份覆盖。
排查法留一条:`worldTouched` 全空 + 同一步收两遍 = 并发覆盖的指纹。
- **点了要有反应,在办事要说出来**(2026-08-24 用户:「点击了都不知道点没点上」):
两件事各缺一半。①**键感**:治理台整套 `g-*` 控件一条 `:active` 都没有,工作台只有零星几个;
更糟的是有 `:active` 的那几个把 transform 一起塞进了 0.16s 的过渡里 ——
一次点击常常比这还短,手抬起来时键帽才刚下去一半。新增 `styles/press.css`
(三面共用、**装在最后**):按下 `scale(.97)`(整行整卡这类大件收到 `.995`,同样幅度会晃)、
**按下这一刻不过渡**(松开仍走控件自己的过渡,回弹是软的)、`:focus-visible` 描圈。
②**在办**:请求在路上的那几百毫秒屏上从前一动不动 —— 新增 `shared/Btn.tsx`,
`busy`(递 `mutation.isPending`)或 `onClick` 返回的 Promise 一亮,按钮脚下走一道
不定长进度条。**不给 `disabled`**:那会把按钮画成灰的,而它此刻正在**做**这件事,
不是做不了(界面不撒谎)。全仓 40 余处会去请求服务端的钮都换成了它;
勾选与下拉给不了键感,改成把**那一行**点亮(认 `mutation.variables`,只亮真正在路上的那一条)。
途中两个坑各留一条账:**条不能画进按钮自己的 background** —— 第一版用 background-image,
在真页面上量出来根本不存在:`.tbtn` 这类皮肤用 `background:` 简写上色,
特异度压过 `[aria-busy]`,图片当场被顶回 none(动画照跑,条却没有);改用 `::after`
(全仓按钮类没有一条定义过伪元素,审过)。**`<select>` 一律不挂 `aria-busy`** ——
替换元素画不出伪元素,且它的下拉箭头本身就是 background-image。
③**防抖按在唯一通道上**(`typed-fetch.call`):在途去重(同一笔还在路上,第二次点击
拿到同一个 Promise)+ 落地后 450ms 的双击窗(**只管写请求**:读请求写完立刻重取的
那一下不能被当成余震压掉);失败的不进窗 —— 那时候再点是重试,重试必须真的重试。
按在这里,所有点击路径一次盖全,不必指望每个调用点自己记得。
④顺手修一个**没接线的钮**:对话区空态那枚「发送」从来没有 `onClick`,
只有文本框里按 Enter 才发得出去 ——「点了都不知道点没点上」在那儿是字面意思。
实证:浏览器里量了层叠(探针类复制 `:active` 规则,`.tbtn`/`.jc-tnav-tab`/`.jc-topbar-logout`
都真到 `scale(.97)`、`transition-duration: 0s`)、真点了会走网络的钮(条真现身真收掉)、
连点三下只发出**一个**请求。新测 9 条(callKey / 在途去重 / 双击窗 / 读写分野 / 失败不进窗)。
- **四件事一起收**(2026-08-24 用户连报):
①**进度条的刻度与业务步数对不上**(「E04 应该是有 7 步、给人确认是 4 步,这里要是 x/7 才对」):
`COVERAGE_PHASES` 从前按归口粗分三段 → 屏上 x/4,而文档开篇写的是「整体流程(7 步)」——
同一件事两个数。改成一段一步(6 段 + 基座那格「放行收口」= 7),与文档那张表逐行对齐;
四张卡就是那 4 个「给人确认」的刻度。实测:`4/7 · 经营计划审批`。
(踩了一脚:注释里写 `x/7` 那个星斜杠就是块注释的结束符,esbuild 当场报在几行之外。)
②**件上新增的行被静默丢掉**(「新增了一个客户,推进到下一步直接就把这一行遗漏掉了」):
主键那几格空着就凑不出钥匙,基座一句 `continue` 跳过,而接口照回 `{ok:true}` ——
**保存说成功,东西却不在**。查的时候又逮出更凶的一面:表里恰好只有一行时,
「全表一致」把那一行的客户编码当成常量补给新行,**当场把老行覆盖掉**
(与 2026-08-23 序号型主键那次同一条规律,当时只按「是不是整数列」绕开了)。
现按**他填得了吗**统一判:件上摆着的主键格,空着就不许基座替他编值;
没落下的行点名回给前端(只点他填得了的那几列),件上方红字说清。
I01 的序号型主键照旧由基座续号,不受影响。
③**界面停在「AI 推进中」,刷新才发现已经在等人**:`createBroadcaster()` 是**进程内**的
一个 Set —— 那会儿两个后端连同一个库,单子被另一台推着走,这一台的浏览器一个字没收到。
加 `broadcast-bridge.ts`:Postgres `LISTEN/NOTIFY` 当桥(不引消息队列),自己发的自己不收,
形状不对的载荷丢掉。实测:另一台 NOTIFY 一条,本机 SSE 立刻收到。
④**数据域按活动重写**(用户定):从前按性质分组(主数据/单据/派生)——那是治理这张表的人的分法;
而人跑完一单来这儿问的是「我这个活动写了哪几张表、现在各有几行、刚那一单落下了什么」。
新增 `/api/gov/data/by-activity`:每个活动 → 它声明的表 + **正式行数** + **在办草稿行数** +
最近三单。三个数各说各的:没物化就不给数(「声明了没写过」与「写过又空了」是两件事)。
实测 E04 一单跑完,那一屏从「正式 18 行」变成「正式 24 行」、最近的单标「已放行」。
- **试跑单也能「落真库」了**(2026-08-24 用户:「活动执行后数据并没有真实入到后台数据库里面」):
实情是一半 —— **单据真落了**(`crm_coverage.*` 有行、有值),**主数据被挡在活动缓存里**
(`ledger-service` 那道门:试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
「新客户来函」样例第二次跑就成了老客户)。可这条保护在演示时正好拦住要看的东西:
台账更新明细白纸黑字写着「亲密度 3 → 10」,而 `customer.contact` 里还是 3 ——
**单据说改了、主数据没改,两份事实对不上**。
现给试跑一个开关:`sys.task.land_master`(迁移 0031)+ 起单入参 `landMaster` +
试跑台上一个复选框;`isSimulated` 认这一列。
**默认打开**(用户当场定:「那你就默认给它打开」)—— 跑一遍活动就该看见数据真的变了,
这是本分;保护没取消,只是**换了落点**:要它的那个调用方自己关
(`tools/e2e-a2.mjs` 显式传 `false` —— 只能测一次的样例归它管)。
**默认值服务于人手点的那条路;脚本要什么,脚本自己说。**
实测(不传任何参数起一单 E04):`land_master = t` → 放行写合并 16 行、**没有**「只留在活动缓存」,
活动自己新建了两位关键人(CU-0142-02/03)、覆盖度 17 → 33。
- **人加的候选客户被整单无视:覆盖闭合成为机检**(2026-08-24 柴浩实测:「覆盖范围确认
步骤我新增了一行要覆盖的用户,后面推进直接无视了」):行**落了**台账(candidate 两行、
focus 段模型看得见、`_rows = 2` 也在),而 plan/receipt/收口全是 CU-0142 的 ——
模型锚在轨迹「识别出 1 家」上;S3 收步判据只数 `plan_rows > 0`、放行也没有一条钉
「盖没盖住每一家」,一路绿灯 released。三处合谋,三处一起修:
①**基座新增 `distinct` 读数**(声明列标 `distinct: true` → `域_对象_distinct_<列>`,
空值不算一种、零行铺 0,静态审计认它)—— 「计划盖住几家客户」从此是基座数得出来的数
(计划行数 ≠ 客户数,一家几位关键人就几行);E04@2.1.1 把闭合
`plan_distinct_customer_code = candidate_rows` 钉在 **S3 收步 / S4 入场哨兵 /
放行 L2** 三处。②**「就地修订」痕点名新增的行**(从前只说「新增 1 行」,哪张表、
哪家客户都不说;撤行 2026-08-02 就点名了,加行是同一条道理的另一半)+ 行动指南
「人加的行同等作数,按台账现读全表办」;说明书立「人核对过的候选清单是每一步的
输入契约」。③**墙要配路**:人加的客户常在主数据里零联系人,S3 给「首访建档」的路
(计划行 contact_code 用 `-00` 占位引用、缺口按默认尺算、不往主数据建假号),
S6 补写它的客户档案、回执带回真人再建真档。
**验收**:e2e e04 复刻案例(件上加 CU-E2E-ADD)—— 决策卡改口「共4条,覆盖2家客户」、
**released、被拒 0**,正式表里那行计划就是 `CU-E2E-ADD-00 · 本月内首访识别关键人并建档`;
痕也点了名(「新增 1 行(CU-E2E-ADD/COV-…)」)。另两案例 released、被拒 11/1 ——
全是 08-23 已记档的既有空转(S6 少写就 finish · no-missed-update 先写 2 条),
没有一次撞在新闭合上;「E04 被拒 0」仍欠着(debt)。1663 测 / typecheck / lint /
depcruise 绿;契约变更(`FieldDeclaration.distinct`)api-report 已刷新待提交。
小尾巴:人加的候选行 S6 忘置 status(状态徽空着,不拦单),随「被拒 0」一并欠着。
- **驳回意见点名「测试111」,改动落到了 Itelma 头上:归属交给基座比对**(2026-08-24
柴浩实测第二发):意见原话「测试111建议动作改成拜访CTO」,计划表里明明有一行
`customer_name=测试111`,模型复述时把「测试111」当杂音丢了,按「谁有CTO」的自我
推理挑了 CU-0142-01 改。与 E04 worldTouched 同一课:**指南让模型判的事,引擎有
现成事实的,一律改成读事实** —— 归属是纯字符串包含。两手:①基座 `note-anchors.ts`:
决策卡带意见回来那一刻,意见与本单草稿逐格字面比对(值 ≥3 字、非纯数字、命中 >5 行
当没命中),命中的行以观察落在 card-reply 旁(「意见按字面点名对上了…先认归属;
点名的行缺什么,改这一行自己的内容,不是挪到别的行头上」)—— harness 侧,在途单
立即受益;②E04@2.1.2 S4 剧本立归属纪律(照观察认、四列逐行比、认不出唯一就重发卡
把「你理解改哪一行」写进 ask 请人确认、重发卡的 ask 用人读名点名改了哪家哪位)。
e2e 案例①升级:点名意见驳回 → 断言改动落在被点名那一行(`expectRevised`)。
7 测(matcher)+ 1 测(guide 钉死);1671 测全绿。
- **落库的台账要摆全给签字的人,AI 填不出的字段在确认屏上就能补**(2026-08-24 柴浩
连报四件:联系人没建上 · 新客户档案一堆空字段 · 两条无名联系人 · 确认屏没摆全):
勘出两类病:①**模型替「计划要经营的岗位」凭空建联系人档**(人名职务全空、却标着
关键人/亲密度3 —— 把计划冒充成档案;两条已人工删掉,CU-0142 覆盖度 100→17 改老实);
②新客户档案连 name 都没抄(候选行里明明有)。修四层(E04@2.1.3):
①声明:`customer.profile.name` / `customer.contact.name` 升必填 ——
`_missing` 本就在 S6 收步与放行哨兵里,**无名档案从此进不了正式表**;
industry/region 标建议档(AI 从来件/note 推得出才写,不许编造;推不出橘胶囊留给人);
②**台账更新确认那份件补两张「落库预览」表**(将写入的客户档案/联系人档案,
即签字后进正式表的行本身,不是摘要):可就地补改、联系人可**加行**,红带与放行哨兵
同源(`customer_*_missing`);③基座:件上「加一行」遇声明 `auto` 的主键由基座续号
(`writeEditedRows` 接 `autoKeys`,与录入卡同一段 —— 「自增列不问人」管到件上;
拒回不再点名人填不了的 auto 列);④剧本:碰过的行把 name 照现值带上、
没有真实姓名不建联系人行、新客户档案 name/cust_type 照候选行抄。
1672 测全绿;e2e 案例 0 再扩:确认屏补一行联系人(编码留空)→ 断言基座续出
`CU-E2E-ADD-01`。**验收(@2.1.4):三案例 3/3 released、全部被拒 0** ——
E04 第一次到零被拒线(08-23 起欠着的那条就此销掉);案例 0 一单连过四关
(加行进计划 / 点名驳回改在被点名行 / 确认屏补录联系人续号 CU-E2E-ADD-01 /
名字必填的新墙零摩擦)。途中顺手治掉一条 guide 自招的空转:S3 ④「顺手补写
cust_type/bt_level」没写「还空着」三个字,模型把有值的行照抄一遍白吃一拒 ——
补上三个字(@2.1.4)。数据侧同批手术:两条无名联系人已删、CU-0142 覆盖度
100→17 改老实、CU-2039 档案回填人给过的 name/cust_type。
- **落库预览要回显「签下去之后这一行长什么样」,不是草稿那半截**(2026-08-24 柴浩:
「Itelma 的字段没回显,点编辑变空白输入框」):两层病 ——
①预览绑的 `mine:` 只有模型本轮写过的列,Itelma 既有的客户类型/行业/区域、联系人的
职务/角色全不在;②文本格用了手写 `<input>`,渲染器只给自动孪生件(`data-slot`+
`data-col` 空 span)填值,手写的一开编辑就是白框。修(E04@2.1.5 + 基座):
①新数据源 `touched:域.对象` = 正式现值 + 本单草稿叠加、按草稿键只取本轮碰过的行
(engines `touchedRows` 纯函数;不进提示词,restPart 滤掉);②预览两表改绑 touched、
文本格全部换自动孪生(select/textarea 本就有 current 填值);③**保存的「真改了吗」
也对着叠加行比**(从前只对草稿:回显值一保存全算「改了」,痕写「改了 2 行」而人
一格没动,日志撒谎)——「是不是新增」同理改判。
连带逮出并修死一个**新增变覆盖**:人在确认屏加联系人,编码空着(自增列本分),
而 `resolveRowKey` 的 findKnownRow 拿半把钥匙(只有 customer_code)把新行错配到
该客户已有的草稿联系人行上 —— 编码从那行借来,模型刚建的那位被顶掉,痕还写
「改了 1 行」。立规:**空着的自增列不是身份线索,是「给我续个号」**——声明 auto
的列件上摆着却空着,一律不走 findKnownRow/全表一致,直进续号(2 测钉死);
e2e 断言同批收紧(按填的姓名找行,防覆盖冒充新增再骗过一次)。
**验收**:案例 0 released、被拒 0;定格的确认屏 HTML 六探针全中(Tier1 OEM /
车规电子 / value="Itelma" / value="Ekaterina Volkova" / 测试CTO王工 /
CU-E2E-ADD-01);痕三条全部老实(新增记新增)。1676 测 / 五门禁绿。
- **括号少一个,一拍全对也白跑:括号栈补上「补」这一档**(2026-08-24 柴浩:「怎么又
推不动了」):卡在 S6 收口拍 —— 全流程括号最深的输出(decision_chain +
interaction_timeline 两个嵌套 JSON 列),关思考的模型在 profile 行上**少吐一个 `}`**;
drop 档(08-02 治「多括号」)把后面本该关 rows 的 `]` 当噪声丢掉,整段错位到尾,
重问一次(温度 0.2)几乎原样复现,两连败 → 兜底卡「需要你判断下一步」交人,
单子看着像卡死。修:`parseJsonLoose` 两档**有序**修复 —— 先 drop(多括号当噪声),
失败再 close(闭括号对不上但栈更深处有它 → 把欠的补上;文本截断收尾 → 全补)。
顺序不能反:close 先跑会把多括号那形的外层提前关掉、行动串截半(测钉死)。
仍只合成闭括号,不发明内容。事故原文实测:三个 write 全解出。1679 测绿。
**同夜第二形**(e2e 逼出):行对象没关、后面直接跟 `,{"key":…`(下一行)——括号到尾
能配平、strict 与两档全看不出;close 档补第三条:**对象里逗号后面冒出 `{`/`[`,就是
上一个对象没关**,把欠的 `}` 补在那个逗号之前(逗号其实属于外层数组)。两起原文
全解出(11 测)。e2e 同批:认出「需要你判断下一步」兜底卡当场判失败收工,
不再空转到 20 分钟超时。
- **数据域记录浏览的 JSON 列不再是 `[object Object]`**(2026-08-24 用户报):
格子文本 `String(row[col])` 对对象一律糊成那七个字 —— 决策链地图 / 互动时间线两列
整列不可读。补 `cellText`:对象/数组 stringify 原样摆,超 120 字截断加省略号、
全文进 title(悬停可读)。别处早就对了(试跑草稿面 JSON.stringify、视图 displayValue),
只有这张表漏了。浏览器实证:决策链格摆出 `{"nodes":[…` 真内容,全页无 [object Object]。
- **「关键人待识别」从计划渗进主数据又渗回来:占位句式全链退役 + 引用墙**(2026-08-24
柴浩:「审批里怎么会有关键人待识别的项目」):三层病 ——
①模型把 2.1.1 的占位句式(查无联系人客户的 -00 行)**泛化到缺口节点**,给 Itelma
编 CU-0142-02/03 引用码 + 占位人名(措辞三连拦不住:反例描述/说明书纪律/禁令);
②某单落库后这些占位行**以「关键人待识别」为姓名进了 customer.contact 主数据**,
下一单从主数据读到它们照常规划 —— 污染自我强化;③一次 e2e 集合共用 bug
(录入卡与决策卡共用已答名单,decideSeq 序号被顶,点名驳回被跳过)。
修四层(E04@2.1.7 + 基座):①**占位句式全链退役** —— 查无联系人的候选客户改走
**数据录入卡**先收第一位关键人(该问人的问人;骨架进 S3 剧本),真人建档后再排计划;
一位联系人一行,缺口节点动作并进已知联系人那一行;②**契约新增 `refersTo`**
('域.对象.列'):声明引用关系,基座 draftWrite 那一刻查引用表(正式+草稿叠加),
查无此值当场拒、拒回指路;安装静态审核 refersTo 目标真实存在
(`reference-target-unbound`,写错地址=永远查无此值的死墙);plan/receipt 的
contact_code 都钉上;③主数据两次清污(占位联系人删除);④e2e:录入卡代答
(entryValues)+ 计划违禁字样断言(forbidInPlan)+ 已答名单分家。
**验收(@2.1.7,主数据清污后)**:三案例 3/3 released、全部被拒 0(16-18 拍);
案例 0 计划恰 2 行全是真人(Ekaterina 那行把两个缺口节点的动作并进她名下、
王工按点名改成约见CTO),录入卡先收人 → 确认屏再补一位续号 -02;案例 2 单人单行。
注意:refersTo 墙在这轮从没响 —— 主数据干净时模型照 2.1.7 的「一位联系人一行」
就不再编码了;墙是备份,不是主路。**清污是前提**:主数据里躺着占位联系人时,
引用检查会「合法」放行它们(引用的确存在),病就从主数据渗回来。
- **报价链 A1–A7 从系统卸载,只留 E04 / I01**(2026-08-24 用户定,**只卸不删源码**):
六个 quote-* 包在库里置 `uninstalled` + 禁用(registry.uninstall),两个 RFQ 触发器
(trg-rfq / trg-rfq-manual)停用 —— 触发器对着已卸载的活动起单只会生出坏任务;
种子同步:`REAL_PACKAGES` 只剩 E04/I01,`seedMvpFlow` 以 `SEED_QUOTE_FLOW=false`
整段不播(统一报价流程包 / 触发器 / 询价邮箱 —— 装不上的活动激活流程包会被闭合
校验拦下)。源码、e2e 套件、包内测试原样保留;`MVP_PACKAGE` 常量仍是流程编译与
触发装配那几组测试的夹具。**库里 `unified-quote` 流程包仍在**(active,节点指向已卸载
的活动 —— 蓝图待补清单会点它),等用户定要不要连流程包一起退。要装回:名字加回
`REAL_PACKAGES` + 拨回 `SEED_QUOTE_FLOW`,跑 activities:install。
- **台账更新确认四件小账**(2026-08-24 柴浩连报,E04@2.1.8):①**重做后旧明细留在表里**
(SQ-0031 的覆盖度同时挂着 50→50 与 50→100):主键是序号,重做时模型新开一行而不是
同 seq 覆盖。结构治:明细表加 `update_key`(台账地址|客户|联系人|字段,必填,
`distinct`),S6 收步 + 新 L2「同一处字段只留一条更新」钉 distinct = rows;
T_release 重做分支写明过期行 `drop` 撤掉。②**依据列带机器键前缀**
(「回执 contact_code=CU-0087-02:…」):说明书与技能改教人话(客户名·人名的回执:…),
不写编码、不带前缀。③**标题旁的单号不摆**:`doc()` 的 `titleRef` 改可选,四份件全去掉
(页脚单号仍在)。④**验收判据与「AI 已完成」文案夹着 `coverage_score`/`decision_chain`**
→ 全改人读名。当前那单的草稿当场手术:删过期行、去六条前缀。包内测试 91 条钉死四条口径。
- **计划 7 行回执 6 行照样交卷、两堵墙对撞熔断、对话区偶发乱序**(2026-08-24 柴浩三连):
①**多行 write 不是原子的** —— 第 2 行撞 refersTo 墙被拒时第 1 行已落草稿,轨迹只记
「被拒」,模型与人都以为一行没写,回执骨架从此少一行;提交闸只数已有行缺不缺必填,
数不出「少了一行」;放行门「每条计划都有回执」拒 → 模型删计划凑数 → 「每家候选都进了
计划」拒 → 加回来 → 循环五拍熔断,而缺的那行回执只有人能填。修:loop 的 write
**整批先验后写**(`draftWrite` 加 `dryRun`,两遍),被拒 = 一行都没落;E04@2.1.9
把「回执行数 = 计划行数」钉进 S3 收步与 S4 入场哨兵(派回执单前就得齐),S4 剧本
加/撤计划行同步骨架,T_release 写明回执少行 → 补骨架重派执行单、**不许删计划凑数**;
e2e 回执单派出那一刻断言骨架 = 计划。②**对话区按任务内序号排,不按墙上时钟**
(`chat-order.ts`):合并从前按 `at` 字符串排,两台后端交替推同一单、时钟一偏,
上一拍的话就排到下一拍后面;记忆 `seq` 与时钟无关,AI/系统项本身是记忆条目、
卡对 card-sent、自由输入对 human-input(`say` 现在把消息 id 记进 detail),
找不到落点的老数据按时刻插位。3 测。
**验收**:脚本 e2e 案例 0 released、被拒 0、回执骨架 = 计划;浏览器全链(AI 账号,
不落主数据):件上加客户 → 录入卡收 CTO → 点名驳回改对行 → 回执单待回执 2/2、
闸拦空手交卷、件上填两行保存即落库 → 验收判据全中文、落库预览回显 → **released**
(17 拍、被拒 1 —— 模型在 S3 先写了计划再写回执骨架、中间撞了一次 doneWhen 收步,
下一拍补齐);对话区 AI 气泡 23:52 → 23:55 → 23:57 单调;更新明细依据人话、位置键唯一。
- **摆给人看的行标识去掉单号那一段**(2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):
缺项红带 / 执行卡提交闸 / 拒回主键名单 / 就地修订痕从前把主键各列全拼上 ——
可人正在这一单里看件,单号是脚下的地面,不是区分行的信息。基座 `rowLabel(id, caseKey)`
按声明的单标识(`caseKey`)去掉那一段,四处同源同改;去完一段都不剩(主键只有单号)
退回整段。`missingReadings` 入参补 `caseKey`。4 测;在途单 `views.repush` 一帧即见。
- **点击后两块同一帧变脸:「在办事」写在回传返回之前**(2026-08-25 柴浩:「右边聊天框
先有反应,中间的 html 过一会儿才变成 AI 正在推进中,看上去很不流畅」):人回传落库后
runtime 要等派发器投递、推进器抢到租约、开跑前那一刻才翻成 `running` ——
前端 onSuccess 那一次重取抓到的还是 `awaiting-event`:对话区(卡状态已落)先变,
工作区(读 runtime)要等下一次推送。修:`TaskService.wake(taskId)` —— 只对
awaiting-event / awaiting-human 翻 running 并 setPhase('active')(顺带广播),
两条回传路由(工单 / 对话卡)在返回之前调它。浏览器探针实测(100ms 采样):
点「通过」后 **401ms** 中间已是「AI 正在推进这单」、对话区同帧「你选了:通过 ·
持续推进中」;8.2s 推进完成两块同帧换成回执件 + 下一张卡。3 测;CONTRACT.md 已记。
- **对话区「上一步的卡跑到这一步下面」的真病根:并卡判紧邻只看对话项**(2026-08-25
柴浩第二次报):08-24 那刀(按记忆 seq 排序)治的是**跨机器时钟偏**,而这次时钟没偏 ——
I01 一单:「两份初稿…请审核」→ 发决策卡 → 人答 → 收口 → 发验收卡;`projectChat`
的「一个气泡 = 交代 + 卡」只看 `out` 末项是不是还没挂卡的 AI 发言,而决策卡 / 录入卡
的 card-sent、人的答复、内部观察都**不进 out**,那句话在它眼里一直「空着」——
41 秒后的验收卡被挂到了上一步那句话上,以它的时刻排到决策卡前面。修:「刚说过话」
按**记忆序列**判(话与工单之间不许隔任何条目;装配戳这类信封件除外;跨人卡 / 决策卡
的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路**
(时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 08-23 灰底那课的第二次。
## 文档权威地图 ## 文档权威地图
| 层 | 文件 | 说明 | | 层 | 文件 | 说明 |
@@ -340,6 +340,10 @@ describe('声明与契约对得上', () => {
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`), (f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
), ),
), ),
// distinct 声明出的不同值计数 —— 覆盖闭合判据的落点(2026-08-24)
...manifest.declarations.ledger.flatMap((d) =>
d.fields.filter((f) => f.distinct === true).map((f) => `${d.domain}_${d.object}_distinct_${f.name}`),
),
]); ]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => { it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
@@ -428,10 +432,32 @@ describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
expect(html).not.toContain('data-col='); expect(html).not.toContain('data-col=');
}); });
it('台账更新明细只读:没有任何 data-edit / data-col —— 归档件不给改', () => { it('台账更新确认摆全落库面:更新明细只读,客户档案与联系人档案两张落库预览可编辑、联系人可加行', () => {
// 2026-08-24 用户定:「没有把要更新的台账展示全;AI 分析填的字段也要能在这一步补」
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? ''; const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
expect(html).not.toContain('data-edit='); // 更新明细那张表仍是摘要、只读 —— 它的 tbody 上不许有 data-edit
expect(html).not.toContain('data-col='); expect(html).toContain('data-rows="mine:crm_coverage.ledger_update"');
expect(html).not.toContain('data-rows="mine:crm_coverage.ledger_update" data-edit');
// 两张落库预览:将写入正式表的行本身,可就地补改
expect(html).toContain('data-edit="customer.profile"');
expect(html).toContain('data-edit="customer.contact"');
/*
* 数据源必须是 touched:(正式现值 + 草稿叠加、只取本轮碰过的行)——
* 绑 mine: 的话草稿里没有的列(Itelma 既有的客户类型/行业)整片空白,
* 编辑框也空(2026-08-24 实测);文本格走自动孪生(data-slot + data-col 空 span),
* 手写 <input> 渲染器不给填值,一开编辑就是白框。
*/
expect(html).toContain('data-rows="touched:customer.profile"');
expect(html).toContain('data-rows="touched:customer.contact"');
expect(html).toContain('data-slot="name" data-col="name"');
expect(html).not.toMatch(/data-edit="customer\.(?:profile|contact)"[\s\S]*?<input class="jc-cell"/);
// 主键列必挂 data-col(行的钥匙);联系人可加行,编码由基座续号(自增列不问人)
expect(html).toContain('data-slot="customer_code" data-col="customer_code"');
expect(html).toContain('data-slot="contact_code" data-col="contact_code"');
expect(html).toContain('data-edit-add="customer.contact"');
// 红带与放行哨兵同源 —— 摆的是基座的 _missing
expect(html).toContain('data-slot="customer_profile_missing"');
expect(html).toContain('data-slot="customer_contact_missing"');
}); });
it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => { it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => {
@@ -150,23 +150,104 @@ describe('doneWhen 是「成果已存在」的判据 —— 开局不许真,干
).toBe(false); ).toBe(false);
}); });
it('计划有行且必填齐 → S3 收得掉;缺一头都收不掉', () => { it('计划有行、必填齐、且盖住每家候选 → S3 收得掉;缺一头都收不掉', () => {
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 0 })).toBe(true); const full = {
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 0, crm_coverage_plan_missing: 0 })).toBe(false); crm_coverage_plan_rows: 3,
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 2 })).toBe(false); crm_coverage_plan_missing: 0,
crm_coverage_plan_distinct_customer_code: 2,
crm_coverage_candidate_rows: 2,
crm_coverage_receipt_rows: 3,
};
expect(safeEval(doneOf('S3_plan'), full)).toBe(true);
expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_rows: 0 })).toBe(false);
expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_missing: 2 })).toBe(false);
}); });
it('台账更新明细齐 + 主数据必填齐 → S6 收得掉', () => { it('**回执骨架比计划少一行 → S3 收不掉、S4 哨兵不开**(2026-08-24 柴浩实测:计划 7 回执 6,人填完就能交卷)', () => {
expect(
safeEval(doneOf('S3_plan'), {
crm_coverage_plan_rows: 7,
crm_coverage_plan_missing: 0,
crm_coverage_plan_distinct_customer_code: 6,
crm_coverage_candidate_rows: 6,
crm_coverage_receipt_rows: 6,
}),
).toBe(false);
const sentry = /<ifPart id="if_pa"><condition>([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
expect(sentry).toContain('crm_coverage_receipt_rows = crm_coverage_plan_rows');
const s4 = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
expect(s4).toContain('加了一条计划就同拍给它加一行');
const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? '';
expect(rel).toContain('不许删计划来凑数');
});
it('**计划漏了一家候选客户 → S3 收不掉**(2026-08-24 柴浩实测:人加的那家被从头无视到尾)', () => {
// 人在覆盖范围确认卡上加了一家(candidate 2 行),计划只盖住 AI 识别的那一家
expect(
safeEval(doneOf('S3_plan'), {
crm_coverage_plan_rows: 3,
crm_coverage_plan_missing: 0,
crm_coverage_plan_distinct_customer_code: 1,
crm_coverage_candidate_rows: 2,
}),
).toBe(false);
// S4 的入场哨兵是同一句闭合 —— 计划没盖全,审批卡就不该发出来
const sentry = /<ifPart id="if_pd"><condition>([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
expect(sentry).toContain('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows');
});
it('覆盖闭合的两边都是基座数:distinct 声明在 plan.customer_code 上,放行 L2 也钉同一句', () => {
const plan = manifest.declarations.ledger.find((l) => l.object === 'plan');
expect(plan?.fields.find((f) => f.name === 'customer_code')?.distinct).toBe(true);
const criterion = manifest.contract.acceptanceCriteria.find((c) => c.key === 'plan-covers-scope');
expect(criterion?.level).toBe('L2');
expect(criterion?.predicate).toBe('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows');
});
it('台账更新明细齐 + 主数据必填齐 + 无重复位置 → S6 收得掉', () => {
expect( expect(
safeEval(doneOf('S6_ledger_update'), { safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4, crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0, crm_coverage_ledger_update_missing: 0,
crm_coverage_ledger_update_distinct_update_key: 4,
customer_profile_missing: 0, customer_profile_missing: 0,
customer_contact_missing: 0, customer_contact_missing: 0,
}), }),
).toBe(true); ).toBe(true);
}); });
it('**重做后旧明细没撤(同一处位置两条)→ S6 收不掉**(2026-08-24 用户:「重做后老的变更记录应该删除」)', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 5,
crm_coverage_ledger_update_missing: 0,
crm_coverage_ledger_update_distinct_update_key: 4,
customer_profile_missing: 0,
customer_contact_missing: 0,
}),
).toBe(false);
const decl = manifest.declarations.ledger.find((l) => l.object === 'ledger_update');
const col = decl?.fields.find((f) => f.name === 'update_key');
expect(col?.distinct).toBe(true);
expect(col?.required).toBe(true);
expect(manifest.contract.acceptanceCriteria.some((c) => c.key === 'no-duplicate-update' && c.level === 'L2')).toBe(true);
// 依据一列写人话:不带联系人编码这类机器键;重做分支写明过期行要撤
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_ledger_update')?.guide ?? '';
expect(s6).toContain('不写联系人编码');
expect(s6).toContain('update_key');
const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? '';
expect(rel).toContain('过期的旧记录要撤掉');
});
it('四份件标题旁不再摆单号(2026-08-24 用户定);验收判据与「AI 已完成」文案不夹字段名', () => {
for (const t of manifest.assets.viewTemplates) {
expect(t.html).not.toMatch(/<h3>[^<]*<span data-slot="round_ref">/);
}
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
const prose = html.replace(/<style>[\s\S]*?<\/style>/, '').replace(/data-[a-z-]+="[^"]*"/g, '');
expect(prose).not.toMatch(/coverage_score|decision_chain/);
});
it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => { it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => {
expect( expect(
safeEval(doneOf('S6_ledger_update'), { safeEval(doneOf('S6_ledger_update'), {
@@ -197,6 +278,45 @@ describe('实测账本对应的剧本口径 —— 改口要连测试一起改',
* T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」 * T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」
* 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。 * 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。
*/ */
it('驳回意见先认归属:S4 剧本写死「照基座比对的观察认、不许改到没点名的行、认不出唯一就重发卡问」', () => {
// 2026-08-24 柴浩实测:意见点名「测试111」,模型把改动落到了 Itelma 那一行
const guide = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
expect(guide).toContain('先认归属');
expect(guide).toContain('意见按字面点名对上了');
expect(guide).toContain('不是把改动挪到别家');
expect(guide).toContain('不许猜');
});
it('姓名是必填:客户档案与联系人档案都不许无名落库(2026-08-24 两条空联系人 + 无名新客户档案)', () => {
const profile = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'profile');
const contact = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'contact');
expect(profile?.fields.find((f) => f.name === 'name')?.required).toBe(true);
expect(contact?.fields.find((f) => f.name === 'name')?.required).toBe(true);
// 行业/区域是建议档:AI 推得出就填,推不出橘胶囊留给人(确认屏上现在改得了)
expect(profile?.fields.find((f) => f.name === 'industry')?.expect).toBe(true);
expect(profile?.fields.find((f) => f.name === 'region')?.expect).toBe(true);
// 剧本写死:没有真实姓名的人不建联系人行;碰过的行 name 照现值带上
const guide = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
expect(guide).toContain('没有真实姓名的人不许建联系人行');
expect(guide).toContain('name');
expect(guide).toContain('照候选行抄');
expect(guide).toContain('不许编造一个查无出处的行业或地区');
});
it('计划只指向真人:占位句式已退役,查无联系人走数据录入卡(2026-08-24 用户裁定)', () => {
// 实测:模型把占位句式泛化到缺口节点,给 Itelma 编了两个顺延引用码 + 占位人名
const guide = COVERAGE_STEPS.find((x) => x.item === 'S3_plan')?.guide ?? '';
expect(guide).toContain('真实存在的编码');
expect(guide).toContain('数据录入卡');
expect(guide).toContain('"kind":"entry"');
// 占位引用码的句式一个不留 —— 给了可照抄的样板,模型就会把它用到不该用的地方
expect(guide).not.toContain('-00');
expect(guide).not.toContain('待识别');
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
expect(s6).not.toContain('-00');
expect(manifest.contract.prohibitions.some((x) => x.key === 'no-phantom-contact')).toBe(true);
});
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => { it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = COVERAGE_STEPS.find((s) => s.item === 'T_release'); const release = COVERAGE_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做'); expect(release?.guide).toContain('验收被打回重做');
@@ -315,3 +435,27 @@ function safeEval(expression: string, scope: Record<string, unknown>): boolean {
return false; return false;
} }
} }
/**
* **进度条的刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
* 给人确认是 4 步,这里要是 x/7 才对」)。从前粗分三段 → 屏上 x/4,
* 而文档开篇写的是「整体流程(7 步)」:同一件事两个数,人只能挑一个信。
*/
describe('阶段刻度 = 文档那 7 步', () => {
it('声明 6 段 —— 加上基座那一格「放行收口」,人看到的是 x/7', () => {
expect(COVERAGE_PHASES).toHaveLength(6);
});
it('一段一步:除「更新关系台账」外每段恰好一个计划项', () => {
const multi = COVERAGE_PHASES.filter((p) => p.items.length > 1).map((p) => p.name);
expect(multi).toEqual(['更新关系台账']); // 写回主数据 + 明细留痕,业务上是同一步⑥
});
it('四张卡对应的那四步各占一格 —— 「给人确认是 4 步」说的就是它们', () => {
const names = COVERAGE_PHASES.map((p) => p.name);
// 人确认的三格在声明里,第四格(放行收口)由基座加
expect(names).toContain('覆盖范围确认');
expect(names).toContain('经营计划审批');
expect(names).toContain('执行经营动作与回执');
});
});
@@ -35,11 +35,13 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<caseFileItem id="scope_confirmed_total" name="覆盖范围已核对确认(人环走完的凭据)"/> <caseFileItem id="scope_confirmed_total" name="覆盖范围已核对确认(人环走完的凭据)"/>
<caseFileItem id="crm_coverage_plan_rows" name="经营计划行数(基座现数)"/> <caseFileItem id="crm_coverage_plan_rows" name="经营计划行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_missing" name="经营计划缺必填的行数(基座现数)"/> <caseFileItem id="crm_coverage_plan_missing" name="经营计划缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_distinct_customer_code" name="经营计划盖住了几家客户(基座现数不同值)"/>
<caseFileItem id="plan_approved_total" name="经营计划已审批通过(仅通过时置一)"/> <caseFileItem id="plan_approved_total" name="经营计划已审批通过(仅通过时置一)"/>
<caseFileItem id="crm_coverage_receipt_rows" name="执行回执行数(基座现数)"/> <caseFileItem id="crm_coverage_receipt_rows" name="执行回执行数(基座现数)"/>
<caseFileItem id="crm_coverage_receipt_missing" name="执行回执缺必填的行数(基座现数)"/> <caseFileItem id="crm_coverage_receipt_missing" name="执行回执缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_rows" name="台账更新明细行数(基座现数)"/> <caseFileItem id="crm_coverage_ledger_update_rows" name="台账更新明细行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_missing" name="台账更新明细缺必填的行数(基座现数)"/> <caseFileItem id="crm_coverage_ledger_update_missing" name="台账更新明细缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_distinct_update_key" name="台账更新明细覆盖了几处不同位置(基座现数不同值)"/>
<caseFileItem id="customer_profile_rows" name="本单写下的客户档案行数(基座现数)"/> <caseFileItem id="customer_profile_rows" name="本单写下的客户档案行数(基座现数)"/>
<caseFileItem id="customer_profile_missing" name="客户档案缺必填的行数(基座现数)"/> <caseFileItem id="customer_profile_missing" name="客户档案缺必填的行数(基座现数)"/>
<caseFileItem id="customer_contact_rows" name="本单写下的客户联系人行数(基座现数)"/> <caseFileItem id="customer_contact_rows" name="本单写下的客户联系人行数(基座现数)"/>
@@ -60,12 +62,12 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!-- ③ 经营计划已起草且齐 —— 审批那一步的入场前提 --> <!-- ③ 经营计划已起草且齐 —— 审批那一步的入场前提 -->
<sentry id="s_plan_drafted"> <sentry id="s_plan_drafted">
<onPart id="op_pd" caseFileItemRef="crm_coverage_plan_rows"><standardEvent>update</standardEvent></onPart> <onPart id="op_pd" caseFileItemRef="crm_coverage_plan_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pd"><condition>crm_coverage_plan_rows &gt; 0 and crm_coverage_plan_missing = 0</condition></ifPart> <ifPart id="if_pd"><condition>crm_coverage_plan_rows &gt; 0 and crm_coverage_plan_missing = 0 and crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows</condition></ifPart>
</sentry> </sentry>
<!-- ④ 经营计划已审批通过 —— 交人执行那一步的入场前提 --> <!-- ④ 经营计划已审批通过 —— 交人执行那一步的入场前提 -->
<sentry id="s_plan_approved"> <sentry id="s_plan_approved">
<onPart id="op_pa" caseFileItemRef="plan_approved_total"><standardEvent>update</standardEvent></onPart> <onPart id="op_pa" caseFileItemRef="plan_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pa"><condition>plan_approved_total &gt; 0</condition></ifPart> <ifPart id="if_pa"><condition>plan_approved_total &gt; 0 and crm_coverage_receipt_rows = crm_coverage_plan_rows</condition></ifPart>
</sentry> </sentry>
<!-- <!--
⑤ 执行回执已交回且齐 —— 收口写台账那一步的入场前提。 ⑤ 执行回执已交回且齐 —— 收口写台账那一步的入场前提。
@@ -180,6 +182,17 @@ export const NARRATIVE = `§0 开案:**这一轮要经营哪些客户的关系,
**六步走**:识别候选 → 人核对范围 → 制定计划 → 人审批(可能来回改一轮)→ **六步走**:识别候选 → 人核对范围 → 制定计划 → 人审批(可能来回改一轮)→
人执行填回执 → 收口写台账 → 人签字放行。 人执行填回执 → 收口写台账 → 人签字放行。
**人核对过的候选清单是后面每一步的输入契约**:覆盖范围确认那一步人可能增删行
(轨迹里「就地修订成果」那条痕点名了增删的是谁),**他加的行与你识别的同等作数、
他删的行别再写回去** —— 缺口分析、经营计划、执行回执、收口写台账,每一步都以
台账现读的 \`crm_coverage.candidate\` 全表为准,不以你 S1 识别时的那份记忆为准。
经营计划必须一家不落(收步判据数这个闭合:计划盖住的客户数 = 候选清单行数)。
**计划与回执只指向真实存在的关键人**:\`crm_coverage.plan\` / \`receipt\` 的
contact_code 必须是 \`customer.contact\` 里已有的编码,contact_name 必须是真人姓名 ——
任何一步都不许自造联系人编码、不许拿占位称呼顶在人名格里。查无联系人的候选客户,
先在 S3 发数据录入卡把第一位关键人收进来(骨架见 S3 剧本),再排它的计划。
【本单可能用到的另一种卡 —— 新客户建档,长这样,只在 S1_identify_candidates 这一项里 【本单可能用到的另一种卡 —— 新客户建档,长这样,只在 S1_identify_candidates 这一项里
发生,\`customers\` 里出现对象形状的元素才发】新客户这一刻没有编码,**不能自己编一个** 发生,\`customers\` 里出现对象形状的元素才发】新客户这一刻没有编码,**不能自己编一个**
(客户编码是主数据的主键,编错一次后面每一单都跟着错——这条纪律与 A1 quote-intake (客户编码是主数据的主键,编错一次后面每一单都跟着错——这条纪律与 A1 quote-intake
@@ -383,7 +396,20 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
reads: ['crm_coverage.candidate', 'customer.profile', 'customer.contact'], reads: ['crm_coverage.candidate', 'customer.profile', 'customer.contact'],
view: 'coverage-plan', view: 'coverage-plan',
tools: ['crm-relationship-coverage.recompute-score', 'crm-relationship-coverage.rank-key-persons'], tools: ['crm-relationship-coverage.recompute-score', 'crm-relationship-coverage.rank-key-persons'],
doneWhen: 'crm_coverage_plan_rows > 0 and crm_coverage_plan_missing = 0', /*
* 覆盖闭合(2026-08-24 柴浩实测):只数「计划有没有行」的话,人在覆盖范围确认卡上
* 加的那家客户被漏掉后这一步照样自动收步 —— 计划盖住的客户数(distinct,基座数)
* 必须等于候选清单行数(人增删后的现数),一家不落这一步才算办完。
*/
doneWhen:
'crm_coverage_plan_rows > 0 and crm_coverage_plan_missing = 0 and ' +
'crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows and ' +
/*
* 回执骨架与计划一一对应(2026-08-24 柴浩实测:计划 7 行回执 6 行,人填完 6 条就能交卷,
* 到放行门才撞墙)—— 少一行骨架人就少一格可填,而提交闸只数已有行缺不缺必填,
* 数不出「少了一行」。这里与 S4 入场哨兵同一句,派回执单之前就得齐。
*/
'crm_coverage_receipt_rows = crm_coverage_plan_rows',
guide: `候选范围定了,**现在读现状、找缺口、排计划** —— 固定三拍,一拍一件事,别在同一拍里 guide: `候选范围定了,**现在读现状、找缺口、排计划** —— 固定三拍,一拍一件事,别在同一拍里
把「调工具」和「写台账」串在一起(**工具调用后同拍其余动作作废,结果要下一拍才看得见**; 把「调工具」和「写台账」串在一起(**工具调用后同拍其余动作作废,结果要下一拍才看得见**;
串了也是白串,只会多烧一拍)。这一步只需要调工具**各一次**,拿到结果后不要再调第二次。 串了也是白串,只会多烧一拍)。这一步只需要调工具**各一次**,拿到结果后不要再调第二次。
@@ -396,6 +422,11 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
候选范围毫无关系)——动手前先把候选清单的 \`customer_code\` 抄一遍,写计划时逐条核对 候选范围毫无关系)——动手前先把候选清单的 \`customer_code\` 抄一遍,写计划时逐条核对
是不是都在这张抄出来的清单里,不在的一律不许写。 是不是都在这张抄出来的清单里,不在的一律不许写。
**也一家都不能少** —— 抄名单抄的是台账现读的 \`crm_coverage.candidate\` **全表**,
不是你 S1 识别时记住的那几家:人核对时可能加了行(轨迹里那条「就地修订成果:新增」
点名了加的是谁),**人加的行与你识别的同等作数**。收步判据现在数这个闭合
(计划盖住的客户数 = 候选清单行数),漏一家这一步就收不了。
【第一拍:缺口分析,只调工具,别的什么都不写】把本轮全部候选客户(读 【第一拍:缺口分析,只调工具,别的什么都不写】把本轮全部候选客户(读
\`crm_coverage.candidate\`)一次性打包,调一次 \`crm_coverage.candidate\`)一次性打包,调一次
\`crm-relationship-coverage.recompute-score\`,入参: \`crm-relationship-coverage.recompute-score\`,入参:
@@ -421,6 +452,9 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
任何节点、白算了这一趟。空缺字段(如 \`influence\` 还没定过)照现有值填,是空字符串 任何节点、白算了这一趟。空缺字段(如 \`influence\` 还没定过)照现有值填,是空字符串
或 0 都行,工具认得。 或 0 都行,工具认得。
多家候选客户就都塞进同一个 \`customers\` 数组里,**一次调完,不要一家调一次**。 多家候选客户就都塞进同一个 \`customers\` 数组里,**一次调完,不要一家调一次**。
某家候选客户在 \`customer.contact\` 里一个联系人都没有(人核对时额外纳入的客户常是
这种)—— \`contacts\` 传空数组 \`[]\`,工具会如实给出「三个节点全是缺口」,
这正是要的答案,不是异常。
调完这一拍就结束(工具出参要下一拍才看得见)。 调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:排序,只调工具,别的什么都不写】把上一拍 \`results\` 里出现过的**所有联系人** 【第二拍:排序,只调工具,别的什么都不写】把上一拍 \`results\` 里出现过的**所有联系人**
@@ -436,6 +470,14 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
离这个节点最近的联系人**(按 \`decisionNode\` 匹配,匹配不上就挑影响力最高的那位) 离这个节点最近的联系人**(按 \`decisionNode\` 匹配,匹配不上就挑影响力最高的那位)
写一条「去覆盖这个节点」的动作。**\`gapNodes\` 全员缺口是正常结果,不是异常, 写一条「去覆盖这个节点」的动作。**\`gapNodes\` 全员缺口是正常结果,不是异常,
不需要回去重调工具确认。** 不需要回去重调工具确认。**
**计划表里的 contact_code 只许写 \`customer.contact\` 里真实存在的编码,
contact_name 只许写真人姓名** —— 不许为缺口节点自造顺延的引用码、不许拿占位称呼
顶在关键人那一格(实测走岔过:模型给两个缺口节点各编了一个顺延号、关键人一栏
填了占位称呼 —— 那不是计划,是把「还没认识人」伪装成「有人」;审批的人一眼就退)。
缺口节点的动作挂在**已知联系人**名下,「识别/引荐」写进动作内容,不写进人名。
计划表**一位联系人一行**(主键就是联系人编码):同一位联系人罩几个缺口节点,
几个动作并进**他那一行**的 action(分号连写),不为节点另开行。
引擎会核 contact_code 真的在 \`customer.contact\` 里 —— 编出来的号写不进去。
② **写 \`crm_coverage.plan\`,按第二拍的 rank_score 降序、一个关键人一行**(rows 收数组; ② **写 \`crm_coverage.plan\`,按第二拍的 rank_score 降序、一个关键人一行**(rows 收数组;
模版不排序,你必须已经按序写)。round_ref、contact_code(**必填**)、customer_code 模版不排序,你必须已经按序写)。round_ref、contact_code(**必填**)、customer_code
@@ -447,6 +489,25 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
\`"low"\`)、relationship_gap(**必填**,第二拍算出的数)、rank_score(**必填**,第二拍 \`"low"\`)、relationship_gap(**必填**,第二拍算出的数)、rank_score(**必填**,第二拍
算出的数)、target_date(**必填**,具体日期,没有时间点的建议不算数)。 算出的数)、target_date(**必填**,具体日期,没有时间点的建议不算数)。
**查无任何联系人的候选客户,先把人收进来,再排它的计划**(人核对时额外纳入的客户
常是这种 —— 该经营谁,加行的人自己知道;你不许替他编一个人):在调工具**之前**
给这样的客户各发一张数据录入卡,照这个骨架(一家一张、一单只发一次;
人确认那一下基座直接落草稿,你不经手):
{"kind":"entry","itemId":"S3_plan",
"ask":"新纳入的客户 <客户名> 还没有联系人档案,请录入这一轮要经营的第一位关键人",
"ledger":"customer.contact","key":{"customer_code":"<候选行的客户编码>"},
"fields":[
{"key":"name","label":"姓名","required":true},
{"key":"title","label":"职务"},
{"key":"decision_node","label":"决策链节点(bt-01 采购 / bt-02 技术 / bt-03 高层)"}
]}
联系人编码由基座续号,不进卡面。发完这一拍就结束(发卡挂起);人答回之后这家客户
就有真实联系人了(在 \`customer.contact\` 里看得到),照上面三拍走,计划行引用
**这位真人**的编码与姓名,动作写首访与关系建立(influence/relationship 还没首评,
排序入参按默认:influence 按职务估、没法估写 \`"low"\`,relationship 写 1)。
**不许因为查无联系人就把这家客户跳过** —— 跳过它,收步判据
(计划客户数 = 候选行数)就差一家,这一步永远收不了。
③ **同一拍,给每条计划配一行 \`crm_coverage.receipt\` 骨架**(这一步只写骨架,完成状态那几列 ③ **同一拍,给每条计划配一行 \`crm_coverage.receipt\` 骨架**(这一步只写骨架,完成状态那几列
留空,S5 那一步才由人填):round_ref、contact_code(与 plan 同一个,**必填**)、 留空,S5 那一步才由人填):round_ref、contact_code(与 plan 同一个,**必填**)、
customer_code(选填,照抄)、customer_name(选填,照抄 —— 执行回执工单的「对象」列长 customer_code(选填,照抄)、customer_name(选填,照抄 —— 执行回执工单的「对象」列长
@@ -455,8 +516,10 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
completion_status / result_summary / customer_feedback / key_person_change completion_status / result_summary / customer_feedback / key_person_change
**这一步一格都不写**。 **这一步一格都不写**。
④ 若某候选客户的 \`cust_type\`/\`bt_level\` 在 S1 那步没判出来,顺手补写 ④ 若某候选客户的 \`cust_type\`/\`bt_level\` **还空着**,顺手补写
\`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。 \`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。**两列都已有值的行一个字都不写**
—— 原值重写会被当空转拒(实测:这一句从前没写「还空着」,模型把有值的行
照抄了一遍,白吃一次拒)。
写完这一拍就结束(不必你 finish)——\`crm_coverage_plan_rows > 0 and 写完这一拍就结束(不必你 finish)——\`crm_coverage_plan_rows > 0 and
crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`, crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
@@ -494,20 +557,34 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
发完这一拍就结束了(发卡挂起)。 发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】 【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订 ① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见。**先认归属,再动笔**:
\`crm_coverage.plan\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。 意见里点了名(客户名 / 关键人名 / 编码)就拿那个名字与计划表逐行比对
(customer_name / contact_name / customer_code / contact_code 四列;轨迹里若有一条
「意见按字面点名对上了…」的观察,那是基座比对好的答案,**照它认,不自己另猜**)。
**改的必须是被点名的那一行** —— 点名的客户没有意见里说的那种联系人
(比如让去见某个职位的人,而这家的关键人还待识别),改的是这一行自己的动作内容
(把首访对象换成那个职位),**不是把改动挪到别家碰巧有这种联系人的行头上**;
名字对不上任何一行、或对上不止一行认不出唯一,**不许猜**:重新发卡,把
「你理解要改哪一行」写进 ask 请人确认(这算修订重提交,不算追问理由)。
认准之后**按这条意见**修订 \`crm_coverage.plan\`
(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由** ② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门, (那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:排序有没有明显不合理、 会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:排序有没有明显不合理、
有没有遗漏更紧要的缺口节点、理由那一句站不站得住,尽量改得更有说服力;复核后**至少要有 有没有遗漏更紧要的缺口节点、理由那一句站不站得住,尽量改得更有说服力;复核后**至少要有
一处实质性变化**(改排序、换动作、补理由,不能原样重发)。 一处实质性变化**(改排序、换动作、补理由,不能原样重发)。
③ **改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改** ③ **计划与回执骨架一一对应,改哪边都得同步**:加了一条计划就同拍给它加一行
\`crm_coverage.receipt\` 骨架(round_ref + contact_code,其余照抄计划,完成状态那几列留空);
撤了一条计划就 \`drop\` 它的回执骨架。引擎数「回执行数 = 计划行数」,差一行,执行单派不出去。
**改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改**
(同 PK——\`round_ref\`+\`contact_code\`——覆盖写)。receipt.action 是 S3 那一拍从 (同 PK——\`round_ref\`+\`contact_code\`——覆盖写)。receipt.action 是 S3 那一拍从
plan.action **抄**过来的展示副本(执行回执工单直接读它、只读展示),这里只改了 plan.action **抄**过来的展示副本(执行回执工单直接读它、只读展示),这里只改了
plan 不改 receipt,S5 那一步人看到的还是驳回前的旧动作——已经实测过:审批来回改了 plan 不改 receipt,S5 那一步人看到的还是驳回前的旧动作——已经实测过:审批来回改了
三稿,receipt 里摆的还是第一稿,推进到执行回执时对不上人刚通过的那一版。 三稿,receipt 里摆的还是第一稿,推进到执行回执时对不上人刚通过的那一版。
④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如 ④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与**改了哪一家哪位、改成了什么**
"已复核并修订(第2稿:调整了 CU-0142 的优先级,补充了理由)请再次审批"—— (用人读名:客户名 · 关键人名,别只给编码 —— 这句话是给人核对归属的,
他要一眼看出你改的是不是他点名的那一行),例如
"已复核并修订(第2稿:把 客户甲·采购经理 的动作改为下月带认证报告拜访)请再次审批"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。 措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。 **每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
⑤ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束 ⑤ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
@@ -608,9 +685,13 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
【第二拍:只写这两张,别的表这一拍不碰】 【第二拍:只写这两张,别的表这一拍不碰】
· **写 \`customer.contact\`**(对应联系人行,同 PK 覆盖写):**碰过的每一行, · **写 \`customer.contact\`**(对应联系人行,同 PK 覆盖写):**碰过的每一行,
\`influence\` / \`relationship\` / \`key_person\` 三列一列都不许缺**(基座数缺必填 \`name\` / \`influence\` / \`relationship\` / \`key_person\` 四列一列都不许缺**
只看本单写下的行 —— 你写了这一行却漏了其中一列,\`customer_contact_missing\` (基座数缺必填只看本单写下的行 —— 你写了这一行却漏了其中一列,
就大于零,放行门永远打不开):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、 \`customer_contact_missing\` 就大于零,放行门永远打不开;name 照现值抄一遍即可)。
**没有真实姓名的人不许建联系人行** —— 「计划要经营的岗位」不是人,节点缺口
留在 decision_chain 里如实标 covered:false,不落 customer.contact
(实测炸过:两条人名职务全空的"联系人"进了正式表,还标着关键人、亲密度3 ——
那是把计划冒充成了档案,后来只能人工删掉):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、
\`relationship\` 写推导后的新值(没变化照现值抄)、\`key_person\` 照这轮判断写; \`relationship\` 写推导后的新值(没变化照现值抄)、\`key_person\` 照这轮判断写;
**本单新建档的联系人**(录入卡只落了姓名/职务/邮箱)这三列由你首评: **本单新建档的联系人**(录入卡只落了姓名/职务/邮箱)这三列由你首评:
\`influence\` 按职务估(总监/VP 级 high、经理级 medium、其余 low)、 \`influence\` 按职务估(总监/VP 级 high、经理级 medium、其余 low)、
@@ -618,10 +699,25 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
\`role\`/\`decision_node\`/\`comm_pref\` 若这轮有新信息一并补; \`role\`/\`decision_node\`/\`comm_pref\` 若这轮有新信息一并补;
\`change_log\` 追加一句本轮变动(离职/调岗从回执的 \`key_person_change\` 抓, \`change_log\` 追加一句本轮变动(离职/调岗从回执的 \`key_person_change\` 抓,
没有变动不必写这一列)。 没有变动不必写这一列)。
· **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):\`coverage_score\` = 工具 · **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):**碰过的每一行把
\`name\` 照现值带上(必填,漏了放行门不开)**;\`coverage_score\` = 工具
算出的新分数(**必填**);\`decision_chain\` = 更新后的节点覆盖状态(**必填**, 算出的新分数(**必填**);\`decision_chain\` = 更新后的节点覆盖状态(**必填**,
整个数组重写,不是增量合并);\`interaction_timeline\` 追加本轮的互动条目(选填, 整个数组重写,不是增量合并);\`interaction_timeline\` 追加本轮的互动条目(选填,
没有新增互动的客户不必写这一列)。 没有新增互动的客户不必写这一列)。
**本轮新建档的客户(候选清单里人加的那家)要把档案填实,不许只落两个数**:
\`name\` / \`cust_type\` 照候选行抄(candidate 里人核对过的就是权威);
\`industry\` / \`region\` 从**已知信息**里分析 —— 来件正文、背景说明(note)、
客户类型、回执里人写的内容,推得出就填、推不出留空(空格会以「建议填」摆在
台账更新确认屏上,人补得了)—— **不许编造一个查无出处的行业或地区**。
· 回执若带回了**新认识的关键人**(姓名/职务在 \`result_summary\`/\`key_person_change\` 里),
给这家客户**新建**联系人行 —— \`contact_code\` 照声明的续号规则铸:
\`<customer_code>-两位序号\`,序号接在该客户名下现有联系人(查 \`customer.contact\`
全表)最大号之后,一位都没有就从 \`-01\` 起;姓名/职务照回执写,首评三列
(influence/relationship/key_person)按上面「本单新建档」那条规则给。
回执没带回人,就什么都不建,如实留缺口。
**人工新增的候选客户在 \`customer.profile\` 里还没有档案的,这一拍补一行**
(customer_code/name 照候选行抄,coverage_score 与 decision_chain 照工具出参写)——
没有档案行,这家客户的覆盖度就没有落点。
· **把办完的候选行状态置 \`covered\`**(写 \`crm_coverage.candidate\`,同 PK 覆盖写, · **把办完的候选行状态置 \`covered\`**(写 \`crm_coverage.candidate\`,同 PK 覆盖写,
只带 status 一列):回执里有 \`done\` 动作的客户置 ASCII \`covered\`; 只带 status 一列):回执里有 \`done\` 动作的客户置 ASCII \`covered\`;
全部 \`skipped\` 的留 \`pending\` 如实 —— 清单上那枚状态徽是给人看的, 全部 \`skipped\` 的留 \`pending\` 如实 —— 清单上那枚状态徽是给人看的,
@@ -638,7 +734,9 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
view: 'coverage-ledger-update', view: 'coverage-ledger-update',
tools: [], tools: [],
doneWhen: doneWhen:
'crm_coverage_ledger_update_rows > 0 and crm_coverage_ledger_update_missing = 0 and customer_profile_missing = 0 and customer_contact_missing = 0', 'crm_coverage_ledger_update_rows > 0 and crm_coverage_ledger_update_missing = 0 and ' +
'crm_coverage_ledger_update_distinct_update_key = crm_coverage_ledger_update_rows and ' +
'customer_profile_missing = 0 and customer_contact_missing = 0',
guide: `主数据已在上一步写回,**这一步只写两张表、一拍连做**:先写更新明细 guide: `主数据已在上一步写回,**这一步只写两张表、一拍连做**:先写更新明细
(它最费篇幅,放在串首),再写结论。**上一步那三张表(contact/profile/candidate) (它最费篇幅,放在串首),再写结论。**上一步那三张表(contact/profile/candidate)
这一拍不许再写一遍** —— 再写一遍原值只会被当空转拒掉。 这一拍不许再写一遍** —— 再写一遍原值只会被当空转拒掉。
@@ -649,8 +747,12 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
如"客户主台账·关系覆盖度")、ledger_address(**必填**,机器地址,如 如"客户主台账·关系覆盖度")、ledger_address(**必填**,机器地址,如
"customer.profile.coverage_score")、customer_code(**必填**)、contact_code "customer.profile.coverage_score")、customer_code(**必填**)、contact_code
(选填,客户级字段留空)、field_label(**必填**,人读列名)、before_value(**必填**, (选填,客户级字段留空)、field_label(**必填**,人读列名)、before_value(**必填**,
无值写字面"(无)")、after_value(**必填**)、evidence_ref(**必填**,引用具体回执 无值写字面"(无)")、after_value(**必填**)、evidence_ref(**必填**,**用人话**引用具体
事实,如"回执 contact_code=CU-0142-01:已完成拜访,亲密度由3提升至4")。 回执事实,如"Itelma·Ekaterina 的回执:已完成拜访,亲密度由3提升至4" —— 写客户名与
人名,**不写联系人编码、不带任何机器键前缀**:这一列是摆给签字的人看的)、
update_key(**必填**,更新位置键,按「台账地址|客户编码|联系人编码|字段」四段用竖线
拼,客户级字段第三段留空,如"customer.profile.coverage_score|CU-0142||关系覆盖度"
—— 同一处位置本轮只能有一行,引擎数这个)。
**没有真实变化的字段不必开一行** —— 明细表只记「真改了什么」。 **没有真实变化的字段不必开一行** —— 明细表只记「真改了什么」。
每一处真实变化的依据都在回执与上一步写下的新值里,对照着写,别凭记忆。 每一处真实变化的依据都在回执与上一步写下的新值里,对照着写,别凭记忆。
· **写 \`crm_coverage.verdict\`**(同一拍接着写):ledger_update_confirmed_total = 1、 · **写 \`crm_coverage.verdict\`**(同一拍接着写):ledger_update_confirmed_total = 1、
@@ -669,7 +771,10 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
view: 'coverage-ledger-update', view: 'coverage-ledger-update',
tools: [], tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */ /* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `台账更新明细齐了、主数据缺项也补齐了,**可以走放行了** guide: `台账更新确认那一屏摆着三样:更新明细(你的摘要)+ **两张落库预览**
(将写入的客户档案 / 联系人档案,人在上面补字段、加联系人都是合法的兜底 ——
他补的值、他加的行已在草稿层,**你不许再 write 覆盖**;判齐与哨兵读的是现值,
人补完门自己开)。台账更新明细齐了、主数据缺项也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。 (T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 —— 【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
@@ -682,10 +787,17 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
· 他说**台账更新没依据 / 数不对** → 改 \`crm_coverage.ledger_update\` 的 · 他说**台账更新没依据 / 数不对** → 改 \`crm_coverage.ledger_update\` 的
\`evidence_ref\`(点名是哪条回执支持它)或 \`before_value\`/\`after_value\`。 \`evidence_ref\`(点名是哪条回执支持它)或 \`before_value\`/\`after_value\`。
· 他说**结论没说清** → 改 \`crm_coverage.verdict.summary\`。 · 他说**结论没说清** → 改 \`crm_coverage.verdict.summary\`。
· 放行门说**「每条计划都收到执行回执」不成立**(回执比计划少行)→ **不许删计划来凑数**
(删了「每家候选都进了计划」立刻不成立,两堵墙对着撞);补上缺的那行回执骨架
(round_ref + contact_code,照计划抄动作),再发一张执行工单让人把那条回执填回来 ——
回执是人办事的凭据,只有人能填。
· 他**没写具体意见**(只点了重做)→ 自己把成果复核一遍,挑最站不住的那处改实 · 他**没写具体意见**(只点了重做)→ 自己把成果复核一遍,挑最站不住的那处改实
(理由最空的那条计划、依据最含糊的那条更新),**至少改对一处**; (理由最空的那条计划、依据最含糊的那条更新),**至少改对一处**;
实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。 实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是数**:同一行覆盖写,别新开行、别把行数改了 —— 那会把收步判据弄假。 **改的是值,不是数**:同一行覆盖写,别新开行、别把行数改了 —— 那会把收步判据弄假。
**明细里过期的旧记录要撤掉,不留旧账**(2026-08-24 用户当场指出):重做后某条更新
不再成立(数变了、结论变了),就用 \`drop\` 撤掉那一行再写新的,或直接同 seq 覆盖写;
同一处位置(update_key 相同)出现两条,判据「同一处字段只留一条更新」当场拦。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做: 【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
@@ -708,10 +820,28 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
/** /**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段; * 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。 * 放行那一格由基座统一加(`CLOSING_PHASE`),所以声明 6 段、人看到的是 **x/7**。
*
* **刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
* 给人确认是 4 步,这里要是 x/7 才对」)。从前这里按「归口」粗分成三段,
* 于是屏上是 x/4 —— 而 `E04-活动流程与工单说明.md` 开篇写的是「整体流程(7 步)」:
* 人对着文档数是 7,对着进度条数是 4,**同一件事两个数**。
* 现在一段对一步,与文档那张表逐行对齐:
*
* ① 启动与覆盖范围确认 → 识别候选客户(AI 汇总)+ 覆盖范围确认(**人**,执行工单1)
* ②③④ 读现状 / 缺口分析 / 制定计划 → 制定关系经营计划(AI 算)
* ④ 计划审批 → 经营计划审批(**人**,验收工单1)
* ⑤ 执行经营动作 → 执行与回执(**人**,执行工单2)
* ⑥ 更新关系台账 → 写回主数据 + 台账明细留痕
* ⑦ 复盘与完结(门禁) → 放行收口(**人**,验收工单2,基座那一格)
*
* 四张卡就是那 4 个「给人确认」的刻度 —— 其余三格是 AI 在办事。
*/ */
export const COVERAGE_PHASES = [ export const COVERAGE_PHASES = [
{ name: '确认覆盖范围', items: ['S1_identify_candidates', 'S2_scope_confirm'] }, { name: '识别候选客户', items: ['S1_identify_candidates'] },
{ name: '制定与审批计划', items: ['S3_plan', 'S4_plan_approval'] }, { name: '覆盖范围确认', items: ['S2_scope_confirm'] },
{ name: '执行与更新台账', items: ['S5_execute_receipt', 'S6_writeback', 'S6_ledger_update'] }, { name: '缺口分析与制定计划', items: ['S3_plan'] },
{ name: '经营计划审批', items: ['S4_plan_approval'] },
{ name: '执行经营动作与回执', items: ['S5_execute_receipt'] },
{ name: '更新关系台账', items: ['S6_writeback', 'S6_ledger_update'] },
]; ];
@@ -40,7 +40,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'E04 · 关系覆盖与关键人经营', name: 'E04 · 关系覆盖与关键人经营',
packageVersion: '2.0.9', packageVersion: '2.1.9',
category: '客户关系经营', category: '客户关系经营',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-crm-relationship-coverage', checksum: 'dev-checksum-crm-relationship-coverage',
@@ -185,6 +185,18 @@ export const manifest: ActivityManifest = {
level: 'L2', level: 'L2',
predicate: `${D}_plan_rows > 0 and ${D}_plan_missing = 0`, predicate: `${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
}, },
{
/*
* **覆盖闭合**(2026-08-24 柴浩实测补):人核对后的候选清单是后续每一步的输入契约 ——
* 他加的那家客户,计划里必须有它。两边都是基座数:计划里有几家不同客户
* (`distinct`,声明在 plan.customer_code 上)= 候选清单几行(增删后现数)。
* S3 的收步判据与 S4 的入场哨兵同一句 —— 这里是第二道墙,防的是绕过收步的路。
*/
key: 'plan-covers-scope',
label: '每家确认纳入的候选客户都进了经营计划(含人核对时新增的,一家不落)',
level: 'L2',
predicate: `${D}_plan_distinct_customer_code = ${D}_candidate_rows`,
},
{ {
key: 'receipt-matches-plan', key: 'receipt-matches-plan',
label: '每条计划都收到执行回执,且完成状态、结果摘要、关键人变动均已填写', label: '每条计划都收到执行回执,且完成状态、结果摘要、关键人变动均已填写',
@@ -197,6 +209,13 @@ export const manifest: ActivityManifest = {
level: 'L2', level: 'L2',
predicate: `${D}_ledger_update_rows > 0 and ${D}_ledger_update_missing = 0`, predicate: `${D}_ledger_update_rows > 0 and ${D}_ledger_update_missing = 0`,
}, },
{
/* 2026-08-24:重做后过期的旧明细不许留 —— 同一处字段只能有一条更新 */
key: 'no-duplicate-update',
label: '同一处字段只留一条更新(重做后过期的旧记录已撤掉)',
level: 'L2',
predicate: `${D}_ledger_update_distinct_update_key = ${D}_ledger_update_rows`,
},
{ {
key: 'no-missed-update', key: 'no-missed-update',
label: "每条「已完成」的执行回执至少有一条对应的台账更新(没有漏更)", label: "每条「已完成」的执行回执至少有一条对应的台账更新(没有漏更)",
@@ -218,6 +237,8 @@ export const manifest: ActivityManifest = {
{ key: 'no-guess-contact', statement: '决策链节点对应哪位联系人不确定时不得臆断 —— 匹配不上就留空,那就是缺口' }, { key: 'no-guess-contact', statement: '决策链节点对应哪位联系人不确定时不得臆断 —— 匹配不上就留空,那就是缺口' },
{ key: 'no-skip-evidence', statement: '台账更新明细每条都要有回执依据,不得凭空推算写入主数据' }, { key: 'no-skip-evidence', statement: '台账更新明细每条都要有回执依据,不得凭空推算写入主数据' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' }, { key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
/* 2026-08-24:模型给缺口节点自造过顺延引用码 + 占位人名 —— 计划里「有人」必须真的有人 */
{ key: 'no-phantom-contact', statement: '计划与回执只许引用 customer.contact 已有的联系人编码与真人姓名;不得自造编码或以占位称呼顶格 —— 查无联系人先以数据录入卡收人' },
], ],
authorization: { authorization: {
discretion: [], discretion: [],
@@ -263,9 +284,16 @@ export const manifest: ActivityManifest = {
* 说明书让录入卡落什么,声明里就得有什么(物理列平台基线本就有,声明是权责面)。 * 说明书让录入卡落什么,声明里就得有什么(物理列平台基线本就有,声明是权责面)。
* 与 A1 的 name 声明并存(add-column 合并,同名同型不冲突)。 * 与 A1 的 name 声明并存(add-column 合并,同名同型不冲突)。
*/ */
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' }, /*
{ name: 'industry', type: 'text', primaryKey: false, masked: false, label: '所属行业' }, * name 必填(2026-08-24 柴浩实测:新客户档案落库连名字都是空的 —— 候选行里
{ name: 'region', type: 'text', primaryKey: false, masked: false, label: '区域' }, * 明明写着,模型没抄)。`customer_profile_missing` 本就在 S6 收步与放行哨兵里,
* 标上它,「无名档案」从此进不了正式表;代价是碰过的客户行要把 name 照现值带上
* (剧本写死)。industry/region 标建议填:AI 从来件与背景说明推得出就填,
* 推不出留橘胶囊给人 —— 台账更新确认屏上现在改得了。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, required: true, label: '客户名称' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, expect: true, label: '所属行业' },
{ name: 'region', type: 'text', primaryKey: false, masked: false, expect: true, label: '区域' },
/* /*
* L-02 文档明列的字段,权威维护者是尚未建成的 T02(客户建档评估) —— 平时 * L-02 文档明列的字段,权威维护者是尚未建成的 T02(客户建档评估) —— 平时
* E04 不写它;唯一的例外是"新客户建立合作"这一刻,E04 代替不存在的 T02 * E04 不写它;唯一的例外是"新客户建立合作"这一刻,E04 代替不存在的 T02
@@ -296,8 +324,14 @@ export const manifest: ActivityManifest = {
* (静默退化成留空,不报错)。 * (静默退化成留空,不报错)。
*/ */
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' }, { name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
/* 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏 */ /*
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '姓名' }, * 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏。
* name 必填(2026-08-24 柴浩实测:早前一轮模型替「计划要经营的岗位」凭空建了
* 两条无名联系人档,人名职务全空、却标着关键人/亲密度3 —— 那不是联系人,是把
* 计划里的节点冒充成了人)。`customer_contact_missing` 本就在收步与放行哨兵里,
* 标上它,无名建档从此进不了正式表;代价是碰过的联系人行把 name 照现值带上。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, required: true, label: '姓名' },
{ name: 'title', type: 'text', primaryKey: false, masked: false, label: '职务' }, { name: 'title', type: 'text', primaryKey: false, masked: false, label: '职务' },
{ name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' }, { name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' },
{ name: 'role', type: 'text', primaryKey: false, masked: false, expect: true, label: '角色' }, { name: 'role', type: 'text', primaryKey: false, masked: false, expect: true, label: '角色' },
@@ -346,8 +380,21 @@ export const manifest: ActivityManifest = {
label: '关键人经营计划', label: '关键人经营计划',
fields: [ fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' }, { name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, label: '联系人编码' }, /*
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, label: '客户编码' }, * `refersTo` —— 计划只许指向真实存在的联系人(2026-08-24,措辞治不了的结构治):
* 模型三次给缺口节点自造顺延引用码 + 占位人名,反例描述/说明书纪律/禁令
* 全拦不住;现在基座在写入那一刻查 customer.contact(正式 + 本单草稿),
* 查无此码当场拒并指路(先发数据录入卡收人)。receipt 同款。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, refersTo: 'customer.contact.contact_code', label: '联系人编码' },
/*
* `distinct: true` —— 基座数「计划里有几家不同客户」(`crm_coverage_plan_distinct_customer_code`)。
* 它是覆盖闭合判据的落点(2026-08-24 柴浩实测:人在覆盖范围确认卡上加了一家客户,
* 行落进了台账,而计划/回执/收口把它从头无视到尾 —— 收步与放行只数「计划有没有行」,
* 「盖没盖住每一家候选」没有任何一个基座数说得出来)。计划行数 ≠ 客户数
* (一家客户几位关键人就几行),所以必须另数这一个。
*/
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, distinct: true, label: '客户编码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户名称' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户名称' },
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' }, { name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' },
{ name: 'action', type: 'text', primaryKey: false, masked: false, required: true, label: '建议动作' }, { name: 'action', type: 'text', primaryKey: false, masked: false, required: true, label: '建议动作' },
@@ -385,7 +432,7 @@ export const manifest: ActivityManifest = {
label: '经营计划执行回执', label: '经营计划执行回执',
fields: [ fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' }, { name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, label: '联系人编码(与计划一一对应)' }, { name: 'contact_code', type: 'text', primaryKey: true, masked: false, refersTo: 'customer.contact.contact_code', label: '联系人编码(与计划一一对应)' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户编码' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户编码' },
/* 目标件的「对象」列长「AvtoKom · 产品 VP」—— 客户名与人名都要在,S3 从计划抄 */ /* 目标件的「对象」列长「AvtoKom · 产品 VP」—— 客户名与人名都要在,S3 从计划抄 */
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称(承继自计划,只读展示)' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称(承继自计划,只读展示)' },
@@ -430,7 +477,15 @@ export const manifest: ActivityManifest = {
{ name: 'field_label', type: 'text', primaryKey: false, masked: false, required: true, label: '字段(人读列名)' }, { name: 'field_label', type: 'text', primaryKey: false, masked: false, required: true, label: '字段(人读列名)' },
{ name: 'before_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新前(无值写"(无)")' }, { name: 'before_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新前(无值写"(无)")' },
{ name: 'after_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新后' }, { name: 'after_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新后' },
{ name: 'evidence_ref', type: 'text', primaryKey: false, masked: false, required: true, label: '依据(引用具体回执事实)' }, { name: 'evidence_ref', type: 'text', primaryKey: false, masked: false, required: true, label: '依据(用人话引用回执事实)' },
/*
* 更新位置键(2026-08-24 用户:「重做后老的变更记录应该删除」):主键是序号,
* 同一处字段(同一张表·同一客户·同一联系人·同一字段)重做时若新开一行,旧行就留在
* 明细里骗人 —— SQ-0031 的覆盖度一度同时挂着 50→50 与 50→100 两条。
* 这一列由模型按「台账地址|客户编码|联系人编码|字段」拼,`distinct` 让基座数
* 「有几处不同位置」;判据钉 distinct = rows —— 同一处只留一条。
*/
{ name: 'update_key', type: 'text', primaryKey: false, masked: false, required: true, distinct: true, label: '更新位置键(台账地址|客户|联系人|字段)' },
], ],
}, },
{ {
@@ -61,8 +61,9 @@ export const COVERAGE_SKILLS: Skill[] = [
applicability: '执行回执收齐之后,要把结果写回 L-02/L-08 主数据时。', applicability: '执行回执收齐之后,要把结果写回 L-02/L-08 主数据时。',
method: [ method: [
'每一条更新都要能在回执里找到对应的事实 —— `evidence_ref` 不是可选的装饰,', '每一条更新都要能在回执里找到对应的事实 —— `evidence_ref` 不是可选的装饰,',
'是验收判据①的唯一抓手:写「回执 contact_code=CU-0142-01:已完成拜访,', '是验收判据①的唯一抓手:写「Itelma·Ekaterina 的回执:已完成拜访,亲密度由3提升至4」',
'亲密度由3提升至4」这种能溯源的话,不写「综合判断」这类查无实据的话。', '这种能溯源的**人话**(客户名·人名,不写编码、不带机器键前缀),',
'不写「综合判断」这类查无实据的话。',
'', '',
'覆盖度 `coverage_score` 收口时按执行后的最新数据重算一次(调', '覆盖度 `coverage_score` 收口时按执行后的最新数据重算一次(调',
'`crm-relationship-coverage.recompute-score`),不要凭回执的完成条数估一个大概值 ——', '`crm-relationship-coverage.recompute-score`),不要凭回执的完成条数估一个大概值 ——',
@@ -171,6 +171,11 @@ html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
.proto-root .jc-tag.v-changed { color: var(--danger); } .proto-root .jc-tag.v-changed { color: var(--danger); }
.proto-root .jc-tag.v-unchanged::after { content: "无变动"; } .proto-root .jc-tag.v-unchanged::after { content: "无变动"; }
.proto-root .jc-tag.v-unchanged { color: var(--ink-4); } .proto-root .jc-tag.v-unchanged { color: var(--ink-4); }
/* 关键人标记(boolean 列):值是 true/false,人读字由这里出 —— 与其余枚举徽同一机制 */
.proto-root .jc-tag.v-true::after { content: "是"; }
.proto-root .jc-tag.v-true { color: var(--ok); }
.proto-root .jc-tag.v-false::after { content: "否"; }
.proto-root .jc-tag.v-false { color: var(--ink-4); }
/* /*
* **覆盖深度同一坑的另一半**(2026-08-21 用户:「编辑的下拉框是中文,显示是英文」)—— * **覆盖深度同一坑的另一半**(2026-08-21 用户:「编辑的下拉框是中文,显示是英文」)——
* depth 本身不含中文(说明书写死 ASCII deep/standard/light,不撞 classToken 那道墙), * depth 本身不含中文(说明书写死 ASCII deep/standard/light,不撞 classToken 那道墙),
@@ -219,7 +224,8 @@ const doc = (input: {
tag: string; tag: string;
kicker: string; kicker: string;
title: string; title: string;
titleRef: string; /** 标题旁的单号 —— 2026-08-24 用户定:不摆(页脚已有),留作可选 */
titleRef?: string;
/** 目标件 side 第二行:静态步骤文案(哪份工单盖哪几步是写清单时定死的) */ /** 目标件 side 第二行:静态步骤文案(哪份工单盖哪几步是写清单时定死的) */
step: string; step: string;
state: string; state: string;
@@ -233,7 +239,7 @@ const doc = (input: {
<div class="jc-rp-badge">${input.badge}</div> <div class="jc-rp-badge">${input.badge}</div>
<div class="jc-rp-tit"> <div class="jc-rp-tit">
<em>${input.kicker}</em> <em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3> <h3>${input.title}${input.titleRef === undefined ? '' : ` <span data-slot="${input.titleRef}"></span>`}</h3>
<div class="jc-rp-sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="jc-rp-taskid" data-slot="case:taskId" data-blank></span></div> <div class="jc-rp-sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="jc-rp-taskid" data-slot="case:taskId" data-blank></span></div>
</div> </div>
<div class="jc-rp-side"> <div class="jc-rp-side">
@@ -314,7 +320,6 @@ export const COVERAGE_SCOPE = doc({
tag: '执行工单', tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 1/2', kicker: 'E04 关系覆盖与关键人经营 · 执行工单 1/2',
title: '覆盖范围确认', title: '覆盖范围确认',
titleRef: 'round_ref',
step: '步骤 ① 启动与覆盖范围确认', step: '步骤 ① 启动与覆盖范围确认',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_candidate_rows"></b> 家候选</span>`, state: `<span class="jc-rp-wait">共 <b data-slot="${D}_candidate_rows"></b> 家候选</span>`,
body: body:
@@ -387,7 +392,6 @@ export const COVERAGE_PLAN = doc({
tag: '验收工单', tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 1/2', kicker: 'E04 关系覆盖与关键人经营 · 验收工单 1/2',
title: '关系经营计划审批', title: '关系经营计划审批',
titleRef: 'round_ref',
step: '步骤 ②③④ 产出', step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_plan_rows"></b> 位关键人 · 按影响力 × 亲密度缺口排序</span>`, state: `<span class="jc-rp-wait">共 <b data-slot="${D}_plan_rows"></b> 位关键人 · 按影响力 × 亲密度缺口排序</span>`,
body: body:
@@ -461,7 +465,6 @@ export const COVERAGE_RECEIPT = doc({
tag: '执行工单', tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 2/2', kicker: 'E04 关系覆盖与关键人经营 · 执行工单 2/2',
title: '经营计划执行回执', title: '经营计划执行回执',
titleRef: 'round_ref',
step: '步骤 ⑤⑥ 执行经营动作', step: '步骤 ⑤⑥ 执行经营动作',
state: `<span class="jc-rp-wait warn">待回执 <b data-slot="${D}_receipt_missing"></b> / <b data-slot="${D}_receipt_rows"></b> 项</span>`, state: `<span class="jc-rp-wait warn">待回执 <b data-slot="${D}_receipt_missing"></b> / <b data-slot="${D}_receipt_rows"></b> 项</span>`,
body: body:
@@ -517,6 +520,84 @@ const LEDGER_UPDATE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
</tbody> </tbody>
</table></div>`; </table></div>`;
/*
* ==================== 落库预览两张表(2026-08-24 用户定)====================
* 「没有把要更新的台账展示全」:更新明细是模型写的**摘要**,而真正落库的是
* customer.profile / customer.contact 的整行 —— 签字的人要看的是**将要写进正式表的
* 东西本身**,不是转述。两张表直接摆本单将落的行,且**可编辑**:AI 分析填不出的
* 字段(行业/区域)人在这儿补,AI 没建上的联系人人在这儿加一行(联系人编码留空,
* 由系统按「客户编码-两位序号」续号 —— 自增列不问人,件上同规)。
*/
const PROFILE_LANDING_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:100px">客户编号</th>
<th class="req" style="min-width:150px">客户名称</th>
<th class="sug" style="min-width:110px">客户类型</th>
<th class="sug" style="min-width:120px">所属行业</th>
<th class="sug" style="min-width:110px">区域</th>
<th style="width:90px;min-width:90px">覆盖度</th>
</tr></thead>
<tbody data-rows="touched:customer.profile" data-edit="customer.profile"><tr>
<td class="jc-rn"></td>
<td data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="req" data-h="客户名称"><span class="jc-vcell cust" data-slot="name" data-col="name" data-blank></span></td>
<td class="sug" data-h="客户类型"><span class="jc-vcell" data-slot="cust_type" data-col="cust_type" data-blank></span></td>
<td class="sug" data-h="所属行业"><span class="jc-vcell" data-slot="industry" data-col="industry" data-blank></span></td>
<td class="sug" data-h="区域"><span class="jc-vcell" data-slot="region" data-col="region" data-blank></span></td>
<td class="dimempty" data-h="覆盖度"><span class="jc-vcell mono" data-slot="coverage_score" data-blank></span></td>
</tr></tbody>
</table></div>`;
const CONTACT_LANDING_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:96px">客户编号</th>
<th style="min-width:104px">联系人编码</th>
<th class="req" style="min-width:120px">姓名</th>
<th class="sug" style="min-width:100px">职务</th>
<th class="sug" style="width:150px;min-width:150px">决策链节点</th>
<th class="req" style="width:96px;min-width:96px">影响力</th>
<th class="req" style="width:96px;min-width:96px">亲密度</th>
<th class="req" style="width:96px;min-width:96px">关键人</th>
</tr></thead>
<tbody data-rows="touched:customer.contact" data-edit="customer.contact"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="customer.contact" title="撤掉这一行">×</button></td>
<td data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="dimempty" data-h="联系人编码"><span class="jc-vcell mono" data-slot="contact_code" data-col="contact_code" data-blank></span></td>
<td class="req" data-h="姓名"><span class="jc-vcell cust" data-slot="name" data-col="name" data-blank></span></td>
<td class="sug" data-h="职务"><span class="jc-vcell" data-slot="title" data-col="title" data-blank></span></td>
<td class="sug" data-h="决策链节点">
<select class="jc-cell" data-col="decision_node">
<option value=""></option>
<option value="bt-01">BT-01 · 采购/供应链</option>
<option value="bt-02">BT-02 · 技术</option>
<option value="bt-03">BT-03 · 高层</option>
</select>
<span class="jc-tag" data-class-of="decision_node"></span>
</td>
<td class="req" data-h="影响力">
<select class="jc-cell" data-col="influence" required>
<option value=""></option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<span class="jc-tag" data-class-of="influence"></span>
</td>
<td class="req" data-h="亲密度"><span class="jc-vcell mono" data-slot="relationship" data-col="relationship" data-blank></span></td>
<td class="req" data-h="关键人">
<select class="jc-cell" data-col="key_person" required>
<option value=""></option>
<option value="true">是</option>
<option value="false">否</option>
</select>
<span class="jc-tag" data-class-of="key_person"></span>
</td>
</tr></tbody>
</table></div>`;
/* /*
* 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 L4 判据 * 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 L4 判据
* 让签字的人核「有没有依据 / 有没有张冠李戴」,而 evidence_ref / customer_code 从前 * 让签字的人核「有没有依据 / 有没有张冠李戴」,而 evidence_ref / customer_code 从前
@@ -528,24 +609,57 @@ export const COVERAGE_LEDGER_UPDATE = doc({
tag: '验收工单', tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 2/2', kicker: 'E04 关系覆盖与关键人经营 · 验收工单 2/2',
title: '关系台账更新确认', title: '关系台账更新确认',
titleRef: 'round_ref',
step: '步骤 ⑥⑦ 更新台账', step: '步骤 ⑥⑦ 更新台账',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_ledger_update_rows"></b> 项更新</span>`, state: `<span class="jc-rp-wait">共 <b data-slot="${D}_ledger_update_rows"></b> 项更新</span>`,
body: body:
part('AI 结论说明', 'AI 收到回执后做了哪些更新') + part('AI 结论说明', 'AI 收到回执后做了哪些更新') +
`<div class="jc-derive"> `<div class="jc-derive">
<p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度(coverage_score)、更新决策链地图(decision_chain)、 <p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度、更新决策链地图、
更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。 更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。
本轮覆盖结论:<span data-slot="summary" data-blank></span></p> 本轮覆盖结论:<span data-slot="summary" data-blank></span></p>
</div>` + </div>` +
part('验收判据', '你据此判断「通过 / 驳回」') + part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次台账更新是否无误,请逐项核对', [ criteria('判断这次台账更新是否无误,请逐项核对', [
'<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);', '<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如关系覆盖度上升对应哪几次拜访);',
'<b>数值是否合理</b>:coverage_score、亲密度等数值变化幅度与回执是否吻合;', '<b>数值是否合理</b>:关系覆盖度、亲密度等数值变化幅度与回执是否吻合;',
'<b>有无漏更或误更</b>:该更新的字段都更新了、不该动的没动。', '<b>有无漏更或误更</b>:对照下方「将写入的客户档案 / 联系人档案」两张落库预览 —— 该更新的都在、不该动的没动;缺的字段、漏建的联系人可直接在表上补。',
]) + ]) +
part('拟更新的台账', '只读展示') + part('更新明细', 'AI 的更新摘要,逐条带依据') +
LEDGER_UPDATE_TABLE + LEDGER_UPDATE_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。'), part('将写入的客户档案', '落库预览 · 字段可就地补改') +
`<div class="jc-gapbar" data-class-of="customer_profile_missing">
<span><b>客户档案还差 <span data-slot="customer_profile_missing"></span> 行必填没齐</b>
—— <span data-slot="customer_profile_missing_keys"></span>。补齐了这一单才放行得了。</span>
</div>` +
editGate(
'customer.profile',
`<b>本轮将写入 <span data-slot="customer_profile_rows"></span> 行客户档案</b>,点「编辑」补改字段`,
) +
`<div class="jc-editbar">
<span>这就是签字后要进正式表的行。<b>客户名称</b>必填;<b>客户类型 / 行业 / 区域</b>是建议档
—— AI 从来件里推得出的已经填上,推不出的橘胶囊留给你。覆盖度是算出来的数,改不了。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
PROFILE_LANDING_TABLE +
part('将写入的联系人档案', '落库预览 · 可补改,也可加行') +
`<div class="jc-gapbar" data-class-of="customer_contact_missing">
<span><b>联系人档案还差 <span data-slot="customer_contact_missing"></span> 行必填没齐</b>
—— <span data-slot="customer_contact_missing_keys"></span>。补齐了这一单才放行得了。</span>
</div>` +
editGate(
'customer.contact',
`<b>本轮将写入 <span data-slot="customer_contact_rows"></span> 行联系人档案</b>,点「编辑」补改或加行`,
) +
`<div class="jc-editbar">
<span><b>姓名 / 影响力 / 亲密度 / 关键人</b>是必填;执行中结识了新的关键人而 AI 没建上档的,
在这儿<b>加一行</b>——联系人编码留空,由系统按「客户编码-两位序号」续号,不用你编。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
CONTACT_LANDING_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="customer.contact">+ 加一行(补录联系人)</button>
<em>新加行填客户编号与姓名(必填);联系人编码留空由系统续号。</em>
</div>` +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。上面三张表就是签字后落库的全部内容。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'), foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'),
}); });
+53
View File
@@ -1962,6 +1962,8 @@ export declare const installActivityRoute: {
column: z.ZodString; column: z.ZodString;
value: z.ZodString; value: z.ZodString;
}, z.core.$strip>>; }, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{ listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -2259,6 +2261,55 @@ export declare const listLedgerObjectsRoute: {
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>; }, z.core.$strip>;
}; };
/**
* **按活动看数据域**(2026-08-24 用户定:「按活动数据来分类,要让每个活动执行完成后
* 台账在这里看到变化」)。
*
* 从前这一屏按**性质**(主数据/单据/派生)分组 —— 那是给「治理这张表」的人用的分法,
* 回答的是「这个对象定义全不全」。而人跑完一单来这儿,问的是另一件事:
* **我这个活动写了哪几张表,现在各有几行,刚才那一单落下了什么**。
* 按活动分组、带上行数,那个问题才答得了。
*
* 三个数各有各的意思,不能混:
* · `rows` —— 正式表里现在有几行(过了放行门的事实);
* · `drafts` —— 还在某一单草稿层的几行(**在办中,放行才进正式表**);
* · `recent` —— 这个活动最近几单,让人认出「刚跑完的那一单」。
*/
export declare const ledgerByActivityRoute: {
readonly method: "GET";
readonly path: "/api/gov/data/by-activity";
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
activityId: z.ZodString;
name: z.ZodString;
version: z.ZodOptional<z.ZodString>;
tables: z.ZodArray<z.ZodObject<{
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
kind: z.ZodOptional<z.ZodString>;
caseKey: z.ZodOptional<z.ZodString>;
primaryKey: z.ZodArray<z.ZodString>;
rows: z.ZodOptional<z.ZodNumber>;
drafts: z.ZodNumber;
draftAt: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
recent: z.ZodArray<z.ZodObject<{
id: z.ZodString;
title: z.ZodString;
runtime: z.ZodString;
terminal: z.ZodOptional<z.ZodString>;
at: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>;
unclaimed: z.ZodArray<z.ZodObject<{
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
rows: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const browseLedgerRoute: { export declare const browseLedgerRoute: {
readonly method: "GET"; readonly method: "GET";
readonly path: "/api/gov/data/:domain/:object"; readonly path: "/api/gov/data/:domain/:object";
@@ -2445,6 +2496,7 @@ export declare const testRunActivityRoute: {
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>; data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
message: z.ZodDefault<z.ZodString>; message: z.ZodDefault<z.ZodString>;
ownerId: z.ZodDefault<z.ZodString>; ownerId: z.ZodDefault<z.ZodString>;
landMaster: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>; }, z.core.$strip>;
readonly output: z.ZodObject<{ readonly output: z.ZodObject<{
taskId: z.ZodString; taskId: z.ZodString;
@@ -2977,6 +3029,7 @@ export type GovActivitiesOutput = z.infer<typeof listActivitiesRoute.output>;
export type GovToolsOutput = z.infer<typeof listToolsRoute.output>; export type GovToolsOutput = z.infer<typeof listToolsRoute.output>;
export type GovObjectsOutput = z.infer<typeof listLedgerObjectsRoute.output>; export type GovObjectsOutput = z.infer<typeof listLedgerObjectsRoute.output>;
export type GovLedgerPageOutput = z.infer<typeof browseLedgerRoute.output>; export type GovLedgerPageOutput = z.infer<typeof browseLedgerRoute.output>;
export type GovLedgerByActivityOutput = z.infer<typeof ledgerByActivityRoute.output>;
export type GovMemoryOutput = z.infer<typeof listActivityMemoryRoute.output>; export type GovMemoryOutput = z.infer<typeof listActivityMemoryRoute.output>;
export type GovMetricsOutput = z.infer<typeof govMetricsRoute.output>; export type GovMetricsOutput = z.infer<typeof govMetricsRoute.output>;
export type GovChannelsOutput = z.infer<typeof listChannelsRoute.output>; export type GovChannelsOutput = z.infer<typeof listChannelsRoute.output>;
+5
View File
@@ -4130,6 +4130,11 @@ export declare const saveTableRoute: {
readonly output: z.ZodObject<{ readonly output: z.ZodObject<{
ok: z.ZodBoolean; ok: z.ZodBoolean;
rows: z.ZodNumber; rows: z.ZodNumber;
unkeyed: z.ZodOptional<z.ZodArray<z.ZodObject<{
address: z.ZodString;
keys: z.ZodArray<z.ZodString>;
hint: z.ZodString;
}, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
}; };
/** /**
+4
View File
@@ -57,6 +57,8 @@ export declare const fieldDeclarationSchema: z.ZodObject<{
column: z.ZodString; column: z.ZodString;
value: z.ZodString; value: z.ZodString;
}, z.core.$strip>>; }, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>; }, z.core.$strip>;
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>; export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
/** /**
@@ -125,6 +127,8 @@ export declare const objectDeclarationSchema: z.ZodObject<{
column: z.ZodString; column: z.ZodString;
value: z.ZodString; value: z.ZodString;
}, z.core.$strip>>; }, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>; }, z.core.$strip>;
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>; export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
+6
View File
@@ -75,6 +75,8 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
column: z.ZodString; column: z.ZodString;
value: z.ZodString; value: z.ZodString;
}, z.core.$strip>>; }, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{ listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -562,6 +564,8 @@ export declare const activityManifestSchema: z.ZodObject<{
column: z.ZodString; column: z.ZodString;
value: z.ZodString; value: z.ZodString;
}, z.core.$strip>>; }, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{ listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -710,6 +714,7 @@ export declare const staticAuditFindingSchema: z.ZodObject<{
"acceptance-predicate-unbound": "acceptance-predicate-unbound"; "acceptance-predicate-unbound": "acceptance-predicate-unbound";
"deliverable-address-unbound": "deliverable-address-unbound"; "deliverable-address-unbound": "deliverable-address-unbound";
"ledger-kind-invalid": "ledger-kind-invalid"; "ledger-kind-invalid": "ledger-kind-invalid";
"reference-target-unbound": "reference-target-unbound";
}>; }>;
severity: z.ZodEnum<{ severity: z.ZodEnum<{
error: "error"; error: "error";
@@ -751,6 +756,7 @@ export declare const installVerdictSchema: z.ZodObject<{
"acceptance-predicate-unbound": "acceptance-predicate-unbound"; "acceptance-predicate-unbound": "acceptance-predicate-unbound";
"deliverable-address-unbound": "deliverable-address-unbound"; "deliverable-address-unbound": "deliverable-address-unbound";
"ledger-kind-invalid": "ledger-kind-invalid"; "ledger-kind-invalid": "ledger-kind-invalid";
"reference-target-unbound": "reference-target-unbound";
}>; }>;
severity: z.ZodEnum<{ severity: z.ZodEnum<{
error: "error"; error: "error";
@@ -0,0 +1,105 @@
/**
* 广播的跨进程桥 —— **别的后端动了同一张单,这边的浏览器也得知道**。
*
* 2026-08-24 用户报的第三件事:「一直卡在 AI 推进中,刷新后才发现已经推完了」。
* 推送本身没坏 —— 坏在 `createBroadcaster()` 是进程内的一个 Set:那会儿本机与
* 局域网另一台各跑一个后端连同一个库,单子被那一台推着走,这一台的浏览器一个字没收到。
*
* 这里钉两条:①自己推的顺手 NOTIFY 出去;②**别人发的才在本地转发,自己发的不收** ——
* 不然一条推送在两个进程之间来回弹。
*/
import { describe, expect, it } from 'vitest';
import { bridgeBroadcaster, TOUCH_CHANNEL } from '../broadcast-bridge.ts';
import { createBroadcaster } from '../broadcast.ts';
/** 假连接池:记下 NOTIFY 出去的载荷,并留一个口子把「别人的通知」塞进来 */
function fakePool() {
const notified: Array<{ channel: string; payload: string }> = [];
let onNotification: ((m: { channel: string; payload?: string }) => void) | undefined;
const client = {
on(event: string, handler: (arg: never) => void) {
if (event === 'notification') onNotification = handler as never;
},
query: async () => ({ rows: [] }),
release: () => undefined,
};
return {
notified,
/** 模拟别的进程(或自己)发来一条 */
deliver(wire: Record<string, unknown> | string) {
// 字符串原样送 —— 「载荷坏了」那条用例要的是真·不是 JSON 的字节
onNotification?.({ channel: TOUCH_CHANNEL, payload: typeof wire === 'string' ? wire : JSON.stringify(wire) });
},
get armed() {
return onNotification !== undefined;
},
pool: {
query: async (_sql: string, params: unknown[]) => {
notified.push({ channel: String(params[0]), payload: String(params[1]) });
return { rows: [] };
},
connect: async () => client,
},
};
}
const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
describe('bridgeBroadcaster', () => {
it('本地推一条 → 顺手 NOTIFY 到库上(带着「谁发的」)', async () => {
const fake = fakePool();
const bus = createBroadcaster();
bridgeBroadcaster(fake.pool as never, bus);
bus.publish({ kind: 'task', id: 't-1', reason: 'advanced' });
await settle();
expect(fake.notified).toHaveLength(1);
expect(fake.notified[0]?.channel).toBe(TOUCH_CHANNEL);
const wire = JSON.parse(fake.notified[0]?.payload ?? '{}') as Record<string, unknown>;
expect(wire['id']).toBe('t-1');
expect(typeof wire['from']).toBe('string'); // 认「谁发的」靠它
});
it('**别的进程发来的,在本地转发一次** —— 这正是那一台推进、这一台刷新的那条路', async () => {
const fake = fakePool();
const bus = createBroadcaster();
bridgeBroadcaster(fake.pool as never, bus);
await settle();
expect(fake.armed).toBe(true);
const got: string[] = [];
bus.subscribe((t) => got.push(`${t.kind}:${t.id}:${t.reason}`));
fake.deliver({ kind: 'task', id: 't-9', reason: 'advanced', from: '别的机器' });
expect(got).toEqual(['task:t-9:advanced']);
});
it('**自己发的不再收** —— 否则一条推送在两个进程之间来回弹', async () => {
const fake = fakePool();
const bus = createBroadcaster();
bridgeBroadcaster(fake.pool as never, bus);
bus.publish({ kind: 'task', id: 't-1', reason: 'advanced' });
await settle();
const self = (JSON.parse(fake.notified[0]?.payload ?? '{}') as { from?: string }).from;
const got: string[] = [];
bus.subscribe((t) => got.push(t.id));
fake.deliver({ kind: 'task', id: 't-1', reason: 'advanced', from: self });
expect(got).toEqual([]);
});
it('载荷坏了只丢这一条,连接照旧活着', async () => {
const fake = fakePool();
const bus = createBroadcaster();
bridgeBroadcaster(fake.pool as never, bus);
await settle();
const got: string[] = [];
bus.subscribe((t) => got.push(t.id));
expect(() => fake.deliver('这不是 JSON')).not.toThrow();
// 是 JSON 但形状不对的也得丢掉 —— 频道全库公用,谁都能往里发一句话
expect(() => fake.deliver('"光秃秃一个字符串"')).not.toThrow();
expect(() => fake.deliver({ 没有: 'kind 与 id' })).not.toThrow();
fake.deliver({ kind: 'task', id: 't-3', reason: 'advanced', from: '别处' });
expect(got).toEqual(['t-3']);
});
});
@@ -0,0 +1,42 @@
/**
* 对话区按任务内序号排(2026-08-24):两台后端交替推同一单,时钟一偏,
* 上一拍的话就排到下一拍后面 —— 序号与时钟无关,拿它排。
*/
import { describe, expect, it } from 'vitest';
import { orderChat } from '../chat-order.ts';
const H = [
{ id: 'm1', seq: 1, kind: 'narration', at: '2026-08-24T10:00:30.000Z' }, // 另一台机器,钟快 30s
{ id: 'c1', seq: 2, kind: 'card-sent', at: '2026-08-24T10:00:30.100Z', detail: { id: 'card-A' } },
{ id: 'm2', seq: 3, kind: 'narration', at: '2026-08-24T10:00:05.000Z' }, // 本机,钟准
{ id: 'h1', seq: 4, kind: 'human-input', at: '2026-08-24T10:00:06.000Z', detail: { id: 'say-1' } },
];
describe('orderChat', () => {
it('**时钟倒着、序号顺着:按序号排** —— 上一拍的话不再跑到下一拍后面', () => {
const items = [
{ id: 'm2', at: '2026-08-24T10:00:05.000Z', text: '下一拍' },
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '上一拍' },
];
expect(orderChat(items, H).map((i) => i.text)).toEqual(['上一拍', '下一拍']);
});
it('卡按它的 card-sent 落点排;人的自由输入按 human-input 落点排', () => {
const items = [
{ id: 'say-1', at: '2026-08-24T10:00:06.000Z', text: '人说' },
{ id: 'card-A', at: '2026-08-24T10:00:30.050Z', text: '卡' },
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '话' },
];
expect(orderChat(items, H).map((i) => i.text)).toEqual(['话', '卡', '人说']);
});
it('找不到落点的老数据按时刻插位,不炸', () => {
const items = [
{ id: 'zzz', at: '2026-08-24T10:00:05.500Z', text: '老' },
{ id: 'm2', at: '2026-08-24T10:00:05.000Z', text: '下一拍' },
{ id: 'm1', at: '2026-08-24T10:00:30.000Z', text: '上一拍' },
];
// 老条目的时刻在 m2(seq 3)之后、h1 之前 → 插到 seq 3 那一档,时刻再排在 m2 之后
expect(orderChat(items, H).map((i) => i.text)).toEqual(['上一拍', '下一拍', '老']);
});
});
@@ -9,7 +9,7 @@
* 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。 * 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts'; import { coerceToColumn, distinctReadings, localStamp, missingReadings, rowLabel, rowReadings, rowKeyList, tallyReadings, toolCallReadings, touchedRows } from '../wiring/engines.ts';
describe('空值 = 这一列没有值(不写)', () => { describe('空值 = 这一列没有值(不写)', () => {
it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => { it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => {
@@ -381,3 +381,108 @@ describe('工具调用读数', () => {
]); ]);
}); });
}); });
/**
* 不同值计数 `域_对象_distinct_<列>` —— 覆盖闭合判据的落点(2026-08-24 柴浩实测:
* 人在覆盖范围确认卡上加了一家客户,计划行数照样 > 0,「盖没盖住每一家」没有数说得出来)。
*/
describe('distinctReadings —— 这一列有几个不同的值,基座数', () => {
const DECL = [
{
domain: 'crm_coverage',
object: 'plan',
fields: [{ name: 'customer_code', distinct: true }, { name: 'contact_code' }],
},
];
it('计划行数 ≠ 客户数:一家客户几位关键人就几行,distinct 数的是客户', () => {
const rows = [
{ customer_code: 'CU-0142', contact_code: 'CU-0142-01' },
{ customer_code: 'CU-0142', contact_code: 'CU-0142-02' },
{ customer_code: 'cu-1111', contact_code: 'cu-1111-00' },
];
expect(distinctReadings({ 'mine:crm_coverage.plan': rows }, DECL)).toEqual({
crm_coverage_plan_distinct_customer_code: 2,
});
});
it('没标 distinct 的列不出读数;标了的零行也铺 0 —— 未绑定的名字参与比较求值为假', () => {
const out = distinctReadings({ 'mine:crm_coverage.plan': [] }, DECL);
expect(out).toEqual({ crm_coverage_plan_distinct_customer_code: 0 });
});
it('空值不算一种 —— 「没写」不是一个取值', () => {
const rows = [{ customer_code: 'CU-0142' }, { customer_code: '' }, { customer_code: ' ' }, {}];
expect(distinctReadings({ 'mine:crm_coverage.plan': rows }, DECL)).toEqual({
crm_coverage_plan_distinct_customer_code: 1,
});
});
});
/**
* `touched:域.对象` —— 落库预览的数据源(2026-08-24 柴浩实测:绑 mine: 的话
* Itelma 既有的客户类型/行业整片空白,像是数据丢了)。
*/
describe('touchedRows —— 本轮碰过的行,以叠加后全貌摆出来', () => {
const merged = [
{ customer_code: 'CU-0142', name: 'Itelma', cust_type: 'Tier1 OEM', industry: '车规电子' },
{ customer_code: 'CU-0087', name: '深圳精密', cust_type: '模组厂' },
{ customer_code: 'CU-2039', name: '测试11111' },
];
it('按草稿键挑行:碰过的行带着正式现值的全部列,没碰的行不出现', () => {
const out = touchedRows(merged, [{ customer_code: 'CU-0142' }, { customer_code: 'CU-2039' }]);
expect(out).toHaveLength(2);
// 关键在这一列:草稿里没写过 cust_type,叠加行里必须有 —— 这正是回显的来源
expect((out[0] as Record<string, unknown>)['cust_type']).toBe('Tier1 OEM');
expect((out[1] as Record<string, unknown>)['name']).toBe('测试11111');
});
it('草稿新增的行(正式表没有)也在 —— 叠加视图本就含它;键对不上的丢弃不报错', () => {
const withNew = [...merged, { customer_code: 'CU-NEW', contact_code: 'CU-NEW-01', name: '新人' }];
const out = touchedRows(withNew, [
{ customer_code: 'CU-NEW', contact_code: 'CU-NEW-01' },
{ customer_code: '查无此人', contact_code: 'X' },
]);
expect(out).toHaveLength(1);
expect((out[0] as Record<string, unknown>)['name']).toBe('新人');
});
it('数值键与字符串键按字面等值比(pg 回来的是 number,草稿键常是 string)', () => {
const out = touchedRows([{ seq: 3, name: '三号' }], [{ seq: '3' }]);
expect(out).toHaveLength(1);
});
});
/**
* 摆给人看的行标识去掉单标识(2026-08-25 用户:「COV-…/CU-0142-01 只保留斜杠右边」)。
*/
describe('rowLabel —— 主键去掉单号那一段', () => {
it('单据表:round_ref + contact_code → 只留 contact_code', () => {
expect(rowLabel({ round_ref: 'COV-20260825002227', contact_code: 'CU-0142-01' }, 'round_ref')).toBe('CU-0142-01');
});
it('主数据表没有单标识:全部保留', () => {
expect(rowLabel({ customer_code: 'CU-0142', contact_code: 'CU-0142-01' }, undefined)).toBe('CU-0142/CU-0142-01');
});
it('主键只有单号:去完一段都不剩就退回整段,不摆空串', () => {
expect(rowLabel({ round_ref: 'COV-1' }, 'round_ref')).toBe('COV-1');
});
it('缺项名单走同一把尺 —— 红带 / 提交闸 / 拒回都不再带单号', () => {
const out = missingReadings({ 'mine:crm_coverage.receipt': [{ round_ref: 'COV-1', contact_code: 'CU-0142-01' }] }, [
{
domain: 'crm_coverage',
object: 'receipt',
caseKey: 'round_ref',
fields: [
{ name: 'round_ref', primaryKey: true },
{ name: 'contact_code', primaryKey: true },
{ name: 'completion_status', primaryKey: false, required: true, label: '完成状态' },
],
},
]);
expect(out['crm_coverage_receipt_missing_keys']).toBe('CU-0142-01 缺完成状态');
});
});
@@ -0,0 +1,63 @@
/**
* 驳回意见的**归属比对** —— 2026-08-24 柴浩实测:意见「测试111建议动作改成拜访CTO」,
* 计划表里有一行 customer_name=测试111,模型却把改动落到了 Itelma 那一行。
* 归属是纯字符串包含,引擎数得出来的事实别让模型从没标点的话里自己抠。
*/
import { describe, expect, it } from 'vitest';
import { noteAnchors, noteAnchorTitle } from '../note-anchors.ts';
const row = (object: string, key: Record<string, string>, patch: Record<string, unknown>) => ({
domain: 'crm_coverage',
object,
key,
patch,
});
const PLAN = [
row('plan', { round_ref: 'COV-1', contact_code: 'CU-0142-01' }, { customer_name: 'Itelma', contact_name: 'Ekaterina Volkova', action: '拜访采购' }),
row('plan', { round_ref: 'COV-1', contact_code: 'cu-1234-00' }, { customer_name: '测试111', contact_name: '关键人待识别', action: '首访建档' }),
];
describe('noteAnchors —— 意见按字面点名对上了哪几行', () => {
it('**实测那一单**:意见里的「测试111」恰是一行的客户名 —— 命中它,不命中 Itelma', () => {
const hits = noteAnchors('测试111建议动作改成拜访CTO', PLAN);
expect(hits).toHaveLength(1);
expect(hits[0]).toContain('cu-1234-00');
expect(hits[0]).toContain('测试111');
});
it('点的是编码也认得(主键值同样参与比对)', () => {
const hits = noteAnchors('CU-0142-01 的动作太保守,改激进些', PLAN);
expect(hits).toHaveLength(1);
expect(hits[0]).toContain('CU-0142-01');
});
it('一行都没点名 → 空数组(没有依据的时候不说话)', () => {
expect(noteAnchors('整体排序不合理,重新排一遍', PLAN)).toEqual([]);
});
it('纯数字与短值不算点名 —— 「111」「01」撞上纯属巧合', () => {
const rows = [row('plan', { round_ref: 'COV-1', contact_code: 'X' }, { seq: '111', tag: '高' })];
expect(noteAnchors('测试111建议动作改成拜访CTO', rows)).toEqual([]);
});
it('命中太多行(> 5)当没命中 —— 什么都命中的提示是噪音', () => {
const rows = Array.from({ length: 6 }, (_, i) =>
row('plan', { round_ref: 'COV-1', contact_code: `C-${i}` }, { customer_name: 'Itelma' }),
);
expect(noteAnchors('Itelma 的动作都改一下', rows)).toEqual([]);
});
it('同一行几格都命中时只出一次,展示最长的那个名字', () => {
const rows = [row('plan', { round_ref: 'COV-1', contact_code: 'C-1' }, { customer_name: '测试111', short: '测试' })];
const hits = noteAnchors('测试111建议动作改成拜访CTO', rows);
expect(hits).toHaveLength(1);
expect(hits[0]).toContain('「测试111」');
});
it('观察全文带行动指南:先认归属、点名的行缺什么改这一行自己的内容', () => {
const title = noteAnchorTitle(['crm_coverage.plan COV-1/cu-1234-00(命中「测试111」)']);
expect(title).toContain('先认这几行的归属');
expect(title).toContain('不是把改动挪到别的行头上');
});
});
@@ -99,6 +99,30 @@ describe('件上加行:主键凑得齐才落得下', () => {
}); });
}); });
it('**空着的自增列不许从别的行借号** —— 加的新联系人不再覆盖该客户已有的草稿行(2026-08-24)', () => {
// 人加一行:customer_code 填了、contact_code(auto)在件上但空着;该客户已有一行草稿联系人
const contactDrafts = [{ key: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' } }];
const out = resolveRowKey({
keys: ['customer_code', 'contact_code'],
cells: { customer_code: 'CU-E2E-ADD', contact_code: '', name: '测试CTO王工' },
drafts: contactDrafts,
types: { customer_code: 'text', contact_code: 'text' },
autos: ['contact_code'],
});
// 不许把 -01 借来当自己的钥匙(那是覆盖别人);认不出 → 交给 mint 那条路续号
expect(out).toBeUndefined();
// 编码真填了的(编辑既有行)照旧认得出
expect(
resolveRowKey({
keys: ['customer_code', 'contact_code'],
cells: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01', name: '改名' },
drafts: contactDrafts,
types: { customer_code: 'text', contact_code: 'text' },
autos: ['contact_code'],
}),
).toEqual({ customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' });
});
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => { it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({ expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1', round_ref: 'CTB-1',
@@ -438,3 +462,61 @@ describe('parseRowKey —— 件上传来的主键认哪一行', () => {
expect(parseRowKey('X', [])).toBeUndefined(); expect(parseRowKey('X', [])).toBeUndefined();
}); });
}); });
/**
* **保存说成功,就得真的都存下了**(2026-08-24 用户实测:
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
*
* 主键那几格空着就凑不出钥匙 —— 从前基座一句 `continue` 悄悄跳过,接口照回 `{ok:true}`:
* 人看到「保存成功」,东西却不在,而提交闸也拦不住(一行根本没写进台账,谈何缺列)。
* 现在没落下的行要点名说出来,且**只点他填得了的那几列**。
*/
describe('落不下的行要点名 —— 不许悄悄丢', () => {
const keys = ['round_ref', 'customer_code'];
it('主键那一格空着 → 认不出这一行(resolveRowKey 交白卷)', () => {
expect(
resolveRowKey({
keys,
cells: { customer_code: '', customer_name: '忘了填编号的客户' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toBeUndefined();
});
it('主键填了 → 案号由「全表一致」补齐,行认得出来', () => {
expect(
resolveRowKey({
keys,
cells: { customer_code: 'CU-9527', customer_name: '人工加的客户' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
});
it('**表里只有一行时也不许「继承」老行的编码** —— 那会当场把老行覆盖掉', () => {
// 件上摆着 customer_code、人却空着:一行的表在「全表一致」眼里像个常量,
// 从前新行会拿到 CU-0142(屏上两行、库里一行,而且内容还是新的那份)
expect(
resolveRowKey({
keys,
cells: { customer_code: '', customer_name: '新客户' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toBeUndefined();
});
it('件上没摆的那把钥匙(案号)照旧由基座按全表一致补 —— 那是他碰不到的列', () => {
expect(
resolveRowKey({
keys,
cells: { customer_code: 'CU-9527' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
});
});
+2
View File
@@ -272,6 +272,8 @@ function restPart(scope: Record<string, unknown>, options?: { withoutAttachments
const rest = Object.entries(scope).filter( const rest = Object.entries(scope).filter(
([k]) => ([k]) =>
!k.startsWith('mine:') && !k.startsWith('mine:') &&
// touched: 是给落库预览画面的(叠加后行),内容与 mine/整表重叠 —— 不进提示词
!k.startsWith('touched:') &&
!k.startsWith('focus:') && !k.startsWith('focus:') &&
!(options?.withoutAttachments === true && k === 'attachments'), !(options?.withoutAttachments === true && k === 'attachments'),
); );
+99
View File
@@ -0,0 +1,99 @@
/**
* 广播的**跨进程桥** —— 别的后端动了同一张单,这边的浏览器也得知道。
*
* 起因(2026-08-24 用户报的第三件事):「一直卡在 AI 推进中,刷新后才发现是已经推完了、
* 需要人类确认了」。查下来推送本身是好的 —— 可 `createBroadcaster()` 是**进程内**的
* 一个 Set:谁推进的,只有谁自己的 SSE 客户端收得到。而那会儿本机与局域网另一台
* 各跑着一个后端连同一个库(同一批症状的另一面,见 `workers/task-claim.ts`):
* 单子被那一台推着走,状态在库里早变了,**这一台的浏览器一个字都没收到** ——
* 界面就停在「AI 推进中」,直到人手动刷新。
*
* 桥怎么搭:Postgres 自带的 `LISTEN/NOTIFY` —— 不引消息队列、不加部署件。
* · 本进程 publish 一条 → 顺手 `NOTIFY`(带上进程标识);
* · 一条**专用连接**常年 `LISTEN`,收到别的进程发来的就在本地 publish 一次。
* **自己发的自己不再收**(靠进程标识认),不然一条推送在两台之间来回弹。
*
* 桥断了不影响业务:推送本就是「告诉在线浏览器去重取」的提示,丢一条最坏是
* 那一屏晚两秒(前端还有轮询兜底)。所以这里所有错误都只记一行,不往上抛。
*/
import type { Pool, PoolClient } from 'pg';
import type { Broadcaster, Touch } from './broadcast.ts';
/** 频道名 —— 全库一个就够,载荷里带着 kind/id,订阅方自己分 */
export const TOUCH_CHANNEL = 'jcai_touch';
/** 这一次进程的身份 —— 用来认「这条是我自己发的」,不必稳定、不必持久 */
const SELF = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
type Wire = Touch & { readonly from: string };
/**
* 把本地广播接到库上。返回 `stop()`;起不来不抛 —— 单进程照常跑,只是没有跨进程那一段。
*
* `notify` 用参数化的 `pg_notify` 而不是拼 `NOTIFY` 语句:载荷里有中文与引号,
* 拼字符串迟早出事(而这条路上的字符串全来自业务)。
*/
export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(): void } {
let listener: PoolClient | undefined;
let stopped = false;
/** 本地 → 库:自己推的顺手 NOTIFY 一条(不 await,广播不该拖慢业务) */
const unsubscribe = broadcaster.subscribe((touch) => {
if (stopped) return;
const wire: Wire = { ...touch, from: SELF };
void pool
.query('select pg_notify($1, $2)', [TOUCH_CHANNEL, JSON.stringify(wire)])
.catch((error: unknown) => {
process.stderr.write(`[broadcast-bridge] notify 失败:${error instanceof Error ? error.message : String(error)}\n`);
});
});
/** 库 → 本地:一条专用连接常年 LISTEN,收到别人的就在本地 publish */
const connect = async (): Promise<void> => {
if (stopped) return;
try {
const client = await pool.connect();
listener = client;
client.on('notification', (message) => {
if (message.channel !== TOUCH_CHANNEL || message.payload === undefined) return;
try {
const parsed: unknown = JSON.parse(message.payload);
/*
* **形状不对就丢** —— 频道是全库公用的,谁都能往里 NOTIFY 一句话。
* 不校形状的话,一条 `"hello"` 会被当成 touch 广播出去:
* 前端拿到 `kind: undefined` 谁也失效不了,还白跑一趟(界面不撒谎那一条,
* 对推送同样算数)。
*/
if (typeof parsed !== 'object' || parsed === null) return;
const wire = parsed as Partial<Wire>;
if (typeof wire.kind !== 'string' || typeof wire.id !== 'string') return;
// **自己发的自己不收** —— 否则一条推送在进程之间来回弹
if (wire.from === SELF) return;
broadcaster.publish({ kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' });
} catch {
/* 载荷根本不是 JSON:丢这一条,不拖垮连接 */
}
});
client.on('error', (error) => {
process.stderr.write(`[broadcast-bridge] 连接断了,5 秒后重连:${error.message}\n`);
client.release();
listener = undefined;
setTimeout(() => void connect(), 5000).unref();
});
await client.query(`listen ${TOUCH_CHANNEL}`);
} catch (error) {
process.stderr.write(`[broadcast-bridge] 接不上,5 秒后重试:${error instanceof Error ? error.message : String(error)}\n`);
setTimeout(() => void connect(), 5000).unref();
}
};
void connect();
return {
stop() {
stopped = true;
unsubscribe();
listener?.release();
listener = undefined;
},
};
}
+52
View File
@@ -0,0 +1,52 @@
/**
* **对话区按任务内序号排,不按墙上时钟**(2026-08-24 柴浩:「上一个步骤的消息跑到这一个
* 步骤的后面」)。
*
* 对话流 = 时间线(AI 发言 / 系统事件,来自任务记忆)+ 对话卡族(录入卡 / 决策卡),
* 从前合并时按 `at` 字符串排序。同一台后端上它们出自同一只时钟,没问题;
* 可两台后端连同一个库交替推同一张单时(本机 + 局域网另一台,当天实测的部署形态),
* 各自的时钟差上几秒,上一拍的话就排到了下一拍的后面 —— 「有时候乱」正是这个形状。
*
* 任务记忆的 `seq` 是任务内单调的(取 max+1,与时钟无关):每条对话项都能找到它在记忆里
* 的落点 —— AI/系统项本身就是记忆条目;卡对应 `card-sent` 那条(detail.id);
* 人的自由输入对应 `human-input` 那条(detail.id)。找不到落点的(老数据)退回按
* 「它的时刻之前最后一条记忆的序号」插位 —— 仍是一个总序,只是精度回到时钟。
*/
export type OrderEntry = {
readonly id: string;
readonly seq: number;
readonly kind: string;
readonly at: string;
readonly detail?: Readonly<Record<string, unknown>> | undefined;
};
export function orderChat<T extends { readonly id: string; readonly at: string }>(
items: readonly T[],
history: readonly OrderEntry[],
): T[] {
const byEntryId = new Map<string, number>();
const byLinkedId = new Map<string, number>();
for (const e of history) {
byEntryId.set(e.id, e.seq);
const linked = e.detail?.['id'];
if (typeof linked === 'string' && (e.kind === 'card-sent' || e.kind === 'human-input')) {
if (!byLinkedId.has(linked)) byLinkedId.set(linked, e.seq);
}
}
const sortedByAt = [...history].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
const seqBefore = (at: string): number => {
let seq = 0;
for (const e of sortedByAt) {
if (e.at <= at) seq = Math.max(seq, e.seq);
else break;
}
return seq;
};
const keyed = items.map((item, index) => ({
item,
index,
seq: byEntryId.get(item.id) ?? byLinkedId.get(item.id) ?? seqBefore(item.at),
}));
keyed.sort((a, b) => a.seq - b.seq || (a.item.at < b.item.at ? -1 : a.item.at > b.item.at ? 1 : 0) || a.index - b.index);
return keyed.map((k) => k.item);
}
+43 -6
View File
@@ -83,6 +83,8 @@ import type { AppConfig } from './config.ts';
import { createIdGenerator, systemClock } from './runtime.ts'; import { createIdGenerator, systemClock } from './runtime.ts';
import { createSecretBox, loadSecretKey } from './secret-box.ts'; import { createSecretBox, loadSecretKey } from './secret-box.ts';
import { createBroadcaster } from './broadcast.ts'; import { createBroadcaster } from './broadcast.ts';
import { noteAnchors, noteAnchorTitle } from './note-anchors.ts';
import { bridgeBroadcaster } from './broadcast-bridge.ts';
import type { Broadcaster } from './broadcast.ts'; import type { Broadcaster } from './broadcast.ts';
export type Container = { export type Container = {
@@ -188,6 +190,12 @@ export async function createContainer(config: AppConfig): Promise<Container> {
process.stderr.write(`[pg] 空闲连接异常(已剔除,下次取用自动重连):${error.message}\n`); process.stderr.write(`[pg] 空闲连接异常(已剔除,下次取用自动重连):${error.message}\n`);
}); });
const db = drizzle(pool); const db = drizzle(pool);
/*
* **广播接到库上** —— 多个后端连同一个库时,别的进程动了同一张单,
* 这边的浏览器也得知道(不然就停在「AI 推进中」直到人手动刷新,2026-08-24 用户实测)。
* 单进程部署时它只是空转一条 LISTEN 连接,没有副作用。
*/
const bridge = bridgeBroadcaster(pool, broadcaster);
const ids = createIdGenerator(); const ids = createIdGenerator();
const secrets = createSecretBox(loadSecretKey()); const secrets = createSecretBox(loadSecretKey());
@@ -259,10 +267,18 @@ export async function createContainer(config: AppConfig): Promise<Container> {
simulation: { simulation: {
isSimulated: async (taskId) => { isSimulated: async (taskId) => {
const res = await pool.query<{ simulated: boolean }>( const res = await pool.query<{ simulated: boolean }>(
/*
* **勾了「落真库」的试跑单不算试跑** —— 这道判断只服务一件事:
* 放行合并时主数据要不要挡下来(见 `ledger-service`)。
* 2026-08-24 用户要的正是这个:演示时要看见 `customer.contact` 的亲密度真的变了,
* 而不是单据里写着「3 → 10」、主数据还是 3。默认仍然挡 —— 破例得有人按一下。
*/
`select exists ( `select exists (
select 1 from sys.task t select 1 from sys.task t
left join sys.workflow w on w.id = t.workflow_id left join sys.workflow w on w.id = t.workflow_id
where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed') where t.id = $1
and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
and coalesce(t.land_master, false) = false
) as simulated`, ) as simulated`,
[taskId], [taskId],
); );
@@ -408,11 +424,31 @@ export async function createContainer(config: AppConfig): Promise<Container> {
}, },
}, },
memory: { memory: {
append: async (input) => append: async (input) => {
void (await memory.append({ const stamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] };
...input, await memory.append({ ...input, stamp });
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, /*
})), * **驳回意见点了谁,基座当场比对好摆进轨迹**(2026-08-24 柴浩实测:意见写
* 「测试111建议动作改成拜访CTO」,模型把改动落到了 Itelma 那一行)。
* 归属是纯字符串包含 —— 引擎有现成事实的,不让模型从一句没标点的话里自己抠。
* 观察挂了不拦回传:它是给下一拍的路标,不是这一笔的门。
*/
if (input.kind === 'card-reply') {
const detail = (input.detail ?? {}) as { note?: unknown; value?: unknown };
const custom = typeof detail.value === 'string' && detail.value.startsWith('custom:') ? detail.value.slice('custom:'.length) : '';
const note = typeof detail.note === 'string' && detail.note.trim() !== '' ? detail.note : custom;
if (note.trim() !== '') {
try {
const hits = noteAnchors(note, await ledger.taskDrafts(input.taskId));
if (hits.length > 0) {
await memory.append({ taskId: input.taskId, kind: 'observation', title: noteAnchorTitle(hits), stamp });
}
} catch {
/* 比对失败只是少一条路标 */
}
}
}
},
}, },
events: { events: {
publishHumanEvent: async (input) => { publishHumanEvent: async (input) => {
@@ -703,6 +739,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
report: deskElements.report, report: deskElements.report,
trace, trace,
async close() { async close() {
bridge.stop();
await pool.end(); await pool.end();
}, },
}; };
+74
View File
@@ -0,0 +1,74 @@
/**
* **驳回意见点了谁 —— 按字面比对,基座认得出来的归属别让模型猜**(2026-08-24 柴浩实测)。
*
* 那一单的意见原话是「测试111建议动作改成拜访CTO」,计划表里明明有一行
* `customer_name = 测试111`;模型复述意见时把「测试111」整个丢掉(它把这三个字
* 读成了「测试」+ 杂音),只剩「建议动作改成拜访CTO」,然后按自己的推理挑了
* Itelma 的那一行去改 —— 人看到的是「我说了改测试111,它改到了 Itelma」。
*
* 治法与 E04 的 worldTouched 那一课同款:**指南让模型判的事,引擎有现成事实的,
* 一律改成读事实**。「意见里那个名字对上台账哪一行」是纯字符串包含,机器一趟就
* 数得出来;比对结果以观察进轨迹,落在 card-reply 旁边 —— 模型下一拍读意见时,
* 归属已经是摆好的事实,不必再从一句没有标点的话里自己抠实体。
*
* 只做**字面比对**,不做任何语义猜测:
* · 格子的值(字符串、去空白后 ≥ 3 字、非纯数字)是意见原文的子串 → 这一行被点名;
* · 一行都没对上 → 没有这条观察(没有依据的时候不说话);
* · 对上太多行(> 5)→ 也不说 —— 什么都命中的提示不是提示,是噪音。
*/
/** 本单草稿层的一行 —— key 与 patch 合起来就是这一行现有的格子 */
export type DraftRow = {
readonly domain: string;
readonly object: string;
readonly key: Readonly<Record<string, unknown>>;
readonly patch: Readonly<Record<string, unknown>>;
};
/** 一行至少要这么长的值才算「点名」—— 短于此的子串(如「高」「01」)碰上纯属巧合 */
const MIN_ANCHOR = 3;
/** 命中行数封顶 —— 超过就当没命中(意见里带了个太常见的词) */
const MAX_HITS = 5;
/**
* 意见按字面点名对上了哪几行。返回人读的行描述(空数组 = 别出声)。
*
* 每行取**命中的值里最长的那个**当展示(「测试111」比「111」更像人点的名);
* 行的身份用 `域.对象 主键值` 说 —— 与拒回里主键名单同一口径。
*/
export function noteAnchors(note: string, rows: ReadonlyArray<DraftRow>): string[] {
const text = note.trim();
if (text.length < MIN_ANCHOR) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const row of rows) {
const cells: Record<string, unknown> = { ...row.key, ...row.patch };
let best = '';
for (const value of Object.values(cells)) {
if (typeof value !== 'string') continue;
const v = value.trim();
if (v.length < MIN_ANCHOR) continue;
if (/^\d+$/.test(v)) continue; // 纯数字撞上「2026」「111」这类年份/序号,不算点名
if (!text.includes(v)) continue;
if (v.length > best.length) best = v;
}
if (best === '') continue;
const id = `${row.domain}.${row.object}|${JSON.stringify(row.key)}`;
if (seen.has(id)) continue;
seen.add(id);
out.push(`${row.domain}.${row.object} ${Object.values(row.key).map(String).join('/')}(命中「${best}」)`);
}
return out.length > MAX_HITS ? [] : out;
}
/**
* 命中之后那条观察的全文 —— **它是模型下一拍的行动指南**(与拒回话术同一条纪律):
* 只说归属这个事实与「别改到没点名的行上」,不替模型决定改什么。
*/
export function noteAnchorTitle(hits: readonly string[]): string {
return (
`意见按字面点名对上了本单写下的这几行:${hits.join('、')} —— ` +
'要按意见改行,先认这几行的归属;点名的行缺什么(比如还没有那样的联系人),' +
'改的是这一行自己的内容,不是把改动挪到别的行头上'
);
}
+179 -26
View File
@@ -42,21 +42,45 @@ function rowsOf<T>(result: unknown): T[] {
return Array.isArray(rows) ? (rows as T[]) : []; return Array.isArray(rows) ? (rows as T[]) : [];
} }
import type { AppEnv } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts';
import { coerceToColumn, missingReadings } from '../wiring/engines.ts'; import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
import { orderChat } from '../chat-order.ts';
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */ /** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
type EditedRow = Readonly<Record<string, string>>; type EditedRow = Readonly<Record<string, string>>;
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */ /** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
type WriteBack = { rows: number; added: number; dropped: string[]; bad: string[] }; type WriteBack = {
rows: number;
added: number;
/**
* **人新增的是哪几行**(主键,2026-08-24 柴浩实测补):从前只有个数「新增 1 行」——
* 模型看不见这一屏,只看得见轨迹;不点名,它就守着自己记忆里的「识别出 1 家」
* 把人加的那家从头无视到尾(计划/回执/收口一行都没它)。撤行早就点名了
* (「撤掉的是人定的,别再写回去」),加行是同一条道理的另一半。
*/
addedKeys: string[];
dropped: string[];
bad: string[];
/**
* **认不出是哪一行、因而没落下的那几行**(2026-08-24 用户实测:
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
*
* 从前这里是一句 `continue` —— 行悄悄没了,而接口照旧回 `{ok:true}`:
* 人看到「保存成功」,东西却不在。**保存说成功,就得真的都存下了**;
* 存不下的必须点名说出来(是哪张表、缺哪几列钥匙),让他补得上。
*/
unkeyed: Array<{ address: string; keys: string[]; hint: string }>;
};
const nothing = (): WriteBack => ({ rows: 0, added: 0, dropped: [], bad: [] }); const nothing = (): WriteBack => ({ rows: 0, added: 0, addedKeys: [], dropped: [], bad: [], unkeyed: [] });
const merge = (into: WriteBack, one: WriteBack): void => { const merge = (into: WriteBack, one: WriteBack): void => {
into.rows += one.rows; into.rows += one.rows;
into.added += one.added; into.added += one.added;
into.addedKeys.push(...one.addedKeys);
into.dropped.push(...one.dropped); into.dropped.push(...one.dropped);
into.bad.push(...one.bad); into.bad.push(...one.bad);
into.unkeyed.push(...one.unkeyed);
}; };
/** /**
@@ -86,6 +110,8 @@ export function resolveRowKey(input: {
cells: Readonly<Record<string, string>>; cells: Readonly<Record<string, string>>;
drafts: ReadonlyArray<{ key: Record<string, unknown> }>; drafts: ReadonlyArray<{ key: Record<string, unknown> }>;
types: Readonly<Record<string, string>>; types: Readonly<Record<string, string>>;
/** 声明了 `auto` 的主键列 —— 见下面那条「空着的自增列不许从别的行借号」 */
autos?: readonly string[];
}): Record<string, string> | undefined { }): Record<string, string> | undefined {
const { keys, cells, drafts, types } = input; const { keys, cells, drafts, types } = input;
const key: Record<string, string> = {}; const key: Record<string, string> = {};
@@ -95,7 +121,16 @@ export function resolveRowKey(input: {
} }
if (Object.keys(key).length === keys.length) return key; if (Object.keys(key).length === keys.length) return key;
const known = findKnownRow(drafts, keys, key); /*
* **空着的自增列不是身份线索,是「给我续个号」**(2026-08-24 确认屏补录联系人逼出):
* 人加的新行联系人编码本来就该空着(自增列不问人),可 findKnownRow 拿着只有
* customer_code 的半把钥匙,会把这一行错配到该客户**已有的草稿联系人行**上 ——
* 编码从那行借来,「新增」当场变「覆盖」:模型刚建的那位被人加的这位顶掉,
* 痕还写着「改了 1 行」。声明了 auto 的列,件上摆着却空着 → 一律不许从别的行
* 借值,留给续号那条路(writeEditedRows 的 mint)。
*/
const barred = new Set((input.autos ?? []).filter((k) => k in cells && (cells[k] ?? '').trim() === ''));
const known = barred.size > 0 ? undefined : findKnownRow(drafts, keys, key);
const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number'; const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number';
/* /*
* **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的): * **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的):
@@ -105,7 +140,19 @@ export function resolveRowKey(input: {
*/ */
for (const k of keys) { for (const k of keys) {
if (key[k] !== undefined) continue; if (key[k] !== undefined) continue;
if (barred.has(k)) continue;
if (known === undefined && isSeq(k)) continue; if (known === undefined && isSeq(k)) continue;
/*
* **件上摆着、他却空着的那一列,不许替他编一个值**(2026-08-24 用户实测的第二面)。
*
* 「全表一致」这条路是给**件上压根没摆**的主键用的(如案号 `round_ref`:
* 一单一号,全表当然一致,基座补它天经地义)。可如果那一列就在件上、人能填、
* 他没填 —— 补出来的值是**替他决定这一行是谁**:表里恰好只有一行时,
* 新加的那行会拿到老行的客户编码,**当场把老行覆盖掉**(屏上两行,库里一行)。
* 序号型主键 2026-08-23 已经因为同一个原因单独绕开过 —— 那是这条规律的一个特例,
* 现在按「他填得了吗」统一判:填得了就必须他填,基座只补他碰不到的那几列。
*/
if (known === undefined && k in cells) continue;
const v = const v =
known === undefined known === undefined
? (() => { ? (() => {
@@ -133,14 +180,24 @@ export function resolveRowKey(input: {
async function writeEditedRows( async function writeEditedRows(
container: Container, container: Container,
taskId: TaskId, taskId: TaskId,
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean }> }, declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean; auto?: boolean | undefined }> },
lines: readonly EditedRow[], lines: readonly EditedRow[],
): Promise<WriteBack> { ): Promise<WriteBack> {
const { domain, object } = declared; const { domain, object } = declared;
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name); const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
const out = nothing(); const out = nothing();
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增 // 本单草稿行 —— 补主键用(件上没摆全的那几列按它认)
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object); const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
/*
* **「真改了吗」要对着叠加后的行比,不能只对草稿**(2026-08-24 落库预览逼出来的):
* 预览回显的是正式现值 + 草稿的叠加 —— 人一格没动按下保存,回传里带着的是回显值;
* 只对草稿比的话,草稿里没有的那几列(如 Itelma 既有的客户类型)全算「改了」,
* 痕上写「改了 2 行」而人一个字没动(日志撒谎),还平白多出一笔草稿。
* 「这一行是不是新增」同理:正式表里有、草稿里没有的行,不是新增。
*/
const page = await container.ledger
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type])); const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
for (const cells of lines) { for (const cells of lines) {
/* /*
@@ -150,7 +207,13 @@ async function writeEditedRows(
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法, * 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。 * 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
*/ */
const key = resolveRowKey({ keys, cells, drafts, types }); const key = resolveRowKey({
keys,
cells,
drafts,
types,
autos: declared.fields.filter((f) => f.primaryKey && f.auto === true).map((f) => f.name),
});
/* /*
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。 * **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
* *
@@ -169,12 +232,73 @@ async function writeEditedRows(
if (coerced.kind === 'value') patch[name] = coerced.value; if (coerced.kind === 'value') patch[name] = coerced.value;
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`); else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
} }
if (key === undefined) continue; if (key === undefined) {
const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k])); const filled = Object.entries(cells).filter(([, v]) => v.trim() !== '');
if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue; /*
* **自增主键空着不是「他没填」,是「不用他填」**(2026-08-24 用户在台账更新确认
* 屏上补录联系人逼出来的):声明标了 `auto` 的主键(联系人编码)本就不该问人 ——
* 录入卡那条路 2026-08-02 就立了这条规矩,件上「加一行」是同一件事的另一个入口。
* 其余主键他都填了、缺的恰好全是 auto 列 → 由基座按声明的续号规则铸
* (同组其余主键值-两位序号,序号数正式版 + 本单草稿,与录入卡同一段 autoKeys)。
*/
const autoCols = keys.filter((k) => declared.fields.find((f) => f.name === k)?.auto === true);
const blankKeys = keys.filter((k) => (cells[k] ?? '').trim() === '');
const mintable =
filled.length > 0 &&
autoCols.length > 0 &&
blankKeys.length > 0 &&
blankKeys.every((k) => autoCols.includes(k)) &&
keys.filter((k) => !autoCols.includes(k)).every((k) => (cells[k] ?? '').trim() !== '');
if (mintable) {
const given = Object.fromEntries(keys.filter((k) => !autoCols.includes(k)).map((k) => [k, (cells[k] ?? '').trim()]));
const minted = await autoKeys(container, taskId, declared as LedgerDeclaration, given);
const newKey = { ...given, ...minted };
if (keys.every((k) => (newKey[k] ?? '') !== '')) {
await container.ledger.draftWrite({ taskId, address: { domain, object }, key: newKey, patch });
out.added += 1;
out.addedKeys.push(rowLabel(newKey, declared.caseKey));
continue;
}
}
/*
* **认不出这一行,就得说出来** —— 从前这里是一句 `continue`:人在件上加了一行、
* 按了保存、接口回 `{ok:true}`,而那一行根本没落下(2026-08-24 用户实测)。
* 键凑不齐只有一种情形:主键那几格他没填(件上摆着、标着必填,但空着)。
* 空行不算 —— 那是「加一行」点出来还没动的骨架,人自己也知道它是空的。
*/
if (filled.length > 0) {
/*
* **只点名他填得了的那几列** —— 件上摆着、却空着的主键格。
* 压根不在件上的主键(如案号 `round_ref`,由基座按全表一致补)不许出现在这句话里:
* 那是基座的活,写进去等于把人指向一扇他打不开的门(拒回指错方向比不指更糟)。
*/
// 自增列不点名 —— 人填不了它(拒回指向打不开的门比不指更糟)
const missing = keys.filter(
(k) => k in cells && (cells[k] ?? '').trim() === '' && declared.fields.find((f) => f.name === k)?.auto !== true,
);
// 人读名优先(件上摆的是「客户编号」,不是 customer_code)—— 取不到就退列名
const label = (name: string): string => {
const found = declared.fields.find((f) => f.name === name) as { label?: string } | undefined;
return found?.label ?? name;
};
out.unkeyed.push({
address: `${domain}.${object}`,
keys: missing,
hint:
missing.length > 0
? `这一行没能落下 —— ${missing.map(label).join('、')}是它的钥匙,填上才存得住`
: '这一行没能落下 —— 认不出它是哪一行(主键这几格件上没摆出来),请报给开发',
});
}
continue;
}
const was = page.rows.find((r) => keys.every((k) => String((r as Record<string, unknown>)[k] ?? '') === key[k]));
if (!rowChanged(was as Record<string, unknown> | undefined, patch)) continue;
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch }); await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
if (was === undefined) out.added += 1; if (was === undefined) {
else out.rows += 1; out.added += 1;
out.addedKeys.push(rowLabel(key, declared.caseKey));
} else out.rows += 1;
} }
return out; return out;
} }
@@ -192,7 +316,7 @@ async function writeEditedRows(
async function dropEditedRows( async function dropEditedRows(
container: Container, container: Container,
taskId: TaskId, taskId: TaskId,
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }, declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
lines: readonly EditedRow[], lines: readonly EditedRow[],
): Promise<WriteBack> { ): Promise<WriteBack> {
const { domain, object } = declared; const { domain, object } = declared;
@@ -208,7 +332,7 @@ async function dropEditedRows(
const one = hit.length === 1 ? hit[0] : undefined; const one = hit.length === 1 ? hit[0] : undefined;
if (one === undefined) continue; if (one === undefined) continue;
const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key }); const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key });
if (gone) out.dropped.push(Object.values(one.key).map(String).join('/')); if (gone) out.dropped.push(rowLabel(one.key, declared.caseKey));
} }
return out; return out;
} }
@@ -483,23 +607,35 @@ export function parseRowKey(raw: string, keys: readonly string[]): Record<string
*/ */
async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> { async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> {
const touched = got.rows + got.added + got.dropped.length; const touched = got.rows + got.added + got.dropped.length;
if (touched === 0 && got.bad.length === 0) return touched; // 一行都没落下也要留痕 —— 那正是最该说出来的一次(从前这里把 unkeyed 一并吞了)
if (touched === 0 && got.bad.length === 0 && got.unkeyed.length === 0) return touched;
const parts: string[] = []; const parts: string[] = [];
if (got.rows > 0) parts.push(`改了 ${got.rows} 行`); if (got.rows > 0) parts.push(`改了 ${got.rows} 行`);
if (got.added > 0) parts.push(`新增 ${got.added} 行`); /*
* **加的行要点名**(2026-08-24 柴浩实测):只写「新增 1 行」,模型守着轨迹里
* 「识别出 1 家」把人加的那家从计划到收口全程无视 —— 这条痕是它下一拍的行动指南,
* 得说清加的是谁、且与既有的行同等作数。
*/
if (got.added > 0) parts.push(`新增 ${got.added} 行(${got.addedKeys.slice(0, 5).join('、')})`);
if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`); if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`);
if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`); if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`);
// 没落下的行要在日志里点名 —— 下一拍模型看的是这条痕,人回看也是它
if (got.unkeyed.length > 0) {
parts.push(`${got.unkeyed.length} 行没落下(${got.unkeyed.map((u) => u.hint).slice(0, 2).join(';')})`);
}
await container.memory.append({ await container.memory.append({
taskId, taskId,
kind: 'human-input', kind: 'human-input',
title: title:
`就地修订成果:${parts.join(';')}` + `就地修订成果:${parts.join(';')}` +
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对'), (got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对') +
(got.added === 0 ? '' : ' —— 新增的行是人定的,与既有的行同等作数:后续每一步按台账现读的全表办,别按你先前识别的行数办'),
detail: { detail: {
rows: got.rows, rows: got.rows,
...(got.added === 0 ? {} : { added: got.added }), ...(got.added === 0 ? {} : { added: got.added, addedKeys: got.addedKeys }),
...(got.dropped.length === 0 ? {} : { dropped: got.dropped }), ...(got.dropped.length === 0 ? {} : { dropped: got.dropped }),
...(got.bad.length === 0 ? {} : { rejected: got.bad }), ...(got.bad.length === 0 ? {} : { rejected: got.bad }),
...(got.unkeyed.length === 0 ? {} : { unkeyed: got.unkeyed }),
}, },
// 版本戳:这条不由活动产出,与引擎那几条同一口径 // 版本戳:这条不由活动产出,与引擎那几条同一口径
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
@@ -529,9 +665,9 @@ async function applyViewTableEdits(
tables: Readonly<Record<string, readonly EditedRow[]>>; tables: Readonly<Record<string, readonly EditedRow[]>>;
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined; drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
}, },
): Promise<number> { ): Promise<{ rows: number; unkeyed: WriteBack['unkeyed'] }> {
const declarations = await declarationsOf(container, taskId); const declarations = await declarationsOf(container, taskId);
if (declarations === undefined) return 0; if (declarations === undefined) return { rows: 0, unkeyed: [] };
const got = nothing(); const got = nothing();
/* /*
* **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。 * **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。
@@ -543,13 +679,15 @@ async function applyViewTableEdits(
for (const { declared, lines } of writesOf(declarations, payload.tables)) { for (const { declared, lines } of writesOf(declarations, payload.tables)) {
merge(got, await writeEditedRows(container, taskId, declared, lines)); merge(got, await writeEditedRows(container, taskId, declared, lines));
} }
return noteEdits(container, taskId, got); return { rows: await noteEdits(container, taskId, got), unkeyed: got.unkeyed };
} }
/** 台账声明的最小形状 —— 这一段只用到这几样 */ /** 台账声明的最小形状 —— 这一段只用到这几样 */
type LedgerDeclaration = { type LedgerDeclaration = {
domain: string; domain: string;
object: string; object: string;
/** 单标识列 —— 摆给人看的行标识里去掉它(rowLabel) */
caseKey?: string | undefined;
fields: ReadonlyArray<{ fields: ReadonlyArray<{
name: string; name: string;
type: string; type: string;
@@ -990,8 +1128,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
} }
} }
// 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选 // 对话流 = 时间线 + 对话卡族,**按任务内序号排**(时钟跨机器会偏,序号不会;见 chat-order.ts)
const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); const merged: ChatMessage[] = orderChat([...chat, ...cards], await container.memory.history(taskId));
// 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏); // 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏);
// 尚无推送时给一份进度概览,不留空屏 // 尚无推送时给一份进度概览,不留空屏
@@ -1191,10 +1329,16 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' }; const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' };
return c.json(body, 409); return c.json(body, 409);
} }
const rows = await applyViewTableEdits(container, taskId, parsed.data); const saved = await applyViewTableEdits(container, taskId, parsed.data);
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值 // 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
if (rows > 0) await container.views.repush(taskId); if (saved.rows > 0) await container.views.repush(taskId);
return c.json({ ok: true, rows }); /*
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
* 仍回 200:别的格子是真落下了,不该因为一行没钥匙把整次保存判死;
* 但把没落下的那几行摆回去,前端据此红字点名,人补上主键再存一次。
*/
return c.json({ ok: true, rows: saved.rows, ...(saved.unkeyed.length === 0 ? {} : { unkeyed: saved.unkeyed }) });
}); });
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */ /** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
@@ -1267,6 +1411,14 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。 * 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
*/ */
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data); await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
/*
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
* 从前 runtime 要等派发器投递、推进器抢到租约、开跑前那一刻才翻成 running ——
* 前端 onSuccess 那一次重取抓到的还是 awaiting-event:对话区(卡状态已落)
* 先变,工作区(读 runtime)要等下一次推送。两块该读同一份事实。
*/
await container.task.wake(outcome.taskId).catch(() => false);
// 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts) // 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts)
void pulse(); void pulse();
return c.json({ ok: true, producesCompletion: outcome.producesCompletion }); return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
@@ -1388,6 +1540,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动, * 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
* 人以为没点上,会再点一次。见 `workers/pulse.ts`。 * 人以为没点上,会再点一次。见 `workers/pulse.ts`。
*/ */
await container.task.wake(cardTaskId).catch(() => false); // 与工单回传同一条理:返回前先标「在办」
void pulse(); void pulse();
return c.json({ ok: true }); return c.json({ ok: true });
} catch (error) { } catch (error) {
+140
View File
@@ -19,6 +19,7 @@ import {
listChannelsRoute, listChannelsRoute,
listActivityMemoryRoute, listActivityMemoryRoute,
listGovTasksRoute, listGovTasksRoute,
ledgerByActivityRoute,
listLedgerObjectsRoute, listLedgerObjectsRoute,
installActivityRoute, installActivityRoute,
listProcessPackagesRoute, listProcessPackagesRoute,
@@ -699,6 +700,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
data, data,
...(message === '' ? {} : { message }), ...(message === '' ? {} : { message }),
}); });
/*
* **勾了「落真库」就在这一单上做个记号** —— 放行合并时主数据不再挡下来
* (见 `container.ts` 的 `isSimulated` 与 `ledger-service` 那道门)。
*
* 为什么落在路由里直接改一列,而不是让 `task.start` 收这个参数:
* 它是**试跑台这一处的破例**,不是任务本身的属性 ——
* 让整个任务模块的契约为一个试跑开关让路,是把临时的东西修进地基里。
*/
if (parsed.data.landMaster) {
await container.db.execute(sql`update sys.task set land_master = true where id = ${task.id}`);
}
container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' }); container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' });
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' }); container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
return c.json({ taskId: task.id, title: task.title }); return c.json({ taskId: task.id, title: task.title });
@@ -1416,6 +1428,134 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json({ objects: await container.ledger.listObjects() }); return c.json({ objects: await container.ledger.listObjects() });
}); });
/**
* **按活动看数据域** —— 「我这个活动写了哪几张表,现在各有几行,刚跑那一单落下了什么」。
*
* 数据只从三处来,一处不编:
* · 表与主键 —— 活动清单的台账声明(谁声明的,就归谁);
* · 行数 —— `describe` + `query` 的 total(正式表的真实行数;没物化就没有这个数,
* **不填 0** —— 「还没建表」与「建了但空着」是两件事);
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
*/
app.get(ledgerByActivityRoute.path, async (c) => {
const denied = await readData(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const [cards, materialized] = await Promise.all([
container.registry.list(),
container.ledger.listObjects(),
]);
const known = new Map(materialized.map((o) => [`${o.domain}.${o.object}`, o]));
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
await container.db.execute(
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft group by domain, object`,
),
);
const drafts = new Map(
draftRows.map((r) => [
`${r.domain}.${r.object}`,
{ n: Number(r.n), at: r.at instanceof Date ? r.at.toISOString() : String(r.at) },
]),
);
/** 每个活动最近几单 —— 「刚跑完那一单」在这儿认得出来 */
const recentRows = rowsOf<{
activity_id: string;
id: string;
title: string;
runtime: string;
terminal: string | null;
at: Date | string;
}>(
await container.db.execute(sql`
select activity_id, id, title, runtime, terminal, coalesce(finished_at, started_at) as at
from (select *, row_number() over (partition by activity_id order by coalesce(finished_at, started_at) desc) as rn
from sys.task) t
where rn <= 3`),
);
const claimed = new Set<string>();
const activities = await Promise.all(
cards.map(async (card) => {
const manifest = await container.registry.manifestOf(card.activityId).catch(() => undefined);
const declared = manifest?.declarations.ledger ?? [];
const tables = await Promise.all(
declared.map(async (d) => {
const address = `${d.domain}.${d.object}`;
claimed.add(address);
const meta = known.get(address);
/*
* 行数要**真去数**:`listObjects` 只说有没有这张表。数不出来(表还没物化)
* 就不给这个数 —— 摆一个 0 会把「活动声明了但一次都没写过」说成「写过、现在空了」。
*/
const total =
meta === undefined
? undefined
: await container.ledger
.query({ address: { domain: d.domain, object: d.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
.then((p) => p.total)
.catch(() => undefined);
const draft = drafts.get(address);
return {
domain: d.domain,
object: d.object,
...(d.label === undefined ? {} : { label: d.label }),
...(d.kind === undefined ? {} : { kind: d.kind }),
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
primaryKey: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
...(total === undefined ? {} : { rows: total }),
drafts: draft?.n ?? 0,
...(draft === undefined ? {} : { draftAt: draft.at }),
};
}),
);
return {
activityId: card.activityId,
name: card.name ?? card.activityId,
/*
* 版本取**此刻在用的那一版** —— 按装入时刻取最新的 active。
* 不能拿「第一个 active」:一个活动装过二十几版,数组里前面躺的是老版本,
* 屏上就会写着 1.0.0 而实际跑的是 2.1.0(**界面不撒谎**,版本号尤其不能错)。
*/
...(((): { version?: string } => {
const usable = (card.packageVersions ?? []).filter((v) => v.state !== 'uninstalled');
const newest = [...usable].sort((a, b) => (a.installedAt < b.installedAt ? 1 : -1))[0];
return newest === undefined ? {} : { version: newest.version };
})()),
tables,
recent: recentRows
.filter((r) => r.activity_id === card.activityId)
.map((r) => ({
id: r.id,
title: r.title,
runtime: r.runtime,
...(r.terminal === null ? {} : { terminal: r.terminal }),
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
})),
};
}),
);
/* 库里有、没人声明的:多半是退役活动留下的空壳 —— 单列一档,不混进上面装作正常 */
const unclaimed = await Promise.all(
materialized
.filter((o) => !claimed.has(`${o.domain}.${o.object}`))
.map(async (o) => ({
domain: o.domain,
object: o.object,
...(o.label === undefined ? {} : { label: o.label }),
...((await container.ledger
.query({ address: { domain: o.domain, object: o.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
.then((p) => ({ rows: p.total }))
.catch(() => ({}))) as { rows?: number }),
})),
);
return c.json({ activities, unclaimed });
});
app.get(browseLedgerRoute.path, async (c) => { app.get(browseLedgerRoute.path, async (c) => {
const denied = await readData(c.get('account')); const denied = await readData(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied)); if (denied) return c.json(denied, denyStatus(denied));
+8 -10
View File
@@ -23,16 +23,14 @@ import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
* A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」, * A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」,
* 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。 * 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。
*/ */
const REAL_PACKAGES = [ /*
'quote-intake', * 2026-08-24 用户定:**只装 E04 / I01**,A1–A7 报价链六个包从系统里卸载(源码留着,
'quote-alternative', * 只是不再播种、不再安装;库里已装的版本已置 uninstalled)。要装回来把名字加回这里
'quote-sourcing', * 再跑 activities:install 即可 —— 它们的 dist 照旧随 activities:build 产出。
'quote-pricing', * 退役名单:quote-intake · quote-alternative · quote-sourcing · quote-pricing ·
'quote-negotiate', * quote-negotiate · quote-archive
'quote-archive', */
'crm-relationship-coverage', const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit'] as const;
'brand-content-toolkit',
] as const;
/** /**
* 活动包根目录。 * 活动包根目录。
+12
View File
@@ -344,8 +344,20 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({
spec: { kind: 'registration' }, spec: { kind: 'registration' },
}); });
/**
* 2026-08-24 用户定:报价链 A1–A7 已从系统卸载,建在它们上面的「统一报价」流程包、
* 两个 RFQ 触发器与两个询价邮箱**不再播种**(装不上的活动激活流程包会被闭合校验拦下)。
* `MVP_PACKAGE` / 触发器常量原样留着 —— 它们是流程编译与触发装配那几组测试的夹具,
* 也是日后装回报价链时的种子。要恢复播种把这一位拨回 true。
*/
const SEED_QUOTE_FLOW = false;
export async function seedMvpFlow(container: Container): Promise<void> { export async function seedMvpFlow(container: Container): Promise<void> {
const log = (line: string) => process.stdout.write(line + '\n'); const log = (line: string) => process.stdout.write(line + '\n');
if (!SEED_QUOTE_FLOW) {
log('④ 流程包「统一报价」与 RFQ 触发器/邮箱:报价链已卸载(2026-08-24),不播种');
return;
}
/* /*
* 保存即生效,且**内容没变就不再存新版**。 * 保存即生效,且**内容没变就不再存新版**。
+139 -3
View File
@@ -236,11 +236,27 @@ export function rowReadings(
* 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里, * 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里,
* 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。 * 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。
*/ */
/**
* **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。
*
* 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、
* 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里**
* 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。
* `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。
*/
export function rowLabel(id: Readonly<Record<string, unknown>>, caseKey: string | undefined): string {
const parts = Object.entries(id)
.filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '')
.map(([, v]) => String(v));
return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/');
}
export function missingReadings( export function missingReadings(
mine: Readonly<Record<string, readonly unknown[]>>, mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ declared: ReadonlyArray<{
domain: string; domain: string;
object: string; object: string;
caseKey?: string | undefined;
fields: ReadonlyArray<{ fields: ReadonlyArray<{
name: string; name: string;
label?: string | undefined; label?: string | undefined;
@@ -252,7 +268,7 @@ export function missingReadings(
}>, }>,
): Record<string, string | number> { ): Record<string, string | number> {
const out: Record<string, string | number> = {}; const out: Record<string, string | number> = {};
for (const { domain, object, fields } of declared) { for (const { domain, object, caseKey, fields } of declared) {
const rows = mine[`mine:${domain}.${object}`] ?? []; const rows = mine[`mine:${domain}.${object}`] ?? [];
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name); const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
const blank = (one: Record<string, unknown>, c: string): boolean => const blank = (one: Record<string, unknown>, c: string): boolean =>
@@ -282,7 +298,7 @@ export function missingReadings(
if (gaps.length === 0) continue; if (gaps.length === 0) continue;
const id: Record<string, string> = {}; const id: Record<string, string> = {};
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]); for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
bad.push(`${Object.values(id).join('/')} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`); bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
} }
out[`${domain}_${object}_${suffix}`] = bad.length; out[`${domain}_${object}_${suffix}`] = bad.length;
if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';'); if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';');
@@ -331,6 +347,65 @@ export function tallyReadings(
return out; return out;
} }
/**
* **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。
*
* 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」):
* 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据
* 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。
* 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。
* 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数;
* 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数
* 恰恰就是这次数漏的那一个。
*
* 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。
* 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。
*/
export function distinctReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>;
}>,
): Record<string, number> {
const out: Record<string, number> = {};
for (const { domain, object, fields } of declared) {
for (const field of fields) {
if (field.distinct !== true) continue;
const rows = mine[`mine:${domain}.${object}`] ?? [];
const seen = new Set<string>();
for (const row of rows) {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const value = String(one[field.name] ?? '').trim();
if (value !== '') seen.add(value);
}
out[`${domain}_${object}_distinct_${field.name}`] = seen.size;
}
}
return out;
}
/**
* **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。
*
* 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿),
* 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、
* Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。
* 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加;
* 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤:
* 本轮碰过哪几行,就摆那几行的叠加后全貌。
*/
export function touchedRows(
merged: ReadonlyArray<unknown>,
draftKeys: ReadonlyArray<Readonly<Record<string, unknown>>>,
): unknown[] {
const rows = merged.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null);
return draftKeys
.map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? ''))))
.filter((r) => r !== undefined);
}
/** /**
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。 * **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
* *
@@ -782,12 +857,25 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。 * (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。
*/ */
const mine: Record<string, unknown[]> = {}; const mine: Record<string, unknown[]> = {};
const draftKeysOf: Record<string, Array<Record<string, unknown>>> = {};
for (const draft of await deps.ledger.taskDrafts(taskId)) { for (const draft of await deps.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch }); (mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
(draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key });
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) { for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
if (value !== null && value !== undefined) scope[field] = value; if (value !== null && value !== undefined) scope[field] = value;
} }
} }
/*
* `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿),
* 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有
* 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 ——
* 内容与 mine/整表重叠,白占截断窗)。
*/
const touched: Record<string, unknown[]> = {};
for (const [address, draftKeys] of Object.entries(draftKeysOf)) {
const merged = scope[address];
if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys);
}
/* /*
* **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。 * **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。
@@ -826,8 +914,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...rowKeyList(mine, declared), ...rowKeyList(mine, declared),
...missingReadings(mine, declared), ...missingReadings(mine, declared),
...tallyReadings(mine, declared), ...tallyReadings(mine, declared),
...distinctReadings(mine, declared),
...toolCallReadings(trail, manifest.declarations.tools), ...toolCallReadings(trail, manifest.declarations.tools),
...mine, ...mine,
...touched,
}; };
return enriched; return enriched;
}, },
@@ -845,7 +935,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
object: d.object, object: d.object,
label: d.label ?? `${d.domain}.${d.object}`, label: d.label ?? `${d.domain}.${d.object}`,
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type })), fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
})); }));
}, },
/** /**
@@ -866,6 +956,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
object: string; object: string;
key: Record<string, string | number>; key: Record<string, string | number>;
patch: Record<string, unknown>; patch: Record<string, unknown>;
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
dryRun?: boolean;
}) { }) {
const allowed = await taskData.writable(input.taskId); const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
@@ -882,6 +974,39 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`); throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
} }
/*
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
* contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 +
* 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住;
* 现在写入那一刻当场拒,拒回指路(墙要配路)。
*/
for (const field of target.fields) {
if (field.refersTo === undefined) continue;
const raw = input.key[field.name] ?? input.patch[field.name];
const value = raw === undefined || raw === null ? '' : String(raw).trim();
if (value === '') continue;
const [rd, ro, rc] = field.refersTo.split('.');
if (rd === undefined || ro === undefined || rc === undefined) continue;
const referenced = await deps.ledger.taskView(input.taskId, {
address: { domain: rd, object: ro },
where: {},
orderBy: [],
limit: 500,
offset: 0,
});
const exists = referenced.rows.some((r) => String((r as Record<string, unknown>)[rc] ?? '') === value);
if (!exists) {
const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`;
throw new Error(
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
'或者把这一笔挂到已存在的那一行名下',
);
}
}
/* /*
* **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。 * **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。
* *
@@ -921,6 +1046,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
before === undefined || before === undefined ||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
if (input.dryRun === true) return { changed };
await deps.ledger.draftWrite({ await deps.ledger.draftWrite({
taskId: input.taskId, taskId: input.taskId,
address: { domain: input.domain, object: input.object }, address: { domain: input.domain, object: input.object },
@@ -1933,6 +2059,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
} }
const domain = input.address.slice(0, at); const domain = input.address.slice(0, at);
const object = input.address.slice(at + 1); const object = input.address.slice(at + 1);
/*
* **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写,
* 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」——
* 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6),
* 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。
* 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。
*/
for (const row of input.rows) {
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true });
}
for (const row of input.rows) { for (const row of input.rows) {
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch }); await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch });
} }
@@ -0,0 +1,77 @@
/**
* 跨进程租约 —— **两个后端连同一个库时,一张单只许一个在推**。
*
* 2026-08-24 柴浩那一单就死在这上头:本机与局域网另一台各跑一个后端,两边的推进器
* 同时推同一张单,两拍并发写一份 `loop_state`,后写的整份覆盖先写的 ——
* 「给 S2 发过卡」那个事实(worldTouched)被抹掉、S1 被收了两遍;
* 随后 finish 那道门说「从没发过卡」、重发那道门说「人已答过」,两堵墙对着撞,五拍熔断。
* 进程内那把 `taskMutex` 对这种局面无能为力(它自己的注释里就写着)。
*
* 这里测的是**发给库的那句话对不对**:判与占必须在同一条 SQL 里
* (分两句写就是经典竞态),且带过期时间(进程崩了不锁死)。
* SQL 的并发语义另以两条真连接对着真库跑过 20 轮 —— 每轮恰好一个赢。
*/
import { describe, expect, it } from 'vitest';
import { claimTask, releaseTask, LEASE_MS } from '../task-claim.ts';
/** 记下发出去的 SQL —— drizzle 的 sql`` 片段拼回可读字符串 */
function fakeDb(rowCount: number) {
const sent: string[] = [];
return {
sent,
execute: async (q: unknown) => {
const chunks = (q as { queryChunks?: unknown[] }).queryChunks ?? [];
sent.push(
chunks
.map((c) => {
const one = c as { value?: unknown };
return Array.isArray(one.value) ? one.value.join('') : String(one.value ?? '?');
})
.join(''),
);
return { rows: Array.from({ length: rowCount }, () => ({ id: 't1' })) };
},
};
}
/** 传给被测函数那一下再转 —— 测试桩不必长得跟真 Db 一样 */
const asDb = (stub: unknown): Parameters<typeof claimTask>[0] => stub as Parameters<typeof claimTask>[0];
describe('claimTask —— 抢这一张单的推进权', () => {
it('抢到了(UPDATE 命中一行)返回 true', async () => {
expect(await claimTask(asDb(fakeDb(1)), 't1')).toBe(true);
});
it('**没抢到返回 false** —— 别的进程正推着,这一轮就跳过', async () => {
expect(await claimTask(asDb(fakeDb(0)), 't1')).toBe(false);
});
it('判与占在同一条 SQL 里:where 带「没人占 或 已过期」,并 returning', async () => {
const db = fakeDb(1);
await claimTask(asDb(db), 't1');
const sql = db.sent[0] ?? '';
expect(sql).toContain('update sys.task');
expect(sql).toContain('advancing_until is null or advancing_until < now()');
expect(sql).toContain('returning id');
});
it('**租约带过期时间** —— 进程崩了不把这张单锁死', async () => {
const db = fakeDb(1);
await claimTask(asDb(db), 't1');
expect(db.sent[0] ?? '').toContain('milliseconds');
// 默认租约比最慢的一轮宽得多,又不至于锁上半天
expect(LEASE_MS).toBeGreaterThanOrEqual(60_000);
expect(LEASE_MS).toBeLessThanOrEqual(15 * 60_000);
});
it('还锁就是清空那一列', async () => {
const db = fakeDb(1);
await releaseTask(asDb(db), 't1');
expect(db.sent[0] ?? '').toContain('advancing_until = null');
});
it('**还锁失败不抛** —— 这一拍的成果不该被还锁连累掉', async () => {
const broken = { execute: async () => Promise.reject(new Error('库连不上')) };
await expect(releaseTask(asDb(broken), 't1')).resolves.toBeUndefined();
});
});
+12
View File
@@ -13,6 +13,7 @@ import type { Delivery } from '@modules/event';
import { claimPendingDeliveries } from '@modules/event'; import { claimPendingDeliveries } from '@modules/event';
import type { Container } from '../container.ts'; import type { Container } from '../container.ts';
import { taskMutex } from './task-lock.ts'; import { taskMutex } from './task-lock.ts';
import { claimTask, releaseTask } from './task-claim.ts';
/** 消费者:按目标类型分派 */ /** 消费者:按目标类型分派 */
export type Consumer = (delivery: Delivery, container: Container) => Promise<void>; export type Consumer = (delivery: Delivery, container: Container) => Promise<void>;
@@ -31,6 +32,14 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
for (const delivery of batch) { for (const delivery of batch) {
// 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮 // 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮
await taskMutex.tryRun(`${delivery.target.kind}:${String(delivery.target.id)}`, async () => { await taskMutex.tryRun(`${delivery.target.kind}:${String(delivery.target.id)}`, async () => {
/*
* **投递也会推一拍**(consumer 里 `task.deliver` 末尾就是 `tick`)——
* 所以它同样要抢库里那份租约:上面那把锁只在本进程内算数,而两个后端连同一个库时
* (2026-08-24 实测)投递与另一台的推进器会并发写同一份 `loop_state`。
* 抢不到就先不处理,这一条留给下一轮 —— 它还在 pending,不会丢。
*/
const taskId = delivery.target.kind === 'task' ? String(delivery.target.id) : undefined;
if (taskId !== undefined && !(await claimTask(container.db, taskId))) return;
try { try {
const consumer = deps.consumers.get(delivery.target.kind); const consumer = deps.consumers.get(delivery.target.kind);
if (consumer !== undefined) { if (consumer !== undefined) {
@@ -53,6 +62,9 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
handled += 1; handled += 1;
} catch (error) { } catch (error) {
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error)); await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
} finally {
// 推完就还租约 —— 不还也不会锁死(会过期),还了下一条投递立刻接得上
if (taskId !== undefined) await releaseTask(container.db, taskId);
} }
}); });
} }
+18 -1
View File
@@ -9,6 +9,7 @@ import { and, eq, sql } from 'drizzle-orm';
import { taskTable } from '@modules/task'; import { taskTable } from '@modules/task';
import type { Container } from '../container.ts'; import type { Container } from '../container.ts';
import { taskMutex } from './task-lock.ts'; import { taskMutex } from './task-lock.ts';
import { claimTask, releaseTask } from './task-claim.ts';
export type AdvancerOptions = { export type AdvancerOptions = {
readonly container: Container; readonly container: Container;
@@ -51,6 +52,22 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
* 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。 * 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。
*/ */
const ran = await taskMutex.tryRun(`task:${row.id}`, async () => { const ran = await taskMutex.tryRun(`task:${row.id}`, async () => {
/*
* **再抢一次库里的租约** —— 上面那把锁只在本进程内算数。
*
* 两个后端连同一个库时(本机 + 局域网另一台,2026-08-24 实测),两边的推进器
* 各扫各的、同时推同一张单:两拍并发写一份 `loop_state`,后写的整份覆盖先写的,
* 「发过卡」这个事实就此消失,门与门对着撞,五拍熔断。
* 抢不到说明别的进程正推着 —— 跳过,它推完自然轮得到。
*/
if (!(await claimTask(container.db, row.id))) return;
try {
return await advanceOne();
} finally {
await releaseTask(container.db, row.id);
}
async function advanceOne(): Promise<void> {
/** 这一轮之前它是什么样 —— 变了才广播,不然每两秒给在线浏览器发一次噪声 */ /** 这一轮之前它是什么样 —— 变了才广播,不然每两秒给在线浏览器发一次噪声 */
const before = await container.task.get(row.id as never).catch(() => undefined); const before = await container.task.get(row.id as never).catch(() => undefined);
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) { for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
@@ -95,7 +112,7 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
break; break;
} }
} }
return true; }
}); });
void ran; void ran;
} }
+59
View File
@@ -0,0 +1,59 @@
/**
* 任务推进的**跨进程租约** —— 一张单同一时刻只许一个后端在推。
*
* `task-lock.ts` 那把是**进程内**的互斥,它开头就写着:「MVP 单进程,进程内互斥就够;
* 多进程部署时再叠一层」。2026-08-24 那一天就撞上了:本机与局域网另一台机器上各跑着
* 一个后端,连的是同一个库,两边的推进器各自 2 秒扫一次「running 的单」,
* **同时推同一张**。两拍并发跑在同一份 `loop_state` 上,后写的整份覆盖先写的 ——
* 「给 S2 发过卡」这个事实被抹掉、S1 被收了两遍,随后 finish 与重发两道门对着撞,
* 连拒五拍熔断(柴浩那一单)。
*
* 为什么不用 `pg_try_advisory_lock`:它是**会话级**的,而我们经连接池取连接 ——
* 加锁与解锁多半落在不同连接上。`pg_try_advisory_xact_lock` 是事务级的,而推一拍
* 要调模型、要写好几张表,不该整个包在一个事务里。
*
* 所以用**库里的一列**当租约:抢到才推,推完就还;**带过期时间** —— 进程崩了不锁死,
* 租约一过别的进程照样接得上。这是任务队列的老办法,跨进程、跨机器都算数。
*/
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
/**
* 租约多久过期。推一拍最慢是一次模型调用(实测 1.5-5 秒,关思考之前有过 56 秒),
* 而推进器一轮最多连推 3 拍 —— 给到 5 分钟:比最慢的一轮宽得多,又不至于
* 让崩掉的进程把一张单锁上半天。
*/
export const LEASE_MS = 5 * 60 * 1000;
function rowsOf(result: unknown): unknown[] {
if (Array.isArray(result)) return result;
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows : [];
}
/**
* 抢这一张单的推进权。**抢到返回 true**,没抢到(别的进程正推着)返回 false。
*
* 一条 SQL 里判 + 占:`where advancing_until is null or advancing_until < now()` ——
* 两个进程同时来,PG 的行锁保证只有一个的 UPDATE 命中,另一个 RETURNING 空。
* 判和占分两句写是经典的竞态,这里不给它机会。
*/
export async function claimTask(db: Db, taskId: string, leaseMs: number = LEASE_MS): Promise<boolean> {
const result = await db.execute(sql`
update sys.task
set advancing_until = now() + (${leaseMs} || ' milliseconds')::interval
where id = ${taskId}
and (advancing_until is null or advancing_until < now())
returning id`);
return rowsOf(result).length > 0;
}
/**
* 推完就还 —— 不还也不会锁死(租约会过期),但还了下一轮就能接着推,
* 不必干等到过期。**失败不抛**:还锁失败不该把这一拍的成果连累掉。
*/
export async function releaseTask(db: Db, taskId: string): Promise<void> {
await db
.execute(sql`update sys.task set advancing_until = null where id = ${taskId}`)
.catch(() => undefined);
}
@@ -107,6 +107,15 @@ describe('判据谓词的名字必须绑得上', () => {
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]); expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
}); });
it('`distinct` 声明出的名字也绑得上 —— 覆盖闭合判据的落点(2026-08-24)', () => {
const bound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
(bound.declarations.ledger[0]!.fields[0] as { distinct?: boolean }).distinct = true;
expect(codes(bound)).toEqual([]);
// 没标 distinct,这个名字就不存在 —— 拒装,别让判据指着一个永远为假的读数
const unbound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
expect(codes(unbound)).toEqual(['acceptance-predicate-unbound']);
});
it('L3 不机检 —— 没谓词也不报', () => { it('L3 不机检 —— 没谓词也不报', () => {
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]); expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
}); });
@@ -295,3 +304,47 @@ describe('工具调用读数绑得上', () => {
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0); expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
}); });
}); });
/* ── refersTo 的引用目标必须声明过(2026-08-24)──────────────────────── */
import { referenceFindings } from '../internal/domain/static-audit.ts';
const REF_LEDGER = (refersTo: string) => ({
declarations: {
ledger: [
{
domain: 'crm_coverage',
object: 'plan',
kind: 'record' as const,
caseKey: 'round_ref',
label: '计划',
fields: [
{ name: 'round_ref', type: 'text' as const, primaryKey: true, masked: false },
{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false, refersTo },
],
},
{
domain: 'customer',
object: 'contact',
kind: 'master' as const,
label: '联系人',
fields: [{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false }],
},
],
},
});
describe('refersTo 目标要在本活动的声明里 —— 写错的地址会变成永远查无此值的死墙', () => {
it('指向声明过的对象与列 —— 放行', () => {
expect(referenceFindings(REF_LEDGER('customer.contact.contact_code'))).toEqual([]);
});
it('表名写错 —— 拒装', () => {
const found = referenceFindings(REF_LEDGER('customer.contacts.contact_code'));
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
});
it('列名写错 —— 拒装', () => {
const found = referenceFindings(REF_LEDGER('customer.contact.code'));
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
});
});
@@ -290,6 +290,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, ''); const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`); if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
} }
/*
* 不同值计数读数 `域_对象_distinct_<列>`(2026-08-24 起)——
* 「每家候选客户都进了计划」这类**覆盖闭合**判据的落点:
* 计划行数 ≠ 客户数,闭合要成为机检就得有「有几家不同客户」这个基座数。
*/
if (field.distinct === true) out.add(`${table.domain}_${table.object}_distinct_${field.name}`);
} }
} }
/* /*
@@ -413,6 +419,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
out.push(...danglingListeners(manifest)); out.push(...danglingListeners(manifest));
out.push(...deliverableAddressFindings(manifest)); out.push(...deliverableAddressFindings(manifest));
out.push(...referenceFindings(manifest));
out.push(...ledgerKindFindings(manifest)); out.push(...ledgerKindFindings(manifest));
out.push(...acceptanceLevelFindings(manifest)); out.push(...acceptanceLevelFindings(manifest));
out.push(...loopFindings(manifest)); out.push(...loopFindings(manifest));
@@ -420,6 +427,36 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
return out; return out;
} }
/**
* `refersTo` 的引用目标必须是**本活动声明过的对象与列**(2026-08-24 立)。
*
* 引用检查在写入那一刻执行 —— 目标地址写错(表名手滑、列名不存在)的话,
* 那道墙就成了「永远查无此值」的死墙,活动装上就跑不动。与判据谓词绑定同一个道理:
* 这种错只有跑到写入那一刻才炸,必须拦在安装期。
*/
export function referenceFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const table of manifest.declarations.ledger) {
for (const field of table.fields) {
if (field.refersTo === undefined) continue;
const [rd, ro, rc] = field.refersTo.split('.');
const referenced = manifest.declarations.ledger.find((l) => l.domain === rd && l.object === ro);
const column = referenced?.fields.find((f) => f.name === rc);
if (referenced !== undefined && column !== undefined) continue;
out.push({
code: 'reference-target-unbound',
severity: 'error',
message:
`${table.domain}.${table.object}.${field.name} 的 refersTo 指向 \`${field.refersTo}\`,` +
`而本活动的台账声明里${referenced === undefined ? '没有这张表' : '这张表没有这一列'} —— ` +
'引用检查会变成「永远查无此值」的死墙,装上就跑不动。改成声明里真实存在的 域.对象.列。',
at: `declarations.ledger ${table.domain}.${table.object}.${field.name}`,
});
}
}
return out;
}
export function auditPassed(findings: readonly StaticAuditFinding[]): boolean { export function auditPassed(findings: readonly StaticAuditFinding[]): boolean {
return !findings.some((f) => f.severity === 'error'); return !findings.some((f) => f.severity === 'error');
} }
@@ -138,6 +138,39 @@ describe('同一轮的交代与卡并成一条', () => {
expect(out).toHaveLength(3); expect(out).toHaveLength(3);
}); });
it('**隔着一张决策卡(另一路合并、不进对话项)也不并** —— 上一步的话不许认领这一步的卡(2026-08-25 实测)', () => {
// I01 实测形:说「请审核」→ 发决策卡 → 人答 → 收口 → 发验收卡;验收卡曾被挂到「请审核」上,排到决策卡前面
const out = projectChat([
say('n', '两份内容初稿已草拟完成,请审核'),
entry('card-sent', { id: 'd', title: '内容初稿已草拟,共 2 份,请审核。', detail: { k: 'decision', kind: 'chat-card', id: 'dc-1' } }),
entry('card-reply', { id: 'r', title: '内容初稿已草拟,共 2 份,请审核。', detail: { value: 'approve' } }),
entry('observation', { id: 'o', title: '事件到达(human):dc-1', detail: { kind: 'human' } }),
card('c', '收口验收', 'wo-7'),
]);
const ai = out.filter((m) => m.k === 'ai');
expect(ai).toHaveLength(2);
expect(ai[0]?.k === 'ai' ? ai[0].ref : 'x').toBeUndefined(); // 「请审核」那句话不带工单
expect(ai[1]?.k === 'ai' ? ai[1].ref : undefined).toBe('wo-7'); // 验收卡自成一条,排在后面
});
it('隔着人的答复 / 内部观察也不并 —— 「刚说过」按记忆序列判,不按对话项判', () => {
const out = projectChat([
say('n', '我去查一下'),
entry('observation', { id: 'o', title: '这一拍在往下办事', detail: { internal: true } }),
card('c', '受理需求确认', 'wo-8'),
]);
expect(out.filter((m) => m.k === 'ai')).toHaveLength(2);
});
it('装配戳夹在中间不算隔开 —— 它是信封件,不是一拍', () => {
const out = projectChat([
say('n', '请核对'),
entry('thought', { id: 't', title: '装配技能:x@1.0.0' }),
card('c', '受理需求确认', 'wo-9'),
]);
expect(out).toHaveLength(1);
});
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => { it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组) // 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]); const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
@@ -70,13 +70,25 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。 * 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
*/ */
let lastIsTerminal = false; let lastIsTerminal = false;
/**
* **「刚说过话」要按记忆序列判,不按对话项判**(2026-08-25 柴浩:「上一步的卡片跑到
* 这一步的下面来了」)。从前只看 `out` 的末项是不是还没挂卡的 AI 发言 —— 可决策卡 /
* 录入卡的 card-sent、人的答复、内部观察都**不进 out**,在它眼里那句话一直「空着」:
* 实测 I01 一单,「两份初稿…请审核」那句话后面发的是决策卡(另一路合并),41 秒后
* 人答完、模型收口、**验收卡被挂到了上一步那句话上**,以它的时刻排到了决策卡前面。
* 紧邻 = 这张卡与那句话之间没有任何别的记忆条目(装配戳这类信封件除外)。
*/
let sayable = false;
for (const e of entries) { for (const e of entries) {
const id = cardIdSchema.parse(e.id); const id = cardIdSchema.parse(e.id);
const envelope = e.kind === 'thought' && e.title.startsWith('装配技能');
if (e.kind === 'narration') { if (e.kind === 'narration') {
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) }); out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
lastIsTerminal = false; lastIsTerminal = false;
sayable = true;
continue; continue;
} }
if (!envelope && e.kind !== 'card-sent') sayable = false;
/* /*
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)—— * 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」, * 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
@@ -126,8 +138,11 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
} }
if (e.kind === 'card-sent') { if (e.kind === 'card-sent') {
const ref = workOrderRef(e.detail); const ref = workOrderRef(e.detail);
// 跨人卡进对方的消息要素,不在本任务对话流上露面 // 跨人卡 / 决策卡 / 录入卡进别的路,不在这里露面 —— 但它们隔在话与工单之间,那句话就不再「刚说过」
if (ref === undefined) continue; if (ref === undefined) {
sayable = false;
continue;
}
// 验收件请人裁决 = 判据;执行件派工 = 指导 // 验收件请人裁决 = 判据;执行件派工 = 指导
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const); const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
/* /*
@@ -141,10 +156,12 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。 * 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
*/ */
const last = out[out.length - 1]; const last = out[out.length - 1];
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) { if (sayable && last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) }; out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
sayable = false; // 一句话只领一张卡
continue; continue;
} }
sayable = false;
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) }); out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
continue; continue;
} }
@@ -168,7 +168,7 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
* `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。 * `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。
* 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。 * 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。
*/ */
const address = path.replace(/^mine:/, ''); const address = path.replace(/^(?:mine|touched):/, '');
const body = rows const body = rows
.map((row, index) => { .map((row, index) => {
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>; const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
@@ -59,7 +59,8 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
by, by,
}; };
await deps.store.appendMessage(taskId, message); await deps.store.appendMessage(taskId, message);
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by } }); // 带上消息 id —— 对话区按记忆序号排序时要靠它认出这条话的落点(chat-order.ts)
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by, id: message.id } });
await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } }); await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } });
broadcast({ kind: 'chat', taskId, message }); broadcast({ kind: 'chat', taskId, message });
return message; return message;
@@ -41,4 +41,44 @@ describe('parseJsonLoose:模型出参的宽容提取', () => {
it('实在不是 JSON:undefined,不抛', () => { it('实在不是 JSON:undefined,不抛', () => {
expect(parseJsonLoose('这不是 JSON')).toBeUndefined(); expect(parseJsonLoose('这不是 JSON')).toBeUndefined();
}); });
/*
* 2026-08-24 实测形(E04 收口拍,全流程括号最深的输出):模型在 profile 行上
* **少吐了一个 `}`** —— 行对象没关上,后面关 rows 的 `]` 被 drop 档当噪声丢掉,
* 整段错位、到尾配不平,两连败退化成兜底卡。close 档把欠的那个补上。
*/
it('**少吐一个闭括号:自动补上,整拍不作废**(实测形状,E04 收口拍)', () => {
// 第一行 patch 后少了关「行对象」的 } —— ]}]},{ 处 drop 档会把 ] 丢掉
const slip =
'{"say":"收口","actions":[' +
'{"kind":"write","address":"customer.profile","rows":[{"key":{"customer_code":"CU-0142"},' +
'"patch":{"decision_chain":{"nodes":[{"node":"bt-01","covered":true}]}}]},' +
'{"kind":"write","address":"crm_coverage.candidate","rows":[{"key":{"customer_code":"CU-0142"},"patch":{"status":"covered"}}]}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string; address: string }> };
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'write']);
expect(got.actions[1]?.address).toBe('crm_coverage.candidate');
});
it('输出被截断(结尾整段闭括号没吐):欠的全补上,能救就救', () => {
const cut = '{"say":"写一半断了","actions":[{"kind":"write","rows":[{"patch":{"a":1';
const got = parseJsonLoose(cut) as { actions: Array<{ kind: string }> };
expect(got.actions[0]?.kind).toBe('write');
});
it('**少括号的第二形:行对象没关、后面直接跟下一行** —— 对象里逗号后冒出 { 就是上一个没关(实测形状)', () => {
// rows 第一行的 } 少了,紧接着 ,{"key"… —— 括号到尾能配平,strict 与前两档全看不出
const slip =
'{"say":"收口","actions":[{"kind":"write","address":"customer.profile","rows":[' +
'{"key":{"customer_code":"CU-0142"},"patch":{"decision_chain":{"nodes":[{"node":"bt-01"}]}},' +
'{"key":{"customer_code":"CU-E2E-ADD"},"patch":{"coverage_score":0}}]}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ rows: Array<{ key: { customer_code: string } }> }> };
expect(got.actions[0]?.rows.map((r) => r.key.customer_code)).toEqual(['CU-0142', 'CU-E2E-ADD']);
});
it('两档顺序不能反:多括号那形仍走 drop 档,行动串一个不丢', () => {
// 与上面「多吐一个闭括号」同形 —— close 档若先跑,会提前关掉 actions 把 finish 截掉
const slip = '{"say":"x","actions":[{"kind":"write","rows":[{"patch":{"t":"A"}}]}},{"kind":"finish","itemId":"S1"}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> };
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
});
}); });
@@ -95,7 +95,12 @@ export function parseJsonLoose(text: string): unknown {
try { try {
return JSON.parse(candidate); return JSON.parse(candidate);
} catch { } catch {
return firstBalanced(candidate); /*
* 两档修复**有序**:先按「多余闭括号当噪声丢」(2026-08-02 那形),
* 不行再按「缺失闭括号自动补」(2026-08-24 那形)—— 顺序不能反:
* 补括号那一档对「多括号」的输出会提前把外层对象关掉,行动串被截半。
*/
return firstBalanced(candidate, 'drop') ?? firstBalanced(candidate, 'close');
} }
} }
@@ -117,20 +122,39 @@ export function parseJsonLoose(text: string): unknown {
* 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。 * 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。
* 字符串与转义照认,否则值里带 `}` 的会把栈带偏。 * 字符串与转义照认,否则值里带 `}` 的会把栈带偏。
*/ */
function firstBalanced(text: string): unknown { /*
* ── 2026-08-24:少一个闭括号的形,丢字符治不了,得**补** ────────────────
*
* E04 收口那一拍是全流程括号最深的输出(decision_chain + interaction_timeline
* 两个嵌套 JSON 列),关思考的模型在 profile 行上少吐了一个 `}`:
* `…"contactCode":"CU-0142-01"}]}}]}` —— 行对象没关上,后面的 `]`(关 rows)
* 在栈眼里就成了「对不上的闭括号」被当噪声丢掉,整段从此错位、到尾都配不平,
* 解析两连败,六个动作全对的一拍退化成「需要你判断下一步」的兜底卡。
*
* `close` 档的两条规则(仍只合成闭括号,不发明任何内容):
* · 闭括号对不上栈顶、但**栈更深处有它** → 把中间欠的闭括号补上再收它
* (那正是模型漏写的那几个);栈里压根没有 → 还是噪声,丢;
* · 文本走完栈没清空(输出被截断的形)→ 把欠的全补上。
*/
function firstBalanced(text: string, mode: 'drop' | 'close'): unknown {
const start = text.search(/[[{]/); const start = text.search(/[[{]/);
if (start < 0) return undefined; if (start < 0) return undefined;
const stack: string[] = []; const stack: string[] = [];
const kept: string[] = []; const kept: string[] = [];
let inString = false; let inString = false;
let escaped = false; let escaped = false;
/** 上一个串外的有意义字符 —— close 档靠它认「对象里逗号后面冒出了 { 」 */
let lastSignificant = '';
for (let i = start; i < text.length; i += 1) { for (let i = start; i < text.length; i += 1) {
const ch = text[i] as string; const ch = text[i] as string;
if (inString) { if (inString) {
kept.push(ch); kept.push(ch);
if (escaped) escaped = false; if (escaped) escaped = false;
else if (ch === '\\') escaped = true; else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false; else if (ch === '"') {
inString = false;
lastSignificant = '"';
}
continue; continue;
} }
if (ch === '"') { if (ch === '"') {
@@ -139,14 +163,35 @@ function firstBalanced(text: string): unknown {
continue; continue;
} }
if (ch === '{' || ch === '[') { if (ch === '{' || ch === '[') {
/*
* 2026-08-24 第二种少括号形:行对象没关上,后面直接跟 `,{"key":…`(下一行)。
* 括号本身到尾都能配平(少的那个 `}` 被后面某个多出来的位置吃掉),strict 与
* 前两档全看不出 —— 但**对象里逗号后面只能跟键名**,冒出 `{`/`[` 一定是上一个
* 对象没关。close 档:把欠的 `}` 补在那个逗号之前(逗号其实属于外层数组)。
*/
if (mode === 'close' && lastSignificant === ',' && stack[stack.length - 1] === '}') {
const comma = kept.lastIndexOf(',');
if (comma >= 0) {
kept.splice(comma, 0, '}');
stack.pop();
}
}
stack.push(ch === '{' ? '}' : ']'); stack.push(ch === '{' ? '}' : ']');
kept.push(ch); kept.push(ch);
lastSignificant = ch;
continue; continue;
} }
if (ch === '}' || ch === ']') { if (ch === '}' || ch === ']') {
if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢 if (stack[stack.length - 1] !== ch) {
// 对不上的闭括号:drop 档当噪声丢;close 档若栈更深处有它,先把欠的补上
if (mode === 'drop' || !stack.includes(ch)) continue;
while (stack.length > 0 && stack[stack.length - 1] !== ch) {
kept.push(stack.pop() as string);
}
}
stack.pop(); stack.pop();
kept.push(ch); kept.push(ch);
lastSignificant = ch;
if (stack.length === 0) { if (stack.length === 0) {
try { try {
return JSON.parse(kept.join('')); return JSON.parse(kept.join(''));
@@ -157,6 +202,16 @@ function firstBalanced(text: string): unknown {
continue; continue;
} }
kept.push(ch); kept.push(ch);
if (!/\s/.test(ch)) lastSignificant = ch;
}
// 文本走完栈没清空:close 档把欠的闭括号全补上(输出被截断的形);drop 档照旧作废
if (mode === 'close' && stack.length > 0) {
while (stack.length > 0) kept.push(stack.pop() as string);
try {
return JSON.parse(kept.join(''));
} catch {
return undefined;
}
} }
return undefined; return undefined;
} }
@@ -1737,3 +1737,57 @@ describe('同一张卡再发一遍的三档', () => {
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send'); expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
}); });
}); });
/**
* **发过卡这件事要落住**(2026-08-24,柴浩那一单熔断逼出来的一组回归)。
*
* `worldTouched` 是 finish 那道门唯一的证据:人工激活的项完成与否不该由数据形状推出来,
* 只认「真给它发过一张卡」。那一单里这个标记不见了 —— 于是 finish 说「从没发过卡」、
* 重发说「人已答过」,两堵墙对着撞,五拍熔断。
* 查下来标记本身在这三条路上都落得住(下面三条钉死),丢是丢在**两个后端并发写同一份
* 局面**上(见 `workers/task-claim.ts`)—— 这三条测试保证的是:这一侧别再出新的洞。
*/
describe('worldTouched 要落进保存的局面', () => {
it('给人工激活的项发一张卡 → 保存下来的那一项标着 worldTouched', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, 'GATED 上没有 worldTouched —— 发过的卡没被记住').toBe(true);
});
it('**再跑一拍之后仍然记得**(柴浩那一单:卡发过了,下一拍 finish 却说「从没发过卡」)', async () => {
// 桩按调用时读 over.action —— 留着这个引用,拍与拍之间换动作
const over: { parsed: CaseModel; runtime: CaseRuntime; action: Record<string, unknown> } = {
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
};
const h = harness(over);
await h.runner.runTurn(TASK);
expect(h.envelope.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
// 第二拍:干点别的(写一笔)—— 它会把局面重新存一遍
over.action = { kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] };
await h.runner.runTurn(TASK);
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, '第二拍存完之后,发过卡这件事被抹掉了').toBe(true);
});
it('**人把卡答回来之后仍然记得** —— 回音落库那一下也存局面', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
});
await h.runner.runTurn(TASK);
const cardId = 'card-1';
await h.runner.onEvent(TASK, { kind: 'human', ref: cardId, payload: { values: {} } });
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
});
});
+5
View File
@@ -23,6 +23,11 @@
port 注入:memory / event / message / interaction / ledger port 注入:memory / event / message / interaction / ledger
被依赖: process, org(停用前置检查的在途任务), report, app 被依赖: process, org(停用前置检查的在途任务), report, app
近变(2026-08-25):`TaskService.wake(taskId)` —— 人答复落库那一刻就把 runtime 翻成
`running` 并 setPhase('active')(顺带广播);只对 awaiting-event / awaiting-human 生效,
已终结 / suspended / 已在跑的原样不动并返回 false。回传路由在返回之前调它,
让对话区(卡状态)与工作区(runtime)读到同一份事实。
近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+ 近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+
`sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠 `sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠
`source->>'ref'='testbed'` 认,任务这侧从前没有对应记号: `source->>'ref'='testbed'` 认,任务这侧从前没有对应记号:
@@ -0,0 +1,80 @@
/**
* `wake` —— 人答复落库那一刻就把「在办事」写进投影(2026-08-25 用户:「右边聊天框先有反应,
* 中间的 html 过一会儿才变成 AI 正在推进中」)。从前 runtime 要等派发器投递、推进器抢到
* 租约、开跑前那一刻才翻;那几秒里对话区与工作区读到的是两份相反的事实。
*/
import { describe, expect, it } from 'vitest';
import type { LandedFacts, Task } from '../contract.ts';
import { createTaskService } from '../internal/usecase/task-service.ts';
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
function harness(runtime: Task['runtime'], terminal?: Task['terminal']) {
const phases: string[] = [];
const task = {
id: 't-1',
activityId: 'e04',
ownerId: 's-1',
title: 'x',
runtime,
terminal,
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
startedAt: '2026-08-25T00:00:00.000Z',
} as unknown as Task;
const tasks = new Map<string, Task>([[task.id, task]]);
const deps: TaskServiceDeps = {
store: {
get: async (id) => tasks.get(id),
insert: async (t) => void tasks.set(t.id, t),
update: async (id, patch) => {
const next = { ...tasks.get(id)!, ...patch };
tasks.set(id, next);
return next;
},
addConsumption: async (id) => tasks.get(id)!.used,
lastProgressAt: async () => new Date(),
markProgress: async () => undefined,
listByOwner: async () => [],
},
activities: { load: async () => ({ caps: task.caps, envelope: {}, inputSchema: {}, businessModel: {}, stallAfter: 'PT30M' }) },
schemas: { validate: () => ({ ok: true, errors: [] }) },
engine: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
memory: { append: async () => undefined, hasHistory: async () => true, seal: async () => undefined, distill: async () => undefined },
landing: { land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }) },
listeners: { hasArmedListener: async () => true },
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
notifier: { notify: async () => undefined },
interaction: { setPhase: async (_id, phase) => void phases.push(phase) },
intake: { askCompletion: async () => undefined },
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-08-25T00:00:00.000Z') },
ids: { next: () => 't-x' },
};
return { service: createTaskService(deps), tasks, phases };
}
describe('task.wake —— 人答完那一刻就标「在办」', () => {
it('挂起等外部(awaiting-event)→ running,并把 phase 推成 active(顺带广播)', async () => {
const h = harness('awaiting-event');
expect(await h.service.wake('t-1' as never)).toBe(true);
expect(h.tasks.get('t-1')?.runtime).toBe('running');
expect(h.phases).toEqual(['active']);
});
it('挂起等人(awaiting-human)同样翻', async () => {
const h = harness('awaiting-human');
expect(await h.service.wake('t-1' as never)).toBe(true);
expect(h.tasks.get('t-1')?.runtime).toBe('running');
});
it('**已在跑 / 暂停 / 已终结的原样不动**,返回 false、不广播', async () => {
for (const [runtime, terminal] of [['running', undefined], ['suspended', undefined], ['terminal', 'released']] as const) {
const h = harness(runtime, terminal);
expect(await h.service.wake('t-1' as never), runtime).toBe(false);
expect(h.tasks.get('t-1')?.runtime).toBe(runtime);
expect(h.phases).toEqual([]);
}
});
});
+7
View File
@@ -103,6 +103,13 @@ export type TaskService = {
/** 控制三语义:暂停 / 叫停 / 终止 */ /** 控制三语义:暂停 / 叫停 / 终止 */
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>; control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
resume(taskId: TaskId, by: StaffId): Promise<Task>; resume(taskId: TaskId, by: StaffId): Promise<Task>;
/**
* **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器
* 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。
* 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。
* 返回翻没翻 —— 调用方据此决定要不要广播。
*/
wake(taskId: TaskId): Promise<boolean>;
/** 改派:在途任务的负责人变更,**留痕并通知双方** */ /** 改派:在途任务的负责人变更,**留痕并通知双方** */
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>; reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
@@ -393,6 +393,16 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
return outcome; return outcome;
}, },
async wake(taskId) {
const task = await require(taskId);
if (task.terminal !== undefined) return false;
if (task.runtime !== 'awaiting-event' && task.runtime !== 'awaiting-human') return false;
await deps.store.update(taskId, { runtime: 'running' });
// setPhase 顺带广播(interaction 的 onPush → broadcaster),浏览器立刻重取
await deps.interaction.setPhase(taskId, 'active');
return true;
},
async resume(taskId, by) { async resume(taskId, by) {
const task = await require(taskId); const task = await require(taskId);
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal); if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
+3 -7
View File
@@ -28,6 +28,7 @@ import { call } from '@web-shared/typed-fetch.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 { queryKeys } from './query.ts'; import { queryKeys } from './query.ts';
import { Btn } from '@web-shared/Btn.tsx';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -165,15 +166,10 @@ export function AppShell() {
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span> <span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
<div className="jc-topbar-user"> <div className="jc-topbar-user">
<span className="jc-topbar-name">{me}</span> <span className="jc-topbar-name">{me}</span>
<button <Btn className="jc-topbar-logout" busy={logout.isPending} onClick={() => logout.mutate()}>
type="button"
className="jc-topbar-logout"
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
<Ico name="log-out" s={14} /> <Ico name="log-out" s={14} />
退出登录 退出登录
</button> </Btn>
</div> </div>
</header> </header>
+3 -2
View File
@@ -36,6 +36,7 @@ import { ArchiveHome } from '@features/gov-archive';
import { TestbedHome } from '@features/gov-testbed'; import { TestbedHome } from '@features/gov-testbed';
import { CockpitHome } from '@features/gov-memory'; import { CockpitHome } from '@features/gov-memory';
import { ChanPage, LlmPage, ModelsHome, SystemHome } from '@features/gov-system'; import { ChanPage, LlmPage, ModelsHome, SystemHome } from '@features/gov-system';
import { Btn } from '@web-shared/Btn.tsx';
/* ── URL 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */ /* ── URL 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */
@@ -203,10 +204,10 @@ export function GovShell({ surface }: { surface: Surface }) {
<Ico name="list-checks" s={13} /> <Ico name="list-checks" s={13} />
工作台 工作台
</a> </a>
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}> <Btn className="g-tbtn" onClick={() => logout.mutate()} busy={logout.isPending}>
<Ico name="log-out" s={13} /> <Ico name="log-out" s={13} />
登出 登出
</button> </Btn>
</div> </div>
</header> </header>
+5
View File
@@ -62,6 +62,11 @@ if (govSurface !== undefined) {
await import('./styles/proto.vendor.css'); await import('./styles/proto.vendor.css');
await import('./styles/app.css'); await import('./styles/app.css');
} }
/*
* 键感与「在办」那一层三面共用,**装在最后** —— 同名规则以它为准。
* (2026-08-24 用户:「点击了都不知道点没点上」;见 styles/press.css 头注)
*/
await import('./styles/press.css');
/** /**
* 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。 * 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。
+10
View File
@@ -2907,6 +2907,16 @@
.proto-root .jc-tnav-btn:disabled { opacity: .35; cursor: default; } .proto-root .jc-tnav-btn:disabled { opacity: .35; cursor: default; }
.proto-root .jc-tnav-n { min-width: 34px; text-align: center; font-size: 11px; font-weight: 800; color: var(--ink-2); } .proto-root .jc-tnav-n { min-width: 34px; text-align: center; font-size: 11px; font-weight: 800; color: var(--ink-2); }
/* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */ /* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */
/*
* 保存之后的红字 —— **有行没落下就摆在件的上方**(2026-08-24 用户:件上新增的
* 客户推到下一步就不见了)。它不是错误提示:别的格子确实存下了,只有没钥匙的那几行没落 ——
* 所以用「提醒」的分量,点名缺哪几列,人补上再存一次。
*/
.proto-root .jc-savewarn { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 10px;
padding: 9px 12px; border: 1px solid #f3b6b3; border-radius: 9px; background: #fff5f5;
color: var(--danger); font-size: 12.5px; line-height: 1.6; }
.proto-root .jc-savewarn svg { flex: none; margin-top: 2px; }
.proto-root .jc-tnav-mk { font-style: normal; font-weight: 700; color: var(--ink-3); margin-right: 3px; } .proto-root .jc-tnav-mk { font-style: normal; font-weight: 700; color: var(--ink-3); margin-right: 3px; }
.proto-root .jc-tnav-btn.back { width: auto; padding: 0 14px 0 10px; font-size: 12.5px; font-weight: 700; line-height: 1; white-space: nowrap; } .proto-root .jc-tnav-btn.back { width: auto; padding: 0 14px 0 10px; font-size: 12.5px; font-weight: 700; line-height: 1; white-space: nowrap; }
.proto-root .jc-tnav-btn svg { flex: none; display: block; } .proto-root .jc-tnav-btn svg { flex: none; display: block; }
+54
View File
@@ -245,6 +245,60 @@
哪几张只是一单一份的凭证。而「该不该管它、谁有权改它」全看性质: 哪几张只是一单一份的凭证。而「该不该管它、谁有权改它」全看性质:
主数据要治理(谁能改、依据、去重、时效),单据只需查得到、派生根本不该手写。 主数据要治理(谁能改、依据、去重、时效),单据只需查得到、派生根本不该手写。
所以分区要**看得见**:一条带色的区头 + 一句说清这一类是什么。 */ 所以分区要**看得见**:一条带色的区头 + 一句说清这一类是什么。 */
/* 试跑台:「连主数据一起落真库」那一格 —— 破例要看得见自己在破例 */
.tb-land { display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px; cursor: pointer;
border: 1px dashed var(--g-line, #dfe1e5); border-radius: 10px; background: #fbfbfc; transition: all .16s; }
.tb-land:hover { border-color: var(--g-brand-soft, #b6d0ff); }
.tb-land.on { border-style: solid; border-color: #f3b6b3; background: #fff7f6; }
.tb-land input { margin-top: 2px; flex: none; }
.tb-land b { display: block; font-size: 12.5px; font-weight: 700; color: var(--g-ink, #1f2329); }
.tb-land.on b { color: #c9372c; }
.tb-land em { display: block; margin-top: 2px; font-style: normal; font-size: 11.5px; line-height: 1.65; color: var(--g-ink-3, #646a73); }
/* ══ 数据域:按活动分组(2026-08-24 重写)══════════════════════════════
一屏回答的是「我这个活动写了哪几张表、现在各有几行」——
所以行数是主角:字号最大、放行末端;性质降成一枚小徽,地址与主键退成灰字。 */
.g-actdata { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px 13px;
background: #fff; border: 1px solid var(--g-line); border-radius: 12px; box-shadow: var(--g-shadow, 0 1px 2px rgba(31,35,41,.05)); }
.g-actdata + .g-actdata { margin-top: 14px; }
.g-actdata-h { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.g-actdata-h > svg { color: var(--g-brand, #3370ff); flex: none; }
.g-actdata-h b { font-size: 14.5px; font-weight: 800; color: var(--g-ink, #1f2329); }
.g-actdata-h .n { margin-left: auto; font-size: 12px; font-weight: 700; color: var(--g-ink-3, #646a73); }
.g-actdata-h .n em { font-style: normal; color: var(--g-brand, #3370ff); }
.g-actdata-h p { flex-basis: 100%; margin: 2px 0 0; font-size: 12px; line-height: 1.7; color: var(--g-ink-3, #646a73); }
.g-actdata-tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 8px; }
.g-tbl { display: grid; grid-template-columns: 1fr auto; grid-template-areas: 't n' 'a n';
gap: 2px 12px; width: 100%; padding: 9px 12px; text-align: left; font: inherit; cursor: pointer;
background: var(--g-soft, #f7f8fa); border: 1px solid transparent; border-radius: 9px; transition: all .16s; }
.g-tbl:hover { background: #fff; border-color: var(--g-brand-soft, #b6d0ff); box-shadow: 0 2px 8px rgba(51,112,255,.10); }
.g-tbl-t { grid-area: t; display: flex; align-items: center; gap: 6px; min-width: 0; }
.g-tbl-t b { font-size: 13px; font-weight: 700; color: var(--g-ink, #1f2329); }
.g-tbl-k { font-style: normal; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 4px;
background: #eef1f5; color: #5a6068; }
.g-tbl-k.t-m { background: #eaf1ff; color: #2b5fd9; }
.g-tbl-k.t-r { background: #f2f3f5; color: #5a6068; }
.g-tbl-k.t-d { background: #fdf0e6; color: #b25e15; }
.g-tbl-a { grid-area: a; font-size: 11px; color: var(--g-ink-4, #8f959e); overflow-wrap: anywhere; }
.g-tbl-a em { font-style: normal; }
/* 行数:这一屏的主角 */
.g-tbl-n { grid-area: n; align-self: center; text-align: right; font-size: 11.5px; color: var(--g-ink-3, #646a73); white-space: nowrap; }
.g-tbl-n b { font-size: 17px; font-weight: 800; color: var(--g-ink, #1f2329); margin-right: 2px; }
.g-tbl-n .none { font-style: normal; color: var(--g-ink-4, #8f959e); }
.g-tbl-n .draft { display: block; font-style: normal; font-size: 10.5px; font-weight: 700; color: var(--g-brand, #3370ff); }
.g-actdata-runs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding-top: 2px; }
.g-actdata-runs > em { font-style: normal; font-size: 11px; font-weight: 700; color: var(--g-ink-4, #8f959e); }
.g-run { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px;
font-size: 11px; font-weight: 700; background: #f2f3f5; color: #5a6068; }
.g-run i { font-style: normal; font-weight: 600; opacity: .72; }
.g-run.t-ok { background: #e9f7ee; color: #1f7a45; }
.g-run.t-live { background: #eaf1ff; color: #2b5fd9; }
.g-run.t-wait { background: #fdf0e6; color: #b25e15; }
.g-run.t-warn { background: #fff1f0; color: #c9372c; }
.g-actdata.t-u { background: #fbfbfc; }
.g-kind { display: flex; flex-direction: column; gap: 12px; } .g-kind { display: flex; flex-direction: column; gap: 12px; }
.g-kind + .g-kind { margin-top: 22px; } .g-kind + .g-kind { margin-top: 22px; }
.g-kind-h { display: grid; grid-template-columns: auto auto auto 1fr; align-items: center; gap: 8px; .g-kind-h { display: grid; grid-template-columns: auto auto auto 1fr; align-items: center; gap: 8px;
+136
View File
@@ -0,0 +1,136 @@
/* ==========================================================================
按下去要有反应,在办事要说出来 —— 三面共用的一层(2026-08-24)
起因是用户一句话:「点击了都不知道点没点上」。查下来是两件事各缺一半:
① **按下那一刻没有键感** —— 治理台整套 `g-*` 控件一条 `:active` 都没有,
工作台也只有零星几个。点下去屏上纹丝不动,人只能靠「后来发生了什么」倒推。
② **等回音那一段完全静默** —— 请求在路上的几百毫秒里,按钮长得和没点过一模一样。
这一段恰恰是人最想要个准信的时候(慢的接口要好几秒)。
所以这一层只做两件事:**键感**(`:active`)与**在办**(`aria-busy`)。
两者都只动 transform / background,不改布局、不换 DOM —— 谁都不必为它改结构。
装载在两份基座之后(main.tsx),所以同名规则以它为准;
台面自己已经写过的 `:active` 特异度更高,原样保留(它们本就是同一个手感)。
========================================================================== */
/* --------------------------------------------------------------------------
一 · 键感:按下就下沉
小控件缩 .97 手感最准;整行整卡那种大件同样缩会「晃」,只给极轻的一点。
-------------------------------------------------------------------------- */
button:not(:disabled):not([aria-busy='true']):active,
summary:active,
[role='button']:not([aria-disabled='true']):active {
transform: scale(.97);
}
/*
* 按下要**立刻**到位。不少控件把 transform 一起塞进了 0.16s 的过渡里
* (`transition: all var(--t-fast)`)—— 而一次点击常常比这还短:
* 手抬起来的时候键帽才刚下去一半,看着就像「没反应」。
* 按下这一刻不过渡;松开后 `:active` 不再命中,回弹仍走控件自己的过渡(软的)。
* 两档特异度是为了压过工作台那批 `.proto-root .x`(0,2,0)的基础规则。
*/
.proto-root button:not(:disabled):active,
.proto-root [role='button']:not([aria-disabled='true']):active,
button:not(:disabled):active,
[role='button']:not([aria-disabled='true']):active {
transition-duration: 0s;
}
/* 大件(整行清单 / 整张卡 / 整条规则):幅度收到几乎看不见,只留一点「陷下去」 */
.g-nrow:active,
.g-entry:active,
.g-tile:active,
.g-wf:active,
.g-rule:active,
.g-fcard:active,
.g-mcard:active,
.proto-root .jc-dv-row:active,
.proto-root .task-item:active,
.proto-root .jc-branch:active {
transform: scale(.995);
}
/* 键盘走到哪儿要看得见 —— 鼠标点击不出这个圈(:focus-visible 的本分) */
button:focus-visible,
a:focus-visible,
summary:focus-visible,
[role='button']:focus-visible {
outline: 2px solid var(--brand, #3370ff);
outline-offset: 2px;
}
/* --------------------------------------------------------------------------
二 · 在办:脚下走一道不定长进度条
**为什么是 `::after` 而不是 background-image**:第一版把这道条画进按钮自己的
background,在真页面上量出来是**画不出来的** —— `.tbtn` 这类控件的皮肤用
`background:` 简写上色,特异度(0,2,0 起)压过 `[aria-busy]`(0,1,0),
`background-image` 当场被顶回 none(动画倒是在跑,条却不存在)。
`::after` 这一层没人跟它抢:全仓的按钮类没有一条定义过 `::before/::after`(审过)。
同理**不给 `<select>` 挂 aria-busy** —— 替换元素画不出伪元素,它那一行才是该点亮的东西。
条只动 transform,`currentColor` 取字色:白字蓝底的主钮出白条,深字白底的次钮出深条。
不给 `disabled`:那会把按钮画成灰的,而它此刻正在**做**这件事,不是做不了。
真做不了的照旧 `disabled`(见 shared/Btn.tsx 的注)。
-------------------------------------------------------------------------- */
[aria-busy='true'] {
position: relative;
cursor: progress;
}
[aria-busy='true']::after {
content: '';
position: absolute;
/* 左右各让出一点、离底边抬起 2px —— 贴着边画会与控件自己的边框糊成一条,看着只是「框粗了」 */
left: 6px;
right: 6px;
bottom: 2px;
height: 3px;
border-radius: 999px;
background: currentColor;
opacity: .85;
pointer-events: none;
animation: jc-busy-run 1.1s cubic-bezier(.45, 0, .55, 1) infinite;
}
/* 从左抽出、再从左收走 —— 不定长进度条的经典两拍,全程只有 transform 在动 */
@keyframes jc-busy-run {
0% { transform: scaleX(0); transform-origin: left; }
45% { transform: scaleX(1); transform-origin: left; }
55% { transform: scaleX(1); transform-origin: right; }
100% { transform: scaleX(0); transform-origin: right; }
}
/* 在办的时候不再摆「可点」的手感:悬停不上色、按下不下沉(点了也不算数) */
[aria-busy='true']:hover,
[aria-busy='true']:active {
transform: none;
}
/* --------------------------------------------------------------------------
三 · 关掉动效的人:条不跑了,但**仍然要看得出在办事** ——
停在中间摆一道静态的杠,别让这类用户回到「什么都没有」那个起点。
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
button:active,
summary:active,
[role='button']:active,
.g-nrow:active,
.g-entry:active,
.g-tile:active,
.g-wf:active,
.g-rule:active {
transform: none;
}
/* 条不跑了,但**仍然要看得出在办事** —— 摆一道静态的杠,别让这类用户回到「什么都没有」那个起点 */
[aria-busy='true']::after {
animation: none;
transform: scaleX(1);
}
}
@@ -10,6 +10,7 @@ import type { Session } from '@contracts';
import { loginRoute } from '@contracts'; import { loginRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { ApiCallError, call } from '@web-shared/typed-fetch.ts'; import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
import { Btn } from '@web-shared/Btn.tsx';
export function LoginPage({ onLogin }: { onLogin(session: Session): void }) { export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
const [account, setAccount] = useState(''); const [account, setAccount] = useState('');
@@ -75,9 +76,10 @@ export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
</div> </div>
)} )}
<button className="jc-login-go" type="submit" disabled={busy || !ready}> {/* 在办的那一段照旧可提交 —— `submit` 自己有 `busy` 闸,按下去什么也不会多发一遍 */}
<Btn className="jc-login-go" type="submit" disabled={!ready} busy={busy}>
{busy ? '登录中…' : '登录'} {busy ? '登录中…' : '登录'}
</button> </Btn>
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p> <p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
</form> </form>
</div> </div>
@@ -14,6 +14,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx'; import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { atMinute } from '@web-shared/at.ts'; import { atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
const STATE_CHIP = { const STATE_CHIP = {
pending: { tone: 'todo' as const, label: '待审批', pulse: true }, pending: { tone: 'todo' as const, label: '待审批', pulse: true },
@@ -159,24 +160,24 @@ export function ApprovalDesk() {
/> />
{error !== undefined && <div className="jc-callout t-bad">{error}</div>} {error !== undefined && <div className="jc-callout t-bad">{error}</div>}
<div className="jc-mod-actions"> <div className="jc-mod-actions">
<button <Btn
type="button"
className="card-btn" className="card-btn"
busy={decide.isPending && decide.variables.verdict === 'reject'}
disabled={decide.isPending} disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'reject' })} onClick={() => decide.mutate({ item: sel, verdict: 'reject' })}
> >
<Ico name="rotate-ccw" s={13} /> <Ico name="rotate-ccw" s={13} />
驳回 驳回
</button> </Btn>
<button <Btn
type="button"
className="card-btn primary" className="card-btn primary"
busy={decide.isPending && decide.variables.verdict === 'pass'}
disabled={decide.isPending} disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'pass' })} onClick={() => decide.mutate({ item: sel, verdict: 'pass' })}
> >
<Ico name="check" s={13} /> <Ico name="check" s={13} />
通过 通过
</button> </Btn>
</div> </div>
</Section> </Section>
) : ( ) : (
@@ -15,6 +15,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx'; import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { atFull, atMinute } from '@web-shared/at.ts'; import { atFull, atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = { const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' }, notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
@@ -261,10 +262,10 @@ function ConfirmFire({ confirm }: { confirm: NonNullable<SystemMessageView['conf
return ( return (
<> <>
<NoteBand>这封来件命中了触发器,但运营配置要求人工确认 —— 你点了,流程才起;不点,它就停在这儿。</NoteBand> <NoteBand>这封来件命中了触发器,但运营配置要求人工确认 —— 你点了,流程才起;不点,它就停在这儿。</NoteBand>
<button className="jc-btn primary" disabled={fire.isPending} onClick={() => fire.mutate()}> <Btn className="jc-btn primary" busy={fire.isPending} onClick={() => fire.mutate()}>
<Ico name="play" s={13} /> <Ico name="play" s={13} />
{fire.isPending ? '起单中…' : '确认起单'} {fire.isPending ? '起单中…' : '确认起单'}
</button> </Btn>
{fire.isError && <NoteBand>没起成:{(fire.error as { message?: string }).message ?? '未知原因'}</NoteBand>} {fire.isError && <NoteBand>没起成:{(fire.error as { message?: string }).message ?? '未知原因'}</NoteBand>}
</> </>
); );
@@ -13,6 +13,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx'; import { AssistantAside, Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Btn } from '@web-shared/Btn.tsx';
export function MinuteDesk() { export function MinuteDesk() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -165,15 +166,15 @@ export function MinuteDesk() {
<button type="button" className="card-btn" onClick={() => setComposing(false)}> <button type="button" className="card-btn" onClick={() => setComposing(false)}>
取消 取消
</button> </button>
<button <Btn
type="button"
className="card-btn primary" className="card-btn primary"
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === '' || create.isPending} disabled={draft.title.trim() === '' || draft.occurredOn.trim() === ''}
busy={create.isPending}
onClick={() => create.mutate()} onClick={() => create.mutate()}
> >
<Ico name="check" s={13} /> <Ico name="check" s={13} />
保存纪要 保存纪要
</button> </Btn>
</div> </div>
</DocPanel> </DocPanel>
) : sel === undefined ? ( ) : sel === undefined ? (
@@ -235,14 +236,9 @@ export function MinuteDesk() {
onChange={(e) => setPackageId(e.target.value)} onChange={(e) => setPackageId(e.target.value)}
placeholder="流程包 id" placeholder="流程包 id"
/> />
<button <Btn className="card-btn primary" busy={register.isPending} onClick={() => register.mutate(r.id)}>
type="button"
className="card-btn primary"
disabled={register.isPending}
onClick={() => register.mutate(r.id)}
>
提交起单 提交起单
</button> </Btn>
<button type="button" className="card-btn" onClick={() => setRegisterFor(undefined)}> <button type="button" className="card-btn" onClick={() => setRegisterFor(undefined)}>
取消 取消
</button> </button>
@@ -291,10 +287,10 @@ function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
/> />
<button <Btn
type="button"
className="card-btn primary" className="card-btn primary"
disabled={text.trim() === '' || busy} disabled={text.trim() === ''}
busy={busy}
onClick={() => { onClick={() => {
onAppend(text.trim()); onAppend(text.trim());
setText(''); setText('');
@@ -302,7 +298,7 @@ function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void
> >
<Ico name="plus" s={13} /> <Ico name="plus" s={13} />
追加 追加
</button> </Btn>
</div> </div>
); );
} }
@@ -13,6 +13,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx'; import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Btn } from '@web-shared/Btn.tsx';
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [ const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
{ k: 'day', ico: 'calendar-days' }, { k: 'day', ico: 'calendar-days' },
@@ -183,10 +184,10 @@ export function ReportDesk() {
已确认提交 · 汇报进入组织视图 已确认提交 · 汇报进入组织视图
</span> </span>
) : ( ) : (
<button type="button" className="card-btn primary" disabled={submit.isPending} onClick={() => submit.mutate()}> <Btn className="card-btn primary" busy={submit.isPending} onClick={() => submit.mutate()}>
<Ico name="check" s={13} /> <Ico name="check" s={13} />
确认提交 确认提交
</button> </Btn>
)} )}
</div> </div>
</DocPanel> </DocPanel>
@@ -14,6 +14,7 @@ import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx'; import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
import { Btn } from '@web-shared/Btn.tsx';
export function TaskChat({ export function TaskChat({
detail, detail,
@@ -115,9 +116,23 @@ export function TaskChat({
} }
}} }}
/> />
<button className="copilot-send" disabled={say.isPending}> <Btn
className="copilot-send"
busy={say.isPending}
onClick={(e) => {
/*
* 这一钮从前**没有 onClick** —— 点它什么都不会发生,只有在文本框里按 Enter 才发得出去。
* 「点了都不知道点没点上」在这儿是字面意思:它根本就没接线。
*/
const box = e.currentTarget.closest('.composer-row')?.querySelector('textarea');
const text = box?.value.trim() ?? '';
if (text === '' || box === null || box === undefined) return;
say.mutate(text);
box.value = '';
}}
>
<Ico name="send" s={14} /> 发送 <Ico name="send" s={14} /> 发送
</button> </Btn>
</div> </div>
</div> </div>
</div> </div>
@@ -131,6 +146,8 @@ export function TaskChat({
orders={detail.workOrders} orders={detail.workOrders}
thinking={say.isPending} thinking={say.isPending}
working={working} working={working}
/* 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
replying={replyCard.isPending || replyOrder.isPending}
onSay={(text) => say.mutate(text)} onSay={(text) => say.mutate(text)}
onReplyCard={(reply) => replyCard.mutate(reply)} onReplyCard={(reply) => replyCard.mutate(reply)}
onReplyOrder={(reply) => replyOrder.mutate(reply)} onReplyOrder={(reply) => replyOrder.mutate(reply)}
@@ -25,6 +25,7 @@ import type { ViewEdits, ViewSave } from '@ui';
import { GuideCard, WorkOrderReport } from './work-order-report.tsx'; import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
import { FileViewer } from './FileViewer.tsx'; import { FileViewer } from './FileViewer.tsx';
import { atFull, atMinute } from '@web-shared/at.ts'; import { atFull, atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log'; type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
@@ -189,16 +190,30 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。 * 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。
* 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。** * 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。**
*/ */
/**
* **有行没落下就说出来**(2026-08-24 用户:「件上新增的那个客户,推到下一步就不见了」)。
* 主键那几格空着就凑不出钥匙、存不住;从前基座悄悄跳过、接口照回 ok ——
* 人看到「保存成功」,东西却不在。现在把没落下的行红字点名,他补上钥匙再存一次。
*/
const [saveWarn, setSaveWarn] = useState<string | undefined>(undefined);
const saveTable = useMutation({ const saveTable = useMutation({
mutationFn: (save: ViewSave) => mutationFn: (save: ViewSave) =>
call(saveTableRoute, { call<{ ok: boolean; rows: number; unkeyed?: Array<{ hint: string }> }>(saveTableRoute, {
params: { taskId: detail.card.id }, params: { taskId: detail.card.id },
body: { body: {
tables: save.tables as Record<string, Array<Record<string, string>>>, tables: save.tables as Record<string, Array<Record<string, string>>>,
drops: save.drops as Record<string, Array<Record<string, string>>>, drops: save.drops as Record<string, Array<Record<string, string>>>,
}, },
}), }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }), onSuccess: (out) => {
setSaveWarn(
out.unkeyed === undefined || out.unkeyed.length === 0
? undefined
: `有 ${out.unkeyed.length} 行没能存下 —— ${out.unkeyed.map((u) => u.hint).join(';')}`,
);
void queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] });
},
}); });
/* /*
@@ -221,6 +236,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'), onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
}); });
/*
* 正在取的是哪一份 —— 先摘成两个普通值再用。
* 直接在 JSX 里写 `openAssetFile.isPending && openAssetFile.variables.key === …` 不行:
* 上面那一层三元已经按 `isPending` 分过岔,进到 else 分支里它被收窄成「不在办」,
* `variables` 整个不存在(TS 判 never)—— 同一个判断在同一棵树里只能问一次。
*/
const fetchingFileKey = openAssetFile.isPending ? openAssetFile.variables.key : undefined;
const markRead = useMutation({ const markRead = useMutation({
mutationFn: (input: { commId: string; read: boolean }) => mutationFn: (input: { commId: string; read: boolean }) =>
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }), call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
@@ -490,7 +513,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
vpClassName="wf-body" vpClassName="wf-body"
inset={-6} inset={-6}
> >
{openFile !== undefined ? ( {openAssetFile.isPending ? (
/*
* **取件的那一段要说出来** —— 资料名是件上的一个链接,点它没有按钮可以变样;
* 那份文件又是服务端**现造**出来的字节(造 + 传,慢的有一两秒)。
* 从前这一段屏上一动不动,人只好再点一次 —— 于是同一份资料取两遍。
*/
<EmptyState ico="file-text" tone="brand" title="正在取这一份资料…" desc="文件是台账那一行现造出来的,造好就摆在这儿。" />
) : openFile !== undefined ? (
<FileViewer doc={openFile} href={fileHref} /> <FileViewer doc={openFile} href={fileHref} />
) : fileError !== undefined ? ( ) : fileError !== undefined ? (
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */ /* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
@@ -528,16 +558,15 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。 不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
反向也留着:看了一半想留个记号,标回未读。 反向也留着:看了一半想留个记号,标回未读。
*/} */}
<button <Btn
type="button"
className={'jc-comm-mark' + (m.unread ? '' : ' done')} className={'jc-comm-mark' + (m.unread ? '' : ' done')}
disabled={markRead.isPending} busy={markRead.isPending && markRead.variables.commId === m.id}
aria-label={m.unread ? `把「${m.subject}」标为已读` : `把「${m.subject}」标回未读`} aria-label={m.unread ? `把「${m.subject}」标为已读` : `把「${m.subject}」标回未读`}
onClick={() => markRead.mutate({ commId: m.id, read: m.unread })} onClick={() => markRead.mutate({ commId: m.id, read: m.unread })}
> >
<Ico name={m.unread ? 'check' : 'rotate-ccw'} s={11} /> <Ico name={m.unread ? 'check' : 'rotate-ccw'} s={11} />
{m.unread ? '标为已读' : '标回未读'} {m.unread ? '标为已读' : '标回未读'}
</button> </Btn>
</header> </header>
<div className="jc-comm-meta"> <div className="jc-comm-meta">
<span className="jc-mono">{m.channel}</span> <span className="jc-mono">{m.channel}</span>
@@ -641,10 +670,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>} {detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
</div> </div>
{detail.assetFiles.map((f) => ( {detail.assetFiles.map((f) => (
<button <Btn
key={`${f.address}/${f.key}`} key={`${f.address}/${f.key}`}
type="button"
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format} className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
busy={fetchingFileKey === f.key}
onClick={() => { onClick={() => {
setFileError(undefined); setFileError(undefined);
openAssetFile.mutate({ address: f.address, key: f.key }); openAssetFile.mutate({ address: f.address, key: f.key });
@@ -664,7 +693,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span> <span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
<Ico name="chevron-right" s={15} /> <Ico name="chevron-right" s={15} />
</span> </span>
</button> </Btn>
))} ))}
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>} {doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
</> </>
@@ -780,6 +809,17 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳 * 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
* 呈现,或收边传值(`onField`)时才用得上。 * 呈现,或收边传值(`onField`)时才用得上。
*/ */
<>
{/*
**有行没落下,红字摆在件的上方** —— 而不是让人以为存好了。
位置有意)在件之上:他刚按完保存,眼睛就在这一带。
*/}
{saveWarn !== undefined && (
<div className="jc-savewarn" role="alert">
<Ico name="info" s={14} />
<span>{saveWarn}</span>
</div>
)}
<ViewContainer <ViewContainer
html={detail.viewHtml} html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`} title={`${detail.card.activity} · 汇报报告`}
@@ -806,6 +846,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
if (cur !== undefined) collectEdits(save.tables, cur, onField); if (cur !== undefined) collectEdits(save.tables, cur, onField);
}} }}
/> />
</>
) : cur !== undefined ? ( ) : cur !== undefined ? (
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */ /* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
<article className="jc-mst-doc"> <article className="jc-mst-doc">
@@ -15,13 +15,29 @@
* *
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。 * DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
*/ */
import { useEffect, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useRef, useState } from 'react';
import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts'; import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import { EVENT_TYPE_LABEL, isRejection } from '@contracts'; import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx'; import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts'; import { atClock } from '@web-shared/at.ts';
/**
* 「这一下交上去了,回音还在路上」—— 卡族共用一个在办标记。
*
* 卡上的钮全都要走一趟服务端(交工单 / 裁决 / 选一支 / 答一张卡),而这一趟里
* 屏上从前**什么都不变**:人按下「提交」之后只能干等,常常再按一次。
* 逐层往下传 prop 要穿过 ChatStream → Msg → 各卡三四层,卡族又都在这一份文件里 ——
* 用一个上下文最省事,也最不容易漏掉某一张卡(漏掉的那张就是下一次「点了没反应」)。
*/
const ReplyBusyContext = createContext(false);
/** 卡上的钮据此说「在办事」;`ChatStream` 从任务面递进来 */
function useReplyBusy(): boolean {
return useContext(ReplyBusyContext);
}
/** /**
* 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。 * 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。
* *
@@ -81,6 +97,8 @@ export type TaskChatProps = {
* 状态来自任务投影的 runtime,不是前端猜的。 * 状态来自任务投影的 runtime,不是前端猜的。
*/ */
readonly working?: boolean; readonly working?: boolean;
/** 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
readonly replying?: boolean;
onSay(text: string): void; onSay(text: string): void;
onReplyCard(reply: ChatCardReply): void; onReplyCard(reply: ChatCardReply): void;
onReplyOrder(reply: WorkOrderReply): void; onReplyOrder(reply: WorkOrderReply): void;
@@ -98,6 +116,7 @@ export function ChatStream({
orders, orders,
thinking, thinking,
working = false, working = false,
replying = false,
onSay, onSay,
onReplyCard, onReplyCard,
onReplyOrder, onReplyOrder,
@@ -125,6 +144,7 @@ export function ChatStream({
}; };
return ( return (
<ReplyBusyContext.Provider value={replying}>
<div className="jc-tch"> <div className="jc-tch">
<header className="jc-tch-head"> <header className="jc-tch-head">
<span className="jc-tch-mark"> <span className="jc-tch-mark">
@@ -186,12 +206,13 @@ export function ChatStream({
} }
}} }}
/> />
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}> <Btn className="copilot-send" disabled={text.trim() === '' || blocked} busy={thinking} onClick={send}>
<Ico name="send" s={14} /> 发送 <Ico name="send" s={14} /> 发送
</button> </Btn>
</div> </div>
</div> </div>
</div> </div>
</ReplyBusyContext.Provider>
); );
} }
@@ -274,6 +295,7 @@ function Msg({
orderValues?: Readonly<Record<string, string>> | undefined; orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void; onField: (key: string, value: string) => void;
}) { }) {
const busy = useReplyBusy();
// 人的消息:右侧品牌气泡(纯文本) // 人的消息:右侧品牌气泡(纯文本)
if (m.k === 'human') { if (m.k === 'human') {
return ( return (
@@ -495,9 +517,10 @@ function Msg({
{m.chosen === undefined ? ( {m.chosen === undefined ? (
<div className="jc-branches"> <div className="jc-branches">
{m.options.map((o, i) => ( {m.options.map((o, i) => (
<button <Btn
key={o.value} key={o.value}
className={'jc-branch' + (o.recommended === true ? ' rec' : '')} className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
busy={busy}
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })} onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
> >
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span> <span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
@@ -514,7 +537,7 @@ function Msg({
)} )}
</span> </span>
<Ico name="chevron-right" s={14} /> <Ico name="chevron-right" s={14} />
</button> </Btn>
))} ))}
{m.allowCustom && ( {m.allowCustom && (
<CustomOption <CustomOption
@@ -767,6 +790,7 @@ function ExecForm({
orderValues?: Readonly<Record<string, string>> | undefined; orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void; onField: (key: string, value: string) => void;
}) { }) {
const busy = useReplyBusy();
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? ''; const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
/* /*
* **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门: * **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门:
@@ -842,9 +866,10 @@ function ExecForm({
</div> </div>
)} )}
<div className="jc-fc-ft"> <div className="jc-fc-ft">
<button <Btn
className="jc-btn jc-fc-go" className="jc-btn jc-fc-go"
disabled={!ok} disabled={!ok}
busy={busy}
onClick={() => onClick={() =>
onReplyOrder({ onReplyOrder({
mode: 'execution', mode: 'execution',
@@ -854,7 +879,7 @@ function ExecForm({
} }
> >
<Ico name="check" s={13} /> {order.submitLabel ?? '提交'} <Ico name="check" s={13} /> {order.submitLabel ?? '提交'}
</button> </Btn>
</div> </div>
</div> </div>
); );
@@ -880,6 +905,7 @@ function AcceptForm({
*/ */
orderValues?: Readonly<Record<string, string>> | undefined; orderValues?: Readonly<Record<string, string>> | undefined;
}) { }) {
const busy = useReplyBusy();
const [why, setWhy] = useState(''); const [why, setWhy] = useState('');
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */ /** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
const [port, setPort] = useState<string | undefined>(undefined); const [port, setPort] = useState<string | undefined>(undefined);
@@ -971,9 +997,10 @@ function AcceptForm({
/> />
</div> </div>
<div className="jc-fc-ft"> <div className="jc-fc-ft">
<button <Btn
className="jc-btn jc-btn-bad" className="jc-btn jc-btn-bad"
disabled={why.trim() === ''} disabled={why.trim() === ''}
busy={busy}
onClick={() => onClick={() =>
onReplyOrder({ onReplyOrder({
mode: 'acceptance', mode: 'acceptance',
@@ -987,11 +1014,12 @@ function AcceptForm({
} }
> >
重做 重做
</button> </Btn>
<span className="jc-fc-sp" /> <span className="jc-fc-sp" />
<button <Btn
className="jc-btn jc-btn-ok" className="jc-btn jc-btn-ok"
disabled={needChoice && port === undefined} disabled={needChoice && port === undefined}
busy={busy}
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined} title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
onClick={() => onClick={() =>
onReplyOrder({ onReplyOrder({
@@ -1021,7 +1049,7 @@ function AcceptForm({
} }
> >
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'} <Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
</button> </Btn>
</div> </div>
</div> </div>
); );
@@ -1029,6 +1057,7 @@ function AcceptForm({
/* 决策的收尾项:自定义口径 */ /* 决策的收尾项:自定义口径 */
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) { function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
const busy = useReplyBusy();
const [txt, setTxt] = useState(''); const [txt, setTxt] = useState('');
return ( return (
<div className="jc-branch jc-branch-custom"> <div className="jc-branch jc-branch-custom">
@@ -1044,9 +1073,9 @@ function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim()); if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
}} }}
/> />
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}> <Btn className="jc-btn" disabled={txt.trim() === ''} busy={busy} onClick={() => onPick(txt.trim())}>
就按这个 就按这个
</button> </Btn>
</span> </span>
</span> </span>
</div> </div>
@@ -1068,6 +1097,7 @@ function FillForm({
* 人核对一眼、补上它填不出的那个即可。 * 人核对一眼、补上它填不出的那个即可。
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。 * 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
*/ */
const busy = useReplyBusy();
const [vals, setVals] = useState<Record<string, string>>(() => const [vals, setVals] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])), Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])),
); );
@@ -1093,9 +1123,9 @@ function FillForm({
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}> <button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
← 还是填吧 ← 还是填吧
</button> </button>
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}> <Btn className="jc-btn" disabled={reason.trim() === ''} busy={busy} onClick={() => onCant(reason.trim())}>
<Ico name="send" s={13} /> 让 AI 调整需求 <Ico name="send" s={13} /> 让 AI 调整需求
</button> </Btn>
</div> </div>
</div> </div>
); );
@@ -1120,9 +1150,9 @@ function FillForm({
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}> <button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
没法填?告诉 AI 原因 没法填?告诉 AI 原因
</button> </button>
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}> <Btn className="jc-btn" disabled={!ok} busy={busy} onClick={() => onDone(vals)}>
<Ico name="check" s={13} /> 补给 AI <Ico name="check" s={13} /> 补给 AI
</button> </Btn>
</div> </div>
</div> </div>
); );
@@ -1130,6 +1160,7 @@ function FillForm({
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */ /* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) { function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
const busy = useReplyBusy();
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [hover, setHover] = useState(0); const [hover, setHover] = useState(0);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
@@ -1164,9 +1195,9 @@ function RateForm({ onDone }: { onDone: (score: number, note: string) => void })
/> />
</label> </label>
<div className="jc-cfill-acts"> <div className="jc-cfill-acts">
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}> <Btn className="jc-btn" disabled={score === 0} busy={busy} onClick={() => onDone(score, note.trim())}>
<Ico name="check" s={13} /> 提交评价 <Ico name="check" s={13} /> 提交评价
</button> </Btn>
</div> </div>
</div> </div>
); );
@@ -1192,6 +1223,7 @@ function EntryCard({
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。 * 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。 * 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。
*/ */
const busy = useReplyBusy();
const groups = [ const groups = [
{ {
title: '', title: '',
@@ -1290,9 +1322,9 @@ function EntryCard({
{!done && ( {!done && (
<div className="jc-cfill-acts"> <div className="jc-cfill-acts">
{blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>} {blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>}
<button className="jc-btn" disabled={blank.length > 0} onClick={() => onDone(vals)}> <Btn className="jc-btn" disabled={blank.length > 0} busy={busy} onClick={() => onDone(vals)}>
<Ico name="database" s={13} /> 确认写入 <Ico name="database" s={13} /> 确认写入
</button> </Btn>
</div> </div>
)} )}
</> </>
@@ -41,6 +41,7 @@ import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, useGovApprovalTemplates } from '@web-shared/gov/gov-api.ts'; import { useGovActivities, useGovApprovalTemplates } from '@web-shared/gov/gov-api.ts';
import type { GovApprovalTemplates } from '@web-shared/gov/gov-api.ts'; import type { GovApprovalTemplates } from '@web-shared/gov/gov-api.ts';
import { atFull } from '@web-shared/at.ts'; import { atFull } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
@@ -177,14 +178,9 @@ function ApprovalTemplates({ data }: { data: GovApprovalTemplates['templates'] }
<button className="mbtn" aria-label="编辑" onClick={() => setEditing({ ...t })}> <button className="mbtn" aria-label="编辑" onClick={() => setEditing({ ...t })}>
<Ico name="pen-line" s={13} /> <Ico name="pen-line" s={13} />
</button> </button>
<button <Btn className="mbtn bad" aria-label="删除" busy={drop.isPending} onClick={() => drop.mutate(t.id)}>
className="mbtn bad"
aria-label="删除"
disabled={drop.isPending}
onClick={() => drop.mutate(t.id)}
>
<Ico name="trash-2" s={13} /> <Ico name="trash-2" s={13} />
</button> </Btn>
</div> </div>
<div className="plate"> <div className="plate">
<span> <span>
@@ -288,7 +284,8 @@ function TemplateForm({
label: pending ? '保存中…' : '保存', label: pending ? '保存中…' : '保存',
ico: 'send', ico: 'send',
primary: true, primary: true,
disabled: pending || !idOk || t.name.trim() === '' || t.basis.some((b) => b.field === '' || b.label === ''), disabled: !idOk || t.name.trim() === '' || t.basis.some((b) => b.field === '' || b.label === ''),
busy: pending,
onClick: () => onSave({ ...t, name: t.name.trim() }), onClick: () => onSave({ ...t, name: t.name.trim() }),
}, },
]} ]}
@@ -516,7 +513,8 @@ function InstallCard() {
label: install.isPending ? '两审中…' : '装入并送审', label: install.isPending ? '两审中…' : '装入并送审',
ico: 'circle-check', ico: 'circle-check',
primary: true, primary: true,
disabled: install.isPending || manifestText.trim() === '' || artifact.trim() === '', disabled: manifestText.trim() === '' || artifact.trim() === '',
busy: install.isPending,
ask: { ask: {
title: '经安装通道装入活动包', title: '经安装通道装入活动包',
note: <Note>无代码通道:交的是数据。两审过了才上架;不过审会把是哪一条拦的说清楚。</Note>, note: <Note>无代码通道:交的是数据。两审过了才上架;不过审会把是哪一条拦的说清楚。</Note>,
+157 -88
View File
@@ -11,14 +11,31 @@
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移: * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
*/ */
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx'; import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts'; import { useGovDataByActivity, useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
import { domainShort, sortDomains } from '@web-shared/gov/domains.ts'; import { domainShort } from '@web-shared/gov/domains.ts';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
/**
* 记录浏览的格子文本 —— JSON 列(决策链地图 / 互动时间线)从前被 String() 成
* `[object Object]`(2026-08-24 用户报):对象与数组原样 stringify 摆出来,
* 超长截断进 title(截了才加省略号,悬停看全文)。这是取值,不是判断。
*/
const cellText = (v: unknown): string => {
if (v === null || v === undefined) return '';
if (typeof v === 'object') {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
return String(v);
};
/* ==================== 设计侧:数据域(定义面) ==================== */ /* ==================== 设计侧:数据域(定义面) ==================== */
/** /**
@@ -26,103 +43,151 @@ export type HomeProps = { tab: string; nav: Nav };
* 行数与行浏览入口不在这里 —— 那在运营中心的台账。 * 行数与行浏览入口不在这里 —— 那在运营中心的台账。
*/ */
/** /**
* 三类的呈现次序与说辞 —— **按「要不要管它」排**,不按字母。 * **按活动看**(2026-08-24 用户定:「按活动数据来分类,要让每个活动执行完成后
* 台账在这里看到变化」)。
* *
* 主数据摆最前:一屏里真正需要治理的就是它(谁能改、依据是什么、多久算过期)。 * 从前这一屏按**性质**分组(主数据/单据/派生)—— 那是「治理这张表」的人的分法,
* 单据量最大但不需要「管」—— 查得到、回放得出就够了,所以摆中间。 * 答的是「这个对象定义全不全」。可人跑完一单来这儿,问的是另一件事:
* 派生摆最后,并且明写「不该手写」:手写的派生值会和它的来源打架,而没人会发现。 * **我这个活动写了哪几张表,现在各有几行,刚才那一单落下了什么**。
* 按活动分组、把行数摆在最显眼处,那个问题才答得了;性质降成一枚徽,
* 它仍要紧(单据与主数据的治理方式不同),但不该再当分组的轴。
* *
* `unknown` 是**库里有表、却没有哪个在装的活动声明它**——多半是退役活动留下的空壳。 * 三个数各说各的,不许混成一个:
* 它不是一种性质,是一种状况,所以单列一档、说清楚,不混进上面三类假装正常。 * · **正式** —— 过了放行门的事实,这张表现在真有几行;
* · **草稿** —— 还在某一单草稿层的行(在办中,放行才进正式表);
* · **未物化** —— 活动声明了、一次都没写过,所以库里还没这张表。
* 它与「有表但空着」是两件事,不拿 0 混过去。
*/ */
const KIND_ORDER = ['master', 'record', 'derived', 'unknown'] as const; const KIND_BADGE: Readonly<Record<string, { label: string; tone: string }>> = {
type KindKey = (typeof KIND_ORDER)[number]; master: { label: '主数据', tone: 'm' },
record: { label: '单据', tone: 'r' },
derived: { label: '派生', tone: 'd' },
};
const KIND_VIEW: Readonly<Record<KindKey, { title: string; ico: string; desc: string; tone: string }>> = { /** 一单此刻是什么样 —— 终态优先(它才是结论),没终态就说运行态 */
master: { function runWord(run: { runtime: string; terminal?: string | undefined }): { text: string; tone: string } {
title: '主数据', if (run.terminal === 'released') return { text: '已放行', tone: 'ok' };
ico: 'library', if (run.terminal !== undefined) return { text: run.terminal, tone: 'warn' };
tone: 'm', if (run.runtime === 'running') return { text: 'AI 推进中', tone: 'live' };
desc: '跨单积累的经营资产 —— 会被反复更新,下一单读它做判断。治理都在这一类上:谁能改、依据是什么、怎么去重、多久算过期。', return { text: '等人办', tone: 'wait' };
}, }
record: {
title: '单据', const atMinute = (iso: string): string => {
ico: 'file-text', const d = new Date(iso);
tone: 'r', return Number.isNaN(d.getTime()) ? '' : `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
desc: '一单一份的过程与结论 —— 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出;主键第一列就是那一单的标识。',
},
derived: {
title: '派生',
ico: 'trending-up',
tone: 'd',
desc: '能由单据与主数据算出来的指标 —— 本不该手写:手写的派生值会和它的来源打架,而没人会发现。标出来,至少让它可被重算校验。',
},
unknown: {
title: '无人声明',
ico: 'help-circle',
tone: 'u',
desc: '库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。',
},
}; };
export function DataHome({ nav }: HomeProps) { export function DataHome({ nav }: HomeProps) {
const q = useGovObjects(); const q = useGovDataByActivity();
const objects = q.data?.objects ?? []; const activities = q.data?.activities ?? [];
const unclaimed = q.data?.unclaimed ?? [];
// 一张表都没声明的活动不占地方 —— 那多半是还没写到台账的槽位
const withTables = activities.filter((a) => a.tables.length > 0);
return ( return (
<QueryGate query={q}> <QueryGate query={q}>
<> <>
{KIND_ORDER.map((kind) => { {withTables.map((a) => {
const inKind = objects.filter((o) => (o.kind ?? 'unknown') === kind); const formal = a.tables.reduce((n, t) => n + (t.rows ?? 0), 0);
if (inKind.length === 0) return null; const drafting = a.tables.reduce((n, t) => n + t.drafts, 0);
const view = KIND_VIEW[kind];
const domains = sortDomains([...new Set(inKind.map((o) => o.domain))]);
return ( return (
<section key={kind} className={'g-kind t-' + view.tone}> <section key={a.activityId} className="g-actdata">
<div className="g-kind-h"> <div className="g-actdata-h">
<Ico name={view.ico} s={15} /> <Ico name="list-checks" s={15} />
<b>{view.title}</b> <b>{a.name}</b>
<span className="n">{inKind.length} 张表</span> <span className="g-mono">
<p>{view.desc}</p> {a.activityId}
{a.version === undefined ? '' : `@${a.version}`}
</span>
<span className="n">
{a.tables.length} 张表 · 正式 {formal} 行
{drafting > 0 && <em> · 在办 {drafting} 行</em>}
</span>
</div> </div>
{domains.map((d) => {
const inDomain = inKind.filter((o) => o.domain === d); <div className="g-actdata-tables">
return ( {a.tables.map((t) => {
<Card const badge = KIND_BADGE[t.kind ?? ''];
key={d} return (
ico="database" <button
title={domainShort(d)} key={`${t.domain}.${t.object}`}
extra={ type="button"
<span className="g-mono"> className="g-tbl"
{d} · {inDomain.length} 对象 onClick={() => nav.push({ k: 'obj', id: `${t.domain}.${t.object}` })}
>
<span className="g-tbl-t">
<b>{t.label ?? t.object}</b>
{badge !== undefined && <i className={'g-tbl-k t-' + badge.tone}>{badge.label}</i>}
</span> </span>
} <span className="g-tbl-a g-mono">
> {t.domain}.{t.object}
<div className="g-entries"> {t.primaryKey.length > 0 && <em> · 主键 {t.primaryKey.join(' + ')}</em>}
{inDomain.map((o) => ( </span>
<Entry <span className="g-tbl-n">
key={o.object} {/* 「没物化」不写 0 —— 声明了没写过,与写过又空了不是一回事 */}
ico="table" {t.rows === undefined ? (
// 人读名优先;没登记 label 才退回对象键 —— 治理台不该拿 `intake_record` 给人看 <em className="none">还没建表</em>
title={o.label ?? o.object} ) : (
/* 单据把**按什么分单**摆出来 —— 那是它和主数据最要紧的差别 */ <>
desc={ <b>{t.rows}</b> 行
o.caseKey === undefined </>
? `${o.domain}.${o.object}` )}
: `${o.domain}.${o.object} · 按 ${o.caseKey} 分单` {t.drafts > 0 && <i className="draft">在办 {t.drafts} 行</i>}
} </span>
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })} </button>
/> );
))} })}
</div> </div>
</Card>
); {a.recent.length > 0 && (
})} <div className="g-actdata-runs">
<em>最近的单</em>
{a.recent.map((run) => {
const word = runWord(run);
return (
<span key={run.id} className={'g-run t-' + word.tone}>
{word.text}
<i>{atMinute(run.at)}</i>
</span>
);
})}
</div>
)}
</section> </section>
); );
})} })}
{objects.length === 0 && (
<Clear>还没有物化的对象 —— 活动第一次写入时,系统按它的声明把表建出来,然后出现在这里。</Clear> {unclaimed.length > 0 && (
<section className="g-actdata t-u">
<div className="g-actdata-h">
<Ico name="help-circle" s={15} />
<b>无人声明</b>
<span className="n">{unclaimed.length} 张表</span>
<p>库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。</p>
</div>
<div className="g-actdata-tables">
{unclaimed.map((o) => (
<button
key={`${o.domain}.${o.object}`}
type="button"
className="g-tbl"
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
>
<span className="g-tbl-t">
<b>{o.label ?? o.object}</b>
</span>
<span className="g-tbl-a g-mono">
{o.domain}.{o.object}
</span>
<span className="g-tbl-n">{o.rows === undefined ? <em className="none">读不到</em> : <><b>{o.rows}</b> 行</>}</span>
</button>
))}
</div>
</section>
)}
{withTables.length === 0 && unclaimed.length === 0 && (
<Clear>还没有活动声明台账 —— 装一个活动包,它声明的表就出现在这里;第一次写入时表才真建出来。</Clear>
)} )}
</> </>
</QueryGate> </QueryGate>
@@ -238,11 +303,15 @@ export function ObjPage({ id }: { id: string }) {
<tbody> <tbody>
{(page.data?.rows ?? []).map((row, i) => ( {(page.data?.rows ?? []).map((row, i) => (
<tr key={i}> <tr key={i}>
{columns.map((c) => ( {columns.map((c) => {
<td key={c.name} className="g-mono"> const full = cellText(row[c.name]);
{String(row[c.name] ?? '')} const long = full.length > 120;
</td> return (
))} <td key={c.name} className="g-mono" title={long ? full : undefined}>
{long ? `${full.slice(0, 120)}…` : full}
</td>
);
})}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -50,6 +50,7 @@ import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts'; import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts'; import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts'; import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import { Btn } from '@web-shared/Btn.tsx';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
@@ -239,7 +240,8 @@ function FlowShelf({ nav }: { nav: Nav }) {
label: create.isPending ? '建流程中…' : '建流程并进入', label: create.isPending ? '建流程中…' : '建流程并进入',
ico: 'plus', ico: 'plus',
primary: true, primary: true,
disabled: create.isPending || !idOk || idTaken || nfName.trim() === '', disabled: !idOk || idTaken || nfName.trim() === '',
busy: create.isPending,
onClick: () => create.mutate(), onClick: () => create.mutate(),
}, },
]} ]}
@@ -333,9 +335,9 @@ function FlowShelf({ nav }: { nav: Nav }) {
<> <>
<span className="q">删掉整条流?{f.versions.length} 个版本一起没</span> <span className="q">删掉整条流?{f.versions.length} 个版本一起没</span>
<button onClick={() => setMenu({ id: f.id, drop: false })}>算了</button> <button onClick={() => setMenu({ id: f.id, drop: false })}>算了</button>
<button className="bad" disabled={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}> <Btn className="bad" busy={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
删除 删除
</button> </Btn>
</> </>
) : ( ) : (
<> <>
@@ -793,19 +795,18 @@ function FlowLab({
*/} */}
{editingVersions ? ( {editingVersions ? (
<> <>
<button <Btn
className="g-ver-act" className="g-ver-act"
disabled={saveVersions.isPending}
onClick={() => { onClick={() => {
setEditingVersions(false); setEditingVersions(false);
setEdits({}); setEdits({});
}} }}
> >
取消 取消
</button> </Btn>
<button <Btn
className="g-ver-act" className="g-ver-act"
disabled={saveVersions.isPending} busy={saveVersions.isPending}
onClick={() => { onClick={() => {
const drafts = versions.flatMap((v) => { const drafts = versions.flatMap((v) => {
const d = edits[v.version]; const d = edits[v.version];
@@ -825,13 +826,13 @@ function FlowLab({
}} }}
> >
<Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'} <Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'}
</button> </Btn>
</> </>
) : ( ) : (
<> <>
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending}> <Btn className="g-ver-act" onClick={askClone} busy={cloneVersion.isPending}>
<Ico name="plus" s={11} /> 新建 <Ico name="plus" s={11} /> 新建
</button> </Btn>
{/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */} {/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */}
<button <button
className="g-ver-act" className="g-ver-act"
@@ -995,15 +996,16 @@ function FlowLab({
节点 · 属性 节点 · 属性
{dirty && <span className="n dirty">有改动待保存</span>} {dirty && <span className="n dirty">有改动待保存</span>}
{/* 与「新建 / 编辑」同款素钮 —— 可不可存由禁用态说,不靠换色抢戏 */} {/* 与「新建 / 编辑」同款素钮 —— 可不可存由禁用态说,不靠换色抢戏 */}
<button <Btn
className="g-ver-act" className="g-ver-act"
disabled={!dirty || save.isPending} disabled={!dirty}
busy={save.isPending}
onClick={() => { onClick={() => {
if (graph !== null) save.mutate({ graph, idMap: {} }); if (graph !== null) save.mutate({ graph, idMap: {} });
}} }}
> >
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`} <Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
</button> </Btn>
</div> </div>
<div className="g-nodecard-b"> <div className="g-nodecard-b">
{saveErr !== undefined && ( {saveErr !== undefined && (
@@ -112,6 +112,16 @@ function PackageConfig({
['node-owners'], ['node-owners'],
); );
/*
* 勾选与下拉也是「点一下就去请求」—— 它们不是按钮,给不了键感,
* 那就在办事的这一段把**那一行**点亮(条画在行脚下)。
* 认 `variables` 只亮真正在路上的那一行 —— 三个开关共用一个 mutation,
* 不认就变成整表一起闪,等于没说是哪一条在办。
* 条不画在 `<select>` 自己身上:它的下拉箭头就是 background-image,画上去会顶掉箭头。
*/
const busyTrigger = toggle.isPending ? toggle.variables.id : confirm.isPending ? confirm.variables.id : undefined;
const busyNode = setOwner.isPending ? setOwner.variables.nodeId : undefined;
return ( return (
<Card ico="workflow" title={name} extra={<span className="g-mono">{packageId}</span>}> <Card ico="workflow" title={name} extra={<span className="g-mono">{packageId}</span>}>
{/* ── 触发器的运营面:开不开 · 要不要人点头 · 谁点头 —— 规则本身在设计中心改 ── */} {/* ── 触发器的运营面:开不开 · 要不要人点头 · 谁点头 —— 规则本身在设计中心改 ── */}
@@ -126,7 +136,7 @@ function PackageConfig({
triggers.map((t) => { triggers.map((t) => {
const confirmed = t.confirm.required; const confirmed = t.confirm.required;
return ( return (
<div key={t.id} className="g-runconf-r"> <div key={t.id} className="g-runconf-r" aria-busy={busyTrigger === t.id ? true : undefined}>
<St tone={t.enabled ? 'ok' : 'mute'} ico="zap"> <St tone={t.enabled ? 'ok' : 'mute'} ico="zap">
{t.name} {t.name}
</St> </St>
@@ -186,7 +196,7 @@ function PackageConfig({
</div> </div>
<div className="g-runconf-own"> <div className="g-runconf-own">
{nodes.map((n) => ( {nodes.map((n) => (
<label key={n.id} className="g-runconf-node"> <label key={n.id} className="g-runconf-node" aria-busy={busyNode === n.id ? true : undefined}>
<span className="nid">{n.id}</span> <span className="nid">{n.id}</span>
<span className="lb">{n.label}</span> <span className="lb">{n.label}</span>
{n.kind === 'approval' && <Chip tone="warn">审批</Chip>} {n.kind === 'approval' && <Chip tone="warn">审批</Chip>}
@@ -339,7 +339,8 @@ function WorkGroupForm({
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`, label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
ico: 'circle-check', ico: 'circle-check',
primary: true, primary: true,
disabled: save.isPending || draftName.trim() === '', disabled: draftName.trim() === '',
busy: save.isPending,
ask: { ask: {
title: `保存工作组:${draftName}`, title: `保存工作组:${draftName}`,
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>, note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
@@ -15,6 +15,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx'; import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts'; import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts';
import { Btn } from '@web-shared/Btn.tsx';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
@@ -256,10 +257,15 @@ function LlmDrawer({
</small> </small>
</label> </label>
<div className="g-testrow"> <div className="g-testrow">
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}> <Btn
className="g-btn xs"
disabled={d.model.trim() === '' || urlNeeded}
busy={test.isPending}
onClick={() => test.mutate()}
>
<Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} /> <Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} />
{test.isPending ? '测试中…' : '测试连接'} {test.isPending ? '测试中…' : '测试连接'}
</button> </Btn>
{probed !== undefined && ( {probed !== undefined && (
<span className={'r' + (probed.ok ? ' ok' : ' bad')}> <span className={'r' + (probed.ok ? ' ok' : ' bad')}>
{probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')} {probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')}
@@ -286,12 +292,12 @@ function LlmDrawer({
</div> </div>
<footer> <footer>
<button className="g-btn" onClick={onCancel}> <Btn className="g-btn" onClick={onCancel}>
取消 取消
</button> </Btn>
<button className="g-btn primary" disabled={save.isPending || invalid} onClick={() => save.mutate()}> <Btn className="g-btn primary" disabled={invalid} busy={save.isPending} onClick={() => save.mutate()}>
{save.isPending ? '保存中…' : isNew ? '接入' : '保存'} {save.isPending ? '保存中…' : isNew ? '接入' : '保存'}
</button> </Btn>
</footer> </footer>
</div> </div>
</div>, </div>,
@@ -362,10 +368,10 @@ function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav;
<span className="host" title={i.baseUrl}> <span className="host" title={i.baseUrl}>
{hostOf(i.baseUrl)} {hostOf(i.baseUrl)}
</span> </span>
<button className="tbtn" disabled={probe.isPending} onClick={() => probe.mutate()}> <Btn className="tbtn" busy={probe.isPending} onClick={() => probe.mutate()}>
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={11} /> <Ico name={probe.isPending ? 'hourglass' : 'zap'} s={11} />
{probe.isPending ? '测试中' : '测试'} {probe.isPending ? '测试中' : '测试'}
</button> </Btn>
</> </>
) : ( ) : (
/* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */ /* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */
@@ -391,9 +397,9 @@ function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav;
<> <>
<span className="q">删掉这条实例?活动里声明了它的名就删不掉</span> <span className="q">删掉这条实例?活动里声明了它的名就删不掉</span>
<button onClick={() => setMenu('open')}>算了</button> <button onClick={() => setMenu('open')}>算了</button>
<button className="bad" disabled={drop.isPending} onClick={() => drop.mutate()}> <Btn className="bad" busy={drop.isPending} onClick={() => drop.mutate()}>
删除 删除
</button> </Btn>
</> </>
) : ( ) : (
<> <>
@@ -25,6 +25,7 @@ import { APR_ST, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx'; import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx';
import { atFull, atMinute } from '@web-shared/at.ts'; import { atFull, atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
import { import {
useGovApprovals, useGovApprovals,
useGovStaff, useGovStaff,
@@ -379,6 +380,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
ico: semantic === 'terminate' ? 'power' : semantic === 'halt' ? 'hand' : 'pause', ico: semantic === 'terminate' ? 'power' : semantic === 'halt' ? 'hand' : 'pause',
danger, danger,
disabled: !live, disabled: !live,
busy: control.isPending,
ask: { ask: {
title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`, title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`,
note: <Note>控制事件走统一事件信封(四类事件之控制事件),先持久后投递。</Note>, note: <Note>控制事件走统一事件信封(四类事件之控制事件),先持久后投递。</Note>,
@@ -422,6 +424,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
ico: 'play', ico: 'play',
primary: true, primary: true,
disabled: !suspended, disabled: !suspended,
busy: resume.isPending,
ask: { ask: {
title: `恢复任务:${task.title}`, title: `恢复任务:${task.title}`,
note: <Note tone="ok">恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。</Note>, note: <Note tone="ok">恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。</Note>,
@@ -457,10 +460,15 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
</div> </div>
</div> </div>
<div className="g-acts-r"> <div className="g-acts-r">
<button className="g-btn primary" disabled={assignee === '' || !live} onClick={() => reassign.mutate(assignee)}> <Btn
className="g-btn primary"
disabled={assignee === '' || !live}
busy={reassign.isPending}
onClick={() => reassign.mutate(assignee)}
>
<Ico name="user-plus" s={13} /> <Ico name="user-plus" s={13} />
确认改派 确认改派
</button> </Btn>
</div> </div>
</Sec> </Sec>
@@ -23,6 +23,7 @@ import { QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { DraftGroups } from './drafts.tsx'; import { DraftGroups } from './drafts.tsx';
import { observeFace } from './observe-face.ts'; import { observeFace } from './observe-face.ts';
import { Btn } from '@web-shared/Btn.tsx';
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never; type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
export type ActivityState = Parsed<typeof testActivityStateRoute.output>; export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
@@ -54,6 +55,21 @@ export function ActivityRun({
const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0]; const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0];
const [data, setData] = useState<Readonly<Record<string, string>>>({}); const [data, setData] = useState<Readonly<Record<string, string>>>({});
const [ownerId, setOwner] = useState(''); const [ownerId, setOwner] = useState('');
/**
* **连主数据一起落真库**(2026-08-24 用户:「数据需要真实入库」「那你把这个开关打开了吗?」)。
*
* **试跑台这一处默认勾上** —— 人打开这一屏是来看活动真跑一遍的:
* 台账更新明细写着「亲密度 3 → 10」而 `customer.contact` 还是 3,
* 单据说改了、主数据没改,两份事实对不上,这一屏就白看了。
*
* 代价照旧存在,所以那一格勾上时是**红的**、并写清在破例:主数据按业务实体分行,
* 试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里 ——
* 「新客户来函」那类**只能测一次**的样例,跑之前把这一格取消掉。
*
* 接口那一侧的默认仍是 `false`(`testRunActivityRoute`):e2e 与别的程序化调用照旧受保护,
* 那道保护本来就是为它们设的。默认的差别不是自相矛盾 —— **人手点的和脚本跑的,要的东西不一样**。
*/
const [landMaster, setLandMaster] = useState(true);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -68,7 +84,7 @@ export function ActivityRun({
}, [picked?.activityId]); }, [picked?.activityId]);
const start = useMutation({ const start = useMutation({
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string }) => mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string; landMaster: boolean }) =>
call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }), call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }),
onSuccess: (r) => { onSuccess: (r) => {
setError(undefined); setError(undefined);
@@ -172,7 +188,7 @@ export function ActivityRun({
} }
} }
setError(undefined); setError(undefined);
start.mutate({ activityId: card.activityId, data: shaped, ownerId }); start.mutate({ activityId: card.activityId, data: shaped, ownerId, landMaster });
}; };
return ( return (
@@ -321,6 +337,30 @@ export function ActivityRun({
</div> </div>
</div> </div>
<div className="tb-asn">
<label className={'tb-land' + (landMaster ? ' on' : '')}>
<input
type="checkbox"
checked={landMaster}
onChange={(e) => setLandMaster(e.target.checked)}
/>
<span>
<b>连主数据一起落真库</b>
{/* 星号是 Markdown,JSX 里不渲染 —— 屏上就真的印着两个星号(界面不撒谎,也别摆记号) */}
<em>
{landMaster ? (
<>
这一单放行时,客户档案 / 联系人 / 品牌内容库这类主数据会<b>真的写进正式表</b> ——
演示与联调要看的就是它
</>
) : (
<>只落单据:主数据留在活动缓存,免得试跑把样例客户写成老客户(「新客户来函」那条路就再也试不出来)</>
)}
</em>
</span>
</label>
</div>
<div className="g-rig-h"> <div className="g-rig-h">
<span className="n">03</span> <span className="n">03</span>
这个活动手上有什么 这个活动手上有什么
@@ -338,14 +378,15 @@ export function ActivityRun({
'起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉' '起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉'
)} )}
</span> </span>
<button <Btn
className={'tb-go' + (fireable ? ' armed' : '')} className={'tb-go' + (fireable ? ' armed' : '')}
disabled={!fireable || start.isPending} disabled={!fireable}
busy={start.isPending}
onClick={() => picked && fire(picked)} onClick={() => picked && fire(picked)}
> >
<Ico name="play" s={13} /> <Ico name="play" s={13} />
{start.isPending ? '起单中…' : '起单试跑'} {start.isPending ? '起单中…' : '起单试跑'}
</button> </Btn>
</div> </div>
</div> </div>
</> </>
@@ -642,10 +683,10 @@ function ActivityObserve({
* 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着; * 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着;
* 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。 * 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。
*/ */
<button className="tb-stop" disabled={stopping} onClick={onStop}> <Btn className="tb-stop" busy={stopping} onClick={onStop}>
<Ico name={live ? 'square' : 'rotate-ccw'} s={11} /> <Ico name={live ? 'square' : 'rotate-ccw'} s={11} />
{stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'} {stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'}
</button> </Btn>
), ),
})} })}
@@ -27,6 +27,7 @@ import type { Nav } from '@web-shared/gov/gov-route.ts';
import { ActivityRun } from './activity-run.tsx'; import { ActivityRun } from './activity-run.tsx';
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts'; import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
import { TraceSheet } from './trace-sheet.tsx'; import { TraceSheet } from './trace-sheet.tsx';
import { Btn } from '@web-shared/Btn.tsx';
type RunOutput = { workflowId: string; state: string }; type RunOutput = { workflowId: string; state: string };
@@ -389,10 +390,10 @@ function PackageRun({ nav, hint }: { nav: Nav; hint?: ReactNode }): ReactNode {
'起单会先销毁上一单;任务真实落到被指派人的工作台,抽不出的键由受理补全单收齐' '起单会先销毁上一单;任务真实落到被指派人的工作台,抽不出的键由受理补全单收齐'
)} )}
</span> </span>
<button className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable || run.isPending} onClick={fire}> <Btn className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable} busy={run.isPending} onClick={fire}>
<Ico name="play" s={13} /> <Ico name="play" s={13} />
{run.isPending ? '起单中…' : '起单试跑'} {run.isPending ? '起单中…' : '起单试跑'}
</button> </Btn>
</div> </div>
</div> </div>
</> </>
@@ -519,10 +520,10 @@ function Observe({
</> </>
} }
right={ right={
<button className="tb-stop" disabled={stopping} onClick={onBack}> <Btn className="tb-stop" busy={stopping} onClick={onBack}>
<Ico name="square" s={11} /> <Ico name="square" s={11} />
{stopping ? '销毁中…' : '停止仿真'} {stopping ? '销毁中…' : '停止仿真'}
</button> </Btn>
} }
/> />
@@ -20,6 +20,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { QueryGate } from '@web-shared/gov/primitives.tsx'; import { QueryGate } from '@web-shared/gov/primitives.tsx';
import { DraftGroups } from './drafts.tsx'; import { DraftGroups } from './drafts.tsx';
import { Btn } from '@web-shared/Btn.tsx';
/** 详情比列表多三样正文 —— 形状从契约的 parse 出参接出来,不手搓 */ /** 详情比列表多三样正文 —— 形状从契约的 parse 出参接出来,不手搓 */
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never; type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
@@ -119,15 +120,16 @@ export function TestLogs(): ReactNode {
<span className="word">试跑存档</span> <span className="word">试跑存档</span>
<span className="det">{runs.length === 0 ? '还没有存档' : `${runs.length} 次试跑`}</span> <span className="det">{runs.length === 0 ? '还没有存档' : `${runs.length} 次试跑`}</span>
<span className="x"> <span className="x">
<button <Btn
className="tb-stop" className="tb-stop"
disabled={runs.length === 0 || clearAll.isPending} disabled={runs.length === 0}
busy={clearAll.isPending}
onClick={() => clearAll.mutate()} onClick={() => clearAll.mutate()}
title="清空全部存档" title="清空全部存档"
> >
<Ico name="trash-2" s={11} /> <Ico name="trash-2" s={11} />
{clearAll.isPending ? '清空中…' : '清空'} {clearAll.isPending ? '清空中…' : '清空'}
</button> </Btn>
</span> </span>
</div> </div>
@@ -141,7 +143,12 @@ export function TestLogs(): ReactNode {
) : ( ) : (
<div className="tb-logs"> <div className="tb-logs">
{runs.map((r) => ( {runs.map((r) => (
<button key={r.id} className="tb-log-r" onClick={() => setOpen(r.id)}> <button
key={r.id}
className="tb-log-r"
aria-busy={dropOne.isPending && dropOne.variables === r.id ? true : undefined}
onClick={() => setOpen(r.id)}
>
<span className={'kd ' + r.kind}>{r.kind === 'package' ? '流程' : '活动'}</span> <span className={'kd ' + r.kind}>{r.kind === 'package' ? '流程' : '活动'}</span>
<RunId id={r.id} /> <RunId id={r.id} />
<span className="sub"> <span className="sub">
@@ -218,10 +225,10 @@ function LogDetail({
<Ico name="corner-up-left" s={10} /> <Ico name="corner-up-left" s={10} />
回目录 回目录
</button> </button>
<button className="tb-stop" disabled={dropping} onClick={onDrop}> <Btn className="tb-stop" busy={dropping} onClick={onDrop}>
<Ico name="trash-2" s={11} /> <Ico name="trash-2" s={11} />
{dropping ? '删除中…' : '删掉这条'} {dropping ? '删除中…' : '删掉这条'}
</button> </Btn>
</span> </span>
</div> </div>
@@ -381,7 +381,8 @@ function ImportToolCard() {
label: importTool.isPending ? '两审中…' : '导入并送审', label: importTool.isPending ? '两审中…' : '导入并送审',
ico: 'circle-check', ico: 'circle-check',
primary: true, primary: true,
disabled: importTool.isPending || name.trim() === '' || endpoint.trim() === '', disabled: name.trim() === '' || endpoint.trim() === '',
busy: importTool.isPending,
ask: { ask: {
title: `导入工具:${name.trim()}`, title: `导入工具:${name.trim()}`,
note: <Note>与第一方同一条门:静态审 + 试运行审。过了才入目录;入目录还不等于能用,要再给活动授白名单。</Note>, note: <Note>与第一方同一条门:静态审 + 试运行审。过了才入目录;入目录还不等于能用,要再给活动授白名单。</Note>,
@@ -370,7 +370,8 @@ function TriggerForm({
label: save.isPending ? '保存中…' : '保存规则', label: save.isPending ? '保存中…' : '保存规则',
ico: 'circle-check', ico: 'circle-check',
primary: true, primary: true,
disabled: save.isPending || draft.name.trim() === '', disabled: draft.name.trim() === '',
busy: save.isPending,
ask: { ask: {
title: `保存调起规则:${draft.name}`, title: `保存调起规则:${draft.name}`,
note: <Note>触发器与流程包平级 —— 改规则不动任何流程包版本,已起的单照常跑完。</Note>, note: <Note>触发器与流程包平级 —— 改规则不动任何流程包版本,已起的单照常跑完。</Note>,
+72
View File
@@ -0,0 +1,72 @@
/**
* 会去请求服务端的那种按钮 —— 三面共用(2026-08-24 用户:「点击了都不知道点没点上」)。
*
* 一枚按钮按下去之后,人要在**三个时刻**都看得见东西:
* · **按下那一刻** —— 键帽下沉(`:active`,纯 CSS,见 `styles/press.css`),证明这一下点着了;
* · **在办事的这一段** —— `aria-busy` 亮着,按钮脚下走一道不定长进度条,鼠标也进不来
* (`pointer-events: none`)。这一段从前是**完全静默**的:屏上什么都没变,人只好再点一次;
* · **办完那一刻** —— 条收掉,按钮恢复可按。
*
* 为什么用 `aria-disabled` + `pointer-events: none`,不用原生 `disabled`:
* `disabled` 会把按钮画成灰的 —— 那是「这件事现在做不了」,而它此刻正在**做**。
* 界面不撒谎:在办事就画成在办事,别画成不可用。真正做不了的照旧给 `disabled`。
*
* 在办的判断有两条路,谁先来算谁:
* · `busy`(常用)—— 把 react-query 的 `isPending` 递进来;
* · `onClick` 返回了 Promise —— 自己盯着它落地。
* 两条路之外还有唯一通道上的去重闸(`typed-fetch`),那是**最后一道**:
* 就算这里漏了,同一笔请求也不会真的发两遍。
*/
import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactNode } from 'react';
type BtnProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
/** 在办事(通常是 `mutation.isPending`) */
busy?: boolean | undefined;
/** 返回 Promise 的话,不必再给 `busy` —— 落地之前一直算在办 */
onClick?: ((e: MouseEvent<HTMLButtonElement>) => unknown) | undefined;
children?: ReactNode;
};
function isThenable(v: unknown): v is Promise<unknown> {
return typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function';
}
export function Btn({ busy, onClick, disabled, type = 'button', ...rest }: BtnProps) {
const [awaiting, setAwaiting] = useState(false);
const alive = useRef(true);
// 上来先把旗子插回去 —— StrictMode 开发下 effect 走「挂载 → 拆卸 → 再挂载」,
// 只在拆卸时落旗的话第二次挂载后它永远是 false,靠 Promise 收尾的按钮会一直亮着「在办事」。
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const pending = busy === true || awaiting;
const fire = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
// 在办事的时候再点一下:什么也不做(不是拒绝,是这一下本来就是余震)
if (pending || disabled === true || onClick === undefined) return;
const r = onClick(e);
if (!isThenable(r)) return;
setAwaiting(true);
const settle = (): void => {
if (alive.current) setAwaiting(false);
};
r.then(settle, settle);
},
[pending, disabled, onClick],
);
return (
<button
{...rest}
type={type}
disabled={disabled === true}
{...(pending ? { 'aria-busy': true, 'aria-disabled': true } : {})}
onClick={fire}
/>
);
}
@@ -0,0 +1,125 @@
/**
* 唯一通道上的那道闸 —— 「同一笔请求不发两遍」(2026-08-24)。
*
* 这道闸不是性能优化,是**双击不该变成两笔**:点「新建纪要」手抖点两下,
* 从前真的建两份;点「装入」两下,真的装两遍。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { markCommReadRoute, sessionRoute } from '@contracts';
import { ApiCallError, DOUBLE_CLICK_MS, call, callKey, resetCallGuards } from '../typed-fetch.ts';
const write = markCommReadRoute;
const read = sessionRoute;
/** 手动控制何时落地的假网关 —— 在途那一段要摆得住才测得了 */
function gateway() {
const calls: string[] = [];
const pending: Array<(v: unknown) => void> = [];
const fetchMock = vi.fn((url: string, init?: { method?: string }) => {
calls.push(`${init?.method ?? 'GET'} ${url}`);
return new Promise((resolve) => {
pending.push(() => resolve({ ok: true, status: 200, json: () => Promise.resolve({ n: calls.length }) }));
});
});
vi.stubGlobal('fetch', fetchMock);
return { calls, settleAll: () => pending.splice(0).forEach((f) => f(undefined)) };
}
beforeEach(() => {
resetCallGuards();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('callKey —— 差一个字就是另一笔', () => {
it('方法 + 落地地址 + 报文,三样合起来才是身份', () => {
expect(callKey(write, { params: { commId: 'a' } })).toBe('POST /api/desk/comms/a/read ');
expect(callKey(write, { params: { commId: 'a' }, body: { read: true } })).toBe(
'POST /api/desk/comms/a/read {"read":true}',
);
});
it('**改了一格再存不算重复**:报文不同,身份就不同', () => {
expect(callKey(write, { body: { read: true } })).not.toBe(callKey(write, { body: { read: false } }));
});
});
describe('在途去重', () => {
it('**同一笔还在路上,第二次点击不再发第二个请求**', async () => {
const g = gateway();
const a = call(write, { params: { commId: 'a' } });
const b = call(write, { params: { commId: 'a' } });
expect(g.calls).toHaveLength(1);
g.settleAll();
expect(await a).toEqual(await b);
});
it('不同的那一笔照发不误', async () => {
const g = gateway();
void call(write, { params: { commId: 'a' } });
void call(write, { params: { commId: 'b' } });
expect(g.calls).toEqual(['POST /api/desk/comms/a/read', 'POST /api/desk/comms/b/read']);
g.settleAll();
await vi.runAllTimersAsync();
});
it('读请求同样只发一个(点开同一份资料连点两下)', async () => {
const g = gateway();
void call(read);
void call(read);
expect(g.calls).toHaveLength(1);
g.settleAll();
await vi.runAllTimersAsync();
});
});
describe('落地后的双击窗 —— 只管写请求', () => {
it('**刚成功那一笔在窗内原样重来,交回上一次的结果、不再发**', async () => {
const g = gateway();
const first = call(write, { params: { commId: 'a' } });
g.settleAll();
await first;
const again = await call(write, { params: { commId: 'a' } });
expect(g.calls).toHaveLength(1);
expect(again).toEqual(await first);
});
it('过了窗口就是新的一次点击,照发', async () => {
const g = gateway();
const first = call(write, { params: { commId: 'a' } });
g.settleAll();
await first;
vi.advanceTimersByTime(DOUBLE_CLICK_MS + 1);
void call(write, { params: { commId: 'a' } });
expect(g.calls).toHaveLength(2);
g.settleAll();
await vi.runAllTimersAsync();
});
it('**读请求不设这个窗** —— 写完立刻重取的那一下不能被当成余震压掉', async () => {
const g = gateway();
const first = call(read);
g.settleAll();
await first;
void call(read);
expect(g.calls).toHaveLength(2);
g.settleAll();
await vi.runAllTimersAsync();
});
it('**失败的不进窗**:那时候再点是重试,重试必须真的重试', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 500, statusText: 'x', json: () => Promise.resolve({ code: 'internal', message: '炸了' }) })),
);
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
});
});
+4 -3
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts"; import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts"; import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
import "diagram-js/assets/diagram-js.css"; import "diagram-js/assets/diagram-js.css";
import { Btn } from '@web-shared/Btn.tsx';
/** /**
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈 * 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
@@ -208,10 +209,10 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
> >
<Ico name="x" s={12} /> 取消 <Ico name="x" s={12} /> 取消
</button> </button>
<button <Btn
type="button"
className="be-btn solid" className="be-btn solid"
disabled={!canSave} disabled={!canSave}
busy={busy}
onClick={() => { onClick={() => {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) return; if (!editor) return;
@@ -225,7 +226,7 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
}} }}
> >
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"} <Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
</button> </Btn>
</div> </div>
{error && <p className="fe-error">{error}</p>} {error && <p className="fe-error">{error}</p>}
+14
View File
@@ -24,10 +24,12 @@ import type {
GovWorkGroupsOutput, GovWorkGroupsOutput,
GovWorkflowOutput, GovWorkflowOutput,
ProcessPackage, ProcessPackage,
GovLedgerByActivityOutput,
} from '@contracts'; } from '@contracts';
import type { ApprovalTemplate } from '@contracts'; import type { ApprovalTemplate } from '@contracts';
import { import {
browseLedgerRoute, browseLedgerRoute,
ledgerByActivityRoute,
govMetricsRoute, govMetricsRoute,
govTaskLogRoute, govTaskLogRoute,
govWorkflowRoute, govWorkflowRoute,
@@ -57,6 +59,7 @@ export type GovActivities = GovActivitiesOutput;
export type GovTools = GovToolsOutput; export type GovTools = GovToolsOutput;
export type GovObjects = GovObjectsOutput; export type GovObjects = GovObjectsOutput;
export type GovLedgerPage = GovLedgerPageOutput; export type GovLedgerPage = GovLedgerPageOutput;
export type GovLedgerByActivity = GovLedgerByActivityOutput;
export type GovMemory = GovMemoryOutput; export type GovMemory = GovMemoryOutput;
export type GovMetrics = GovMetricsOutput; export type GovMetrics = GovMetricsOutput;
export type GovChannels = GovChannelsOutput; export type GovChannels = GovChannelsOutput;
@@ -139,6 +142,17 @@ export const useGovObjects = (opts?: { enabled?: boolean }): UseQueryResult<GovO
enabled: opts?.enabled ?? true, enabled: opts?.enabled ?? true,
}); });
/**
* **按活动看数据域**(2026-08-24)—— 「我这个活动写了哪几张表,现在各有几行」。
* 键挂在 `['gov','data','by-activity']` 下:`kind:'system'` 的推送按 `gov` 前缀失效,
* 一单跑完这一屏跟着变(数据域不以任务为轴,靠的就是那条系统级广播)。
*/
export const useGovDataByActivity = (): UseQueryResult<GovLedgerByActivity> =>
useQuery({
queryKey: ['gov', 'data', 'by-activity'],
queryFn: () => call<GovLedgerByActivity>(ledgerByActivityRoute),
});
export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> => export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> =>
useQuery({ useQuery({
queryKey: ['gov', 'ledger', domain, object], queryKey: ['gov', 'ledger', domain, object],
+18 -12
View File
@@ -10,6 +10,7 @@
*/ */
import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react'; import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) { export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) {
return ( return (
@@ -359,16 +360,16 @@ export function ListEdit({
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))} onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))}
/> />
<button className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}> <Btn className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
<Ico name="trash-2" s={13} /> <Ico name="trash-2" s={13} />
</button> </Btn>
</div> </div>
))} ))}
{items.length === 0 && <span className="g-le-e">未声明</span>} {items.length === 0 && <span className="g-le-e">未声明</span>}
<button className="g-le-add" onClick={() => onChange([...items, ''])}> <Btn className="g-le-add" onClick={() => onChange([...items, ''])}>
<Ico name="plus" s={12} /> <Ico name="plus" s={12} />
添加 添加
</button> </Btn>
</div> </div>
); );
} }
@@ -390,6 +391,8 @@ export type ActSpec = {
primary?: boolean; primary?: boolean;
danger?: boolean; danger?: boolean;
disabled?: boolean; disabled?: boolean;
/** 这一钮的请求还在路上 —— 递 `mutation.isPending`,按钮自己会说「在办事」 */
busy?: boolean;
ask?: AskSpec | undefined; ask?: AskSpec | undefined;
onClick?: () => void; onClick?: () => void;
}; };
@@ -417,10 +420,10 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
</div> </div>
</div> </div>
<div className="g-ask-a"> <div className="g-ask-a">
<button className="g-btn" onClick={onCancel}> <Btn className="g-btn" onClick={onCancel}>
取消 取消
</button> </Btn>
<button <Btn
className={'g-btn ' + (danger ? 'danger' : 'primary')} className={'g-btn ' + (danger ? 'danger' : 'primary')}
onClick={() => { onClick={() => {
spec.onOk(); spec.onOk();
@@ -428,7 +431,7 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
}} }}
> >
{spec.okLabel} {spec.okLabel}
</button> </Btn>
</div> </div>
</div> </div>
); );
@@ -441,9 +444,10 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
<div className="g-acts"> <div className="g-acts">
<div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}> <div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}>
{items.map((it, i) => ( {items.map((it, i) => (
<button <Btn
key={i} key={i}
disabled={it.disabled === true} disabled={it.disabled === true}
busy={it.busy === true}
className={ className={
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '') 'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
} }
@@ -451,7 +455,7 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
> >
{it.ico && <Ico name={it.ico} s={13} />} {it.ico && <Ico name={it.ico} s={13} />}
{it.label} {it.label}
</button> </Btn>
))} ))}
</div> </div>
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />} {a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
@@ -495,8 +499,10 @@ export function PageHead({
{acts && ( {acts && (
<div className="g-dh-a"> <div className="g-dh-a">
{acts.map((it, i) => ( {acts.map((it, i) => (
<button <Btn
key={i} key={i}
disabled={it.disabled === true}
busy={it.busy === true}
className={ className={
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '') 'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
} }
@@ -504,7 +510,7 @@ export function PageHead({
> >
{it.ico && <Ico name={it.ico} s={13} />} {it.ico && <Ico name={it.ico} s={13} />}
{it.label} {it.label}
</button> </Btn>
))} ))}
</div> </div>
)} )}
+72 -1
View File
@@ -1,6 +1,16 @@
/** /**
* 服务端交互的唯一通道(编码规范 §9.2):组件内**禁止裸 fetch**。 * 服务端交互的唯一通道(编码规范 §9.2):组件内**禁止裸 fetch**。
* 路由契约来自 `@contracts/api`;**禁止 Hono RPC 客户端** —— 那要求前端 import 后端类型(§5.1)。 * 路由契约来自 `@contracts/api`;**禁止 Hono RPC 客户端** —— 那要求前端 import 后端类型(§5.1)。
*
* **同一笔请求不发两遍**(2026-08-24):唯一通道正是安放这道闸的地方 ——
* 按在这里,所有点击路径(按钮 / 下拉 / 勾选 / 组件里临时写的 mutate)一次盖全,
* 不必指望每个调用点自己记得防抖。两段:
* · **在途去重** —— 同一笔还在路上,第二次点击拿到的是同一个 Promise,不再发第二个请求;
* · **落地后的双击窗**(仅写请求)—— 刚成功那一笔在 `DOUBLE_CLICK_MS` 内原样重来,
* 当作同一次点击的余震,交回上一次的结果。失败的不进这个窗:那时候再点是**重试**,
* 重试必须真的重试。
* 读请求(GET)只做在途去重,不设落地窗 —— 写完立刻重取的那一下不能被当成余震压掉,
* 否则屏上摆的是改之前的数(界面不撒谎)。
*/ */
import type { RouteContract } from '@contracts'; import type { RouteContract } from '@contracts';
@@ -36,7 +46,36 @@ function queryString(query: Readonly<Record<string, string | undefined>> = {}):
return s === '' ? '' : `?${s}`; return s === '' ? '' : `?${s}`;
} }
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> { /** 双击阈值 —— 一次成功落地之后这么久之内的同一笔,算同一次点击的余震 */
export const DOUBLE_CLICK_MS = 450;
/**
* 一笔请求的身份 = 方法 + 落地后的完整地址 + 报文。
* 差一个字就是另一笔(改了一格再存不会被当成重复),一模一样才谈得上「点了两下」。
*/
export function callKey(route: RouteContract, options: CallOptions = {}): string {
const url = resolvePath(route.path, options.params) + queryString(options.query);
return `${route.method} ${url} ${options.body === undefined ? '' : JSON.stringify(options.body)}`;
}
/** 在路上的:键 → 那一个 Promise */
const inFlight = new Map<string, Promise<unknown>>();
/** 刚落地的写请求:键 → { 时刻, 结果 } */
const justDone = new Map<string, { at: number; value: unknown }>();
/** 过了窗口的旧条目扫掉 —— 这张表只服务「刚刚那一下」,不该随会话一直长 */
function sweep(): void {
const now = Date.now();
for (const [k, v] of justDone) if (now - v.at >= DOUBLE_CLICK_MS) justDone.delete(k);
}
/** 测试用:把两张表清空(浏览器里没有调用方) */
export function resetCallGuards(): void {
inFlight.clear();
justDone.clear();
}
async function send<T>(route: RouteContract, options: CallOptions): Promise<T> {
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), { const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
method: route.method, method: route.method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -56,3 +95,35 @@ export async function call<T>(route: RouteContract, options: CallOptions = {}):
} }
return payload as T; return payload as T;
} }
export function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
const key = callKey(route, options);
const write = route.method !== 'GET';
const flying = inFlight.get(key);
if (flying !== undefined) return flying as Promise<T>;
if (write) {
const done = justDone.get(key);
if (done !== undefined && Date.now() - done.at < DOUBLE_CLICK_MS) return Promise.resolve(done.value as T);
if (done !== undefined) justDone.delete(key);
}
const promise = send<T>(route, options).then(
(value) => {
inFlight.delete(key);
// 只记成功的:失败之后再点是重试,重试得真的发出去
if (write) {
sweep();
justDone.set(key, { at: Date.now(), value });
}
return value;
},
(error: unknown) => {
inFlight.delete(key);
throw error;
},
);
inFlight.set(key, promise);
return promise;
}
@@ -0,0 +1,16 @@
-- 任务推进的**跨进程租约** —— 两个后端连同一个库时,一张单只许一个进程在推。
--
-- 起因(2026-08-24,柴浩那一单熔断):本机与局域网另一台机器上跑着两个后端,
-- 连的是同一个库。两边的推进器各自 2 秒扫一次「running 的单」,同时推同一张 ——
-- 而互斥锁是**进程内**的(`task-lock.ts` 开头写着「MVP 单进程…多进程部署时再叠一层」)。
-- 于是两拍并发跑在同一份 `sys.loop_state` 上,**后写的整份覆盖先写的**:
-- 「给 S2 发过卡」这个事实(worldTouched)被抹掉,S1 被收了两遍。
-- 随后 finish 那道门说「从没给它发过卡」,重发那道门说「这张卡人已答过」——
-- 两堵墙对着撞,连拒五拍熔断。
--
-- 租约怎么用:推之前 `update … where advancing_until is null or advancing_until < now()`
-- 抢到才推(RETURNING 有行 = 我抢到了),推完清空。**带过期时间**是为了进程崩了不锁死:
-- 租约一过,别的进程照样接得上。
alter table sys.task add column if not exists advancing_until timestamptz;
-- 推进器每轮扫的就是「running 且没被别人占着」,给它一条能走的索引
create index if not exists task_advancing_idx on sys.task (runtime, advancing_until);
+14
View File
@@ -0,0 +1,14 @@
-- 试跑单也**落真库**的开关 —— 一列布尔,默认关。
--
-- 起因(2026-08-24 用户):「活动执行后数据并没有真实入到后台数据库里面,数据需要真实入库」。
-- 查下来是设计使然:`ledger-service` 那道门挡的是**主数据** ——
-- 试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
-- 「新客户来函」那条样例第二次跑就成了老客户,这条路从此试不出来。单据不挡。
--
-- 可这条保护在**演示/联调**时正好拦住了要看的东西:E04 的台账更新明细白纸黑字写着
-- 「亲密度 3 → 10」,而 `customer.contact` 里还是 3 —— 单据说改了,主数据没改,
-- 两份事实对不上,谁看谁糊涂。
--
-- 所以给试跑一个**明说**的开关:起单时勾了就连主数据一起落,不勾照旧只落单据。
-- 默认 false —— 保护不变,要破例得有人按一下。
alter table sys.task add column if not exists land_master boolean not null default false;
+14
View File
@@ -211,6 +211,20 @@
"when": 1785130120177, "when": 1785130120177,
"tag": "0029_task_asset_file", "tag": "0029_task_asset_file",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1785130120178,
"tag": "0030_task_advancing_lease",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1785130120179,
"tag": "0031_task_land_master",
"breakpoints": true
} }
] ]
} }
+30
View File
@@ -993,3 +993,33 @@ A2 踩过「媒体查询被基础规则盖掉」),两包各自的编辑态孪生
而不是台账后来被下一单改成的样子。工作成果那一屏另起一摞「产出的资料」, 而不是台账后来被下一单改成的样子。工作成果那一屏另起一摞「产出的资料」,
点开即预览、可下载原件。冻结这件事就此定了:**归档的资料与台账脱钩** —— 点开即预览、可下载原件。冻结这件事就此定了:**归档的资料与台账脱钩** ——
台账后来改了,归档件不改口。 台账后来改了,归档件不改口。
## 2026-08-24 · 两个后端连同一个库:局面被并发覆盖(已修一半)
**症状**(柴浩实测,E04 一单五拍熔断):`finish S2` 被拒「从没给它发过卡」,
而重发那张卡又被拒「这件事刚问过,人也答过了」—— 两堵墙对着撞,推不动。
**根因**:本机与局域网另一台机器上各跑着一个后端,连的是同一个库。两边的推进器各自
2 秒扫一次「running 的单」,**同时推同一张**;而互斥只在进程内(`task-lock.ts` 开头
自己写着「MVP 单进程…多进程部署时再叠一层」)。两拍并发跑在同一份 `sys.loop_state` 上,
**后写的整份覆盖先写的** —— 「给 S2 发过卡」这个事实(`worldTouched`)被抹掉,
S1 被收了两遍(两条 rationale 只差一个标点,是两次独立采样)。
**已修**:`workers/task-claim.ts` —— 库里一列 `advancing_until` 当**跨进程租约**,
推之前一条 SQL 判+占(`where advancing_until is null or advancing_until < now() … returning`),
推完就还,带 5 分钟过期(进程崩了不锁死)。推进器与派发器两条推进路径都接了。
实测:两条真连接对着真库抢 20 轮,每轮恰好一个赢;租约过期后另一方接得上。
**还欠**:`loop_state` 本身**没有乐观并发**(没有版本号 / CAS)。租约挡住了「两边同时推」
这条主路,但只要有第三条路绕过租约去写局面,后写的仍会整份覆盖先写的,而且**悄无声息**。
该补的是:`save` 带上读到的版本号,`update … where version = ?`,不命中就当场炸
(「这一拍的局面已被别处改过」),而不是默默覆盖。等真要多机部署时补齐。
## 2026-08-24 · 覆盖闭合是条数对账,不是集合对账
「每家候选客户都进了经营计划」(E04@2.1.1,S3 收步 / S4 哨兵 / 放行 L2 三处同一句)
数的是 `plan_distinct_customer_code = candidate_rows` —— **两个数相等,集合未必相等**:
计划里冒出一家非候选客户、同时又漏掉一家真候选,条数照样对得上。这一半靠 S3 guide 的
「抄名单逐条核对,一家都不能多」看着(实测这半边模型守得住;漏家那半边守不住,
所以才立的机检)。真要机检集合相等,得让基座出「A 表某列的值集 ⊆ B 表某列的值集」
这类读数 —— 等第二次真踩到再立,别为想象中的路修桥。
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+3 -2
View File
@@ -114,14 +114,15 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
它存在的理由:门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。 它存在的理由:门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
- **归型前移**:`draftWrite` 按声明列型归(空值 = 不写这一列、数值/布尔字符串转真类型、 - **归型前移**:`draftWrite` 按声明列型归(空值 = 不写这一列、数值/布尔字符串转真类型、
归不了当场拒)。**人手改的值与 AI 写的值过同一道门**。 归不了当场拒)。**人手改的值与 AI 写的值过同一道门**。
- **字段四标记**(`fieldDeclarationSchema`): - **字段五标记**(`fieldDeclarationSchema`):
| 标记 | 语义 | 基座据此产出 | | 标记 | 语义 | 基座据此产出 |
|---|---|---| |---|---|---|
| `required` | 这一列缺了就是活没干完(验收标准;不建 NOT NULL) | `域_对象_missing` / `_missing_keys`;执行卡提交闸;件上缺项红带 | | `required` | 这一列缺了就是活没干完(验收标准;不建 NOT NULL) | `域_对象_missing` / `_missing_keys`;执行卡提交闸;件上缺项红带 |
| `expect` | 缺了不拦单,但那一问不能省(客户该给而没给的) | `域_对象_gaps` / `_gaps_keys`;L4「选填缺口」谓词读它 | | `expect` | 缺了不拦单,但那一问不能省(客户该给而没给的) | `域_对象_gaps` / `_gaps_keys`;L4「选填缺口」谓词读它 |
| `tally: ['A','B','C']` | 分类列,给出取值域 | `域_对象_<列>_<值>` 按值现数(没出现的值也铺 0) | | `tally: ['A','B','C']` | 分类列,给出取值域 | `域_对象_<列>_<值>` 按值现数(没出现的值也铺 0) |
| `auto` | 内部流水号,**不问人、不让模型编** | 写入那一刻按「同组其余主键值-两位序号」生成(`CU-9001-01`);录入卡上那一格不出现,另摆一句「由系统按顺序生成」 | | `auto` | 内部流水号,**不问人、不让模型编** | 写入那一刻按「同组其余主键值-两位序号」生成(`CU-9001-01`);录入卡上那一格不出现,另摆一句「由系统按顺序生成」;**件上「加一行」同规**(2026-08-24):其余主键都填了、缺的恰好是 auto 列 → 基座续号落行,拒回也不点名人填不了的 auto 列 |
| `distinct` | 这一列要数「有几个不同的值」(2026-08-24 加,E04 首用) | `域_对象_distinct_<列>`(空值不算一种;零行铺 0)。**覆盖闭合判据的落点**:「每家候选客户都进了计划」= `计划_distinct_客户码 = 候选_rows` —— 行数 ≠ 客户数,没有这个数,「盖没盖住每一家」就只能靠模型自觉(实测靠不住:人在确认卡上加的客户被从计划到收口无视了一整单) |
注意 `expect` 的边界:**我方写的注(如备注列)不算** —— 标了它 `_gaps` 恒大于 0, 注意 `expect` 的边界:**我方写的注(如备注列)不算** —— 标了它 `_gaps` 恒大于 0,
那条判据永远绿不了,门就废了。 那条判据永远绿不了,门就废了。
+73
View File
@@ -606,6 +606,64 @@ export const listLedgerObjectsRoute = {
}), }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/**
* **按活动看数据域**(2026-08-24 用户定:「按活动数据来分类,要让每个活动执行完成后
* 台账在这里看到变化」)。
*
* 从前这一屏按**性质**(主数据/单据/派生)分组 —— 那是给「治理这张表」的人用的分法,
* 回答的是「这个对象定义全不全」。而人跑完一单来这儿,问的是另一件事:
* **我这个活动写了哪几张表,现在各有几行,刚才那一单落下了什么**。
* 按活动分组、带上行数,那个问题才答得了。
*
* 三个数各有各的意思,不能混:
* · `rows` —— 正式表里现在有几行(过了放行门的事实);
* · `drafts` —— 还在某一单草稿层的几行(**在办中,放行才进正式表**);
* · `recent` —— 这个活动最近几单,让人认出「刚跑完的那一单」。
*/
export const ledgerByActivityRoute = {
method: 'GET',
path: '/api/gov/data/by-activity',
output: z.object({
activities: z.array(
z.object({
activityId: z.string(),
name: z.string(),
version: z.string().optional(),
tables: z.array(
z.object({
domain: z.string(),
object: z.string(),
label: z.string().optional(),
kind: z.string().optional(),
caseKey: z.string().optional(),
primaryKey: z.array(z.string()),
/** 正式表行数;表还没物化(活动声明了但一次都没写过)时缺席 */
rows: z.number().int().optional(),
/** 还在草稿层的行数(在办中的单写下的) */
drafts: z.number().int(),
/** 草稿层最近一次落笔的时刻 —— 在办中才有 */
draftAt: z.string().optional(),
}),
),
/** 这个活动最近几单 —— 「刚跑完那一单」在这儿认得出来 */
recent: z.array(
z.object({
id: z.string(),
title: z.string(),
runtime: z.string(),
terminal: z.string().optional(),
at: z.string(),
}),
),
}),
),
/** 库里有表、却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳 */
unclaimed: z.array(
z.object({ domain: z.string(), object: z.string(), label: z.string().optional(), rows: z.number().int().optional() }),
),
}),
} as const satisfies RouteContract;
export const browseLedgerRoute = { export const browseLedgerRoute = {
method: 'GET', method: 'GET',
path: '/api/gov/data/:domain/:object', path: '/api/gov/data/:domain/:object',
@@ -777,6 +835,20 @@ export const testRunActivityRoute = {
message: z.string().default(''), message: z.string().default(''),
/** 派给谁;留空 = 操作者自己(试跑不该派到别人工作台上) */ /** 派给谁;留空 = 操作者自己(试跑不该派到别人工作台上) */
ownerId: z.string().default(''), ownerId: z.string().default(''),
/**
* **连主数据一起落真库** —— 默认 `true`(2026-08-24 用户定:「那你就默认给它打开」)。
*
* 从前主数据一律挡在活动缓存里,理由是保护样例:试跑往 `customer.profile` 建一位客户,
* 那位客户就永久留在库里,「新客户来函」那条样例第二次跑就成了老客户。
* 可实际用起来,这条保护挡住的恰恰是人要看的东西 —— E04 的台账更新明细白纸黑字写着
* 「亲密度 3 → 10」,而 `customer.contact` 里还是 3:**单据说改了、主数据没改**,
* 两份事实对不上,谁看谁糊涂。跑一遍活动就该看见数据真的变了,这是本分。
*
* 保护没有取消,只是**换了个落点**:要它的那个调用方自己关
* (`tools/e2e-a2.mjs` 显式传 `false` —— 只能测一次的样例归它管)。
* 默认值服务于人手点的那条路;脚本要什么,脚本自己说。
*/
landMaster: z.boolean().default(true),
}), }),
output: z.object({ taskId: z.string(), title: z.string() }), output: z.object({ taskId: z.string(), title: z.string() }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
@@ -1078,6 +1150,7 @@ export type GovActivitiesOutput = z.infer<typeof listActivitiesRoute.output>;
export type GovToolsOutput = z.infer<typeof listToolsRoute.output>; export type GovToolsOutput = z.infer<typeof listToolsRoute.output>;
export type GovObjectsOutput = z.infer<typeof listLedgerObjectsRoute.output>; export type GovObjectsOutput = z.infer<typeof listLedgerObjectsRoute.output>;
export type GovLedgerPageOutput = z.infer<typeof browseLedgerRoute.output>; export type GovLedgerPageOutput = z.infer<typeof browseLedgerRoute.output>;
export type GovLedgerByActivityOutput = z.infer<typeof ledgerByActivityRoute.output>;
export type GovMemoryOutput = z.infer<typeof listActivityMemoryRoute.output>; export type GovMemoryOutput = z.infer<typeof listActivityMemoryRoute.output>;
export type GovMetricsOutput = z.infer<typeof govMetricsRoute.output>; export type GovMetricsOutput = z.infer<typeof govMetricsRoute.output>;
export type GovChannelsOutput = z.infer<typeof listChannelsRoute.output>; export type GovChannelsOutput = z.infer<typeof listChannelsRoute.output>;
+16 -1
View File
@@ -317,7 +317,22 @@ export const saveTableRoute = {
*/ */
drops: z.record(z.string(), z.array(z.record(z.string(), z.string()))).optional(), drops: z.record(z.string(), z.array(z.record(z.string(), z.string()))).optional(),
}), }),
output: z.object({ ok: z.boolean(), rows: z.number().int().min(0) }), output: z.object({
ok: z.boolean(),
rows: z.number().int().min(0),
/**
* **认不出是哪一行、因而没落下的那几行**(2026-08-24 用户实测:
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
*
* 主键那几格空着就凑不出钥匙,存不住。从前基座一句 `continue` 悄悄跳过、
* 接口照回 `{ok:true}` —— 人看到「保存成功」,东西却不在。
* 仍回 200(别的格子是真落下了),但把没落下的行摆回来:前端红字点名,
* 人补上钥匙再存一次。**保存说成功,就得真的都存下了。**
*/
unkeyed: z
.array(z.object({ address: z.string(), keys: z.array(z.string()), hint: z.string() }))
.optional(),
}),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/** /**
+23
View File
@@ -139,6 +139,29 @@ export const fieldDeclarationSchema = z.object({
* 提交闸 / 件上红带全部同源生效;`required` 不带它照旧整列必填。 * 提交闸 / 件上红带全部同源生效;`required` 不带它照旧整列必填。
*/ */
requiredWhen: z.object({ column: z.string().min(1), value: z.string().min(1) }).optional(), requiredWhen: z.object({ column: z.string().min(1), value: z.string().min(1) }).optional(),
/**
* **这一列有几个不同的值,也让基座数** —— 出读数 `域_对象_distinct_<列>`
* (只数本单写下的行,空值不算一种)。
*
* 为什么要有这一维(2026-08-24 柴浩实测):覆盖范围确认那张卡上人加了一家客户,
* 行落进了台账,而 S3 的收步判据只数「计划有没有」(`plan_rows > 0`)——
* 计划盖住一家还是两家,没有任何一个基座数说得出来,模型照它记忆里的
* 「1 家候选」办完,引擎自动收步、放行门一路绿灯,**人加的那家从头到尾被无视**。
* 「每家候选客户都进了计划」要成为机检,就得有「计划里有几家客户」这个数,
* 而它必须由基座数(计划行数 ≠ 客户数:一家客户几位关键人就几行)。
*/
distinct: z.boolean().optional(),
/**
* **这一列只许引用另一张表里已存在的值** —— `'域.对象.列'`(2026-08-24,E04 首用)。
*
* 起因:经营计划的 contact_code 本该指向真实联系人,而模型三次给缺口节点
* **自造顺延引用码 + 占位人名**(「关键人待识别」)—— 措辞写到头也拦不住
* (占位句式一入过提示词就会被泛化)。措辞治不了的结构治:声明引用关系,
* 基座在 `draftWrite` 那一刻查引用的表(正式 + 本单草稿叠加),查无此值当场拒,
* 拒回指路(要经营的人还不存在 → 先发数据录入卡收人)。
* 引用目标必须是本活动声明过的对象与列 —— 安装静态审拦住写错的地址。
*/
refersTo: z.string().regex(/^[\w-]+\.[\w-]+\.[\w-]+$/).optional(),
}); });
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>; export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
+2
View File
@@ -279,6 +279,8 @@ export const staticAuditFindingSchema = z.object({
'acceptance-predicate-unbound', 'acceptance-predicate-unbound',
'deliverable-address-unbound', 'deliverable-address-unbound',
'ledger-kind-invalid', 'ledger-kind-invalid',
// refersTo 指向没声明的表/列 —— 引用检查会成「永远查无此值」的死墙(2026-08-24)
'reference-target-unbound',
]), ]),
severity: z.enum(['error', 'warn']), severity: z.enum(['error', 'warn']),
message: z.string(), message: z.string(),
+194 -7
View File
@@ -339,9 +339,62 @@ const SUITES = {
{ {
// 样例 0 = 满载的那条(测试台落地即预填的也是它) // 样例 0 = 满载的那条(测试台落地即预填的也是它)
sample: 0, sample: 0,
name: '事件驱动 · 重大商机点名 CU-0142 · 计划一次通过', name: '事件驱动 · 重大商机点名 CU-0142 · 人在确认卡上加一家 · 点名驳回后通过',
decide: 'approve', /*
* 第一张审批卡以**点名意见**驳回(复刻 2026-08-24 柴浩实测:「测试111建议动作
* 改成拜访CTO」被改到了 Itelma 头上)—— 意见点的是人加的那家客户,断言改动
* 落在**被点名那一行**(expectRevised),不是落到别家。
*/
decideSeq: ['custom:端到端新增客户的建议动作改成约见CTO并建立联系', 'approve'],
expectDecision: true,
expectPort: 'coverage-map', expectPort: 'coverage-map',
expectRevised: { address: 'crm_coverage.plan', where: 'customer_code', equals: 'CU-E2E-ADD', column: 'action', contains: 'CTO' },
/*
* 查无联系人的新客户 → S3 先发数据录入卡收第一位关键人(2026-08-24 用户裁定:
* 计划里不许出现占位人名 —— 从前那两行「待识别」是模型把占位句式泛化出来的)。
*/
entryValues: { name: '测试CTO王工', title: 'CTO', decision_node: 'bt-02' },
forbidInPlan: ['待识别', '-00'],
/*
* 台账更新确认那一屏补录联系人(2026-08-24 用户定):编码留空 —— 自增列不问人,
* 由基座按「客户编码-两位序号」续号;断言草稿里真长出了带号的这一行。
*/
acceptEdits: {
address: 'customer.contact',
rows: [
{
customer_code: 'CU-E2E-ADD',
contact_code: '',
name: '测试副总李工',
title: '副总经理',
decision_node: 'bt-03',
influence: 'high',
relationship: '2',
key_person: 'true',
},
],
minted: { column: 'contact_code', prefix: 'CU-E2E-ADD-' },
},
/*
* **复刻 2026-08-24 柴浩的实测**:覆盖范围确认那张卡上人加一家台账里不存在的
* 客户 —— 从前它落进了台账,而计划/回执/收口把它从头无视到尾,released 照发。
* 现在覆盖闭合(计划客户数 = 候选行数)钉在 S3 收步/S4 哨兵/放行 L2 三处,
* 这一家没进计划这一单就走不下去;expectPlanned 再显式对一遍。
*/
adds: {
address: 'crm_coverage.candidate',
rows: [
{
customer_code: 'CU-E2E-ADD',
customer_name: '端到端新增客户',
cust_type: '组装厂',
bt_level: 'bt-03',
depth: 'standard',
basis: '人工核对时额外纳入(e2e)',
},
],
},
expectPlanned: { address: 'crm_coverage.plan', column: 'customer_code', value: 'CU-E2E-ADD' },
}, },
{ {
sample: 1, sample: 1,
@@ -562,10 +615,14 @@ async function answerDecisions(taskId, kase, answered) {
* 序列走完取末项兜底。没有 decideSeq 的老套件仍走单值 `decide`。 * 序列走完取末项兜底。没有 decideSeq 的老套件仍走单值 `decide`。
*/ */
const want = kase.decideSeq === undefined ? kase.decide : kase.decideSeq[Math.min(answered.size, kase.decideSeq.length - 1)]; const want = kase.decideSeq === undefined ? kase.decide : kase.decideSeq[Math.min(answered.size, kase.decideSeq.length - 1)];
const picked = options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0]; // 'custom:…' = 写具体意见驳回(与人在输入框里写一句话同一条链)
const custom = typeof want === 'string' && want.startsWith('custom:');
const picked = custom
? { value: want, label: `意见:${want.slice(7)}` }
: (options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0]);
await api('/api/desk/chat-cards/reply', { await api('/api/desk/chat-cards/reply', {
method: 'POST', method: 'POST',
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: '试跑代答' } }), body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }),
}); });
answered.add(card.id); answered.add(card.id);
count += 1; count += 1;
@@ -574,6 +631,27 @@ async function answerDecisions(taskId, kase, answered) {
return count; return count;
} }
/**
* 数据录入卡代答(E04 查无联系人的候选:S3 先收第一位关键人再排计划)。
* 人确认那一下基座直写草稿 —— 这里只按案例声明的 entryValues 把表单交回去。
*/
async function answerEntries(taskId, kase, answered) {
if (!kase.entryValues) return 0;
const { body } = await api(`/api/desk/tasks/${taskId}`);
let count = 0;
for (const card of body.chat ?? []) {
if (card.k !== 'entry' || card.chosen !== undefined || answered.has(card.id)) continue;
await api('/api/desk/chat-cards/reply', {
method: 'POST',
body: JSON.stringify({ reply: { k: 'entry', cardId: card.id, values: kase.entryValues } }),
});
answered.add(card.id);
count += 1;
console.log(` 录入卡代答:${(card.ask ?? '').slice(0, 40)} → ${JSON.stringify(kase.entryValues)}`);
}
return count;
}
/** /**
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`): * 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空; * 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
@@ -699,7 +777,19 @@ async function runCase(suite, kase) {
console.log(`\n▶ ${suite.activityId} · ${kase.name}`); console.log(`\n▶ ${suite.activityId} · ${kase.name}`);
const { body: started } = await api('/api/gov/testbed/run-activity', { const { body: started } = await api('/api/gov/testbed/run-activity', {
method: 'POST', method: 'POST',
body: JSON.stringify({ activityId: suite.activityId, data: sample.input, message: sample.message ?? '', ownerId: '' }), /*
* **e2e 显式关掉「落真库」** —— 起单接口默认是开的(人手点那条路要看见数据真变),
* 而这道保护正是为 e2e 设的:A1 的「新客户来函」样例一旦把客户真写进主数据,
* 第二次跑那条路就成了老客户,用例从此测的不是它要测的东西。
* **要保护的那个调用方自己说出来**,别指望一个默认值替所有人做决定。
*/
body: JSON.stringify({
activityId: suite.activityId,
data: sample.input,
message: sample.message ?? '',
ownerId: '',
landMaster: false,
}),
}); });
const taskId = started.taskId; const taskId = started.taskId;
console.log(` 起单 ${taskId}`); console.log(` 起单 ${taskId}`);
@@ -707,12 +797,24 @@ async function runCase(suite, kase) {
const replied = new Set(); const replied = new Set();
const retries = new Map(); const retries = new Map();
const decided = new Set(); const decided = new Set();
// 录入卡的已答名单单独记 —— 与 decided 共用会把 decideSeq 按 size 取的序号顶掉(实测:点名驳回被跳过)
const entered = new Set();
let gateBlocked = false; let gateBlocked = false;
/** 资料文件那一次检查的结论(在办中验,见下面那段注释) */ /** 资料文件那一次检查的结论(在办中验,见下面那段注释) */
let fileCheck; let fileCheck;
let gateLeaked = false; let gateLeaked = false;
const gateChecked = new Set(); const gateChecked = new Set();
let decisions = 0; let decisions = 0;
/** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */
let addedRows = false;
let planned = false;
let revised = false;
let acceptEdited = false;
let minted = false;
let forbidHit = false;
let bailed = false;
let receiptChecked = false;
let receiptMismatch = false;
const deadline = Date.now() + 20 * 60 * 1000; const deadline = Date.now() + 20 * 60 * 1000;
let last; let last;
@@ -736,7 +838,7 @@ async function runCase(suite, kase) {
return hit; return hit;
}; };
while (Date.now() < deadline) { while (Date.now() < deadline && !bailed) {
await sleep(3000); await sleep(3000);
const { body } = await api(`/api/gov/testbed/activity/${taskId}`); const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
last = body; last = body;
@@ -771,14 +873,52 @@ async function runCase(suite, kase) {
console.log(` 资料文件:${fileCheck.note}`); console.log(` 资料文件:${fileCheck.note}`);
} }
if (kase.expectPlanned && !planned) {
const { address, column, value } = kase.expectPlanned;
planned = draftRows(body.drafts ?? [], address).some((r) => String(r[column] ?? '') === value);
}
if (kase.expectRevised && !revised) {
const { address, where, equals, column, contains } = kase.expectRevised;
revised = draftRows(body.drafts ?? [], address).some(
(r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains),
);
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的 // 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided); decisions += await answerDecisions(taskId, kase, decided);
await answerEntries(taskId, kase, entered);
// 计划里不许出现的字样(占位人名等)—— 一出现就记下,收工判失败
if (kase.forbidInPlan && !forbidHit) {
const planRows = draftRows(body.drafts ?? [], 'crm_coverage.plan');
forbidHit = planRows.some((r) => Object.values(r).some((v) => kase.forbidInPlan.some((w) => String(v ?? '').includes(w))));
}
for (const card of body.cards ?? []) { for (const card of body.cards ?? []) {
if (card.done || replied.has(card.id)) continue; if (card.done || replied.has(card.id)) continue;
if (card.mode === 'execution') { if (card.mode === 'execution') {
/*
* 「需要你判断下一步」是引擎的兜底卡(模型两次输出不成形 / 决断不了)——
* 它不属于任何一段,也不会自己好起来;从前每轮打一行警告空转到 20 分钟才超时。
* 认出来就当场收工判失败,把轨迹末尾摆出来尸检。
*/
if (/需要你判断下一步/.test(card.title ?? '')) {
console.log(` ⚠️ 模型交回兜底卡(${card.title})—— 这一单到此判失败`);
bailed = true;
break;
}
const stage = stageOf(card); const stage = stageOf(card);
if (stage === undefined) continue; if (stage === undefined) continue;
// 回执单派出那一刻,骨架必须与计划一一对应(2026-08-24:计划 7 回执 6 的实测账)
if (stage.address === 'crm_coverage.receipt' && !receiptChecked) {
receiptChecked = true;
const planN = draftRows(body.drafts ?? [], 'crm_coverage.plan').length;
const rcptN = draftRows(body.drafts ?? [], 'crm_coverage.receipt').length;
if (planN !== rcptN) {
console.log(` ⚠️ 回执骨架 ${rcptN} 行 ≠ 计划 ${planN} 行 —— 派单前没对齐,判失败`);
receiptMismatch = true;
} else console.log(` 回执骨架与计划一一对应(${planN} 行)`);
}
if (stage.expectGateBlock === true && !gateChecked.has(stage.address)) { if (stage.expectGateBlock === true && !gateChecked.has(stage.address)) {
gateChecked.add(stage.address); gateChecked.add(stage.address);
const verdict = await expectGateBlocks(card.id, taskId, stage); const verdict = await expectGateBlocks(card.id, taskId, stage);
@@ -793,6 +933,12 @@ async function runCase(suite, kase) {
); );
} }
const lines = buildEdits(stage, body.drafts ?? []); const lines = buildEdits(stage, body.drafts ?? []);
// 人加行:与真人在件上「加一行」同一条链 —— 新主键的行落进保存载荷即是新增
if (kase.adds && kase.adds.address === stage.address && !addedRows) {
lines.push(...kase.adds.rows);
addedRows = true;
console.log(` 件上加 ${kase.adds.rows.length} 行(${kase.adds.rows.map((r) => Object.values(r)[0]).join('、')})`);
}
if (lines.length > 0) { if (lines.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, { await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST', method: 'POST',
@@ -824,6 +970,30 @@ async function runCase(suite, kase) {
console.log(` 交卷执行卡:${card.title}`); console.log(` 交卷执行卡:${card.title}`);
replied.add(card.id); replied.add(card.id);
} else if (card.mode === 'acceptance') { } else if (card.mode === 'acceptance') {
if (kase.acceptEdits && !acceptEdited) {
acceptEdited = true;
const { address, rows, minted: mintSpec } = kase.acceptEdits;
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [address]: rows } }),
});
const after = await api(`/api/gov/testbed/activity/${taskId}`);
/*
* 按**我们填的姓名**找行,不能只看编码前缀:错配覆盖的那次(2026-08-24),
* 模型自己建的 CU-E2E-ADD-01 也满足前缀 —— 断言过了,而人加的行其实把它顶掉了。
* 名对上 + 号续上,才是「真加了一行」。
*/
const mineRow = rows[0] ?? {};
const got = draftRows(after.body.drafts ?? [], address).find(
(r) => String(r['name'] ?? '') === String(mineRow['name'] ?? '') && String(r[mintSpec.column] ?? '').startsWith(mintSpec.prefix),
);
minted = got !== undefined;
console.log(
minted
? ` 确认屏上补一行联系人 → 基座续号 ${got[mintSpec.column]}`
: ` ⚠️ 确认屏上补的联系人行没落下(或编码没续上)`,
);
}
await api('/api/desk/work-orders/reply', { await api('/api/desk/work-orders/reply', {
method: 'POST', method: 'POST',
body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }), body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }),
@@ -837,7 +1007,7 @@ async function runCase(suite, kase) {
const rejects = rejectedCount(last?.trail ?? []); const rejects = rejectedCount(last?.trail ?? []);
const fired = (last?.trail ?? []).find((t) => t.kind === 'release-verdict' && t.detail?.ports); const fired = (last?.trail ?? []).find((t) => t.kind === 'release-verdict' && t.detail?.ports);
const ports = fired?.detail?.ports ?? []; const ports = fired?.detail?.ports ?? [];
let ok = last?.terminal === 'released' && rejects === 0; let ok = !bailed && last?.terminal === 'released' && rejects === 0;
console.log( console.log(
` → ${last?.terminal ?? '超时'}${last?.reason ? `(${last.reason})` : ''} · 被拒 ${rejects} · ` + ` → ${last?.terminal ?? '超时'}${last?.reason ? `(${last.reason})` : ''} · 被拒 ${rejects} · ` +
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` + `${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
@@ -855,6 +1025,23 @@ async function runCase(suite, kase) {
if (kase.expectPort && !ports.includes(kase.expectPort)) { if (kase.expectPort && !ports.includes(kase.expectPort)) {
console.log(` ⚠️ 期望口 ${kase.expectPort} 未在发射名单里`); console.log(` ⚠️ 期望口 ${kase.expectPort} 未在发射名单里`);
} }
if (kase.expectPlanned && !planned) {
console.log(` ⚠️ 人加的那家(${kase.expectPlanned.value})没进经营计划 —— 新增行又被无视了,判失败`);
ok = false;
}
if (kase.expectRevised && !revised) {
console.log(` ⚠️ 点名驳回的改动没落在被点名那一行(${kase.expectRevised.equals})—— 归属又认错了,判失败`);
ok = false;
}
if (kase.acceptEdits && !minted) {
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
ok = false;
}
if (receiptMismatch) ok = false;
if (forbidHit) {
console.log(` ⚠️ 计划里出现了不该有的字样(${(kase.forbidInPlan ?? []).join('/')})—— 占位人名又回来了,判失败`);
ok = false;
}
if (suite.assetFiles) { if (suite.assetFiles) {
if (fileCheck === undefined) { if (fileCheck === undefined) {
console.log(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料'); console.log(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料');