资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑 #1

Merged
XiaChangSong merged 1 commits from feat/e04-i01-asset-files into mvp-foundation 2026-08-24 10:34:08 +08:00
92 changed files with 13984 additions and 370 deletions
+243 -1
View File
@@ -3,7 +3,7 @@
> 一句话:组织的工作定义为可编排的工作流,工作流由**活动**编排而成;活动是完成一项任务的 > 一句话:组织的工作定义为可编排的工作流,工作流由**活动**编排而成;活动是完成一项任务的
> Agent 机制,系统外开发、安装即用。人只在责任点与放行点出现 —— 确定交系统,临场交 AI,落地交人。 > Agent 机制,系统外开发、安装即用。人只在责任点与放行点出现 —— 确定交系统,临场交 AI,落地交人。
## 现状快照(2026-08-02) ## 现状快照(2026-08-22)
- **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/ - **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/
大模型/**测试台:三格 —— 流程(投一封信)/ 活动(按契约填入参)/ 日志(历次试跑存档)**)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。 大模型/**测试台:三格 —— 流程(投一封信)/ 活动(按契约填入参)/ 日志(历次试跑存档)**)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。
@@ -783,6 +783,248 @@
截了才加省略号 —— 加了等于说「后面还有」。 截了才加省略号 —— 加了等于说「后面还有」。
实测:一单 8 行、6 行有抽屉、行高 34-36px 齐平,展开那两行 141/103px。 实测:一单 8 行、6 行有抽屉、行高 34-36px 齐平,展开那两行 141/103px。
14 条新测(detailOf 五类 + 压下交代放行 + 首句去重)。 14 条新测(detailOf 五类 + 压下交代放行 + 首句去重)。
- **E04 关系覆盖与关键人经营修到验收线并全流程跑通**(2026-08-22,
`crm-relationship-coverage@2.0.5`,用户:「实现完成了但做的不好」)。接手时两天 18 次
试跑只 2 次 released、活单正熔断;收尾:**e2e 三案例(点名/月度/驳回循环)+ 浏览器
新客户建档路全部 released**,新客户路被拒 0(18 拍/216k/5 人环)。修死的账,harness 四刀:
①**finish 的 doneWhen 按写完后的草稿算**(从前拍首快照,「先写后 finish」这个被鼓励的
省拍打法被误拒,下一拍引擎又用同一份数据自动收步 —— 同一份数据两个裁决);
②**空转写身后还有行动就不废串**(「被拒停在谁那儿」把「写记账旗+发卡」整串弃权,
排在后面的发卡从没执行过,连拒五拍熔断 —— 值没变的写什么也没改,后面行动不该陪葬;
串尾/独苗仍拒,熔断检测靠它);③**调工具挂不上项不拦调**(授权面是工具白名单,
与「写的授权面是台账声明」同款);④**读工具同一份入参重调:第 2 次起出参标题带点名
警告(不算被拒),期间没写过东西的第 5 次才拒**(实测模型把 recompute-score 连调 10 次
赌分数变卦 —— 26 次调用烧穿预算的前科在案而 stalledTurns 只数被拒,成功的重调无兜底)。
活动包三处结构治:⑤**S5 记账旗退役**(receipt_confirmed_total 是模型写的「人环凭据」,
S4 一过它顺手写旗就坚信 S5 办完,[写旗+finish 被拒]×5 熔断两轮;门改读基座数
receipt _rows/_missing —— 回执四列全是人在件上填的,被叫的那一方无账可记);
⑥**收口拆两项**(S6_writeback 写回主数据 / S6_ledger_update 只写明细+结论 ——
三拍纪律写得再明白,模型照样把四张表挤一串,串尾最费字数的明细表被截成空数组;
拆开后剧本只摊自己那几张表,串想长也长不起来);⑦**case:openedAt 真的进 Agent 作用域**
(说明书让拿它铸 round_ref 而这个键从前只注给视图渲染 —— 模型只能编,正式表里一有旧号
就照抄,测试台里所有单共用同一个案号、跨单主键相撞;现注真实开案时刻含年秒,跨单唯一)。
S5 指南改「唯引擎 worldTouched 事实行是从,别自己数 card-reply」——轨迹里躺着 S2/S4 的
card-reply,模型对不准归属,连续三拍幻觉「回执已交回」(措辞治不了的结构治第 N 课:
**指南让模型判的事,引擎已有现成事实的,一律改成读事实**)。视图照目标件 §6.2 补齐:
关联任务(engines 注入 case:taskId)、步骤行、页脚案号、执行工单1 编辑闸措辞
「AI 已识别 N 个客户」(绑基座读数)、计划表补时间点列(判据③指向的东西must在卡面)、
回执对象列「客户 · 关键人」、结果摘要/客户反馈照目标件换 **textarea**(渲染器补
textarea 孪生:值进正文、collect/镜像/增删行全链)、台账更新确认补「依据/编码」次行
(三条 L4 从前让人判他看不见的东西)。verdict.trigger 改名 trigger_label(与入参同名,
首帧摆英文码);plan.status 死列退役;建档卡写的六列补进声明(授权面);两条 L1 谓词
各钉一半基座数(旗单独成谓词是「机检只读基座数」的伪装形)。e2e 加 `e04` 套件
(`stages` 按卡名对号 —— 一单两张不同表的执行单;`fillAllBlank` 整行扫空;
`decideSeq` 驳回→通过按张走;buildEdits 泛化 rowKey,不再钉死 pn)。
新测:turn-runner +6、包内 76(补 steps.test.ts:count(customers) 三形状真 @sfeel 钉死 ——
未绑定名 flatten 成 [undefined] 长度 1 不是 0)、view-render textarea 3、ui-kit 2。
- **验收「重做」第一次真的能退回重做**(2026-08-22,柴浩实测:点了重做,单子卡死):
从前非 pass 裁决也走 `finished` 分支 —— runTurn 把在途验收消费成 null、返回
「finished 未放行」,task 层把它重新挂成 awaiting-human;**人已经说了话,却再没有
任何事件会叫醒它**(A 系列 e2e 从来只点通过,这条路第一次被真人踩到)。
修死(turn-runner + port.ts + CONTRACT.md,3 测):非 pass = **打回重做,不是终局** ——
意见(`PendingAcceptance.comment`,onEvent 从裁决载荷捕入信封)以
「验收被打回重做:<意见>」进轨迹(没写意见就明说「自己复核一遍」),在途验收清掉,
**同一拍照常问模型**;刚被打回的那一拍不走判齐快道(判齐恒真的活动会在意见没人读时
原样再发一张卡)。模型修订后重新 submit,新发的验收卡才是下一次裁决的对象。
卡死的那单以「把消费掉的在途验收原样放回信封 + 拨回 running」救活
(不手插记忆行 —— task_memory 有封链,留痕必须走正规通道),实测:重做意见进轨迹 →
模型复核三条裁量 → 重新提交 → 新验收卡已回到负责人工作台。
- **「重做」得真的退回去改,不是原样再递一次**(2026-08-23,柴浩连点两次重做的实测账):
上一条修好了「重做后单子卡死」,但没治**空转重做** —— 打回那一刻案例模型的项全是
`Completed`,模型除了重新 submit 无事可做:两次都是 6 秒内把三条裁量判据重判一遍、
台账一个字没动就把同一份成果递回来,人看见的是「我说了重做,它当没听见」。两手一起治:
①**基座侧结构闸**(`raiseAcceptance` 一个出口管住模型 submit 与判齐快道两条路,6 测):
打回之后台账没动过(写/撤都算)就不许再提交;软硬两段 —— 头一次拒并指路
(改台账 / 或发卡问清楚要改什么),已因此拒过一次再提交就放行(墙要配路,不锁死单子)。
②**活动侧指路**(`quote-…` 同理,E04@2.0.7 的 T_release 剧本加「被打回重做」分支):
闸只知道「没改」,**改哪一张表是活动自己的知识** —— 理由空改 `plan.rationale`、
依据虚改 `ledger_update.evidence_ref`、结论糊改 `verdict.summary`;没写具体意见就
自己挑最站不住的那处改实,一律同 PK 覆盖写(新开行会把收步判据弄假)。
T_release 的 `writes` 同批补上这三张表 —— 不声明就写不进去(写的授权面是台账声明)。
- **「取不到」不是「还活着」:测试台钉着死号说在跑**(2026-08-23 用户:「推进完成了,
但测试台还在运行中,没有停下来」—— 当场复现):面板钉着一个**已被别处清掉**的任务
(reset-runs / 另一台起单会先销毁上一单),屏上同时说两句相反的话 ——
上面「AWAITING-EVENT · 自动刷新 2s」灯亮着、钮写「停止仿真」,下面一条橙带
「取数失败 —— 任务不存在」。两处一起修:
①**后端状态码说错了一格**:「任务不存在」抛的是普通 Error → 兜底成 `500 internal`,
而 500 的意思是「服务器出毛病了,等会儿再来」—— 面板据此举着上一次的状态每 2 秒重试。
现在这条路由如实回 **404 not-found**(「这一单已经不在了」);
②**前端把「取不到」当成了「在跑」**:`q.data` 在取数失败后保留上一次成功的那份、
其 `terminal` 恒 undefined → `live` 恒真;`s === undefined ? 'live'` 那个兜底更是直接
在撒谎。抽出纯函数 `observeFace`(6 测)分三种局面说话:**没了**(404 → 停轮询、
灯灭、说「这一单已不在了」、当场松手回装配台)、**取数失败**(断网/500 → 不说在跑,
轮询留着等它回来)、**取到了**(照实说)。浏览器实证:起单 → 面板盯着 → 别处清掉 →
6 秒内自己回到装配台,鬼卡片不再出现。
- **立监听的授权面是清单声明 —— 立了没人叫醒的监听不是等待,是失联**(2026-08-23,
验重做那一单当场撞出来的第三个洞):S4 审批过后模型**漏写了 `plan_approved_total` 那面旗**
(人工激活的项没有 doneWhen 兜底,finish 拦不住),S5 的入场哨兵永不成立 → 依赖断点 →
它临场「立一个 data 监听等执行回执」,而 E04 的 `declarations.listeners` 是**空的**、
这一单也不会有任何 data 事件进来(全仓没有一处消费静态监听声明,desk 侧更没有投递 data
的入口 —— 老 debt),单子就此挂在 `awaiting-event` 15 分钟一动不动。
修:`ListenerRegistrar` 补 `declared(taskId)`(engines 从清单取),`listen` 只放行
**声明过的那几种**,没声明的当场拒;拒回把两条真路一起指出来 ——
「要等人办事就发执行工单(人在件上把回音填回来);卡在断点多半是某个入场前提的数
还没写(常见是某面记账旗漏了),回去补那一笔,门自己就开」。3 测 + CONTRACT.md 已记。
- **步数是活动自己声明的预算,记的是 tick 不是模型拍**(2026-08-23 用户问):
测试台那三格读的是 `task.caps` ← 清单 `contract.resources.budget`(E04 声明
80 步 / 50 万 token / 12 次调人,A 系列 50-60 步 —— E04 高是因为四道人环 + 审批循环)。
**一步 = 一次 `task.tick()`**,而不是一次模型决策:实测一单 19 步里只有 12 拍真跑了模型,
另外 7 次是人回卡后「看一眼在途验收就返回」的空转 tick。挂起等人期间不烧步数
(推进器只捡 `runtime='running'` 的单),所以这笔账不会随人拖延而涨。
- **起单入参要预填满**(2026-08-23 用户:「询价受理起单时左边都有预填好的输入,E04 也要」):
测试台落到一个活动上自动铺的是 `samples[0]`,而 E04 的第一条样例只有
`{trigger:'monthly'}` 一个键 —— 表单除了那个下拉全空,看着像没预填(A1 两条样例都是
满载的一封信,所以那边没问题)。E04@2.0.6 把**满载的那条**(重大商机 · 点名 CU-0142,
四字段全有值)挪到第一位、月度例行补上真实背景说明,并把五条样例名收到 **≤14 字** ——
超过 14 字测试台只显示「样例 N」(activity-run.tsx),那等于没名字。
包内测试钉死这三条;e2e 的 e04 套件样例序号同批对调。
- **I01 品牌形象与销售工具包管理按 E04 标准审完并全流程跑通**(2026-08-23,
`brand-content-toolkit@1.1.1`):**e2e 三案例 3/3 released、被拒 0**(9-10 拍 / 66-82k),
浏览器真人路(件上加一行 → 提交 → 初稿审核 → 发布放行)**released、被拒 0**。
审出并修死的问题,与 E04 那一轮同族:
①**两条 L1 只读模型自写旗**(`requirement_confirmed_total`/`draft_approved_total`)——
各钉一半基座数(`_rows > 0 and _missing = 0`);
②**判据指向的东西不在卡面上**:L4「场景与 BT 标签是否登记完整」而 `content.asset`
压根没有 BT 列(剧本拿 `brand_tone` 顶替)—— 补 `bt_tag`(**人读值不带 tally**:
件上是普通格子、没有翻译层,存 ASCII 人看到的就是 ASCII,这是 `scene` 踩过的坑),
两份验收件都摆出来;
③**「发布前场景必须登记」只写在剧本里、机器数不出来** —— `scene`/`bt_tag` 加
`requiredWhen: {content_status: published}`,红带/提交闸/放行门 L2 三处同源;
④卡头/页脚缺说明书 §6.2 的四个共用动态位 —— 补关联任务(`case:taskId`)、步骤行、
页脚单号(最迟交回无真数据源,照 E04 口径不摆);编辑闸措辞改回目标件
「AI 已识别 N 个内容需求」(N 绑基座读数);触发口径并进【做什么】;
⑤样例名超 14 字全退成「样例 N」+ 样例 0 只有两个键 —— 收短名、把满载的那条挪到第一位;
⑥e2e 加 `i01` 套件(一张执行单 + 初稿审核决策卡 + 发布放行,含驳回循环案例)。
包内测试 29 → 44(补 steps.test.ts,把上面每条口径钉死)。
- **人在件上「加一行」会被静默丢掉:序号型主键没人给它续号**(2026-08-23,审 I01 时
逮到、浏览器实测复现):`content_brand.requirement` 的主键是 `round_ref + req_seq`,
而「加一行」那一下所有 `data-col` 都被清空(`ViewContainer.addRow` 如此)——
`req_seq` 空着,而各行序号本就互不相同,「全表一致」那条补法永远取不出 →
`writeEditedRows` 一 continue 就没了:**屏上两行、库里一行,还不报错**。
修:凑主键那一段抽成纯函数 `resolveRowKey`(9 测),三类主键三种凑法 ——
件上给了的用他填的 / 件上没摆的按行认或全表一致 / **整数型主键由基座续号(现有最大 + 1)**。
途中自己踩了一脚并当场修回:一度把序号列的 `data-col` 拿掉「让它只读」,
结果**老行也没了钥匙**,服务端把它当新增行、又拿「只有一行时的全表一致」猜出个 1,
**当场覆盖掉原来那一行** —— **主键列必挂 `data-col`**(它不是编辑面,是行的钥匙),
且序号型主键不走「全表一致」那条路(一致只是行数太少的假象)。
- **兜底卡自己被门拦住:最后的活路砌了墙**(2026-08-23,E04 回归实测):依赖断点那一拍
「当下可为」是空的,归一层给兜底卡兜出来的 `itemId` 就是空串,于是引擎自己造的那张
「需要你判断下一步」被 `readyToWork` 拒 —— 人连被问一句的机会都没有。
修两处:①归一层的项号兜底连**进行中**的项一起看;②**发卡的授权面是调人预算,不是计划项**
——项号挂不上照发、留痕注明(与「写看台账声明」「调工具看白名单」同族);
「这一步已经办完/停用」仍是真墙(既有测钉着),用 `ItemUnattachableError` 把两件事分开。
- **枚举列上下两态得说同一个字**(2026-08-23 用户:「BT 层级下拉框显示不全,
都看不到具体选了哪个」,`quote-…` E04@2.0.8):覆盖范围确认那张件的 BT 层级一格两处错 ——
①**列宽只有 80px**,而选项文字是「BT-01 · 采购/供应链」,下拉被挤扁,选了什么看不见
(覆盖深度那列因为选项只有两个字,同样的窄列一直没暴露);
②**只读那一份是 `data-slot` 直显**,存的是 ASCII `bt-02`,人看到的就是 `bt-02` ——
与编辑态下拉里的「BT-02 · 技术」对不上。**覆盖深度当初专门修过这个坑,BT 层级漏了**。
改:列宽 80 → 150,只读态换成 `data-class-of` 徽标、人读名由样式表出(值仍是 ASCII ——
台账与 recompute-score 的 `btLevel` 入参认它),选项文字统一成人读大写。
两条包内测试钉死:枚举列不许 `data-slot` 直显机器码、BT 列宽 ≥140px。
- **画面挑的是拍首那一步,而卡发的是下一步的**(2026-08-23,走查 E04 S4→S5 撞上):
那一拍收掉 S4(经营计划审批)、当场发出 S5 的回执执行卡,而工作区推的仍是 S4 声明的
**只读审批件** —— 人拿到的卡写着「把回执填到件上」,屏上一个可填的格子都没有;
提交闸如实拦住「缺完成状态、结果摘要、关键人变动」,他无处可填,整单挂死在那儿。
病根与 `liveScope` 那条同源:**拿拍首的局面去决定拍尾的事**(`liveStep` 在行动串跑完
之前就算好了)。修:推帧前按 `currentRuntime` 重算一次在办的那一步,算得出就以它为准;
算不出(活步都收完)照旧走老路,免得把人刚签过的放行页冲掉。设计律第一条
「墙要配路」的第四次现形。测钉死(拿掉修复就退回推 step1)。
- **表宽超出就得能横着滑**(2026-08-23 用户:「覆盖范围确认这个表格显示不全,
不能左右滑动」):E04/I01 抄 quote-* 版式时把 `.jc-tbl-wrap` 的 `overflow-x: auto`
抄成了 `overflow: hidden`(本意是圆角裁表头)—— 列一多、或某列为放下 BT 下拉框加宽,
右边那几列**直接被裁掉且滑不动**。两包改回 `overflow-x: auto; overflow-y: hidden`
(竖向仍关:件是整篇跟着外层滚的)。实证:950px 的表在 586px 容器里滑得动,
编辑态(958px)同样。E04@2.0.9 / I01@1.1.2。
- **翻件与翻页各归各位,没得翻就别摆数字**(2026-08-23 用户两条:「一次叫人不一定只有
一个 HTML 模板所以需要翻页,现在这个 0/0 显示的不正常」「工作成果点进一个工作成果
也需要这个东西」):右上角那对箭头从前**三处各写一遍、两组二选一**
(`docPages > 1 && openList.length <= 1`)—— 多张卡各自多页时**页就翻不动了**,
人只看得见每张卡的第一页。三处收成一个 `Pager`,两组同时在场时各带一个字(件/页)。
0/0 有两个来源,都治了:①`ViewContainer.paginate` 对没写 `data-page` 的件如实回报
0 页 —— **件在手上,再少也是一页**,改 `Math.max(1, …)`;②一件都没有时(在跑 /
等对话区那张卡 / 已收尾)摆的是 `0/0`,**0 分之 0 不是一个数**,改写「—」并禁用两枚箭头。
成果详情补**翻份**:一单常有三五份定稿(执行单快照 / 验收单快照 / 决策卡快照),
从前想看下一份得退出去再点一次;换份时页码归零(上一份翻到第 3 页,下一份可能只有一页)。
判断抽成纯函数 `pagerFace`/`pagerText`(9 测)。浏览器实证:决策卡那一屏「—」、
回执卡「1/1」、办结后四份成果 1/4→4/4 逐份翻过、末份下一枚变灰。
改共享面照例全量回归:1573 测 / typecheck / lint / depcruise 绿,
**e2e e04 三案例全部 released、发口 coverage-map 全对**(16-20 拍);
两案例各有 1-2 次被拒,都是既有的模型空转被墙拦住(recompute-score 同参连调第 5 次、
S6_writeback 少写一行就 finish),非本轮改动引入 —— E04 的 `被拒 0` 仍欠着。
- **I01 的「审核资料」第一次真产出资料文件**(2026-08-23 用户定:「格式可以是 word、excel、
md、html,文件需要能在前端渲染,在表格中点击资料名可以跳转到一个新的页面来渲染这些文件」):
接手时这一步**一个文件都产不出来** —— S3 的剧本白纸黑字写着「file_ref 选填,本 MVP 阶段
可留空」,台账里只有一行标题,人要审的那份初稿根本不在系统里(点不开、下不来)。
**文件是台账那一行的投影,不另存一份**(另存 = 同一份事实两处各存一份,改一处就分叉):
①**声明**:台账对象声明加 `file: {titleColumn, bodyColumn, formatColumn}`(契约变更),
它同时是授权面 —— 没声明的表点不开(403),与「写看台账声明」「立监听看清单声明」同款;
②**现造**:`file-compose.ts` 进「标题 + Markdown 正文 + 格式」出真字节 ——
docx / xlsx 是**自己打的 OOXML 包**(`zip-write.ts`:store + CRC32,不引依赖,
与 `upload-extract.ts` 读 xlsx 同一条路);md 出原文、html 出整份自带样式的文档;
③**读回来渲**:`file-doc.ts` 解**造出来的那份字节** → `FileDoc`(docx→段落 / xlsx→行列)
—— 屏上渲的与下载到的是同一份东西,渲得出来才证明文件是真的;
④**点得开**:视图新增绑定 `data-file-of="主键列"` → 渲成 `data-file="域.对象/主键值"`,
ViewContainer 接住 → 工作区二级界面 `FileViewer`(Excel 网格 / Word 纸面 / 渲好的 Markdown /
沙箱 HTML)+「下载原件」(`?raw=1`,RFC 5987 中文名)。
**Markdown 富文本渲染顺带销账**:`markdownBody` 一个依赖都没引(标题/列表/GFM 表格/
分隔线/粗体/行内码),全程转义、走同一个沙箱 —— 与下载到的 .html 是同一段函数出的。
I01 侧:`content.asset` 加 `body_md`(正文,必填)+ `file_format`(tally 四档,必填),
S3 剧本教它按内容形态挑格式(表→xlsx / 正式文档→docx / 网页→html / 短要点→md),
新增 L2 判据「每份内容都产出了资料文件」(四档计数之和 = 行数,两边都是基座数),
两份验收件的资料名都挂上链接 + 格式徽;新增第 4 条样例「对照表与落地页」——
一封需求里三种形态,专治「一律 docx」那条走不到的路。
**验收**:e2e i01 四案例(含驳回循环)**4/4 released、被拒 0**(8-9 拍 / 68-89k),
资料文件逐份取得回渲得出,末条实测 `xlsx / html / md` 三种格式各出各的;浏览器在**审核那一屏**逐份点开
(Word 纸面出四节标题与要点 / Excel 网格出参数对照表 / md 渲成标题+要点 / html 出纸面),
「下载原件」拿到的 .docx 落盘后 `file` 认作 **Microsoft Word 2007+**、
macOS `textutil` 逐段读得出正文。新测 47 条(现造+读回 22 · `data-file-of` 绑定 4 ·
fileRefOf 4 · 主键解析 6 · 包内 11);全仓 1573 → 1620。I01@1.2.2。
连带治掉一处每单白吃一次的被拒:**「提交前先裁三条 L4」原本只写在 T_release 的剧本里**,
而拟发布与提交常被模型压成同一拍 —— 那一拍摊开的是拟发布那一步的做法,T_release 的
剧本这时**根本不在上下文里**,于是照着 S5 写完就 submit,被门点名「三条还没裁过」。
规矩搬进**说明书**(每拍都在),与 A1 的卡骨架、A6 的决策卡同一课:
**模型会压拍,压过之后只有说明书还在**。
改了共享面(view-render / ViewContainer / desk 路由)照例全量回归:**e2e e04 3/3 released**、
发口全对(两例被拒 0,余一例是 E04 既有的模型空转)。
一处边界记进 debt:资料这一行落在**主数据**上,而试跑单放行时主数据只留在活动缓存、
不落正式表 —— 于是**试跑单放行之后**那份资料点不开(正式单不受此限);
404 的话已分两种说法,不拿「可能已被撤掉」把人支去翻一行根本不会在的东西。
- **产出的资料存了档,工作成果里点得开;查看面的灰底不再半路变白**(2026-08-23 用户两条):
①「渲染页面向下翻的时候背景的灰色在下半截会变成白色」—— **修了两刀,第一刀没打中**。
第一刀治的是走沙箱那两档(markdown / html):外层 `.fileview-body` 在 markdown 那一档
写死 `background:#fff`(它还是一整块 `<pre>` 铺满时的写法),与里面那份自带灰底的文档
不同色;改成同色 `#f4f5f7` + 去内边距,html 那一档一并(它套的是「渐变书桌 + 26px 边距」,
而那份文档自带书桌)。**但用户报的不是这两档** —— 他截图里那份是 docx,
走的是 word / excel 那条**父文档直接渲**的路(不进 iframe)。
真病根:`.fileview-body` 是 `.fileview`(`height:100%`)的弹性子项,默认 `flex:0 1 auto`
**不长个** —— 盒子高度被「看得见的那一屏」封住,而里面那张纸比一屏长时溢出盒子继续排,
**背景只画在盒子里**:滚过一屏,纸两侧的灰书桌就没了,底下是面板自己的白。
改 `flex: 1 0 auto`(不许被压 → 盒子至少和纸一样高;内容短时又能长满 → 下方不留白边)。
实证(同一单来回切):撤掉这一改盒子 529px、屏底取到 `rgb(255,255,255)`;改在盒子 1399px、
屏底取到书桌渐变。四种格式滚到底逐一取色:docx/xlsx 两侧是书桌灰,md/html 纸铺满且外层同色、
无空隙。**教训:同一个症状可能有两条独立的路,治完一条要把另一条也走一遍**。
②「检查当前产出的附件是否存档,在工作成果里也需要有产出的附件,点击后可以预览」——
**查出来的实情是没存**:文件一直是台账那一行**现造**的,而资料落在**主数据**上,
试跑单放行时主数据只留在活动缓存、不落正式表 ——「放行之后再点开」实测 404,
而工作成果恰恰是收工才去看的地方。现在**放行落地之前**(人的修订刚落回草稿层)
把每一份造成字节冻进 `sys.task_asset_file`(迁移 0029,一单一行一份、重复放行覆盖);
取文件那条路**存档优先** —— 归档件上点开的必须是他签字那一刻的那一份,
不是台账后来被下一单改成的样子。工作成果里「产出的资料」自成一摞(与定稿报告分开:
报告是「叫人办的那件事办完长什么样」,资料是**办出来的东西本身**),按格式上色、
带存档时刻,点开即预览、可下载原件;件上与归档报告里的资料名照旧点得开(同一条路)。
路上逮住一个只在存档那条路上出现的坑:**pg 取回来的 `Buffer` 挂在共享的 64KB 内存池上**,
`buf.slice().buffer` 是视图不是拷贝 —— 三份资料下载下来都是 65536 字节
(后面全是别人的残渣);改 `Uint8Array.from` 按元素拷。
验收:e2e i01 断言加了「放行之后逐份仍取得回、原件仍是 ZIP」这一条,
**四案例 4/4 released、被拒 0,每单都是「在办中渲得出 + 放行后存了档点得开」**;
浏览器实证两屏(审核那一屏、工作成果那一屏)都点得开,归档件里的资料名同样点得开,
归档的 .xlsx 落盘后 `file` 认作 **Microsoft Excel 2007+**、python `zipfile` 读得出
整张对照表。新测 8 条(存哪些不存哪些 + 主键拼法);`reset-runs` 一并清这张表。
## 文档权威地图 ## 文档权威地图
@@ -0,0 +1,267 @@
/**
* I01 品牌形象与销售工具包管理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面),
* 方法照抄 E04(`crm-relationship-coverage`)已验证过的测试结构。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有悬空监听(I01 本轮没有静态监听声明)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('L4 判据不给谓词 —— 是裁量项,不是机检项', () => {
const l4 = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4');
expect(l4.length).toBe(3);
for (const c of l4) expect(c.predicate, c.key).toBeUndefined();
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
expect(manifest.contract.requiredDeliverables).toHaveLength(3);
});
/*
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(照抄 E04 2026-08-20 实测栽过
* 那条纪律):基线三次(S2 / S4 通过 / T_release)+ 两轮决策卡循环(每轮 +2)= 7,留够余量。
*/
it('人环预算留够余量:基线三次 + 两轮决策卡循环仍在 cap 之内', () => {
const baseline = 3;
const twoRejectRounds = baseline + 4;
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
});
it('起单只必填 trigger,reason/requester/targetCustomer/note 都可选 —— 两级下拉的形状', () => {
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
const properties = manifest.contract.input.data['properties'] as Record<
string,
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
>;
for (const key of ['trigger', 'reason', 'requester', 'targetCustomer', 'note']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
expect(properties['reason']?.showWhen).toEqual({ field: 'trigger', equals: 'proactive' });
expect(properties['requester']?.showWhen).toEqual({ field: 'trigger', equals: 'reactive' });
});
it('四条样例形状真不同(触发路径各不相同),各自的入参都能通过对应 showWhen 校验', () => {
// 第四条是「资料文件」那一环的常备案例(一封需求要三种形态,格式分得开)
expect(manifest.samples).toHaveLength(4);
const shapes = manifest.samples.map((s) => JSON.stringify(s.input));
expect(new Set(shapes).size).toBe(4);
const proactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'proactive');
expect(proactive).toBeDefined();
const reactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'reactive');
expect(reactive).toBeDefined();
expect((reactive?.input as { requester?: string }).requester).toBe('E04');
expect((reactive?.input as { targetCustomer?: string }).targetCustomer).toBe('CU-0142');
});
it('三张视图模版都声明了,案例模型每一步引用的 view 都在其中', () => {
const names = new Set(manifest.assets.viewTemplates.map((v) => v.name));
expect(names).toEqual(new Set(['requirement-list', 'draft-review', 'publish-confirm']));
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
if (step.view !== undefined) expect(names.has(step.view), step.view).toBe(true);
}
});
});
describe('台账声明:三级占位与自有台账', () => {
const byAddress = (domain: string, object: string) =>
manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
it('L-01 市场信号只读:本活动不写它,字段不带 required/expect', () => {
const signal = byAddress('market', 'signal');
expect(signal).toBeDefined();
for (const f of signal?.fields ?? []) {
expect(f.required, f.name).not.toBe(true);
expect(f.expect, f.name).not.toBe(true);
}
});
it('L-05 内容资产:I01 唯一读写者,必填列齐全(content_type/title/language/version/content_status)', () => {
const asset = byAddress('content', 'asset');
expect(asset).toBeDefined();
const required = new Set(
(asset?.fields ?? []).filter((f) => f.required === true).map((f) => f.name),
);
for (const name of ['content_type', 'title', 'language', 'version', 'content_status']) {
expect(required.has(name), name).toBe(true);
}
const status = asset?.fields.find((f) => f.name === 'content_status');
expect(status?.tally).toEqual(['draft', 'reviewed', 'published', 'deprecated']);
});
it('内容需求清单主键是 round_ref + req_seq(没有天然外部键,照抄 E04 ledger_update.update_seq 的处置)', () => {
const requirement = byAddress('content_brand', 'requirement');
const keys = (requirement?.fields ?? []).filter((f) => f.primaryKey).map((f) => f.name);
expect(keys).toEqual(['round_ref', 'req_seq']);
});
it('优先级是 ASCII tally(high/medium/low),报告靠 data-class-of 投影,不靠模型写中文', () => {
const requirement = byAddress('content_brand', 'requirement');
const priority = requirement?.fields.find((f) => f.name === 'priority');
expect(priority?.tally).toEqual(['high', 'medium', 'low']);
});
});
describe('案例模型与清单的四处对齐', () => {
it('步骤剧本的 item 名与 CMMN humanTask id 一一对应', () => {
const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item);
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(stepItems).toEqual(cmmnIds);
});
it('六个计划项:1 执行 + 2 验收(S2 执行工单,S4/T_release 验收工单)', () => {
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(cmmnIds).toEqual([
'S1_identify_requirement',
'S2_requirement_confirm',
'S3_gather_and_draft',
'S4_draft_review',
'S5_publish_prep',
'T_release',
]);
});
it('三处不给 doneWhen(交人核对/要人拍板/签下去触达世界),其余三处都给了', () => {
const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>;
const noDoneWhen = new Set(['S2_requirement_confirm', 'S4_draft_review', 'T_release']);
for (const s of steps) {
if (noDoneWhen.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
else expect(s.doneWhen, s.item).toBeDefined();
}
});
it('S2/S4/T_release 三项在 CMMN 里都标了人工激活(manualActivationRule)', () => {
for (const id of ['S2_requirement_confirm', 'S4_draft_review', 'T_release']) {
const block = new RegExp(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? '';
expect(block, id).toContain('manualActivationRule');
}
});
it('除放行外每一项恰好属于一个阶段(进度条刻度不重不漏)', () => {
const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>)
.map((s) => s.item)
.filter((item) => item !== 'T_release');
const phaseItems = (manifest.assets.businessModel.phases ?? []).flatMap((p) => p.items);
expect([...phaseItems].sort()).toEqual([...stepItems].sort());
expect(new Set(phaseItems).size).toBe(phaseItems.length);
});
it('说明书点名的计划项都在案例模型里真实存在(没有臆造的 S-id)', () => {
const stepItems = new Set((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item));
// 只认「S数字_字母」这种完整计划项形状 —— 裸的「S1」「S1/S2/S3」是自然语言里的口语简写
// (如「见下方 S1 的 guide」),不是在点名一个具体的计划项 id,不该被当成引用校验。
const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? '');
for (const name of mentioned) {
expect(stepItems.has(name), `说明书提到但不存在的计划项: ${name}`).toBe(true);
}
});
it('决策卡骨架(S4_draft_review)三种 value 与说明书一致:approve/reject/custom:', () => {
expect(manifest.assets.businessModel.narrative).toContain('"kind":"decide","itemId":"S4_draft_review"');
expect(manifest.assets.businessModel.narrative).toContain('"approve"');
expect(manifest.assets.businessModel.narrative).toContain('"reject"');
expect(manifest.assets.businessModel.narrative).toContain('"custom:"');
});
/*
* **T_release 的 judge criterion 必须逐字抄 acceptanceCriteria 的 label**(2026-08-21
* 实测逼出):剧本写了缩写版"版本号与状态是否正确",清单的 label 是
* "版本号与状态是否正确:版本号已更新、状态流转无误"——两边对不上,模型照剧本裁的那句
* 匹配不到清单上的判据,提交放行永远差一条"[L4] 还没裁过",连拒五拍熔断。
* 这条测试钉死:T_release 的 guide 里每一句 `judge criterion="..."` 都要能在
* acceptanceCriteria 的 label 集合里原样找到。
*/
it('T_release 剧本里 judge criterion 的原文,逐字对得上 acceptanceCriteria 的 label', () => {
const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find(
(s) => s.item === 'T_release',
);
const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(quoted.length).toBeGreaterThan(0);
const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label));
for (const q of quoted) expect(labels.has(q), q).toBe(true);
});
});
describe('报告模版:编辑面与只读面各就各位', () => {
it('内容需求清单可编辑:data-edit 与 data-col 都在', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
expect(html).toContain('data-edit="content_brand.requirement"');
expect(html).toContain('data-col="content_type"');
});
it('内容初稿审核只读:没有任何 data-edit / data-col —— 人只审不改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('发布确认只读:没有任何 data-edit / data-col —— 归档件不给改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'publish-confirm')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
const declared = new Set(
manifest.declarations.ledger.find((l) => l.domain === 'content_brand' && l.object === 'requirement')?.fields.map((f) => f.name),
);
for (const c of cols) expect(declared.has(c), c).toBe(true);
});
it('状态徽(优先级/内容状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
const req = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
expect(req).toContain('<span class="jc-tag" data-class-of="priority"></span>');
const draft = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(draft).toContain('<span class="jc-tag" data-class-of="content_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
describe('活动入口', () => {
it('没有 run —— 行为全在清单里', () => {
expect(activity.run).toBeUndefined();
});
});
const CONTRACT = {
activityId: 'brand-content-toolkit',
version: '1.0.0',
data: {},
message: undefined,
outputPorts: { mandatory: ['content-published'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
@@ -0,0 +1,287 @@
/**
* 步骤剧本与呈现面 —— **这段错了不报错,只是活动开始转圈 / 界面开始撒谎**
* (与 quote-* / E04 各包同一套惯例)。
*
* 这一份是 2026-08-23 按 E04 的标准审 I01 时补的:每一条都对应审出来的一处真问题。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, CONTENT_PHASES, CONTENT_STEPS, NARRATIVE } from '../src/case-model.ts';
import { DRAFT_REVIEW, PUBLISH_CONFIRM, REQUIREMENT_LIST } from '../src/views/report.ts';
import { PRD_CSS } from '../src/views/prd-style.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上', () => {
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
for (const step of CONTENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
for (const id of ITEM_IDS) expect(CONTENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
});
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(CONTENT_STEPS);
expect(manifest.assets.businessModel.phases).toBe(CONTENT_PHASES);
});
});
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
it('steps[].writes / steps[].reads(台账那几个)全落在声明过的对象上', () => {
for (const step of CONTENT_STEPS) {
for (const address of step.writes) {
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
}
for (const address of step.reads.filter((r) => r.includes('.'))) {
expect(declaredAddresses.has(address), `${step.item} 要读 ${address},清单没声明`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具(本活动一个工具都不用,那就都得是空)', () => {
for (const step of CONTENT_STEPS) {
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
}
});
});
describe('doneWhen —— 开局不许真,干完必须真', () => {
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
content_brand_requirement_rows: 0,
content_asset_rows: 0,
content_asset_missing: 0,
content_asset_content_status_published: 0,
});
it('**开局一步都不该被自动收掉** —— 三条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of CONTENT_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
}
}
});
it('需求写下了 → S1 收得掉;初稿有行且必填齐 → S3 收得掉,缺一头都收不掉', () => {
expect(safeEval(doneOf('S1_identify_requirement'), { content_brand_requirement_rows: 2 })).toBe(true);
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 0 })).toBe(true);
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 1 })).toBe(false);
});
it('拟发布:记账旗 + 已发布行数 + 必填齐,三样缺一不可', () => {
const done = doneOf('S5_publish_prep');
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 0 }),
).toBe(true);
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 0, content_asset_missing: 0 }),
).toBe(false);
// 场景/BT 标签是「发布态必填」—— 缺了会进 _missing,这一步就收不掉
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 1 }),
).toBe(false);
});
});
describe('这一轮审出来的口径 —— 改口要连测试一起改', () => {
/*
* **两条 L1 各钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」的伪装形
* (E04 那单抢跑写旗、活单熔断,正是踩着这半边过的门)。
*/
it('两条 L1 都不只读记账旗,各自还钉着基座数', () => {
for (const key of ['requirement-confirmed', 'draft-approved']) {
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === key);
expect(found?.predicate, `${key} 没有谓词`).toBeDefined();
expect(found?.predicate).toMatch(/_rows > 0/);
expect(found?.predicate).toMatch(/_missing = 0/);
}
});
/*
* **判据指向的东西必须摆在卡面上**(E04 那一课):L4「场景与 BT 标签是否登记完整」
* 从前让人核一个 `content.asset` 里压根不存在的列。
*/
it('BT 标签有列、发布态必填、两份验收件上都摆得出来', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
const bt = asset?.fields.find((f) => f.name === 'bt_tag');
expect(bt, 'content.asset 缺 bt_tag').toBeDefined();
expect(bt?.requiredWhen).toEqual({ column: 'content_status', value: 'published' });
// 不带 tally —— 件上是普通格子、没有翻译层,存 ASCII 人看到的就是 ASCII(scene 踩过)
expect(bt?.tally).toBeUndefined();
expect(DRAFT_REVIEW).toContain('data-slot="bt_tag"');
expect(PUBLISH_CONFIRM).toContain('data-slot="bt_tag"');
});
it('场景也是发布态必填 —— S5 剧本说「发布前必须登记」,声明就得钉得住', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
expect(asset?.fields.find((f) => f.name === 'scene')?.requiredWhen).toEqual({
column: 'content_status',
value: 'published',
});
});
/*
* **主键列必挂 `data-col`** —— 它不是编辑面,是**行的钥匙**(仓里立过的纪律,A1 从头就带)。
* 这一条是浏览器实测逼出来的:一度把序号列的 `data-col` 拿掉「让它只读」,
* 结果人改的老行也没了钥匙 —— 服务端只好去猜,把它当成新增行、又拿「全表一致」
* 猜出个 1,**当场覆盖掉原来那一行**。钥匙必须随行回传;
* 「人加的新行序号空着」那一半由基座续号(`resolveRowKey`)接住。
*/
it('需求清单的序号列挂着 data-col(行的钥匙),内容类型是可改的必填格', () => {
expect(REQUIREMENT_LIST).toContain('data-slot="req_seq"');
expect(REQUIREMENT_LIST).toContain('data-col="req_seq"');
expect(REQUIREMENT_LIST).toContain('data-col="content_type"');
});
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = CONTENT_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
for (const address of ['content.asset', 'content_brand.verdict']) {
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
}
expect(release?.guide).toContain('覆盖写');
});
});
describe('三份工单模版照说明书 §6.2 的共用动态位', () => {
const ALL = [REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM];
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 三份都有', () => {
for (const view of ALL) {
expect(view).toContain('data-slot="case:owner"');
expect(view).toContain('data-slot="case:taskId"');
expect(view).toContain('jc-rp-step');
expect(view).toContain('data-slot="round_ref"');
}
});
it('执行工单的编辑闸照目标件措辞:「AI 已识别 N 个内容需求」,N 是基座读数', () => {
expect(REQUIREMENT_LIST).toContain('AI 已识别');
expect(REQUIREMENT_LIST).toContain('data-slot="content_brand_requirement_rows"');
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
expect(REQUIREMENT_LIST).not.toContain('【本轮触发】');
});
it('样例 0 是满载的那条,且每条样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
const first = manifest.samples[0]?.input as Record<string, unknown>;
for (const key of ['trigger', 'requester', 'targetCustomer', 'note']) {
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
}
for (const s of manifest.samples) {
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
}
});
});
function doneOf(item: string): string {
const found = CONTENT_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
/**
* **审核资料这一步要真产出资料文件**(2026-08-23 用户定:「格式可以是 word/excel/md/html,
* 文件需要能在前端渲染,在表格中点击资料名可以跳转到一个新的页面来渲染这些文件」)。
*
* 接手时这一步只写下一行标题 —— S3 的剧本白纸黑字写着「file_ref 选填,本 MVP 阶段可留空」:
* 人要审的那份初稿根本不在系统里,点不开也下不来。下面每一条都钉着这一轮补上的一环。
*/
describe('资料文件:声明 → 正文 → 格式 → 点得开', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
it('台账声明了 file 段(它就是授权面 —— 不声明,件上点不开)', () => {
expect(asset?.file).toEqual({ titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' });
});
it('file 段点名的三列都真在 fields 里', () => {
const names = (asset?.fields ?? []).map((f) => f.name);
for (const col of Object.values(asset?.file ?? {})) expect(names, `声明的 ${col} 不在 fields 里`).toContain(col);
});
it('正文与格式都是**必填** —— 少一样这一份就不是文件,只是一行标题', () => {
for (const col of ['body_md', 'file_format']) {
expect(asset?.fields.find((f) => f.name === col)?.required, `${col} 不是必填`).toBe(true);
}
});
it('格式列带 tally 四档(基座据此现数,判据两边才都是基座数)', () => {
expect(asset?.fields.find((f) => f.name === 'file_format')?.tally).toEqual(['md', 'html', 'docx', 'xlsx']);
});
it('L2 判据「资料文件已产出」两边都是基座数:四档之和 = 行数', () => {
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === 'asset-file-ready');
expect(found?.level).toBe('L2');
for (const fmt of ['md', 'html', 'docx', 'xlsx']) {
expect(found?.predicate, `谓词漏了 ${fmt}`).toContain(`content_asset_file_format_${fmt}`);
}
expect(found?.predicate).toContain('= content_asset_rows');
// 自报列一个都不许出现在机检谓词里
expect(found?.predicate).not.toMatch(/file_ref|reviewer/);
});
it('S3 的剧本教它写正文与挑格式,且不再说「可留空」', () => {
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
expect(s3?.writes).toContain('content.asset');
expect(s3?.guide).toContain('body_md');
expect(s3?.guide).toContain('file_format');
for (const fmt of ['xlsx', 'docx', 'html', 'md']) expect(s3?.guide, `剧本没讲 ${fmt} 什么时候用`).toContain(fmt);
// 那句话正是「这一步不产出文件」的出处 —— 它在,行为就回去了
expect(s3?.guide).not.toContain('本 MVP 阶段可留空');
expect(s3?.guide).not.toContain('待补充');
});
it('S3 的 doneWhen 覆盖得住新的必填(必填齐才收步)', () => {
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
expect(s3?.doneWhen).toContain('content_asset_missing = 0');
// 正文/格式没写 → missing 不为 0 → 收不掉
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 1 })).toBe(false);
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 0 })).toBe(true);
});
it('审核那一屏:资料名挂 data-file-of,旁边摆格式徽', () => {
expect(DRAFT_REVIEW).toContain('data-file-of="content_id"');
expect(DRAFT_REVIEW).toContain('data-class-of="file_format"');
// 归档那一屏同样点得开 —— 办结之后回看资料是常事
expect(PUBLISH_CONFIRM).toContain('data-file-of="content_id"');
});
it('资料名那一格看得出能点(样式表里有它,不是一格普通文字)', () => {
expect(DRAFT_REVIEW).toContain('jc-fileln');
expect(PRD_CSS).toContain('.jc-fileln');
expect(PRD_CSS).toContain('cursor: pointer');
});
});
/**
* **提交前要裁的那三条,写在说明书里**(2026-08-23 实测:S5 与 submit 被压成一拍,
* 那一拍摊开的是 S5 的剧本,T_release 的「先 judge 再 submit」看不见 → 每单白吃一次被拒)。
* 与 A1 的卡骨架、A6 的决策卡同一课:**模型会压拍,压过之后只有说明书还在**。
*/
describe('裁量判据的规矩住在说明书里,不只住在 T_release 的剧本里', () => {
it('三条 L4 的整句都在 NARRATIVE 里(criterion 要照抄,抄不到就对不上名)', () => {
for (const c of manifest.contract.acceptanceCriteria.filter((x) => x.level === 'L4')) {
expect(NARRATIVE, `说明书里没有「${c.label}」`).toContain(c.label);
}
});
it('说明书说清了顺序:同一串先 judge 再 submit', () => {
expect(NARRATIVE).toContain('judge');
expect(NARRATIVE).toContain('submit');
});
});
@@ -0,0 +1 @@
{ "name": "@activities/brand-content-toolkit", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
@@ -0,0 +1,464 @@
/**
* I01 品牌形象与销售工具包管理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与
* 案例说明书(叙事面)。方法论与文件结构照抄 E04(`crm-relationship-coverage`)已验证过的样子。
*
* 业务出处:`docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md`(7 步业务链条,
* 工单只针对生产流程①→⑤,复盘⑥⑦独立拎出不并入)与
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-01/L-05 台账字段表)。
*
* 7 个业务步收成 6 个案例模型项,非逐一对应:
* ① 需求识别与受理 → S1_identify_requirement(AI 起草)+ S2_requirement_confirm(人核对);
* ②③ 市场依据采集 + 内容创作 → S3_gather_and_draft(一项,AI,纯加工态);
* ④ 内部审核与调性把关 → S4_draft_review(决策卡循环,通过/驳回+AI重做);
* ⑤ 发布入库 → S5_publish_prep(AI 拟发布)+ T_release(终局验收放行)。
* ⑥⑦ 分发追踪与复盘迭代**不在这份案例模型里**(业务说明明文:工单只针对生产流程,
* 复盘是独立触发的另一条时间轴,不并入这一次生产任务)。
*
* 与 E04 的一处刻意不同:I01 没有「AI 做不到、必须人去线下执行」的环节
* (内容初稿由 AI 草拟,人只审不做)——所以只有 1 张执行工单(S2),
* 少 E04 那张「执行经营动作」;验收工单反倒多一张(S4 审初稿 + T_release 发布确认),
* 与 E04(S4 审计划 + T_release 台账更新)结构同型。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="brand-content-toolkit">
<!--
案卷项的 id 就是任务视图里的键名。入参顶层是 trigger/reason/requester/targetCustomer/note
五个字段(不进这份 caseFileModel——没有谓词引用它们,S1 的步骤 reads 里单独声明);
content_brand_* 与 content_asset_* 那几个是基座读数(行数 / 缺必填 / 按值计数,
由基座现算,模型碰不着)。
-->
<caseFileModel>
<caseFileItem id="content_brand_requirement_rows" name="内容需求行数(基座现数)"/>
<caseFileItem id="requirement_confirmed_total" name="内容需求已核对确认(人环走完的凭据)"/>
<caseFileItem id="content_asset_rows" name="内容资产行数(基座现数)"/>
<caseFileItem id="content_asset_missing" name="内容资产缺必填的行数(基座现数)"/>
<caseFileItem id="draft_approved_total" name="内容初稿已审核通过(仅通过时置一)"/>
<caseFileItem id="content_asset_content_status_published" name="已置为已发布状态的行数(基座现数)"/>
<caseFileItem id="publish_prepared_total" name="发布准备已收口(S5 写完时置一)"/>
</caseFileModel>
<casePlanModel id="plan" name="品牌形象与销售工具包管理" autoComplete="true">
<!-- ① 内容需求已起草 —— 人核对那一步的入场前提 -->
<sentry id="s_requirement_drafted">
<onPart id="op_rd" caseFileItemRef="content_brand_requirement_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>content_brand_requirement_rows &gt; 0</condition></ifPart>
</sentry>
<!-- ① 内容需求已核对 —— 采集与创作那一步的入场前提 -->
<sentry id="s_requirement_confirmed">
<onPart id="op_rc" caseFileItemRef="requirement_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rc"><condition>requirement_confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!-- ②③ 内容初稿已草拟且齐 —— 审核那一步的入场前提 -->
<sentry id="s_draft_ready">
<onPart id="op_dr" caseFileItemRef="content_asset_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_dr"><condition>content_asset_rows &gt; 0 and content_asset_missing = 0</condition></ifPart>
</sentry>
<!-- ④ 内容初稿已审核通过 —— 拟发布那一步的入场前提 -->
<sentry id="s_draft_approved">
<onPart id="op_da" caseFileItemRef="draft_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_da"><condition>draft_approved_total &gt; 0</condition></ifPart>
</sentry>
<!--
⑤ 发布已准备好 —— 放行门的入场前提。
认「办完了没有」:发布状态已置且不缺必填、且 S5 自己显式记了账
(publish_prepared_total 是写完之后才产生的数,不是「写过一笔就算数」)。
-->
<sentry id="s_publish_ready">
<onPart id="op_pr" caseFileItemRef="publish_prepared_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>publish_prepared_total &gt; 0 and content_asset_content_status_published &gt; 0 and content_asset_missing = 0</condition></ifPart>
</sentry>
<!-- ① 识别内容需求,铸造 round_ref(能算的不问人) -->
<humanTask id="S1_identify_requirement" name="识别本轮内容需求"/>
<!-- ① 交人核对需求清单(增删改)—— 触达世界前的人环,不给 doneWhen -->
<humanTask id="S2_requirement_confirm" name="需求受理:核对需求清单,增删改">
<entryCriterion id="ec_s2" sentryRef="s_requirement_drafted"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ②③ 采集市场依据 → 草拟多语言内容初稿(一项,不触达世界) -->
<humanTask id="S3_gather_and_draft" name="采集市场依据、按 BT 层级草拟内容初稿">
<entryCriterion id="ec_s3" sentryRef="s_requirement_confirmed"/>
</humanTask>
<!-- ④ 内容初稿审核 —— 决策卡循环,通过或驳回+AI重做,不给 doneWhen -->
<humanTask id="S4_draft_review" name="内容初稿审核:通过或驳回(驳回则AI修订重提交)">
<entryCriterion id="ec_s4" sentryRef="s_draft_ready"/>
<itemControl><manualActivationRule id="m_s4"/></itemControl>
</humanTask>
<!-- ⑤(前半)拟发布:版本号递增、状态流转、登记场景与 BT 标签(不触达世界,给 doneWhen) -->
<humanTask id="S5_publish_prep" name="拟发布:版本递增、状态流转、登记场景与标签">
<entryCriterion id="ec_s5" sentryRef="s_draft_approved"/>
</humanTask>
<!-- ⑤(后半)发布确认:终局唯放行,必须人工激活 -->
<humanTask id="T_release" name="发布确认放行">
<entryCriterion id="ec_rel" sentryRef="s_publish_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**这一轮要生产哪份对外内容资产,产完把它审核发布**。
入参五个顶层字段:
· \`trigger\`——**必有**,\`"proactive"\`(主动触发)或 \`"reactive"\`(被动触发·使用方请求),
翻成人读标签写进 \`content_brand.verdict.trigger\`(报告上不摆系统内英文码)。
· \`reason\`——仅 \`trigger:"proactive"\` 时可能有,四种之一:\`"strategy_shift"\`(战略调整)/
\`"new_market"\`(进入新市场)/ \`"tool_outdated"\`(工具过期)/ \`"monthly_update"\`
(月度定期内容更新)。
· \`requester\`——仅 \`trigger:"reactive"\` 时可能有,四种之一:\`"E04"\`(关系覆盖需要价值
传递素材)/ \`"T03"\`(触达需要材料)/ \`"T04"\`(攻坚需要定制内容)/ \`"E03"\`(方案需要支撑)——
T03/E03 在本仓库尚未实现,只是描述来源,不必真去调它们。
\`reason\`/\`requester\` 二选一有值,翻成人读标签一起写进
\`content_brand.verdict.trigger_detail\`(对照表见下方 S1 的 guide)。
· \`targetCustomer\`——选填,定向客户的编码(如为特定客户定制素材);通用内容留空。
· \`note\`——本轮背景说明(选填),帮你判断内容侧重与紧迫程度。
**成品是三样东西,缺一不可**:①一份**核对过的内容需求清单**;②一份**经审核通过的
内容初稿**(多语言,按 BT 层级定制结构);③**写入 L-05 品牌内容库的正式发布记录**
(版本号、状态流转、场景与 BT 标签登记齐全)。
**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**:识别需求、采集市场依据、
草拟初稿、拟发布,都是你的活;核对需求清单、审核初稿、签字发布,三道都要人过。
**没有物理执行环节**:与 E04 不同,I01 没有「AI 做不到、必须人去线下办」的步骤——
内容初稿由你草拟,人只审核通过或驳回,不亲自动笔、不用到场。
**五步走**:识别需求 → 人核对 → 采集依据+草拟初稿(一步) → 人审核(可能来回改一轮)→
拟发布 → 人签字放行。
【这一单唯一的岔路口 —— 内容初稿审核,长这样,无论哪一拍都照这个发,别自己另编一张】
审核不是一次性的:人可能驳回、要求你按他的意见改。**这一步只在 S4_draft_review
这一项里发生**,发决策卡之前先在轨迹里找有没有已经答过、还没处理的 \`[card-reply]\`:
· **\`value\` 是 \`"approve"\`**——写 \`draft_approved_total=1\`,把这一轮草拟的每一行
\`content.asset\` 的 \`content_status\` 改成 \`reviewed\`(同 PK 覆盖写),finish 这一项,
别再发卡;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头**——都算驳回:\`"reject"\` 是点了固定
按钮、没写具体意见,**不许再发卡追问理由**(那两个按钮点出来的回复永远没有 note,
再问一遍是把人指向一扇打不开的门);以 \`"custom:"\` 开头的,\`note\` 就是他写的具体
意见,按这条意见改。两种都要对 \`content.asset\` 做出至少一处实质性修订(同 PK
覆盖写,不开新计划项、不重新进入 sentry),然后**换一种问法**再发一张 decide 卡
(措辞不换会被系统当成同一张卡静默吞掉);
· **没有**——才发第一张 decide 卡。
{"kind":"decide","itemId":"S4_draft_review","allowCustom":true,
"ask":"内容初稿已草拟,共 <N> 份,请审核。",
"options":[{"value":"approve","label":"通过","recommended":true},
{"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有这三种形态:\`"approve"\`、\`"reject"\`、\`"custom:"\` 开头(人自己写的话)——
**别自己造第四种**。
【提交放行之前,先把三条裁量判据裁掉 —— 同一串 judge×3 + submit】
这三条机器判不了,**没裁过就提交必被拒**(拒回会点名是哪三条):
· "版本号与状态是否正确:版本号已更新、状态流转无误"
· "场景与 BT 标签是否登记完整"
· "多语言版本是否齐全"
criterion 照上面整句原样抄;裁成 satisfied=false 照样提交得了 —— 理由连着卡交给签字的人。
**这条写在这儿而不是只写在 T_release 的剧本里**:拟发布与提交常被压成同一拍,
那一拍摊开的是拟发布那一步的做法,T_release 的剧本这时看不见 —— 说明书每拍都在。
【一路上要说话】负责人正看着这一单。**每一拍带一句 say** —— 说业务的话:
识别到几条需求、初稿写了几份、发布准备好了什么;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 7-9 拍就该收口**(比常路多两道人环:需求核对 + 初稿审核 + 放行,共三次;
审核若被驳回会多出一到两拍)。
§Z 结案:AM Lead 在验收单上签字,系统把草稿合并进 L-05 正式版 ——
这一轮生产的内容资产正式上线,可供 E04/T03/T04/E03 调用(分发追踪与复盘迭代是
独立触发的另一条时间轴,不在这一单里)。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_requirement_confirm`(交人核对触达世界)、`S4_draft_review`
* (要人拍板,且这一项内部可能循环)、`T_release`(签下去触达世界)——
* 给了等于给引擎一把替人签字的钥匙。
*/
export const CONTENT_STEPS = [
{
item: 'S1_identify_requirement',
intent: '识别本轮内容需求来源与类型,铸造本轮案号',
writes: ['content_brand.requirement', 'content_brand.verdict'],
/* 入参顶层字段不声明就会被整表段截断刀砍掉 —— 被动触发那几条路全靠这五个字段 */
reads: ['trigger', 'reason', 'requester', 'targetCustomer', 'note', 'content.asset'],
view: 'requirement-list',
tools: [],
doneWhen: 'content_brand_requirement_rows > 0',
guide: `第一件事:把这一轮**内容需求**列出来。
【先铸造本轮案号 round_ref】格式固定:\`'CTB-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`
(引擎注入的开案时刻,你不必也不许自己编)。**整单只有一个 round_ref**,写
\`content_brand.requirement\` / \`.verdict\` 时都用这同一个串 —— 造出两个号就是把一单裂成两单。
【识别需求】读 \`content.asset\`(已有资产,判断是否已有类似内容、避免重复):
· \`trigger:"proactive"\`——按 \`reason\` 映射:\`strategy_shift\`(战略调整)/
\`new_market\`(进入新市场)通常对应"公司介绍"/"能力声明"这类通用内容;
\`tool_outdated\`(工具过期)对应更新既有工具包;\`monthly_update\`(月度定期更新)
对应例行内容刷新,可参考已有资产里哪些版本号最旧、最久未更新。
· \`trigger:"reactive"\`——按 \`requester\` 映射到内容类型:\`E04\` 请求价值传递素材
→系统方案白皮书/案例集;\`T03\` 触达需要材料→销售工具包/选型指南;\`T04\` 攻坚需要
定制内容→系统方案白皮书(定向 \`targetCustomer\`);\`E03\` 方案需要支撑→技术规格书/
替代对照表。\`targetCustomer\` 有值就把这条需求的 \`target_customer\` 填成它。
一轮可以识别出不止一条需求(如月度更新可能同时刷新两三份内容)。
【写 \`content_brand.requirement\`,一条需求一行】(rows 收数组)
round_ref、req_seq(**必填**,本轮内从 1 递增)、content_type(**必填**,如
"系统方案白皮书"/"公司介绍"/"能力声明"/"销售工具包"/"案例集"/"品牌调性标准"/
"方案模板")、source(选填,一句需求来源,如"使用方请求(E04 价值传递)"或
"战略调整(进入俄罗斯市场)")、target_customer(选填,定向客户;通用内容写"—(通用)")、
scene(选填,**写人读标签**"首触"/"方案"/"攻坚"/"展会"/"报价"之一,按用途判断——这一列
在编辑面上是普通格子、不是徽标,不带翻译层,写 ASCII 码人看到的就是 ASCII 码)、
priority(选填,ASCII \`high\`/\`medium\`/\`low\`,按紧迫程度判断)、origin(固定写 \`ai\`)。
【同一拍写 \`content_brand.verdict\`(一行)】round_ref、trigger(**翻成人读标签写,
不写入参那个英文码**——\`"proactive"\`→"主动触发"、\`"reactive"\`→"被动触发")、
trigger_detail(把 \`reason\` 或 \`requester\` 也翻成人读标签写进来——
\`strategy_shift\`→"战略调整"、\`new_market\`→"进入新市场"、\`tool_outdated\`→"工具过期"、
\`monthly_update\`→"月度定期内容更新"、\`E04\`→"E04 关系覆盖需要价值传递素材"、
\`T03\`→"T03 触达需要材料"、\`T04\`→"T04 攻坚需要定制内容"、\`E03\`→"E03 方案需要支撑")、
target_customer(入参原样抄,没有留空)、note(入参原样抄,没有留空)、summary 一句人话
(本轮识别到几条需求、依据是什么)。写完 finish 这一项。`,
},
{
item: 'S2_requirement_confirm',
intent: '交人核对需求清单:增删改,确认本轮真正要生产什么',
writes: ['content_brand.verdict'],
reads: ['content_brand.requirement'],
view: 'requirement-list',
tools: [],
/* 不给 doneWhen —— 交人核对触达世界(manualActivation),引擎不替人收 */
guide: `需求清单起草好了,**该内容运营核对了** —— 这是本活动第一张执行单,零输入。
工作区摆的是需求清单的报告(编辑闸开着就能增删改),他在那份件上核对:AI 规则之外的
目标客户、适用场景、优先级、定制需求(如使用方要一份方案,他线下知道要针对某客户
做定向版)。
这一步标了人工激活:发卡就算启动,不必单发 start;人交回后你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号,别绑 \`blocks\`
或 \`fields\` —— 整份清单在工作区那份件上,执行卡零输入)
{"kind":"human","itemId":"S2_requirement_confirm","workOrder":"execution","card":{
"title":"需求受理:本轮共 <N> 条内容需求",
"ask":"AI 已按规则识别出 <N> 条内容需求,请核对确认。",
"steps":["工作区的件上核对需求清单:内容类型是必填,缺一条这一单走不到内容创作那一步",
"加一行 = 补充新需求(AI 规则之外的目标客户/场景/优先级/定制)",
"删一行 = 排除(AI 识别但实际不需要生产的需求)",
"改单元格 = 修正 AI 识别的内容 —— 尤其是目标客户、适用场景、优先级",
"改完保存,回这张卡提交"],
"standard":"需求清单里每一行内容类型都齐;该补充的都补充、该排除的都排除了。",
"basis":[{"k":"触发口径","v":"<照 content_brand.verdict 刚写的 trigger/trigger_detail 说,
如「主动触发:月度定期内容更新」或「被动触发:E04 关系覆盖需要价值传递素材」>"},
{"k":"AI 识别","v":"<N> 条内容需求"}]}}
发完这一拍就结束了(发卡挂起)。
【人交回之后】这一步你只做两件事,然后 finish:
① 写 \`content_brand.verdict.requirement_confirmed_total = 1\`;他在件上撤掉了行或加了行
的话,下一步的市场依据采集与内容创作按当下 \`content_brand.requirement\` 的实际行数走,
不必你重写。
② finish 这一项。**别在这一拍采集市场依据或草拟初稿** —— 那是下一步的活,这里做了也白做。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着,
卡回来时内容类型一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_gather_and_draft',
intent: '采集市场依据、按 BT 层级草拟多语言内容初稿',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content_brand.requirement', 'market.signal', 'content.asset'],
view: 'draft-review',
tools: [],
doneWhen: 'content_asset_rows > 0 and content_asset_missing = 0',
guide: `需求确认了,**现在读市场依据、草拟内容初稿** —— 一拍连做,这一步纯加工态,
不必找人。
【采集市场依据】读 \`market.signal\`(L-01 宏观市场台账):按需求里的 \`target_customer\`/
业务背景筛选相关的 \`market_region\`/\`industry\`/\`chip_category\` 信号,归纳产业趋势与
差异化定位。**读不到相关信号是正常结果**(不是每条内容需求都有现成的市场信号支持)——
读不到就如实基于已有资产与客户信息拟定,别在报告里编一条不存在的市场依据。
【按 BT 层级草拟内容初稿】逐条 \`content_brand.requirement\` 草拟一份初稿:
· BT-01(采购/供应链层)侧重:供货能力 / 资质文件 / 渠道政策;
· BT-02(技术层)侧重:技术规格书 / 替代对照表 / 认证报告;
· BT-03(高层)侧重:系统方案白皮书 / 参考设计 / 成功案例。
需求没指明 BT 层级的,按内容类型判断("系统方案白皮书"多半是 BT-03,"技术规格书"多半
是 BT-02)。**维护多语言版本**:中文必备,俄罗斯市场相关(俄语区客户/进入俄罗斯市场
的需求)加俄语,其余按需求背景判断要不要加英语。同一份内容的多语言版本写进**同一行**
的 \`language\` 列(用" · "连接,如"中文 · Русский"),不必为每种语言各开一行。
读 \`content.asset\` 里已有资产与品牌调性标准,保持措辞一致、避免重复生产同一份内容。
【写 \`content.asset\`,一份内容初稿一行】(rows 收数组)
content_id(**必填**,格式固定 \`'CT-' + 本年年份 + '-' + 本轮内四位序号\`,如
"CT-2026-0001"、"CT-2026-0002",本轮内递增、不与已有资产的编号相撞)、content_type
(**必填**,照抄需求的 content_type)、title(**必填**,具体标题,不写"内容初稿"这类
占位词)、language(**必填**,如"中文"或"中文 · Русский")、version(**必填**,本步一律
写 \`v0.1\`)、scene(选填,照抄需求的 scene 人读标签)、content_status(固定写 \`draft\`)、
brand_tone(选填,按品牌调性标准挑 1-2 个最贴合的,如"专业 · 可信赖")、bt_tag(选填,
**写人读值** "BT-01"/"BT-02"/"BT-03" 之一,按上面那三档判 —— 这一列在件上是普通格子、
不带翻译层,写 ASCII 码人看到的就是 ASCII 码;验收工单要人核「BT 标签登记完整」,
核的就是它)、target_customer(选填,照抄需求的 target_customer)、file_ref(选填,
留空即可)、reviewer(选填,本步留空,S4 审核通过后再填)。
【**正文与格式:这一步真正要产出的资料**】(\`body_md\` 与 \`file_format\`,两列都**必填**)
人在下一步要审的是**资料本身**,不是一行标题 —— 件上点资料名就打开这份文件,
所以正文得真写出来。
· \`body_md\` = 这份资料的**全文,Markdown 原文**。照 BT 层级该有的结构写实内容:
一级标题一句话说清这是什么,再按"##"分节。**每一节都要有实打实的内容** ——
一句"回头再补"或者把人指去别处的话,等于这一份资料没产出。认得的写法就这几样,够用:
"# 标题" / "## 小节" / 段落 / "- 要点" / "1. 步骤" / "| 列 | 列 |" 表格 / "---" 分隔线 /
"**重点**"。多语言版本写进同一份正文的不同小节
(如 "## Русская версия"),不为每种语言另开一行。
· \`file_format\` = 这份资料**该以哪种格式交出去**,四选一,按内容形态挑,别一律写 md:
- \`xlsx\`:主体是表 —— 替代对照表、参数对比表、价格/交期清单;
- \`docx\`:正式文档 —— 系统方案白皮书、技术规格书、认证报告、渠道政策;
- \`html\`:要在网页上直接看的 —— 落地页文案、案例展示页;
- \`md\`:短说明、要点清单、内部速记。
挑 \`xlsx\` 的那一份,正文里**必须真有一张 Markdown 表格**(否则导出来是一张只有
标题的空表)。同一轮里几份内容各按自己的形态挑,不必统一。
【同一拍写 \`content_brand.verdict\`】summary 补一句人话(本轮草拟了几份内容、依据是
什么市场信号或已有资产)。
写完这一拍就结束(不必你 finish)——\`content_asset_rows > 0 and content_asset_missing = 0\`
一旦为真,引擎自己收这一步。`,
},
{
item: 'S4_draft_review',
intent: '内容初稿审核:通过就往下走,驳回则按意见修订后换一种问法再提交',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset'],
view: 'draft-review',
tools: [],
/* 决策卡循环 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**动手之前先在轨迹里找一行 \`[card-reply]\` 带这一项的答复:**
· **没找到** —— 这是第一次,跳到下面【发一张决策卡】那一段;
· **\`value\` 是 \`"approve"\`** —— 把这一轮 \`content.asset\` 里状态还是 \`draft\` 的每一行
改成 \`content_status = "reviewed"\`(同 PK 覆盖写,\`reviewer\` 补一句"AM Lead 审核通过"),
写 \`content_brand.verdict.draft_approved_total = 1\`,finish 这一项,**不许再发卡**;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头** —— 都算驳回,跳到下面【驳回之后】
那一段,修订初稿再重新发卡。
【这一步办完了没有,看一个数】\`draft_approved_total\`:它写成 1 就是这一步办完了,
引擎不会再把这一项交给你(它归零 doneWhen 帮不上忙,只有你自己 finish)。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发;\`allowCustom\` 别漏 —— 少了它,人只有
两个固定按钮可点,连意见都没地方写;\`ask\` 每一轮都要按下面的规则换措辞)
{"kind":"decide","itemId":"S4_draft_review",
"allowCustom":true,
"ask":"内容初稿已草拟,共 <N> 份,请审核。",
"options":[
{"value":"approve","label":"通过","recommended":true,"consequence":"内容进入发布准备阶段"},
{"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按调性标准复核一遍初稿,重新提交审核"}],
"basis":[{"k":"本轮初稿","v":"<N> 份,覆盖 <涉及的内容类型>"},
{"k":"调性依据","v":"专业 / 可信赖 / 高效 / 本土化"},
{"k":"想说明理由","v":"不点上面两个按钮,直接在下方输入框写你的意见再提交"}]}
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订
\`content.asset\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:对照调性标准
(专业/可信赖/高效/本土化)重新审视措辞,技术数据有没有可疑处;复核后**至少要有
一处实质性变化**(改措辞、补细节、调标签,不能原样重发)。
③ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如
"已复核并修订(第2稿:调整了措辞、补充了认证细节)请再次审核"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
④ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
(发卡挂起)。
**这一步只在这一项里循环,不许把驳回意见带去改别的项**(S1/S2/S3 写下的东西不动)。`,
},
{
item: 'S5_publish_prep',
intent: '拟发布:版本号递增、状态流转、登记场景与 BT 标签',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset', 'content_brand.requirement'],
view: 'publish-confirm',
tools: [],
doneWhen: 'publish_prepared_total > 0 and content_asset_content_status_published > 0 and content_asset_missing = 0',
guide: `审核通过了,**现在拟发布** —— 一拍连做。
【写 \`content.asset\`,把审核通过的每一行(\`content_status = "reviewed"\`)改成拟发布态】
(同 PK 覆盖写)version(**必填**,由 \`v0.1\` 改成 \`v1.0\`;若这份内容是已发布内容的
修订版本(\`content.asset\` 里已有同标题的 \`published\` 行),小版本递增,如 \`v1.1\`)、
content_status(**必填**,固定写 \`published\`)、scene(**必填**,若上一步没填齐,这一步
补上 —— 发布前场景必须登记完整)、bt_tag(**必填**,同上,人读值 "BT-01"/"BT-02"/"BT-03";
场景与 BT 标签这两列**声明里钉的就是「发布态才必填」**,状态一写成 published,
它们空着就会被红带、提交闸、放行门 L2 同时拦下)、brand_tone(选填,若上一步没填,
这一步按调性标准补)。
**这一步只是把状态写进本单草稿层,不是真的对外发布** —— 铁律②:草稿层的变更要等
T_release 人签字放行才合并进 L-05 正式版、真正对外可见。
【同一拍写 \`content_brand.verdict\`】publish_prepared_total = 1、
content_produced_total = 这一轮拟发布的内容份数、summary 补一句人话(这一轮生产了
哪几类内容、准备发布几份)。
写完 finish 这一项 —— 或者不必你 finish,\`doneWhen\` 会在必填列都齐的那一刻自动收步。`,
},
{
item: 'T_release',
intent: '发布已准备好,提交放行交 AM Lead 验收;被打回就按意见改完再交',
/* 被打回重做时要改的就是这两张 —— 不声明就写不进去(写的授权面是台账声明) */
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset', 'content_brand.requirement'],
view: 'publish-confirm',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `拟发布内容齐了、必填也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
人已经看过这份成果并把它退回来了,原样递上去他看见的还是刚被他打回的那一份
(基座那道闸也会当场拒:「打回之后台账一个字没改」)。照他的意见改**台账本身**,
改完这一拍再 judge + submit:
· 他说**版本号/状态不对** → 改 \`content.asset\` 的 \`version\`/\`content_status\`(同 PK 覆盖写);
· 他说**场景或 BT 标签没登记全** → 补 \`scene\`/\`bt_tag\`;
· 他说**语言版本不齐** → 补 \`language\`(同一行用" · "连接,不新开行);
· 他说**标题/调性不合** → 改 \`title\`/\`brand_tone\`;
· 他**没写具体意见**(只点了重做)→ 自己把这几份内容复核一遍,挑最站不住的那处改实,
**至少改对一处**;实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是行**:同一行覆盖写,别新开行 —— 那会把收步判据与发布份数弄假。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="版本号与状态是否正确:版本号已更新、状态流转无误" —— 逐条看
\`content.asset\` 的 \`version\`/\`content_status\`:版本号已从 \`v0.1\` 更新、状态流转
(草稿 → 已审核 → 已发布)每一步都走过没有跳步,就 satisfied=true;有一条对不上,
satisfied=**false**,rationale 逐条写清,交签字的人定。
· judge criterion="场景与 BT 标签是否登记完整" —— 核对每条拟发布内容的 \`scene\`/
\`bt_tag\` 是否都有值(这两列在声明里是「发布态必填」,基座也数得出来;
你要判的是**登记得对不对**,不只是有没有值)。
· judge criterion="多语言版本是否齐全" —— 核对 \`language\` 是否覆盖了这条内容该有的
语言版本(如面向俄罗斯市场的内容至少要有俄语)。
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄,
写别的名字对不上。
这一单没有分支去向 —— 全程线性,只发一口 \`content-published\`,不必你选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/3。
*/
export const CONTENT_PHASES = [
{ name: '需求识别与受理', items: ['S1_identify_requirement', 'S2_requirement_confirm'] },
{ name: '采集与创作', items: ['S3_gather_and_draft'] },
{ name: '审核与发布', items: ['S4_draft_review', 'S5_publish_prep'] },
];
@@ -0,0 +1,11 @@
/**
* I01 · 品牌形象与销售工具包管理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 三张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
export default defineActivity({ name: 'brand-content-toolkit' });
@@ -0,0 +1,526 @@
/**
* 活动包清单 —— I01 品牌形象与销售工具包管理(案例活动)。
*
* 业务出处:`docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md`(7 步业务链条,
* 工单只针对生产流程①→⑤,收成 1 张执行工单 + 2 张验收工单)、
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-01 宏观市场台账 /
* L-05 品牌内容库的字段表与读写关系)。
*
* 这是本仓库第二个不属于 `quote-*` A1-A7 报价管线的活动,方法论照抄第一个
* (`crm-relationship-coverage` / E04)已验证过的样子:双级下拉入参、活动自有工作台账
* + 共享主数据、decide 决策卡循环做中间验收、六步案例模型。
*
* 两处与 E04 的刻意不同:
* ① E04 核心是**读写**共享主数据(`customer.profile`/`customer.contact`),
* I01 核心是**只读** L-01(`market.signal`)、**读写**它独有的 L-05
* (`content.asset`)——本 Demo 范围内 I01 是 `content.asset` 唯一的读写者,
* 不追加列合并,是这份活动包**第一次**完整声明这两张台账的物理结构。
* ② I01 没有 E04 那种「AI 做不到、必须人去线下执行」的环节(内容初稿由 AI 草拟,
* 人只审不做)——所以只有 1 张执行工单(S2_requirement_confirm),
* 验收工单反倒有 2 张(S4_draft_review 审初稿 + T_release 发布确认),
* 与 E04(1 张验收决策 + 1 张终局放行)结构同型,只是执行工单少一张。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { CONTENT_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, CONTENT_PHASES, CONTENT_STEPS } from './case-model.ts';
import { REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM } from './views/report.ts';
const D = 'content_brand';
const ACTIVITY_ID = activityIdSchema.parse('brand-content-toolkit');
/** 没有判定表 —— 触发事件→内容类型、BT 层级→内容结构都是文字规则,不是离散分支命中表。 */
const EMPTY_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'I01 · 品牌形象与销售工具包管理',
packageVersion: '1.2.2',
category: '内容生产',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-brand-content-toolkit',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 不是来件解析(没有唯一「这封信」),是触发参数 —— 与 E04 同一处置
* (两级下拉 + 选填客户/说明):
* ① `trigger` 必填 —— 主动 / 被动,显式下拉,不接受省略;
* ② `reason`/`requester` 选填,只在对应 `trigger` 值时现身(`showWhen`);
* ③ `targetCustomer` 选填字符串 —— 定向客户编码,通用内容留空。
*/
data: {
type: 'object',
required: ['trigger'],
properties: {
trigger: {
type: 'string',
enum: ['proactive', 'reactive'],
enumNames: ['主动触发(战略调整/新市场/工具过期/月度更新)', '被动触发(使用方请求)'],
title: '触发方式',
},
reason: {
type: 'string',
enum: ['strategy_shift', 'new_market', 'tool_outdated', 'monthly_update'],
enumNames: ['战略调整', '进入新市场', '工具过期', '月度定期内容更新'],
title: '主动触发的原因(仅主动触发时选)',
showWhen: { field: 'trigger', equals: 'proactive' },
},
requester: {
type: 'string',
enum: ['E04', 'T03', 'T04', 'E03'],
enumNames: [
'E04 关系覆盖需要价值传递素材',
'T03 触达需要材料',
'T04 攻坚需要定制内容',
'E03 方案需要支撑',
],
title: '被动触发的使用方(仅被动触发时选)',
showWhen: { field: 'trigger', equals: 'reactive' },
},
targetCustomer: {
type: 'string',
title: '定向客户编码(选填,为特定客户定制素材时填;通用内容留空)',
examples: ['CU-0142'],
},
note: {
type: 'string',
title: '本轮说明(选填,背景信息 —— 帮AI判断内容侧重与紧迫程度)',
examples: ['进入俄罗斯市场,需要一份中俄双语的公司介绍与能力声明,月底展会要用'],
},
},
},
messageAllowed: true,
messageHint: '内容运营可在此说明本轮生产的特殊要求、目标受众、或需要重点突出的内容侧重',
},
output: {
/* 单一必发口,无分支:I01 生产流程线性(①→②③→④→⑤),没有岔路口。 */
mandatory: [
{
name: 'content-published',
description:
'本轮已发布的内容资产 —— 供 E04/T03/T04/E03 分发调用;当前代码库尚未实现这些下游活动的读取消费者,此口暂无消费者,仅按"载荷=已落地事实"的契约纪律预先声明',
payload: {
type: 'object',
required: ['roundRef', 'publishedContentTotal'],
properties: {
roundRef: { type: 'string', title: '本轮案号' },
publishedContentTotal: { type: 'integer', title: '本轮已发布内容份数' },
},
},
},
],
selective: [],
},
/*
* 三份必交付物,逐一对应三张工单产出的成果。全部 `editableAtRelease: []`——
* 业务说明明文"验收工单只读展示,不可修改"(内容初稿与拟发布明细都是 AI 的结论,
* 人只做通过/驳回或签字,不在验收页上就地改)。
*/
requiredDeliverables: [
{
key: 'requirement-confirm',
label: '内容需求清单',
ledger: `${D}.requirement`,
ledgers: [`${D}.requirement`, `${D}.verdict`],
editableAtRelease: [],
},
{ key: 'draft-review', label: '内容初稿', ledger: 'content.asset', editableAtRelease: [] },
{ key: 'publish-confirm', label: '拟发布内容', ledger: 'content.asset', editableAtRelease: [] },
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:`_rows`/`_missing`/按值计数基座现数,
* 与执行卡的提交闸同一个数同一套算法。L4 三条是纯裁量(版本状态对不对、标签齐不齐、
* 多语言够不够)——机器数得出「有没有值」,数不出「对不对、够不够」,那是人对结果负责的地方。
*/
acceptanceCriteria: [
/*
* 两条 L1 的「人过了没有」一半靠记账旗(只有人回卡之后模型才记 —— 与 A1 的
* confirmed_total 同款,规范 §5.3-4 背书的人环凭据);但**每条都必须再钉一半
* 基座数**(2026-08-23 照 E04 的账整改):旗是模型写的,单独成谓词就是
* 「机检只读基座数」第 N 次违反的伪装形 —— E04 那单抢跑写旗、活单熔断,
* 正是踩着这半边过的门。
*/
{
key: 'requirement-confirmed',
label: '内容需求已经内容运营核对确认(执行工单已交回,清单有行且内容类型齐)',
level: 'L1',
predicate: `requirement_confirmed_total > 0 and ${D}_requirement_rows > 0 and ${D}_requirement_missing = 0`,
},
{
key: 'draft-approved',
label: '内容初稿已经 AM Lead 审核通过(验收工单1已过,不处在驳回待改状态)',
level: 'L1',
predicate: 'draft_approved_total > 0 and content_asset_rows > 0 and content_asset_missing = 0',
},
{
key: 'draft-complete',
label: '内容初稿逐份完整:标题、内容类型、语言版本、状态都有',
level: 'L2',
predicate: 'content_asset_rows > 0 and content_asset_missing = 0',
},
/*
* **资料文件真产出了没有** —— 两边都是基座现数:每一行都报了一种格式
* (四档计数之和 = 行数),而正文空着的行会被 `_missing` 数出来(body_md 是必填)。
* 从前这一步只产出一行标题,人点不开也下不来 —— 判据得能指向一个看得见的东西:
* 这一条为真,件上每个资料名都点得开。
*/
{
key: 'asset-file-ready',
label: '每份内容都产出了资料文件(格式已定、正文已写,件上点资料名即可打开)',
level: 'L2',
predicate:
'content_asset_rows > 0 and content_asset_missing = 0 and ' +
'content_asset_file_format_md + content_asset_file_format_html + ' +
'content_asset_file_format_docx + content_asset_file_format_xlsx = content_asset_rows',
},
{
key: 'publish-registered',
label: '拟发布内容已登记为已发布状态且逐项完整',
level: 'L2',
predicate: 'content_asset_content_status_published > 0 and content_asset_missing = 0',
},
{ key: 'version-status-correct', label: '版本号与状态是否正确:版本号已更新、状态流转无误', level: 'L4' },
{ key: 'scene-tag-complete', label: '场景与 BT 标签是否登记完整', level: 'L4' },
{ key: 'language-complete', label: '多语言版本是否齐全', level: 'L4' },
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'owner-release', label: '发布入库放行:签下去即正式生效并写入 L-05', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-fabricate-signal', statement: '市场信号读不到相关内容时不得编造 —— 如实基于已有资产与客户信息拟定' },
{ key: 'no-skip-review', statement: '内容状态流转不得跳步 —— draft → reviewed → published 每一步都要真的写过' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
],
authorization: {
discretion: [],
preauthorizedActions: [],
/* 基线三次(S2/S4通过/T_release)+ 决策卡循环预留两轮(每轮驳回+重提交各算一次,+4),留够余量 */
humanCallBudget: 10,
},
resources: {
tools: [],
skills: CONTENT_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['AM Lead'],
budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 10 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [],
ledger: [
/*
* ── L-01 宏观市场台账(kind:'master',I01 只读)──
* 首次完整声明:本仓库尚无其它活动读写它,不是追加列。
*/
{
domain: 'market',
object: 'signal',
kind: 'master',
label: '宏观市场台账',
fields: [
{ name: 'signal_id', type: 'text', primaryKey: true, masked: false, label: '信号ID' },
{
name: 'signal_type',
type: 'text',
primaryKey: false,
masked: false,
tally: ['macro_economy', 'industry_trend', 'competition', 'policy', 'customer_market'],
label: '信号类型',
},
{ name: 'title', type: 'text', primaryKey: false, masked: false, label: '标题' },
{ name: 'content', type: 'text', primaryKey: false, masked: false, label: '详情' },
{ name: 'source', type: 'text', primaryKey: false, masked: false, label: '信号来源' },
{ name: 'signal_date', type: 'date', primaryKey: false, masked: false, label: '信号日期' },
{ name: 'market_region', type: 'text', primaryKey: false, masked: false, label: '所属市场' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, label: '细分行业' },
{ name: 'chip_category', type: 'text', primaryKey: false, masked: false, label: '关联芯片品类' },
{ name: 'importance', type: 'integer', primaryKey: false, masked: false, label: '重要性评分(1-5)' },
{
name: 'impact_dir',
type: 'text',
primaryKey: false,
masked: false,
tally: ['opportunity', 'threat', 'neutral'],
label: '影响方向',
},
{
name: 'signal_status',
type: 'text',
primaryKey: false,
masked: false,
tally: ['pending', 'evaluated', 'archived', 'expired'],
label: '信号状态',
},
{ name: 'version', type: 'text', primaryKey: false, masked: false, label: '版本号' },
{ name: 'review_note', type: 'text', primaryKey: false, masked: false, label: '复盘备注' },
],
},
/*
* ── L-05 品牌内容库(kind:'master',I01 唯一读写者)──
* 首次完整声明并持有全部基本列(不是追加,`declarations.ledger` 里明写 required/expect,
* 因为没有别的活动会与 I01 在这张表上"谁先立谁的字段"打架)。
*/
{
domain: 'content',
object: 'asset',
kind: 'master',
label: '品牌内容库',
fields: [
{ name: 'content_id', type: 'text', primaryKey: true, masked: false, label: '内容ID' },
{ name: 'content_type', type: 'text', primaryKey: false, masked: false, required: true, label: '内容类型' },
{ name: 'title', type: 'text', primaryKey: false, masked: false, required: true, label: '内容标题' },
{
name: 'language',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '语言版本(多语言用" · "连接)',
},
{ name: 'version', type: 'text', primaryKey: false, masked: false, required: true, label: '版本号' },
/*
* **发布那一刻才必填**(2026-08-23):S5 的剧本明写「发布前场景必须登记」,
* 而声明只标 expect —— 机器数不出来,那句话就只能靠 L4 让人肉眼核。
* `requiredWhen` 把必填钉在**已发布**的那几行:草稿态不拦(那时还没定场景),
* 一旦状态流到 published,红带/提交闸/放行门 L2 三处同源同数地拦。
*/
{
name: 'scene',
type: 'text',
primaryKey: false,
masked: false,
expect: true,
requiredWhen: { column: 'content_status', value: 'published' },
label: '适用场景',
},
/*
* BT 标签 —— 目标件的验收工单1 就有这一列,而从前 `content.asset` 压根没有它:
* L4 判据「场景与 BT 标签是否登记完整」让人核一个卡面上不存在的东西
* (判据指向的东西必须摆得出来,E04 那一课)。与 `scene` 同款:发布那一刻才必填。
*
* **不带 tally** —— 与 `requirement.scene` 同一条教训(2026-08-21):
* tally 的取值域得是 ASCII(`data-class-of` 只认 ASCII),而这一列在件上是
* 普通格子、走 `data-slot` 直显,没有翻译层 —— 存 `bt_03` 人看到的就是 `bt_03`。
* 存人读值("BT-01"/"BT-02"/"BT-03"),屏上与目标件一字不差。
*/
{
name: 'bt_tag',
type: 'text',
primaryKey: false,
masked: false,
expect: true,
requiredWhen: { column: 'content_status', value: 'published' },
label: 'BT 层级标签',
},
{
name: 'content_status',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['draft', 'reviewed', 'published', 'deprecated'],
label: '内容状态',
},
{ name: 'brand_tone', type: 'text', primaryKey: false, masked: false, expect: true, label: '品牌调性标签' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, label: '定向客户版本' },
/*
* ── 一份内容 = 一份**资料文件**(2026-08-23 用户:「审核资料这一步要真实
* 产出资料文件,格式可以是 word/excel/md/html,点资料名能打开渲染」)──
*
* 从前只有一句 `file_ref: '文件位置'`,而 S3 的剧本明写「本 MVP 阶段可留空」:
* **这一步压根没产出过任何文件** —— 屏上一行标题,点不开、下不来,
* 人要审的那份初稿根本不在系统里。
*
* 现在正文落 `body_md`(Markdown 原文,AI 写的就是它),格式落 `file_format`,
* 基座按 `file` 段现造成真字节(见 `apps/api/src/app/file-compose.ts`):
* .docx 用 Word 打得开、.xlsx 用 Excel 打得开,屏上渲的就是那份字节读回来的样子。
*
* 为什么正文只写一种(Markdown)而不是四种各写一遍:四份就是四份事实,
* 改一处就分叉。**写一份,按格式投影** —— 与「文件是台账的投影」同一条。
*/
{
name: 'body_md',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '内容正文(Markdown 原文)',
},
{
name: 'file_format',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['md', 'html', 'docx', 'xlsx'],
label: '资料文件格式',
},
{ name: 'file_ref', type: 'text', primaryKey: false, masked: false, label: '文件位置' },
{ name: 'reviewer', type: 'text', primaryKey: false, masked: false, label: '审核人' },
],
/**
* 这张表的每一行还是**一份资料文件**:标题、正文、格式各取哪一列。
* 这一段同时是授权面 —— 不声明它,件上的资料名就点不开(基座 403)。
*/
file: { titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' },
},
/* ── I01 自有工作台账(kind:'record',caseKey:'round_ref') ── */
{
domain: D,
object: 'requirement',
kind: 'record',
caseKey: 'round_ref',
label: '内容需求清单',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '案号' },
{ name: 'req_seq', type: 'integer', primaryKey: true, masked: false, label: '本轮需求序号' },
{ name: 'content_type', type: 'text', primaryKey: false, masked: false, required: true, label: '内容类型' },
{ name: 'source', type: 'text', primaryKey: false, masked: false, expect: true, label: '需求来源' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, expect: true, label: '目标客户' },
/*
* **不带 tally**(2026-08-21 实测逼出的修死):这一列是编辑面上的普通格子
* (`REQUIREMENT_TABLE` 里就是一个 `<span data-col>`,不是 `data-class-of` 徽标),
* tally 过的 ASCII 码没有翻译层就直接摆上屏——实测人看见的是裸的 "solution",
* 不是"方案"。`content.asset.scene`(S3 照抄这一列)本来就是自由文本,两处
* 现在口径一致:AI 写人读标签(首触/方案/攻坚/展会/报价),不写 ASCII 码。
*/
{ name: 'scene', type: 'text', primaryKey: false, masked: false, expect: true, label: '适用场景' },
{
name: 'priority',
type: 'text',
primaryKey: false,
masked: false,
tally: ['high', 'medium', 'low'],
label: '优先级',
},
{ name: 'origin', type: 'text', primaryKey: false, masked: false, label: '来源(ai/human-added)' },
],
},
{
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'round_ref',
label: '本轮内容生产结论',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '案号' },
{ name: 'trigger', type: 'text', primaryKey: false, masked: false, label: '触发方式(人读标签,非入参英文码)' },
{ name: 'trigger_detail', type: 'text', primaryKey: false, masked: false, label: '触发细节(人读标签)' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, label: '定向客户' },
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '本轮说明' },
{ name: 'requirement_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '需求已确认' },
{ name: 'draft_approved_total', type: 'integer', primaryKey: false, masked: false, label: '初稿已审核通过' },
{ name: 'publish_prepared_total', type: 'integer', primaryKey: false, masked: false, label: '发布准备已收口' },
{ name: 'content_produced_total', type: 'integer', primaryKey: false, masked: false, label: '本轮产出内容份数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '本轮生产结论' },
],
},
],
listeners: [],
harness: {
memoryWindow: 24,
/* 内容生产不是紧迫单,给够时间让人审核 */
stallAfter: 'PT4H',
nudge: [{ after: 'PT2H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: EMPTY_DMN,
steps: CONTENT_STEPS,
phases: CONTENT_PHASES,
},
skills: CONTENT_SKILLS,
/*
* 三个局面各一张:
* · `requirement-list` —— S1/S2 摆,内容需求清单,可编辑;
* · `draft-review` —— S3/S4 摆,内容初稿,只读(业务说明明文"人只审不改");
* · `publish-confirm` —— S5/T_release 摆,拟发布内容,只读,放行后即归档件。
*/
viewTemplates: [
{ name: 'requirement-list', html: REQUIREMENT_LIST },
{ name: 'draft-review', html: DRAFT_REVIEW },
{ name: 'publish-confirm', html: PUBLISH_CONFIRM },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本轮内容生产:需求识别依据、审核是否被驳回过(为什么)、市场依据来自哪些信号、发布的内容覆盖哪些场景与语言。',
},
],
},
/*
* ── 样例集 —— 三条形状真不同 ────────────────────────────────
*
* **第一条必须是满载的那条**(2026-08-23,与 E04 同一课):测试台落到一个活动上
* 就自动铺 `samples[0]`(activity-run.tsx 的 useEffect),第一条就是人打开这一屏
* 看见的那份入参。从前第一条是月度更新、只有两个键,表单大半是空的,看着像没预填。
* **样例名一律 ≤14 字**:超了测试台的样例钮只显示「样例 N」,那等于没名字。
*/
samples: [
{
name: 'E04 请求定向素材',
input: {
trigger: 'reactive',
requester: 'E04',
targetCustomer: 'CU-0142',
note: 'E04 关系覆盖与关键人经营需要一份面向 Itelma 的价值传递素材,用于下轮拜访',
},
expect: { ports: ['content-published'] },
},
{
name: '月度定期内容更新',
input: {
trigger: 'proactive',
reason: 'monthly_update',
note: '八月例行内容刷新:优先看版本最旧、最久未更新的那几份工具包',
},
expect: { ports: ['content-published'] },
},
{
name: '进入新市场 · 俄罗斯',
input: {
trigger: 'proactive',
reason: 'new_market',
note: '公司战略进入俄罗斯市场,需要一份中俄双语的公司介绍与能力声明,月底展会要用',
},
expect: { ports: ['content-published'] },
},
/*
* **一轮里三种形态各要一份** —— 这一条样例是为「资料文件」那一环常备的
* (2026-08-23):前三条要的都是成篇的文档,模型一律挑 docx 并不算错,
* 于是表格型与网页型那两条路平时走不到。这一条把三种形态摆在同一封需求里:
* 对照表是表(xlsx)、落地页是网页(html)、销售速记是短要点(md)。
*/
{
name: '对照表与落地页',
input: {
trigger: 'reactive',
requester: 'FAE 华东',
note:
'客户要一份 TI 车规 MCU 的替代料参数对照表(逐型号列封装/温度/认证/交期,做成表格发过去);' +
'同时官网要上一版车规 MCU 能力的落地页文案(网页直接看);' +
'再给销售一页随身速记,三五条要点即可。',
},
expect: { ports: ['content-published'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-brand-content-toolkit' },
};
@@ -0,0 +1,80 @@
/**
* I01 技能 —— 品牌内容生产的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const CONTENT_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('brand-content.requirement-mapping'),
label: '触发事件映射到内容需求',
applicability: '识别本轮内容需求之前,要把触发事件(主动/被动)翻成具体该产什么。',
method: [
'主动触发按 `reason` 映射:`strategy_shift`/`new_market` 多半对应通用类内容',
'(公司介绍、能力声明——面向新市场或新战略方向,受众是还不认识我们的人);',
'`tool_outdated` 对应刷新既有工具包(读 `content.asset` 找版本号最旧的那份);',
'`monthly_update` 对应例行内容盘点,不是逢月必产新内容——先看有没有真正过期的。',
'',
'被动触发按 `requester` 映射:E04(关系覆盖)要的是「价值传递素材」,多半是系统方案',
'白皮书或案例集,常常带 `targetCustomer`(定向版);T03/T04/E03 目前本仓库未实现,',
'只是描述来源,产出物按它们的业务含义(触达材料/攻坚定制内容/方案支撑)对应到',
'内容类型即可,不必真的调用它们。',
'',
'同一轮识别出的多条需求不必是同一类型 —— 一次月度更新可能同时该刷新公司介绍',
'和某个过期的选型指南,分开列成两条需求,各自的优先级、目标客户可能完全不同。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['触发事件 × 内容类型映射表'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('brand-content.bt-structure'),
label: '按 BT 层级套内容结构、维护多语言',
applicability: '市场依据采集完、要草拟内容初稿时。',
method: [
'内容结构按决策链层级走,不是一刀切:BT-01(采购/供应链)要的是「能不能稳定供货」',
'——供货能力、资质文件、渠道政策;BT-02(技术层)要的是「参数对不对得上」——',
'技术规格书、替代对照表、认证报告;BT-03(高层)要的是「值不值得投入」——',
'系统方案白皮书、参考设计、成功案例。给错层级的内容(拿技术规格书糊弄高层)',
'传递不出该传递的价值,人审核这一步会驳回。',
'',
'多语言不是全都要三语俱全:中文必备;涉及俄罗斯市场或俄语区客户的内容配俄语;',
'其余按需求背景判断——面向国际展会或英语区客户才配英语,通用内部资料未必需要。',
'语言版本写进同一行的 `language` 列(如"中文 · Русский"),不是同一份内容开多行,',
'否则发布确认那一步的验收判据③(多语言版本是否齐全)没法在一行里核对齐不齐。',
'',
'市场依据读不到相关信号是正常结果,不是异常——不是每条需求都有现成的产业趋势',
'支持,读不到就基于已有资产与客户信息拟定,报告里如实说依据来自哪里,不编一条',
'不存在的市场信号撑场面。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['BT 层级内容结构', '多语言版本策略'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('brand-content.publish-flow'),
label: '版本受控、状态流转不跳步',
applicability: '审核通过之后,要把内容拟发布、准备交人签字放行时。',
method: [
'状态流转是台阶,不是开关:`draft`(草稿)→`reviewed`(已审核)→`published`(已发布),',
'每一步都要真的走过——审核通过那一刻先落 `reviewed`,拟发布那一步才落 `published`,',
'不要图省事在审核通过的同一拍就直接写成 `published`(那样发布确认那一步的判据①',
'核不出「状态流转无误」,因为 `reviewed` 这一站压根没被记录过)。',
'',
'版本号只在真正定稿时才递增到 v1.0——v0.x 是草稿阶段的内部编号,不对外。',
'同一份内容之后若再修订(如客户反馈需要调整),版本号在已发布版本基础上加小版本',
'(v1.0 → v1.1),不要退回 v0.x 或另起 content_id——同一份内容资产的版本历史',
'要能连起来看,另起编号等于假装这是一份全新的内容。',
'',
'场景与 BT 标签是发布前必须补齐的两列,不是可选的元数据——它们是 E04/T03/T04',
'将来按场景/BT 层级匹配调用这份内容时唯一的检索依据,缺了这两列,发布出去的内容',
'实际上没人找得到、等于白产。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['内容状态机', '版本号递增规则'],
version: '1.0.0',
},
];
@@ -0,0 +1,163 @@
/**
* I01 品牌形象与销售工具包管理 · 视觉底(**I01 自己的 PRD 目标件**)。
*
* `docs/SS01-客户与市场/i01-html/` 下的三份 HTML(执行工单 1、验收工单 1/2)逐字量出。
* 颜色变量、卡头渐变、判据框、表格排版与 E04(`crm-relationship-coverage`)的目标件
* **逐字节相同**——两份目标件本就是同一套设计语言产出的(SS01 客户与市场同一批交付),
* 不是巧合也不是抄近路;`activities/*` 只准 import `@sdk`/`@contracts`,两个活动包之间
* 没有代码通道,只能各自持有一份(与 E04 那份原是 A1 家族版面拷贝、后改为自有目标件同理)。
*
* 版面照目标件分两层:这份文件是**底**(配色变量、卡壳、头/尾、判据框、说明框、表格基础排版),
* `report.ts` 里的 `BIND_CSS` 是**面**(哪个值决定哪种颜色/文案——三档占位、缺项红带、
* 状态徽、编辑闸开合、行增删这些"数据驱动的开关",判断在样式表,取值在模版,两处不许混)。
*/
export const PRD_CSS = `
.proto-root {
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4;
--danger:#d83931; --danger-soft:#feeae9;
--warn-accent:#a9711e; --warn-soft:#f6ecd8;
--mono: "SF Mono", Menlo, Consolas, monospace;
--t-fast: .16s;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
font-size: 14px;
line-height: 1.7;
}
.proto-root, .proto-root * { box-sizing: border-box; }
/* ==================== 卡壳(嵌进工作区,不自带浮起——外层已经是一张卡)==================== */
.proto-root .jc-report { background: #fff; overflow: hidden; }
.proto-root .jc-rp-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 2px solid var(--ink); }
.proto-root .jc-report.tone-human .jc-rp-head { background: linear-gradient(120deg, #fffaf1 0%, #fff 55%, #fbf6ea 100%); }
.proto-root .jc-report.tone-ledger .jc-rp-head { background: linear-gradient(120deg, #fdf3f1 0%, #fff 55%, #fbf2f0 100%); }
.proto-root .jc-rp-badge {
flex: none; width: 44px; height: 44px; border-radius: 10px;
display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px;
}
.proto-root .jc-report.tone-human .jc-rp-badge { background: var(--human); box-shadow: 0 6px 16px rgba(169,113,30,.25); }
.proto-root .jc-report.tone-ledger .jc-rp-badge { background: var(--ledger); box-shadow: 0 6px 16px rgba(178,58,46,.25); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { font-style: normal; font-size: 11px; letter-spacing: .08em; color: var(--ink-4); font-weight: 700; }
.proto-root .jc-rp-tit h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; line-height: 1.3; }
.proto-root .jc-rp-sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; }
.proto-root .jc-rp-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
/* 目标件 side 第二行:步骤(静态文案),同目标件 .step 的字号字色 */
.proto-root .jc-rp-step { font-size: 12px; color: var(--ink-3); }
/* 关联任务号:全长 UUID 如实给,排版上截尾 —— 截断是排版,不是数据 */
.proto-root .jc-rp-taskid {
display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: 11px;
}
.proto-root .jc-rp-tag { font-size: 11px; font-weight: 800; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
.proto-root .jc-report.tone-human .jc-rp-tag { color: var(--human); background: var(--human-soft); border-color: var(--human-line); }
.proto-root .jc-report.tone-ledger .jc-rp-tag { color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line); }
.proto-root .jc-rp-wait { font-size: 12px; color: var(--ink-3); }
.proto-root .jc-rp-wait.warn { color: var(--danger); font-weight: 700; }
.proto-root .jc-rp-bd { padding: 20px 22px; }
/* 【做什么】/【交回什么】这类分区小标——括号label + 破折号说明,同一行 */
.proto-root .jc-rp-part { font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-4); margin: 20px 0 8px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part b { color: var(--ink-3); }
/* AI 已完成 / 需要你 —— 说明框,左侧色条按 tone 走 */
.proto-root .jc-derive { border: 1px solid var(--line); border-left: 3px solid; border-radius: 8px; background: var(--soft); padding: 14px 16px; margin-bottom: 16px; }
.proto-root .jc-report.tone-human .jc-derive { border-left-color: var(--human); }
.proto-root .jc-report.tone-ledger .jc-derive { border-left-color: var(--ledger); }
.proto-root .jc-derive p { margin: 0; font-size: 13px; color: var(--ink-2); }
.proto-root .jc-derive p + p { margin-top: 6px; }
.proto-root .jc-derive b { color: var(--ink); }
.proto-root .jc-derive .done { color: var(--ok); font-weight: 700; }
.proto-root .jc-report.tone-human .jc-derive .need { color: var(--human); font-weight: 700; }
.proto-root .jc-report.tone-ledger .jc-derive .need { color: var(--ledger); font-weight: 700; }
/* 验收判据框——目标件里两份验收工单同一色(赭红),不随 tone 变 */
.proto-root .jc-criteria { border: 1px solid var(--ledger-line); border-radius: 10px; background: var(--ledger-soft); padding: 13px 16px; margin-bottom: 18px; }
.proto-root .jc-criteria-t { font-size: 11px; font-weight: 800; letter-spacing: .08em; color: var(--ledger); margin-bottom: 7px; }
.proto-root .jc-criteria ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.proto-root .jc-criteria li { margin: 4px 0; }
.proto-root .jc-criteria b { color: var(--ink); }
/* 表格基础排版(交互态的显隐开关在 BIND_CSS) */
/*
* **表宽超出就得能横着滑**(2026-08-23 用户:「表格已经显示不全了,不能左右滑动」)。
* 从前这里是 overflow: hidden —— 那是为了让圆角裁住表头,可它同时把超出的列
* **直接裁掉且滑不动**:列一多(或某列为了放下下拉框加宽)就看不全,人还没有任何办法。
* quote-* 各包一直是 overflow-x: auto,这两个新包抄版式时把这一条抄丢了。
* (注释里不写反引号 —— 整段 CSS 是模版字符串,一个反引号就把它截断,本轮踩第三次。)
* 竖向仍 hidden:件是整篇铺开跟着外层滚的,表格自己不该再套一层竖滚动条。
*/
.proto-root .jc-tbl-wrap { border: 1px solid var(--line-soft); border-radius: 10px; overflow-x: auto; overflow-y: hidden; margin-bottom: 4px; }
/*
* 资料名 = 可点开的那一格(2026-08-23)。**看得出能点**才会有人点:
* 品牌蓝 + 下划虚线 + 手型;悬停实线。点了去哪由外层接(件里一行脚本也没有)。
*/
.proto-root .jc-fileln { color: var(--brand); border-bottom: 1px dashed var(--brand-soft); cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-fileln:hover { border-bottom-style: solid; background: var(--brand-wash); }
/* 空标题不该长得像能点开 —— 那一格根本没有资料 */
.proto-root .jc-fileln:empty { border-bottom: 0; cursor: default; background: none; }
/* 格式徽:摆的是这份资料交出去时的样子(md / html / docx / xlsx) */
.proto-root .jc-fmt { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 19px;
padding: 1px 7px; border-radius: 5px; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
background: var(--soft); color: var(--ink-3); border: 1px solid var(--line); }
.proto-root .jc-fmt.v-docx { background: #eaf1ff; color: #2b5fd9; border-color: #c3d6ff; }
.proto-root .jc-fmt.v-xlsx { background: #e9f7ee; color: #1f7a45; border-color: #bfe6cd; }
.proto-root .jc-fmt.v-html { background: #fdf0e6; color: #b25e15; border-color: #f3d3b4; }
.proto-root .jc-fmt.v-md { background: #f2f3f5; color: #5a6068; border-color: #dfe1e5; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: collapse; font-size: 13px; }
.proto-root .jc-tbl-in th { text-align: left; background: var(--soft); font-size: 11px; letter-spacing: .04em; font-weight: 800; color: var(--ink-3); padding: 9px 11px; border-bottom: 1px solid var(--line); }
.proto-root .jc-tbl-in th.req::after { content: " *"; color: var(--danger); font-size: 11px; }
.proto-root .jc-tbl-in th.sug::after { content: " 建议"; color: var(--warn-accent); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in th.opt::after { content: " 选填"; color: var(--ink-4); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in td { padding: 9px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in td.jc-rn { width: 38px; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--ink-4); text-align: center; position: relative; }
.proto-root .jc-vcell { display: block; font-size: 12.5px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vcell.cust { font-weight: 700; }
.proto-root .jc-vcell.dim { color: var(--ink-4); text-decoration: line-through; }
.proto-root .jc-diffnew { font-weight: 800; color: var(--ok); }
.proto-root .jc-cell, .proto-root select.jc-cell, .proto-root textarea.jc-cell {
width: 100%; min-width: 60px; border: 1px solid var(--line); border-radius: 6px;
padding: 6px 8px; font: inherit; font-size: 12.5px; color: var(--ink); background: #fff;
}
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-rowdel {
width: 22px; height: 22px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 14px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-addrow em { font-style: normal; font-size: 11.5px; color: var(--ink-4); }
.proto-root .jc-addrow button {
cursor: pointer; border: 1px solid var(--brand-soft); border-radius: 7px; padding: 5px 12px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--brand-deep); background: var(--brand-wash);
}
.proto-root .jc-addrow button:hover { background: var(--brand-soft); }
/* 本工单只承载内容,提交/审批请在对话区——目标件三份文件都有这一条 */
.proto-root .jc-flow { margin-top: 16px; padding: 9px 12px; border-radius: 8px; background: var(--soft); border: 1px dashed var(--line); font-size: 12px; color: var(--ink-3); }
.proto-root .jc-flow b { color: var(--ink); }
.proto-root .jc-rp-foot { padding: 13px 22px; border-top: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-4); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.proto-root .jc-rp-foot span:last-child { font-family: var(--mono); }
`;
@@ -0,0 +1,387 @@
/**
* I01 品牌形象与销售工具包管理的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 三个局面各一张:
* · `requirement-list` —— S1/S2 摆,内容需求清单,可编辑(增删改);
* · `draft-review` —— S3/S4 摆,内容初稿,**只读**(业务说明明文"人只审不改" ——
* 通过/驳回的按钮在对话区的决策卡上,不在这份件里);
* · `publish-confirm` —— S5/T_release 摆,拟发布内容,**只读**,放行后即归档件。
*
* 版面语言逐字量自 I01 自己的目标件(`docs/SS01-客户与市场/i01-html/` 三份 HTML,
* `prd-style.ts` 头注写了它与 E04 目标件"逐字节相同"的原因)。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:content_brand.<object>` 或
* `mine:content.asset`;列名逐字照 `declarations.ledger` 抄。**模版不算不判** ——
* 缺必填红带、状态徽、排序位次全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { PRD_CSS } from './prd-style.ts';
const D = 'content_brand';
/* ==================== 绑定期补的样式(数据驱动的开关,判断都在这一层)==================== */
const BIND_CSS = `
/* 空占位三级(全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件 / 只读件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺必填的整行浅红 + 行首红条 —— 由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区,卡壳不再自带外边框(外层容器已经是一张卡) */
.jc-stage { max-width: none; padding: 0 0 4px; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 14px 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 12px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 6px 13px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--human); border-color: var(--human); }
.proto-root .jc-egbtn.save:hover { filter: brightness(1.06); color: #fff; }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--human-soft); border-color: var(--human-line); color: #7c4a12; }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺必填红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px dashed var(--brand-soft); color: var(--ink-3);
font-size: 12px; line-height: 1.6;
}
.proto-root .jc-editbar b { color: var(--ink-2); }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel { display: block; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
/*
* 统一的枚举标签(优先级 / 内容状态 共用同一套机制):值是机器码,人读名与颜色一律由
* data-class-of 投影出的 .v-xxx 决定,不写死在取值那一侧。
*/
.proto-root .jc-tag { font-weight: 800; }
.proto-root .jc-tag::after { content: "待定"; color: var(--ink-4); font-weight: 600; }
.proto-root .jc-tag.v-high::after { content: "高"; }
.proto-root .jc-tag.v-high { color: var(--danger); font-family: var(--mono); }
.proto-root .jc-tag.v-medium::after { content: "中"; }
.proto-root .jc-tag.v-medium { color: var(--ink-2); font-family: var(--mono); }
.proto-root .jc-tag.v-low::after { content: "低"; }
.proto-root .jc-tag.v-low { color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-tag.v-draft::after { content: "草稿"; }
.proto-root .jc-tag.v-draft { color: var(--ink-3); }
.proto-root .jc-tag.v-reviewed::after { content: "已审核"; }
.proto-root .jc-tag.v-reviewed { color: var(--warn-accent); }
.proto-root .jc-tag.v-published::after { content: "已发布"; }
.proto-root .jc-tag.v-published { color: var(--ok); }
.proto-root .jc-tag.v-deprecated::after { content: "已废弃"; }
.proto-root .jc-tag.v-deprecated { color: var(--ink-4); text-decoration: line-through; }
`;
/** 三层壳照目标件:少一层样式表全失效 */
/**
* 三层壳照目标件。卡头/页脚的共用动态位照说明书 §6.2 补齐(2026-08-23):
* 收件人(case:owner)、**关联任务**(case:taskId,基座注入)、**步骤**(step,
* 每份工单盖哪几步是写清单时定死的静态文案)、页脚**单号**(round_ref)。
* 目标件还有一格「最迟交回」—— 本仓没有工单期限这个事实,**不摆假数据**,那一格不做
* (与 E04 同一口径)。
*/
const doc = (input: {
tone: 'human' | 'ledger';
badge: string;
tag: string;
kicker: string;
title: string;
titleRef: string;
/** 目标件 side 第二行:静态步骤文案 */
step: string;
state: string;
body: string;
foot: string;
}): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report tone-${input.tone}">
<header class="jc-rp-head">
<div class="jc-rp-badge">${input.badge}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <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>
<div class="jc-rp-side">
<span class="jc-rp-tag">${input.tag}</span>
<span class="jc-rp-step">${input.step}</span>
${input.state}
</div>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = ''): string =>
`<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
`<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */
const sign = (note: string, tag: string): string =>
`<span>${note}</span><span>${tag} · <span data-slot="round_ref" data-blank></span></span>`;
/** 编辑闸 —— `label` 是闸上那句话(照目标件:「AI 已识别 N 个内容需求,点「编辑」增删改」) */
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${label}</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
/* ==================== 局面一 · 内容需求清单(S1/S2)==================== */
const REQUIREMENT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:56px;min-width:56px">序</th>
<th class="req" style="min-width:150px">内容类型</th>
<th class="sug" style="min-width:170px">需求来源</th>
<th class="sug" style="min-width:120px">目标客户</th>
<th class="sug" style="min-width:100px">适用场景</th>
<th style="width:80px;min-width:80px">优先级</th>
</tr></thead>
<tbody data-rows="mine:${D}.requirement" data-edit="${D}.requirement"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.requirement" title="撤掉这一行">×</button></td>
<td data-h="序"><span class="jc-vcell mono" data-slot="req_seq" data-col="req_seq" data-blank></span></td>
<td class="req" data-h="内容类型"><span class="jc-vcell" data-slot="content_type" data-col="content_type" data-blank></span></td>
<td class="sug" data-h="需求来源"><span class="jc-vcell" data-slot="source" data-col="source" data-blank></span></td>
<td class="sug" data-h="目标客户"><span class="jc-vcell" data-slot="target_customer" data-col="target_customer" data-blank></span></td>
<td class="sug" data-h="适用场景"><span class="jc-vcell" data-slot="scene" data-col="scene" data-blank></span></td>
<td data-h="优先级"><span class="jc-tag" data-class-of="priority"></span></td>
</tr></tbody>
</table></div>`;
export const REQUIREMENT_LIST = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'I01 品牌形象与销售工具包管理 · 执行工单 1/3',
title: '需求受理与信息补充',
titleRef: 'round_ref',
step: '步骤 ① 需求识别与受理',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_requirement_rows"></b> 条需求</span>`,
body:
part('做什么', '确认本轮要生产的内容') +
`<div class="jc-derive">
<p><span class="done">AI 已完成</span>:按规则识别本轮内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)与
需求类型(公司介绍 / 能力声明 / 销售工具包 / 案例集 / 白皮书等),产出下方需求清单(候选基线)。</p>
<p class="jc-note-line">本轮触发:<b data-slot="trigger" data-blank></b> <b data-slot="trigger_detail" data-blank></b></p>
<p class="jc-note-line">本轮说明:<span data-slot="note" data-blank></span></p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」在清单上增删改 —— <b>加一行 = 补充新需求</b>、
<b>删一行 = 排除</b>、<b>改单元格 = 修正</b>;尤其要补「目标客户、适用场景、优先级」这些 AI 规则之外的信息。</p>
</div>` +
part('交回什么', '核对后的内容需求清单(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_requirement_missing">
<span><b>还差 <span data-slot="${D}_requirement_missing"></span> 行内容类型没填</b>
—— <span data-slot="${D}_requirement_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.requirement`,
`<b>AI 已识别 <span data-slot="${D}_requirement_rows"></span> 个内容需求</b>,点「编辑」增删改`,
) +
`<div class="jc-editbar">
<span>加一行 = 补充新需求,删一行 = 排除,改单元格 = 修正 AI 识别的内容。
<b>内容类型</b>是必填,缺一条这一单走不到内容创作那一步。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
REQUIREMENT_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${D}.requirement">+ 加一行(补充新需求)</button>
<em>新加行的「内容类型」为必填;「目标客户」填定向客户,通用内容可留空;<b>序号留空即可</b> —— 系统按本轮现有最大值往下续。</em>
</div>` +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'),
foot: sign('I01 品牌形象与销售工具包管理 · 执行工单 1/3 · 需求受理', '工单类型:执行'),
});
/* ==================== 局面二 · 内容初稿(S3/S4,只读)==================== */
/*
* **资料名点得开**(2026-08-23 用户定):标题那一格挂 `data-file-of="content_id"`,
* 渲染时换成 `data-file="content.asset/<内容ID>"` —— 点它,工作区就跳到那一份资料的
* 查看面(基座按台账那一行现造成 docx / xlsx / md / html 再读回来渲)。
* 这份件仍然只做取值:「点了去哪」不在 HTML 里,它只说清「这一格是哪一份资料」。
*
* 格式一列摆在标题右边 —— 人点之前就知道点开的是张表还是一份文档。
*/
const DRAFT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:200px">内容标题</th>
<th style="width:70px;min-width:70px">资料</th>
<th style="min-width:130px">内容类型</th>
<th style="width:120px;min-width:120px">语言版本</th>
<th style="width:80px;min-width:80px">BT 标签</th>
<th style="width:110px;min-width:110px">品牌调性</th>
<th style="width:90px;min-width:90px">状态</th>
</tr></thead>
<tbody data-rows="mine:content.asset">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="内容标题"><span class="jc-vcell cust jc-fileln" data-file-of="content_id" data-slot="title" data-blank></span></td>
<td data-h="资料"><span class="jc-fmt" data-class-of="file_format" data-slot="file_format" data-blank></span></td>
<td class="dimempty" data-h="内容类型"><span class="jc-vcell" data-slot="content_type" data-blank></span></td>
<td class="dimempty" data-h="语言版本"><span class="jc-vcell mono" data-slot="language" data-blank></span></td>
<td class="dimempty" data-h="BT 标签"><span class="jc-vcell mono" data-slot="bt_tag" data-blank></span></td>
<td class="dimempty" data-h="品牌调性"><span class="jc-vcell mono" data-slot="brand_tone" data-blank></span></td>
<td data-h="状态"><span class="jc-tag" data-class-of="content_status"></span></td>
</tr>
</tbody>
</table></div>`;
export const DRAFT_REVIEW = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 2/3',
title: '内容初稿审核',
titleRef: 'round_ref',
step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="content_asset_rows"></b> 份初稿</span>`,
body:
part('AI 结论说明', '结论是怎么算出来的') +
`<div class="jc-derive">
<p><b>AI 已完成的计算</b>:读取 L-01 宏观市场信号(产业趋势、市场依据),按 BT 层级草拟内容初稿——
BT-01 侧重供货能力 / 资质文件、BT-02 侧重技术规格书 / 替代对照表 / 认证报告、
BT-03 侧重系统方案白皮书 / 参考设计 / 成功案例;维护多语言版本(中 / 英 / 俄)。</p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这份内容初稿是否合格,请逐项核对', [
'<b>品牌调性是否一致</b>:是否符合品牌"气质"标准(专业 / 可信赖 / 高效 / 本土化);',
'<b>技术内容是否准确</b>:规格、数据、认证信息有无错误;',
'<b>合规与多语言校对</b>:合规检查、多语言校对是否通过。',
]) +
part('AI 产出的内容初稿', '只读展示') +
DRAFT_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后进入发布,驳回由 AI 重做。'),
foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 2/3 · 内容初稿审核', '工单类型:验收'),
});
/* ==================== 局面三 · 拟发布内容(S5/T_release,只读)==================== */
const PUBLISH_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:110px">内容 ID</th>
<th style="min-width:200px">标题</th>
<th style="width:120px;min-width:120px">语言版本</th>
<th style="width:80px;min-width:80px">版本号</th>
<th style="min-width:100px">适用场景</th>
<th style="width:80px;min-width:80px">BT 标签</th>
<th style="min-width:150px">状态流转</th>
</tr></thead>
<tbody data-rows="mine:content.asset">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="内容 ID"><span class="jc-vcell mono" data-slot="content_id" data-blank></span></td>
<td class="dimempty" data-h="标题"><span class="jc-vcell cust jc-fileln" data-file-of="content_id" data-slot="title" data-blank></span></td>
<td class="dimempty" data-h="语言版本"><span class="jc-vcell mono" data-slot="language" data-blank></span></td>
<td class="dimempty" data-h="版本号"><span class="jc-vcell mono" data-slot="version" data-blank></span></td>
<td class="dimempty" data-h="适用场景"><span class="jc-vcell" data-slot="scene" data-blank></span></td>
<td class="dimempty" data-h="BT 标签"><span class="jc-vcell mono" data-slot="bt_tag" data-blank></span></td>
<td data-h="状态流转"><span class="jc-vcell dim">已审核</span> → <span class="jc-tag" data-class-of="content_status"></span></td>
</tr>
</tbody>
</table></div>`;
export const PUBLISH_CONFIRM = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 3/3',
title: '发布确认',
titleRef: 'round_ref',
step: '步骤 ⑤ 发布入库',
state: `<span class="jc-rp-wait">共 <b data-slot="content_asset_content_status_published"></b> 份拟发布</span>`,
body:
part('AI 结论说明', 'AI 做了什么') +
`<div class="jc-derive">
<p><b>AI 已完成</b>:内容初稿通过审核后,拟正式发布 —— 更新版本号、状态流转(待审核 → 已审核 → 已发布)、
登记适用场景与品牌调性标签,写入 L-05 品牌内容库。本轮产出:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次发布是否无误,请逐项核对', [
'<b>版本号与状态是否正确</b>:版本号已更新、状态流转(草稿 → 已审核 → 已发布)无误;',
'<b>场景与 BT 标签是否登记完整</b>;',
'<b>多语言版本是否齐全</b>。',
]) +
part('拟发布的内容', '只读展示') +
PUBLISH_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。通过即落账写入 L-05,本次内容生产结束。'),
foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 3/3 · 发布确认', '工单类型:验收 → 完结'),
});
@@ -0,0 +1,538 @@
/**
* E04 关系覆盖与关键人经营的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有悬空监听(E04 本轮没有静态监听声明)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(2026-08-20 实测栽过):
* 基座按 `used >= cap` 判耗尽,若 cap 恰等于完成放行所需的调人数,「裁决:pass」
* 那一次回复本身就把 used 顶到 cap,harnessCheck 在 land() 之前就判 exhausted ——
* 验收卡显示全部达标、人也点了通过,任务却终态 exhausted,草稿从没合并进正式版。
* 基线四次(S2/S4通过/S5/T_release)+ 两轮决策卡循环(每轮 +2)= 8,留够余量。
*/
it('人环预算留够余量:基线四次 + 两轮决策卡循环仍在 cap 之内', () => {
const baseline = 4;
const twoRejectRounds = baseline + 4;
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
});
it('起单只必填 trigger,eventType/customers/note 都可选 —— 两级下拉+客户数组的形状', () => {
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
const properties = manifest.contract.input.data['properties'] as Record<
string,
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
>;
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
expect(properties['trigger']?.enum).toEqual(['monthly', 'event']);
expect(properties['eventType']?.enum).toEqual(['new_customer', 'key_person_change', 'major_deal', 't04_project']);
// eventType 只在 trigger:'event' 时该露面 —— 测试台靠这个字段做级联下拉
expect(properties['eventType']?.showWhen).toEqual({ field: 'trigger', equals: 'event' });
});
it('五条常备样例形状真不同:月度例行 1 条 + 事件驱动四种事件类型各 1 条', () => {
expect(manifest.samples.length).toBe(5);
const [majorDeal, monthly, keyPerson, newCustomer, t04] = manifest.samples;
type Input = { trigger?: string; eventType?: string; customers?: unknown[]; note?: string };
expect((monthly?.input as Input).trigger).toBe('monthly');
expect((monthly?.input as Input).customers).toBeUndefined();
expect((majorDeal?.input as Input).eventType).toBe('major_deal');
expect(typeof (majorDeal?.input as Input).customers?.[0]).toBe('string');
expect((keyPerson?.input as Input).eventType).toBe('key_person_change');
expect((keyPerson?.input as Input).customers).not.toEqual((majorDeal?.input as Input).customers);
// 新客户样例:customers 里是对象(新建 L-02 档案的字段),不是客户编码字符串
expect((newCustomer?.input as Input).eventType).toBe('new_customer');
const newCustomerItem = (newCustomer?.input as Input).customers?.[0] as { name?: string } | undefined;
expect(typeof newCustomerItem).toBe('object');
expect(newCustomerItem?.name).toBeDefined();
// 攻坚项目(T04)立项:四种事件类型里最后一种,点名已有客户(字符串形状)
expect((t04?.input as Input).eventType).toBe('t04_project');
expect((t04?.input as Input).customers).toEqual(['CU-0142']);
// 四种事件类型两两不同,凑齐 enum 的全部取值
const eventTypes = new Set([majorDeal, keyPerson, newCustomer, t04].map((s) => (s?.input as Input).eventType));
expect(eventTypes).toEqual(new Set(['major_deal', 'key_person_change', 'new_customer', 't04_project']));
for (const s of manifest.samples) expect(s.expect.ports).toEqual(['coverage-map']);
});
/*
* **落到这个活动上就该看见一份填好的入参**(2026-08-23 用户:「询价受理起单时左边
* 都有预填好的活动输入,E04 也要」)。测试台自动铺的是 `samples[0]`,所以第一条
* 必须四个字段全有值 —— 从前第一条是月度例行、只有 trigger 一个键,表单看着像没预填。
* 每一条也都至少要有背景说明:整屏只有一个下拉有值的样例,人看不出这活动吃什么。
*/
it('样例 0 是满载的那条(四个字段全有值)—— 测试台落地即预填', () => {
const first = manifest.samples[0]?.input as Record<string, unknown>;
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
}
expect((first['customers'] as unknown[]).length).toBeGreaterThan(0);
});
it('每条样例都带本轮说明,且样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
for (const s of manifest.samples) {
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
}
});
it('主数据追加列全部显式标 kind:master —— 与平台声明一致,不然静态审会拒', () => {
for (const object of ['profile', 'contact']) {
const decl = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === object);
expect(decl?.kind, `customer.${object}`).toBe('master');
}
});
it('influence / priority / completion_status 三个 tally 列的取值域全是 ASCII', () => {
for (const d of manifest.declarations.ledger) {
for (const f of d.fields) {
for (const value of f.tally ?? []) {
expect(/^[a-z0-9_]+$/.test(value), `${d.domain}.${d.object}.${f.name} 的取值 ${value} 不是纯 ASCII —— data-class-of 会把它清空成空 class`).toBe(true);
}
}
}
});
it('customer.profile 新增 cust_type:L-02 文档明列的字段,expect 而非 required,不带 tally', () => {
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
const custType = profile?.fields.find((f) => f.name === 'cust_type');
expect(custType, 'customer.profile 缺 cust_type').toBeDefined();
expect(custType?.expect).toBe(true);
expect(custType?.required).toBeUndefined();
expect(custType?.tally).toBeUndefined();
});
it('crm_coverage.verdict 新增 event_type 列(人读标签,月度例行留空)', () => {
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
expect(verdict?.fields.find((f) => f.name === 'event_type')).toBeDefined();
});
it('我方追加的批注列(change_log)不标 expect —— 标了会让 _gaps 永远大于零', () => {
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
expect(contact?.fields.find((f) => f.name === 'change_log')?.expect).toBeUndefined();
});
it('候选客户清单的客户编码/客户名称都标 required —— 业务说明明文两列必填', () => {
const candidate = manifest.declarations.ledger.find((d) => d.object === 'candidate');
expect(candidate?.fields.find((f) => f.name === 'customer_code')?.required).toBe(true);
expect(candidate?.fields.find((f) => f.name === 'customer_name')?.required).toBe(true);
});
it('receipt 的完成状态/结果摘要/关键人变动三列必填,客户反馈选填', () => {
const receipt = manifest.declarations.ledger.find((d) => d.object === 'receipt');
for (const col of ['completion_status', 'result_summary', 'key_person_change']) {
expect(receipt?.fields.find((f) => f.name === col)?.required, col).toBe(true);
}
expect(receipt?.fields.find((f) => f.name === 'customer_feedback')?.required).toBeUndefined();
});
it('两个工具都是纯工具(pure),批量入参 customers/contacts 优先于单条', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
expect(declared.has('crm-relationship-coverage.recompute-score')).toBe(true);
expect(declared.has('crm-relationship-coverage.rank-key-persons')).toBe(true);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/*
* 收口拆两项(2.0.4 结构治):写回主数据(S6_writeback)与留更新明细(S6_ledger_update)
* 各一项 —— 从前一项三拍,模型照样把四张表挤进一串,串尾最费字数的明细表被截成
* 空数组挨拒(两轮实测同一处);剧本按项摊开,拆开之后串想长也长不起来。
*/
it('八个计划项:识别候选 / 人核对 / 制定计划 / 审批 / 执行回执 / 写回主数据 / 留更新明细 / 放行', () => {
expect(items(model.cmmnXml)).toEqual([
'S1_identify_candidates',
'S2_scope_confirm',
'S3_plan',
'S4_plan_approval',
'S5_execute_receipt',
'S6_writeback',
'S6_ledger_update',
'T_release',
]);
});
it('四处人的门都标了人工激活:核对范围 / 审批 / 执行回执 / 放行(识别/制定计划/写台账是查表或AI起草,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_scope_confirm', 'S4_plan_approval', 'S5_execute_receipt', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('人工激活的四项在剧本里都没给 doneWhen —— 给了就是给引擎替人签字的钥匙', () => {
const manual = new Set(cmmn.manualActivation);
for (const step of model.steps ?? []) {
if (manual.has(step.item)) expect(step.doneWhen, `${step.item} 不该有 doneWhen`).toBeUndefined();
}
});
it('AI 起草的三项都给了 doneWhen,且不是「未绑定名字恒真」那种坑', () => {
const manual = new Set(cmmn.manualActivation);
for (const step of model.steps ?? []) {
if (manual.has(step.item)) continue;
expect(step.doneWhen, `${step.item} 缺 doneWhen`).toBeDefined();
expect(step.doneWhen, `${step.item} 的 doneWhen 不许用 != null`).not.toContain('!= null');
}
});
/*
* **放行门的入场哨兵认「办完了没有」,四处全读基座数**(2026-08-02 那类「只有 onPart
* 没有 ifPart 等于没有门」的坑,与「写台账要给行 rows 为空」的哨兵松绑同一类)。
*/
it('放行门哨兵带 ifPart,四个条件全是基座读数或本步显式记账的旗', () => {
const condition = sentryIf(model.cmmnXml, 's_ledger_updated');
expect(condition).toContain('ledger_update_confirmed_total');
expect(condition).toContain('crm_coverage_ledger_update_rows');
expect(condition).toContain('crm_coverage_ledger_update_missing');
expect(condition).toContain('customer_profile_missing');
expect(condition).toContain('customer_contact_missing');
const done = {
ledger_update_confirmed_total: 1,
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 写完了但主数据还缺必填 → 门不开
expect(safeEval(condition, { ...done, customer_profile_missing: 1 })).toBe(false);
// 写过一笔但还没显式记账(旗还没置一)→ 门不开(踩过「只有 onPart 没有 ifPart」的坑)
expect(safeEval(condition, { ...done, ledger_update_confirmed_total: 0 })).toBe(false);
});
it('S3 制定计划的入场哨兵认「人核对过了」,不认「AI 起草过」', () => {
const condition = sentryIf(model.cmmnXml, 's_scope_confirmed');
expect(condition).toContain('scope_confirmed_total');
expect(safeEval(condition, { scope_confirmed_total: 1 })).toBe(true);
expect(safeEval(condition, {})).toBe(false);
});
it('每个计划项都有剧本,guide 有实质内容', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名不存在的计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z_]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
/*
* **决策卡循环三处写死的东西也进说明书**(照 A6 T_haggle 立的规矩):
* itemId / 三个 value / allowCustom 断点那几拍模型手上只剩这份说明书。
*/
it('说明书写死了决策卡的 itemId / 三个 value / allowCustom', () => {
expect(model.narrative).toContain('S4_plan_approval');
for (const value of ['approve', 'reject', 'custom:']) {
expect(model.narrative, `说明书少了 value ${value}`).toContain(value);
}
expect(model.narrative).toContain('allowCustom');
});
/*
* **重发判断走在「问理由」之前**(2026-08-20 实测逼出来的修复):固定按钮点出来的
* 驳回永远没有 note,再问一遍是把人指向一扇打不开的门。S4 的 guide 必须写死
* 「不许再发卡追问理由」,而不是 A6 T_haggle 那种「找不到答复才发卡」的单发模式
* (E04 的决策卡允许多轮循环,与 A6 恰好相反,两处不能弄混)。
*/
it('S4 的 guide 写死「reject 不许追问理由」,不是无脑重问', () => {
const s4 = (model.steps ?? []).find((s) => s.item === 'S4_plan_approval');
expect(s4?.guide).toContain('不许再发卡追问理由');
});
/*
* **新客户建档的数据录入卡骨架也写死进说明书**(与 S4 决策卡同一条纪律):
* "断点"那几拍模型手上只保证摊开 NARRATIVE,S1 自己的 guide 不一定摊开。
* 照抄 A1 quote-intake 的 S1_customer 机制——`customer_code` 从不给 AI 编,
* 必须由人在卡上现场给。
*/
it('说明书写死了新客户录入卡的骨架:entry / customer.profile / customer_code 现场给', () => {
expect(model.narrative).toContain('"kind":"entry"');
expect(model.narrative).toContain('"ledger":"customer.profile"');
expect(model.narrative).toContain('customer_code');
expect(model.narrative, '不许自己编客户编码').toContain('不能自己编一个');
});
it('S1 的 guide 写死「人确认后不许再 write 一遍 customer.profile/.contact」', () => {
const s1 = (model.steps ?? []).find((s) => s.item === 'S1_identify_candidates');
expect(s1?.guide).toContain('不许你再');
expect(s1?.guide).toContain('customer.profile');
expect(s1?.guide).toContain('customer.contact');
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('四件必交付物 editableAtRelease 全是空数组 —— 验收工单只读展示,不可修改', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.editableAtRelease, d.key).toEqual([]);
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
if (identifier === 'plan_approved_total' || identifier === 'scope_confirmed_total') continue; // 一次性记账旗,直接是 caseFileItem
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('每条 L1/L2 都至少读一个基座读数或本步记账旗 —— 不许两边都由活动自报凑数', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
const grounded = names.some((n) => rowReadings.has(n) || n.endsWith('_total'));
expect(grounded, `${c.key} 一个基座读数/记账旗都没读`).toBe(true);
}
});
it('单一必发口 coverage-map,无选择组(E04 全程线性,无分支)', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['coverage-map']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('coverage-map 出口标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const port = manifest.contract.output.mandatory[0];
const properties = (port?.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port?.name}.${field} → ${snake(field)}`).toBe(true);
}
});
it('L4 判据裁量语句整句出现在放行剧本里 —— judge 才对得上名', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L4') continue;
expect(release?.guide, `T_release 的 guide 要整句抄 ${c.key}`).toContain(c.label);
}
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('四个局面各一张:候选清单 / 经营计划 / 执行回执 / 台账更新明细', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
'coverage-scope',
'coverage-plan',
'coverage-receipt',
'coverage-ledger-update',
]);
});
it('剧本每一步的 view 都在模版名单里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是台账更新明细 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('coverage-ledger-update');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('候选清单可编辑:data-edit 指向 crm_coverage.candidate,主键客户编码挂了 data-col', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
expect(html).toContain('data-edit="crm_coverage.candidate"');
expect(html).toContain('data-col="customer_code"');
});
it('经营计划只读:没有任何 data-edit / data-col —— 人只审不改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('台账更新明细只读:没有任何 data-edit / data-col —— 归档件不给改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
expect(html).toContain('data-edit="crm_coverage.receipt"');
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
expect(cols).toEqual(
new Set(['contact_code', 'completion_status', 'result_summary', 'customer_feedback', 'key_person_change']),
);
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
const editableViews: Array<[string, string]> = [
['coverage-scope', 'candidate'],
['coverage-receipt', 'receipt'],
];
for (const [view, object] of editableViews) {
const html = manifest.assets.viewTemplates.find((t) => t.name === view)?.html ?? '';
const declared = new Set(manifest.declarations.ledger.find((d) => d.object === object)?.fields.map((f) => f.name));
for (const col of [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1])) {
expect(declared.has(col ?? ''), `${view} 的 data-col="${col}" 不在 crm_coverage.${object} 的声明里`).toBe(true);
}
}
});
it('缺必填红带与提交闸同源 —— 可编辑件上摆的是基座的 `_missing` 与主键名单', () => {
for (const [name, object] of [
['coverage-scope', 'candidate'],
['coverage-receipt', 'receipt'],
] as const) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain(`crm_coverage_${object}_missing`);
expect(html, name).toContain(`crm_coverage_${object}_missing_keys`);
}
});
it('三个状态徽(候选状态/优先级/完成状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
const scope = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
expect(scope).toContain('<span class="jc-tag" data-class-of="status"></span>');
const plan = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
expect(plan).toContain('<span class="jc-tag" data-class-of="priority"></span>');
const receipt = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
expect(receipt).toContain('<span class="jc-tag" data-class-of="completion_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'crm-relationship-coverage',
version: '1.0.0',
data: {},
message: undefined,
outputPorts: { mandatory: ['coverage-map'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('crm-relationship-coverage');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
/**
* 有的 humanTask 是自闭合标签(`<humanTask .../>`,如没有人工激活的 S1)——
* `[\s\S]*?<\/humanTask>` 这种非贪婪匹配会跳过自闭合标签、错吞下一个 humanTask
* 的内容(把 S2 的 manualActivationRule 记到 S1 头上)。两种收尾分别匹配。
*/
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[^>]*?(?:\/>|>[\s\S]*?<\/humanTask>)/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,317 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 quote-* 各包同一套惯例)。
*
* E04 的实测账本(2026-08-21/22)每一条都对应这里的一类断言:
* · S1 只写一行就 finish → doneWhen 的「该有几行」半句 + count(customers) 语义;
* · S5 抢跑写旗 → 人工激活项不给 doneWhen(activity.test.ts 钉)+ 收口列写全;
* · 收口漏 influence → 新客户路放行门永远打不开(「碰过的行三列都带上」);
* · 剧本 writes/tools 指向没声明的地址 → 运行时被「台账地址未声明」拒到熔断。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, COVERAGE_PHASES, COVERAGE_STEPS } from '../src/case-model.ts';
import { COVERAGE_LEDGER_UPDATE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_SCOPE } from '../src/views/report.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of COVERAGE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(COVERAGE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(COVERAGE_STEPS);
expect(manifest.assets.businessModel.phases).toBe(COVERAGE_PHASES);
});
it('除放行外每个计划项恰属一个阶段 —— 进度条不许漏格,也不许一项占两格', () => {
const inPhases = COVERAGE_PHASES.flatMap((p) => p.items);
const expected = ITEM_IDS.filter((id) => id !== 'T_release');
expect([...inPhases].sort()).toEqual([...expected].sort());
expect(new Set(inPhases).size).toBe(inPhases.length);
});
});
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
it('steps[].writes 全部落在声明过的台账对象上', () => {
for (const step of COVERAGE_STEPS) {
for (const address of step.writes) {
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具', () => {
for (const step of COVERAGE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools.has(tool), `${step.item} 要调 ${tool},清单没声明`).toBe(true);
}
}
});
});
describe('doneWhen 是「成果已存在」的判据 —— 开局不许真,干完必须真', () => {
/** 四条常备样例各自的开案局面:台账一行没有,基座读数按声明铺 0 */
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
crm_coverage_candidate_rows: 0,
crm_coverage_candidate_missing: 0,
crm_coverage_plan_rows: 0,
crm_coverage_plan_missing: 0,
crm_coverage_receipt_rows: 0,
crm_coverage_receipt_missing: 0,
crm_coverage_ledger_update_rows: 0,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
});
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of COVERAGE_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
}
}
});
/*
* **count(customers) 的三种形状钉死**(2026-08-22 注释与实现相悖那一课):
* sfeel 的 count() 对未绑定名 flatten 出 [undefined],长度 1 —— 不是 0。
* 月度例行(键不存在)靠 rows >= 1 与 rows > 0 同义而行为恰好正确;
* 显式空数组才走 count = 0 那半句。这里用真 @sfeel 求值,注释再想撒谎测试拦着。
*/
it('月度例行(customers 键不存在):写下 1 行且必填齐 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('点名两家客户只写了一行 → S1 收不掉(漏写的那家不会自己回来)', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'event',
customers: ['CU-0142', 'CU-0087'],
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(false);
});
it('点名两家客户写齐两行 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'event',
customers: ['CU-0142', 'CU-0087'],
crm_coverage_candidate_rows: 2,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('显式传空数组(customers: []):count = 0 那半句真的兜住', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
customers: [],
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('行齐了但缺必填 → S1 收不掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
crm_coverage_candidate_rows: 2,
crm_coverage_candidate_missing: 1,
}),
).toBe(false);
});
it('计划有行且必填齐 → S3 收得掉;缺一头都收不掉', () => {
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 0 })).toBe(true);
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 0, crm_coverage_plan_missing: 0 })).toBe(false);
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 2 })).toBe(false);
});
it('台账更新明细齐 + 主数据必填齐 → S6 收得掉', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
}),
).toBe(true);
});
it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 1,
}),
).toBe(false);
});
});
describe('实测账本对应的剧本口径 —— 改口要连测试一起改', () => {
const writeback = COVERAGE_STEPS.find((s) => s.item === 'S6_writeback');
const ledgerUpdate = COVERAGE_STEPS.find((s) => s.item === 'S6_ledger_update');
it('写回主数据那一项含 candidate(状态置 covered)与联系人三列的硬要求', () => {
expect(writeback?.writes).toContain('crm_coverage.candidate');
expect(writeback?.guide).toContain('covered');
for (const column of ['influence', 'relationship', 'key_person']) {
expect(writeback?.guide, `S6_writeback guide 没点名 ${column}`).toContain(column);
}
expect(writeback?.guide).toContain('新建档的联系人');
});
/*
* **放行那一步要接得住「被打回」**(2026-08-23,柴浩实测:点重做写了意见,模型照
* T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」
* 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。
*/
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = COVERAGE_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
for (const address of ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict']) {
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
}
// 改的是值不是数 —— 新开行会把收步判据弄假
expect(release?.guide).toContain('覆盖写');
});
/*
* 收口拆两项的口径钉死(2.0.4 结构治):留明细那一项**只**写明细与结论 ——
* 写面一旦混进别的表,「一串挤四张、串尾截成空数组」那口锅迟早再背一次。
*/
it('留更新明细那一项只写 ledger_update + verdict 两张表', () => {
expect(ledgerUpdate?.writes).toEqual(['crm_coverage.ledger_update', 'crm_coverage.verdict']);
expect(ledgerUpdate?.tools).toEqual([]);
});
it('verdict 的触发列叫 trigger_label —— 与入参 trigger 同名会让首帧摆英文码', () => {
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
expect(verdict?.fields.some((f) => f.name === 'trigger_label')).toBe(true);
expect(verdict?.fields.some((f) => f.name === 'trigger')).toBe(false);
});
it('plan 没有 status 列 —— 「过没过审」由 plan_approved_total 一处说了算', () => {
const plan = manifest.declarations.ledger.find((d) => d.domain === 'crm_coverage' && d.object === 'plan');
expect(plan?.fields.some((f) => f.name === 'status')).toBe(false);
});
it('建档卡要写的六列都在声明里 —— 授权面是台账声明,说明书不许开空头支票', () => {
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
for (const column of ['name', 'industry', 'region']) {
expect(profile?.fields.some((f) => f.name === column), `customer.profile 缺 ${column}`).toBe(true);
}
for (const column of ['name', 'title', 'email']) {
expect(contact?.fields.some((f) => f.name === column), `customer.contact 缺 ${column}`).toBe(true);
}
// 邮箱照平台口径标脱敏 —— 不标的话治理面那一层裁剪不到它
expect(contact?.fields.find((f) => f.name === 'email')?.masked).toBe(true);
});
});
describe('四份工单模版照目标件 §6.2 补齐的动态位', () => {
const ALL = [COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE];
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 四份都有', () => {
for (const view of ALL) {
expect(view).toContain('data-slot="case:owner"');
expect(view).toContain('data-slot="case:taskId"');
expect(view).toContain('jc-rp-step');
// 页脚右侧「工单类型:… · <单号>」—— round_ref 是唯一真数据源
expect(view).toContain('data-slot="round_ref"');
}
});
/*
* **枚举列上下两态说同一个字**(2026-08-23 用户:「BT 层级下拉框显示不全,
* 都看不到具体选了哪个」):存的是 ASCII(台账与 recompute-score 的 btLevel 入参认它),
* 那么**只读那一份必须走 data-class-of 徽标**,由样式表出人读名 ——
* data-slot 直显会把 bt-02 摆到人脸上,与编辑态下拉里的「BT-02 · 技术」对不上。
* 覆盖深度当初修过这个坑,BT 层级漏了;列宽也得放得下选项文字,否则下拉被挤扁。
*/
it('BT 层级与覆盖深度:只读态走徽标(不 data-slot 直显 ASCII),且人读名在样式表里', () => {
for (const column of ['bt_level', 'depth']) {
expect(COVERAGE_SCOPE).toContain(`data-class-of="${column}"`);
expect(COVERAGE_SCOPE, `${column} 不该 data-slot 直显机器码`).not.toContain(`data-slot="${column}"`);
}
for (const label of ['BT-01 · 采购/供应链', 'BT-02 · 技术', 'BT-03 · 高层']) {
expect(COVERAGE_SCOPE, `徽标字典缺「${label}」`).toContain(label);
}
});
it('BT 层级列放得下选项文字 —— 80px 那会儿下拉被挤扁,选了什么看不见', () => {
const head = /<th[^>]*>BT 层级<\/th>/.exec(COVERAGE_SCOPE)?.[0] ?? '';
const width = Number(/min-width:(\d+)px/.exec(head)?.[1] ?? 0);
expect(width, `BT 层级列宽 ${width}px,装不下「BT-01 · 采购/供应链」`).toBeGreaterThanOrEqual(140);
});
it('执行工单1 的编辑闸照目标件措辞:「AI 已识别 N 个客户」,N 是基座读数', () => {
expect(COVERAGE_SCOPE).toContain('AI 已识别');
expect(COVERAGE_SCOPE).toContain('data-slot="crm_coverage_candidate_rows"');
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
expect(COVERAGE_SCOPE).not.toContain('【本轮触发】');
expect(COVERAGE_SCOPE).toContain('data-slot="trigger_label"');
});
it('经营计划表摆得出时间点 —— 验收判据③指向的东西必须在卡面上', () => {
expect(COVERAGE_PLAN).toContain('data-slot="target_date"');
});
it('执行回执:对象列长「客户 · 关键人」;结果摘要/客户反馈照目标件是 textarea', () => {
expect(COVERAGE_RECEIPT).toContain('data-slot="customer_name"');
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="result_summary"');
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="customer_feedback"');
});
it('台账更新确认摆得出依据与编码 —— 三条 L4 判据不再让人判他看不见的东西', () => {
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="evidence_ref"');
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="customer_code"');
});
});
function doneOf(item: string): string {
const found = COVERAGE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1 @@
{ "name": "@activities/crm-relationship-coverage", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
@@ -0,0 +1,717 @@
/**
* E04 关系覆盖与关键人经营的案例模型 —— **双表示同版本**:CMMN XML(机器面)与
* 案例说明书(叙事面)。
*
* 业务出处:`docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md`(7 步业务链条,
* 收成 2 张执行工单 + 2 张验收工单)与 `docs/SS01-客户与市场/Demo-E04-I01-Activity台账
* 关联与设计.html`(L-02/L-08 台账读写关系)。
*
* 7 个业务步收成 8 个案例模型项,非逐一对应:
* ① 启动与覆盖范围确认 → S1_identify_candidates(AI 起草)+ S2_scope_confirm(人核对);
* ②③④ 读现状→缺口分析→制定计划 → S3_plan(一项,AI)+ S4_plan_approval(决策卡循环);
* ⑤⑥ 执行经营动作→更新关系台账 → S5_execute_receipt(人执行填回执)+
* S6_writeback(AI 写回主数据)+ S6_ledger_update(AI 留更新明细与结论 ——
* 与写回拆成两项是 2.0.4 的结构治:一串挤四张表,串尾最费字数的明细表
* 被截成空数组挨拒,实测两轮都栽在同一处;拆开后每项的剧本只摊自己那几张表);
* ⑦ 复盘与完结 → T_release(终局验收放行)。
*
* 这是本仓库第一个用 `decide` 实现「非终局通过/驳回+AI重做」循环的活动
* (S4_plan_approval)——`issueAcceptance` 硬绑定终局放行,一个任务只能发一次,
* 不能拿来做②③④这个中间验收点;`decide` 才是唯一能循环的机制,细则见 S4 的 guide。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="crm-coverage">
<!--
案卷项的 id 就是任务视图里的键名。入参顶层是 trigger/eventType/customers/note 四个
字段(不进这份 caseFileModel——没有谓词引用它们,S1 的步骤 reads 里单独声明);
crm_coverage_* 与 customer_profile_* / customer_contact_* 那几个是基座读数
(行数 / 缺必填,由基座现算,模型碰不着)。
-->
<caseFileModel>
<caseFileItem id="crm_coverage_candidate_rows" name="候选客户行数(基座现数)"/>
<caseFileItem id="crm_coverage_candidate_missing" name="候选清单缺必填的行数(基座现数)"/>
<caseFileItem id="scope_confirmed_total" name="覆盖范围已核对确认(人环走完的凭据)"/>
<caseFileItem id="crm_coverage_plan_rows" name="经营计划行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_missing" name="经营计划缺必填的行数(基座现数)"/>
<caseFileItem id="plan_approved_total" name="经营计划已审批通过(仅通过时置一)"/>
<caseFileItem id="crm_coverage_receipt_rows" name="执行回执行数(基座现数)"/>
<caseFileItem id="crm_coverage_receipt_missing" name="执行回执缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_rows" name="台账更新明细行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_missing" name="台账更新明细缺必填的行数(基座现数)"/>
<caseFileItem id="customer_profile_rows" name="本单写下的客户档案行数(基座现数)"/>
<caseFileItem id="customer_profile_missing" name="客户档案缺必填的行数(基座现数)"/>
<caseFileItem id="customer_contact_rows" name="本单写下的客户联系人行数(基座现数)"/>
<caseFileItem id="customer_contact_missing" name="客户联系人缺必填的行数(基座现数)"/>
<caseFileItem id="ledger_update_confirmed_total" name="台账更新已收口(S6 写完时置一)"/>
</caseFileModel>
<casePlanModel id="plan" name="关系覆盖与关键人经营" autoComplete="true">
<!-- ① 候选客户已起草 —— 人核对那一步的入场前提 -->
<sentry id="s_candidates_drafted">
<onPart id="op_cd" caseFileItemRef="crm_coverage_candidate_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cd"><condition>crm_coverage_candidate_rows &gt; 0</condition></ifPart>
</sentry>
<!-- ② 覆盖范围已核对 —— 制定经营计划那一步的入场前提 -->
<sentry id="s_scope_confirmed">
<onPart id="op_sc" caseFileItemRef="scope_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_sc"><condition>scope_confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!-- ③ 经营计划已起草且齐 —— 审批那一步的入场前提 -->
<sentry id="s_plan_drafted">
<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>
</sentry>
<!-- ④ 经营计划已审批通过 —— 交人执行那一步的入场前提 -->
<sentry id="s_plan_approved">
<onPart id="op_pa" caseFileItemRef="plan_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pa"><condition>plan_approved_total &gt; 0</condition></ifPart>
</sentry>
<!--
⑤ 执行回执已交回且齐 —— 收口写台账那一步的入场前提。
**不再有 receipt_confirmed_total 记账旗**(2.0.2 退役,活单熔断两轮实测逼的):
那面旗是模型写的,S4 通过后它顺手把旗写上就坚信 S5 已办完,连续五拍
[空转写旗 + finish 被 worldTouched 拒] 熔断 —— 「该不该往下走」不能由
被叫的那一方自己记账。回执四列全是**人在件上填出来的**,_rows/_missing
是基座数出来的 —— 人填齐那一刻门自己开,模型在 S5 只剩发卡与收步两件事,
没有可抢跑的旗。
-->
<sentry id="s_receipt_confirmed">
<onPart id="op_rc" caseFileItemRef="crm_coverage_receipt_missing"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rc"><condition>crm_coverage_receipt_rows &gt; 0 and crm_coverage_receipt_missing = 0</condition></ifPart>
</sentry>
<!--
⑥(中缝)主数据已写回 —— 留更新明细那一步的入场前提。
收口从一项拆成两项(2.0.4,治「一串挤四张表、串尾被截成空数组」的实测账):
写回主数据(S6_writeback)与留明细(S6_ledger_update)各一项,剧本各只摊自己
要写的表,一串自然变小。四个数全是基座现数:重算分/档案都写齐了,门才开。
-->
<sentry id="s_masters_written">
<onPart id="op_mw" caseFileItemRef="customer_profile_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_mw"><condition>customer_profile_rows &gt; 0 and customer_profile_missing = 0 and customer_contact_rows &gt; 0 and customer_contact_missing = 0</condition></ifPart>
</sentry>
<!--
⑥ 台账已收口 —— 放行门的入场前提。
认「办完了没有」,且四处都是基座现数:台账更新明细齐(有行、不缺必填)、
主数据(客户档案/联系人)写下的那几行不缺必填、且 S6 自己显式记了账
(ledger_update_confirmed_total 是写完之后才产生的数,不是「写过一笔就算数」)。
-->
<sentry id="s_ledger_updated">
<onPart id="op_lu" caseFileItemRef="ledger_update_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_lu"><condition>ledger_update_confirmed_total &gt; 0 and crm_coverage_ledger_update_rows &gt; 0 and crm_coverage_ledger_update_missing = 0 and customer_profile_missing = 0 and customer_contact_missing = 0</condition></ifPart>
</sentry>
<!-- ① 识别候选客户,铸造 round_ref(能算的不问人) -->
<humanTask id="S1_identify_candidates" name="识别候选覆盖客户"/>
<!-- ① 交人核对候选清单(增删改)—— 触达世界前的人环,不给 doneWhen -->
<humanTask id="S2_scope_confirm" name="覆盖范围确认:核对候选清单,增删改">
<entryCriterion id="ec_s2" sentryRef="s_candidates_drafted"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ②③④ 读现状 → 缺口分析 → 制定经营计划(一项,不触达世界) -->
<humanTask id="S3_plan" name="读现状、缺口分析、制定关系经营计划">
<entryCriterion id="ec_s3" sentryRef="s_scope_confirmed"/>
</humanTask>
<!-- ②③④ 经营计划审批 —— 决策卡循环,通过或驳回+AI重做,不给 doneWhen -->
<humanTask id="S4_plan_approval" name="经营计划审批:通过或驳回(驳回则AI修订重提交)">
<entryCriterion id="ec_s4" sentryRef="s_plan_drafted"/>
<itemControl><manualActivationRule id="m_s4"/></itemControl>
</humanTask>
<!-- ⑤⑥(前半)交人执行经营动作并填回执 —— 触达世界,不给 doneWhen -->
<humanTask id="S5_execute_receipt" name="执行经营动作并填回执">
<entryCriterion id="ec_s5" sentryRef="s_plan_approved"/>
<itemControl><manualActivationRule id="m_s5"/></itemControl>
</humanTask>
<!-- ⑥(后半A)写回主数据:重算覆盖度、更新档案与候选状态(不触达世界,给 doneWhen) -->
<humanTask id="S6_writeback" name="写回主数据:重算覆盖度并更新档案">
<entryCriterion id="ec_s6a" sentryRef="s_receipt_confirmed"/>
</humanTask>
<!-- ⑥(后半B)留痕:逐条台账更新明细 + 本轮结论(不触达世界,给 doneWhen) -->
<humanTask id="S6_ledger_update" name="更新关系台账:留更新明细与结论">
<entryCriterion id="ec_s6" sentryRef="s_masters_written"/>
</humanTask>
<!-- ⑦ 复盘与完结:终局唯放行,必须人工激活 -->
<humanTask id="T_release" name="台账更新确认放行">
<entryCriterion id="ec_rel" sentryRef="s_ledger_updated"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**这一轮要经营哪些客户的关系,经营完把结果写回台账**。
入参四个顶层字段:
· \`trigger\`——**必有**,\`"monthly"\`(月度例行)或 \`"event"\`(事件驱动),翻成人读标签
写进 \`crm_coverage.verdict.trigger_label\`(报告上不摆系统内英文码),不用你自己判该归哪一类。
· \`eventType\`——仅 \`trigger:"event"\` 时可能有,四种之一:\`"new_customer"\`(新客户建立
合作)/ \`"key_person_change"\`(关键人离职/调岗)/ \`"major_deal"\`(重大商机启动)/
\`"t04_project"\`(攻坚项目 T04 立项)。同样翻成人读标签写进
\`crm_coverage.verdict.event_type\`(对照表见下方 S1 的 guide),\`trigger:"monthly"\`
时这一列留空。
· \`customers\`——本轮涉及的客户,**元素形状分两种**:①字符串,已有客户的 \`customer_code\`
(就是 L-02 客户主台账的 \`customer_id\`,同一个东西);②对象,**仅
\`eventType:"new_customer"\` 时出现**——这一刻这家客户还没有编码,对象里装的是
"新建一条 L-02 客户档案要用的字段"(公司名等),编码要等人在数据录入卡上现场给
(细节见 S1 的 guide,这是本轮唯一会用到的第二种卡)。**留空 = 月度例行,你自己
按台账规则从整个客户库识别本轮候选**。
· \`note\`——本轮背景说明(选填),帮你判断覆盖深度与经营侧重。
**成品是三样东西,缺一不可**:①一份**核对过的候选覆盖清单**;②一份**经审批通过的
关系经营计划**(逐个关键人的建议动作与优先级);③**写回 L-02/L-08 主数据的台账更新**
(覆盖度重算、决策链更新、联系人档案更新),每条更新都能在执行回执里找到依据。
**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**:识别候选、缺口分析、排序计划、
收口写台账,都是你的活;核对候选清单、审批计划、到场执行、签字放行,四道都要人过。
**能算的不问人**:关键人是否覆盖(决策链节点比对)、覆盖度、排序,都调工具算,
你不临场拍;调 \`crm-relationship-coverage.recompute-score\` 和
\`crm-relationship-coverage.rank-key-persons\`,一批一次,别一条条调。
**六步走**:识别候选 → 人核对范围 → 制定计划 → 人审批(可能来回改一轮)→
人执行填回执 → 收口写台账 → 人签字放行。
【本单可能用到的另一种卡 —— 新客户建档,长这样,只在 S1_identify_candidates 这一项里
发生,\`customers\` 里出现对象形状的元素才发】新客户这一刻没有编码,**不能自己编一个**
(客户编码是主数据的主键,编错一次后面每一单都跟着错——这条纪律与 A1 quote-intake
建新客户档案那一步完全一样,照抄它已经验证过的机制):
{"kind":"entry","itemId":"S1_identify_candidates","ask":"这是位新客户,请核对建档信息并给客户编码",
"ledger":"customer.profile","key":{"customer_code":""},
"fields":[
{"key":"customer_code","label":"客户编码","required":true},
{"key":"name","label":"公司名","value":"青岛新星电子有限公司","required":true},
{"key":"cust_type","label":"客户类型","value":"模组厂"},
{"key":"industry","label":"行业","value":"车规电子"},
{"key":"region","label":"区域","value":"山东"}
],
"also":[{"title":"联系人档案","ledger":"customer.contact","key":{"customer_code":""},
"autoNote":"联系人编码由系统按顺序生成,不用你填",
"fields":[
{"key":"name","label":"联系人姓名","value":"王芳","required":true},
{"key":"title","label":"职位","value":"采购经理"},
{"key":"email","label":"邮箱","value":"wang.fang@example.com"}
]}]}
**只有 \`customers\` 里那个对象已经给出的字段才带 \`value\`**,没给的字段整格别写(界面自己
留白待填,别拿假值凑数);\`customer_code\` 从不给 \`value\`,标 \`required:true\`,人不填就
确认不了;对象里没给 \`contact\` 就不带 \`also\` 段(不是每次都已经知道具体联系人)。
**联系人编码不用你写**——自增列,基座在确认那一刻生成,写进 \`fields\` 也会被摘掉。
**人按下确认那一刻基座已经把这两张表直接落进草稿层**——你不经手,同一拍或下一拍都不许
再 \`write\` 一遍 \`customer.profile\`/\`customer.contact\`(写了会被"写了等于没写"拒回)。
一次只发一张,这张的回复到了、这家客户的编码定下来了,才轮到 \`customers\` 里下一个
对象形状的元素(如果还有的话)。
【这一单唯一的岔路口 —— 经营计划审批,长这样,无论哪一拍都照这个发,别自己另编一张】
计划审批不是一次性的:人可能驳回、要求你按他的意见改。**这一步只在 S4_plan_approval
这一项里发生**,发决策卡之前先在轨迹里找有没有已经答过、还没处理的 \`[card-reply]\`:
· **\`value\` 是 \`"approve"\`**——写 \`plan_approved_total=1\`,finish 这一项,别再发卡;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头**——都算驳回:\`"reject"\` 是点了固定
按钮、没写具体意见,**不许再发卡追问理由**(那两个按钮点出来的回复永远没有 note,
再问一遍是把人指向一扇打不开的门);以 \`"custom:"\` 开头的,\`note\` 就是他写的具体
意见,按这条意见改。两种都要对 \`crm_coverage.plan\` 做出至少一处实质性修订(同 PK
覆盖写,不开新计划项、不重新进入 sentry),然后**换一种问法**再发一张 decide 卡
(措辞不换会被系统当成同一张卡静默吞掉);
· **没有**——才发第一张 decide 卡。
{"kind":"decide","itemId":"S4_plan_approval","allowCustom":true,
"ask":"经营计划已算出,共 <N> 条,覆盖 <M> 家客户的关键人,请审批。",
"options":[{"value":"approve","label":"通过","recommended":true},
{"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有这三种形态:\`"approve"\`、\`"reject"\`、\`"custom:"\` 开头(人自己写的话)——
**别自己造第四种**。
【一路上要说话】负责人正看着这一单。**每一拍带一句 say** —— 说业务的话:
识别到几家候选、计划排了几个关键人、执行回执收了几条、覆盖度改了多少;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 8-11 拍就该收口**(比 A1 多两道人环:候选核对 + 计划审批 + 执行回执 + 放行,
共四次;审批若被驳回会多出一到两拍)。
§Z 结案:AM Lead 在验收单上签字,系统把草稿合并进 L-02/L-08 正式版并发射
\`coverage-map\` 口 —— 这一轮的覆盖地图供下游读取(E05/E06 尚未实现,暂无消费者)。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 四处不给 `doneWhen`:`S2_scope_confirm`(交人核对触达世界)、`S4_plan_approval`
* (要人拍板,且这一项内部可能循环)、`S5_execute_receipt`(拜访/交流等 AI 做不到的线下动作)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const COVERAGE_STEPS = [
{
item: 'S1_identify_candidates',
intent: '按台账规则识别本轮候选覆盖客户,铸造本轮案号',
writes: ['crm_coverage.candidate', 'crm_coverage.verdict', 'customer.profile', 'customer.contact'],
/* 入参顶层字段不声明就会被整表段截断刀砍掉 —— 事件驱动那几条路全靠这四个字段;
case:openedAt 是铸 round_ref 的料(引擎注入的开案时刻,2026-08-22 起真的在作用域里) */
reads: ['trigger', 'eventType', 'customers', 'note', 'case:openedAt', 'customer.profile', 'customer.contact'],
view: 'coverage-scope',
tools: [],
/*
* **`_rows > 0` 独木难支,`_missing = 0` 也只顾得上半条命**(2026-08-22 两轮实测
* 逼出来的:事件驱动、customers 数组两个客户点名的入参)。
* 第一轮:模型写成的那一行 customer_name 是空的——`_missing = 0` 补上就治好了。
* 第二轮:模型干脆**没写第二颗客户那一行**(不是写了个空值,是那一行压根不存在)——
* `_missing` 只查「已经存在的行缺不缺字段」,查不出「该有的行还没生出来」;
* `_rows > 0` 更是从一开始就跟"两颗"这个数没关系,一行也算数。
* 两轮都在 S1/S2/S3/S4 四道全部放行,一路悄悄漏到 S5 提交闸才第一次被拦下。
* 治法只能是**把「该有几行」也摆进判据**:customers 数组点了几个名,
* 候选行就该有几条。
*
* **`count(customers)` 在入参没给这个键时 = 1,不是 0**(sfeel 的 count 对未绑定名
* flatten 出 `[undefined]`,长度 1 —— 包内 steps 测试用真 @sfeel 钉死了这三种形状):
* 月度例行(键不存在)走的是 `rows >= 1` 这一半,与前面 `rows > 0` 恰好同义,
* 所以行为对;但别把 `count(...) = 0` 那半句当成「留空时不约束」的开关 ——
* 它只在显式传了 `customers: []` 时才为真。
*/
doneWhen:
'crm_coverage_candidate_rows > 0 and crm_coverage_candidate_missing = 0 ' +
'and (count(customers) = 0 or crm_coverage_candidate_rows >= count(customers))',
guide: `第一件事:把这一轮**候选覆盖客户**列出来。**分两段**——先把每一位客户的编码
定下来,全部定下来了才写候选清单(候选清单不许分批写,写一半会被引擎当成这一步已经办完)。
**customers 里点了几个名,候选清单就该有几行**——事件驱动且点名的那几条路,
遗漏一颗客户等于让它从这一单里凭空消失,后面所有步骤都不会再补回来;
写完候选清单,自己数一遍行数是不是等于 customers 里点名的客户数,对不上就是漏写了。
【先铸造本轮案号 round_ref】格式固定:\`'COV-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`
(\`case:openedAt\` 就在你的作用域里,形如 "2026-08-22 08:47:58" —— 去掉分隔符即得;
你不必也不许自己编)。**不许从 \`crm_coverage.candidate\` 等正式表里已有的别单案号里挑**
—— 那是别的轮次的号,借了就把两轮的数据搅进同一个主键(A2 借号那一课的同款)。
**整单只有一个 round_ref**,写
\`crm_coverage.candidate\` / \`.plan\` / \`.receipt\` / \`.ledger_update\` / \`.verdict\`
时都用这同一个串 —— 造出两个号就是把一单裂成两单。
【第一段:把 customers 里每一项都定成一个确切的客户编码】
· \`customers\` 留空 —— **月度例行**,不必定编码,直接跳到"没点名"那条候选识别路径。
· 元素是字符串 —— 已经是编码,直接用,不必确认。
· 元素是对象(只在 \`eventType:"new_customer"\` 时出现)——**这是新客户,还没有编码,
发一张数据录入卡请人现场建档**,骨架照 NARRATIVE 那段"新客户建档"原样抄
(\`"kind":"entry"\`,\`ledger:"customer.profile"\`,\`key:{"customer_code":""}\`)。
一次只发一张,发完这一拍就结束(挂起);**人按下确认那一刻基座已经把公司档
(和给了的话,联系人档)落进草稿层**——不许你再 \`write\` 一遍这两张表。回复到了,
从轨迹里取人刚给的 \`customer_code\`,继续处理 \`customers\` 里下一项(还有的话)。
【第二段:全部编码定下来了,才做候选识别与写盘,一拍连做】
· 有确切编码的(月度例行没有这一条)—— **这一轮是事件驱动**,以这些编码为骨架,
逐个从 \`customer.profile\` / \`customer.contact\` 里取画像与联系人现状;
**候选清单只收这几个编码,一个不多、一个不少**——事件驱动点了名的,是这一轮
唯一该覆盖的客户;别顺手把台账里其他覆盖度低、决策链有空白的客户也一并纳入
(那是月度例行的活,这一轮不归它管)。已实测炸过:入参只点了 1 家,模型却在
交代里说"识别到 2 家候选客户",多出来的那一家台账里从没写过——人在核对卡上
看见的候选数量,得和候选清单里躺着的行数完全对得上,不能一个在嘴上一个在表里。
· \`customers\` 从一开始就留空的 —— **这一轮是月度例行**,按台账规则识别:优先选
覆盖度低(\`coverage_score\` 低或缺)、决策链有空白节点、或近期有商机/关键人变动
信号的客户。识别不出候选也要如实写 \`crm_coverage.verdict.summary\`,别硬凑几行充数。
【\`decision_chain\` 是什么形状,没有时怎么办】它是一个 JSON 结构:
\`{nodes:[{node, btLevel:"bt-01"|"bt-02"|"bt-03", required:boolean, contactCode, covered}]}\`。
这家客户若从没跑过 E04(\`customer.profile.decision_chain\` 空的),这一步不必初始化它 ——
留给 S3 那一步在做缺口分析时按标准三节点(bt-01 采购/供应链、bt-02 技术、bt-03 高层)
起草,这一步只管把候选客户与联系人现状读出来、把清单列好。
【写 \`crm_coverage.candidate\`,一颗客户一行】(rows 收数组)
round_ref、customer_code(**必填**,上一段定下来的那个)、customer_name(**必填**)、
cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新客户刚在录入卡上
给过;没有才按 \`grade\`/\`profile_tags\` 猜,如"Tier1 OEM"/"模组厂"/"方案商"/"贸易商"/
"终端",猜不出留空)、bt_level(选填,ASCII \`bt-01\`/\`bt-02\`/\`bt-03\` 之一,按这家
客户目前哪个层级的联系人最缺覆盖来定)、status(选填,ASCII \`pending\`,本步一律先写
这个值)、basis(选填,一句纳入依据,如"覆盖度已 40 天未更新"或"客户点名,商机联动"或
"新客户建档,首轮覆盖")、depth(选填,ASCII \`light\`/\`standard\`/\`deep\` 之一,按 basis
的紧迫程度定)、origin(固定写 \`ai\`)。
【同一拍写 \`crm_coverage.verdict\`(一行)】round_ref、trigger_label(**翻成人读标签写,
不写入参那个英文码**——\`"monthly"\`→"月度例行"、\`"event"\`→"事件驱动";报告上不摆系统内
英文名)、event_type(\`trigger:"event"\` 时把入参 \`eventType\` 也翻成人读标签写进来——
\`new_customer\`→"新客户建立合作"、\`key_person_change\`→"关键人离职/调岗"、
\`major_deal\`→"重大商机启动"、\`t04_project\`→"攻坚项目(T04)立项";\`trigger:"monthly"\`
时这一列留空)、note(入参 \`note\` 原样抄,没有留空)、summary 一句人话(本轮识别到几家
候选、依据是什么)。写完 finish 这一项。`,
},
{
item: 'S2_scope_confirm',
intent: '交人核对候选清单:增删改,确认本轮真正要覆盖谁',
writes: ['crm_coverage.verdict'],
reads: ['crm_coverage.candidate'],
view: 'coverage-scope',
tools: [],
/* 不给 doneWhen —— 交人核对触达世界(manualActivation),引擎不替人收 */
guide: `候选清单起草好了,**该 AM Lead 核对了** —— 这是本活动第一张执行单,零输入。
工作区摆的是候选清单的报告(编辑闸开着就能增删改),他在那份件上核对:AI 没识别到但
他了解有价值的客户,他会加一行;AI 纳入了但实际不该覆盖的,他会删掉;识别错的信息,
他会就地改。
这一步标了人工激活:发卡就算启动,不必单发 start;人交回后你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号,别绑 \`blocks\`
或 \`fields\` —— 整份清单在工作区那份件上,执行卡零输入)
{"kind":"human","itemId":"S2_scope_confirm","workOrder":"execution","card":{
"title":"覆盖范围确认:本轮共 <N> 家候选客户",
"ask":"AI 已按台账规则识别出 <N> 家候选客户,请核对确认。",
"steps":["工作区的件上核对候选清单:客户编码与客户名称是必填,缺一条这一单走不到经营计划那一步",
"加一行 = 额外纳入(AI 没识别但你了解有价值的客户)",
"删一行 = 排除(AI 纳入了但实际应排除的客户)",
"改单元格 = 修正 AI 识别的内容 —— 客户类型、BT 层级、纳入依据都能改",
"改完保存,回这张卡提交"],
"standard":"候选清单里每一行客户编码、客户名称都齐;该纳入的都纳入、该排除的都排除了。",
"basis":[{"k":"触发口径","v":"<照 crm_coverage.verdict 刚写的 trigger_label/event_type 说,如
「月度例行」或「事件驱动:重大商机启动」>"},
{"k":"AI 识别","v":"<N> 家候选客户"}]}}
发完这一拍就结束了(发卡挂起)。
【人交回之后】这一步你只做两件事,然后 finish:
① 写 \`crm_coverage.verdict.scope_confirmed_total = 1\`;他在件上撤掉了行或加了行的话,
下一步的缺口分析、计划制定按当下 \`crm_coverage.candidate\` 的实际行数走,不必你重写。
② finish 这一项。**别在这一拍做缺口分析** —— 那是下一步的活,这里做了也白做。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着,
卡回来时客户编码/客户名称一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_plan',
intent: '读覆盖现状、缺口分析、制定关系经营计划',
writes: ['crm_coverage.plan', 'crm_coverage.receipt', 'crm_coverage.candidate'],
reads: ['crm_coverage.candidate', 'customer.profile', 'customer.contact'],
view: 'coverage-plan',
tools: ['crm-relationship-coverage.recompute-score', 'crm-relationship-coverage.rank-key-persons'],
doneWhen: 'crm_coverage_plan_rows > 0 and crm_coverage_plan_missing = 0',
guide: `候选范围定了,**现在读现状、找缺口、排计划** —— 固定三拍,一拍一件事,别在同一拍里
把「调工具」和「写台账」串在一起(**工具调用后同拍其余动作作废,结果要下一拍才看得见**;
串了也是白串,只会多烧一拍)。这一步只需要调工具**各一次**,拿到结果后不要再调第二次。
**这一步只管 \`crm_coverage.candidate\` 里躺着的那几家,一家都不能多**——\`reads\` 里
还带着 \`customer.profile\`/\`customer.contact\` 整表,那是给你查这几家候选客户各自的
决策链与联系人现状用的**参考资料**,不是"顺手也把看见的其他客户一起排上计划"的许可。
已实测炸过:候选清单里明明只有 1 家,写出来的计划却多了一家台账里压根不存在于候选清单
的客户(那家客户是**从 customer.profile/customer.contact 整表里"看见"的**,和这一轮
候选范围毫无关系)——动手前先把候选清单的 \`customer_code\` 抄一遍,写计划时逐条核对
是不是都在这张抄出来的清单里,不在的一律不许写。
【第一拍:缺口分析,只调工具,别的什么都不写】把本轮全部候选客户(读
\`crm_coverage.candidate\`)一次性打包,调一次
\`crm-relationship-coverage.recompute-score\`,入参:
\`\`\`
{"customers":[
{"customerCode":"CU-0142",
"decisionNodes":[
{"node":"bt-01","btLevel":"bt-01","required":true},
{"node":"bt-02","btLevel":"bt-02","required":true},
{"node":"bt-03","btLevel":"bt-03","required":true}],
"contacts":[
{"contactCode":"CU-0142-01","decisionNode":"bt-01","keyPerson":false,"influence":"","relationship":0}]}
]}
\`\`\`
· \`decisionNodes\`:这家客户若已有 \`customer.profile.decision_chain\`,原样照抄它的节点;
没有(新客户/首次跑 E04)就照抄上面这三行(**node 与 btLevel 写同一个值**,
\`bt-01\`/\`bt-02\`/\`bt-03\`,一个字符不许错、不许留空 —— node 留空会让出参里那个节点
变成 \`null\`,缺口分析就废了一条)。
· \`contacts\`:这家客户名下**所有**已知联系人(读 \`customer.contact\`),逐人填
\`contactCode\`/\`influence\`/\`relationship\`/\`keyPerson\`;\`decisionNode\` 按人现有的
\`customer.contact.decision_node\` 填,没有就按职位/角色估一个最贴近的层级(如「采购部」
估 bt-01),都判不出就填 \`"bt-01"\` 兜底 —— **这一格不许留空**,留空这个人就永远配不上
任何节点、白算了这一趟。空缺字段(如 \`influence\` 还没定过)照现有值填,是空字符串
或 0 都行,工具认得。
多家候选客户就都塞进同一个 \`customers\` 数组里,**一次调完,不要一家调一次**。
调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:排序,只调工具,别的什么都不写】把上一拍 \`results\` 里出现过的**所有联系人**
(不止缺口节点上的那几位)一次性打包,调一次 \`crm-relationship-coverage.rank-key-persons\`,
入参 \`{"contacts":[{"contactCode":"...","influence":"...","relationship":0}, ...]}\`——
**批量调,不要一人调一次**。调完这一拍就结束。
【第三拍:写计划,一拍连做】拿着前两拍的工具出参,**一次都不要再调工具**,直接写:
① 逐家候选客户看第一拍的 \`gapNodes\`:**gapNodes 里的每一个缺口节点,都要在计划里
至少配一个关键人经营动作**——就算这个节点上目前一个联系人都没有覆盖到(\`coveredNodes\`
是空的、\`coverageScore\` 是 0),那正是这一步该管的事:挑这家客户名下**已知的、
离这个节点最近的联系人**(按 \`decisionNode\` 匹配,匹配不上就挑影响力最高的那位)
写一条「去覆盖这个节点」的动作。**\`gapNodes\` 全员缺口是正常结果,不是异常,
不需要回去重调工具确认。**
② **写 \`crm_coverage.plan\`,按第二拍的 rank_score 降序、一个关键人一行**(rows 收数组;
模版不排序,你必须已经按序写)。round_ref、contact_code(**必填**)、customer_code
(**必填**)、customer_name(选填)、contact_name(选填)、action(**必填**,具体到能
执行的建议动作,如"下月中拜访技术总监,带 XX 认证报告"而不是笼统的"拜访")、priority
(**必填**,ASCII \`high\`/\`medium\`/\`low\`,照第二拍算出的档位抄)、rationale(**必填**,
说清为什么是这个人这个动作,引影响力与亲密度差值,或"该节点目前空白,此人是最接近的
候选")、influence(**必填**,写计划这一刻的快照,照抄联系人当前的 influence,没有就写
\`"low"\`)、relationship_gap(**必填**,第二拍算出的数)、rank_score(**必填**,第二拍
算出的数)、target_date(**必填**,具体日期,没有时间点的建议不算数)。
③ **同一拍,给每条计划配一行 \`crm_coverage.receipt\` 骨架**(这一步只写骨架,完成状态那几列
留空,S5 那一步才由人填):round_ref、contact_code(与 plan 同一个,**必填**)、
customer_code(选填,照抄)、customer_name(选填,照抄 —— 执行回执工单的「对象」列长
"客户名 · 关键人",两个名字都从这里来)、contact_name(选填,照抄)、action(选填,
照抄 plan 的 action,供执行回执工单只读展示)、target_date(选填,照抄)。
completion_status / result_summary / customer_feedback / key_person_change
**这一步一格都不写**。
④ 若某候选客户的 \`cust_type\`/\`bt_level\` 在 S1 那步没判出来,顺手补写
\`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。
写完这一拍就结束(不必你 finish)——\`crm_coverage_plan_rows > 0 and
crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
},
{
item: 'S4_plan_approval',
intent: '经营计划审批:通过就往下走,驳回则按意见修订后换一种问法再提交',
writes: ['crm_coverage.plan', 'crm_coverage.receipt', 'crm_coverage.verdict'],
reads: ['crm_coverage.plan'],
view: 'coverage-plan',
tools: [],
/* 决策卡循环 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**动手之前先在轨迹里找一行 \`[card-reply]\` 带这一项的答复:**
· **没找到** —— 这是第一次,跳到下面【发一张决策卡】那一段;
· **\`value\` 是 \`"approve"\`** —— 写 \`crm_coverage.verdict.plan_approved_total = 1\`,
finish 这一项,**不许再发卡**;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头** —— 都算驳回,跳到下面【驳回之后】
那一段,修订计划再重新发卡。
【这一步办完了没有,看一个数】\`plan_approved_total\`:它写成 1 就是这一步办完了,
引擎不会再把这一项交给你(它归零 doneWhen 帮不上忙,只有你自己 finish)。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发;\`allowCustom\` 别漏 —— 少了它,人只有
两个固定按钮可点,连意见都没地方写;\`ask\` 每一轮都要按下面的规则换措辞)
{"kind":"decide","itemId":"S4_plan_approval",
"allowCustom":true,
"ask":"经营计划已算出,共 <N> 条,覆盖 <M> 家客户的关键人,请审批。",
"options":[
{"value":"approve","label":"通过","recommended":true,"consequence":"计划进入执行阶段,交人去落实"},
{"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按自己的判断复核一遍计划,重新提交审批"}],
"basis":[{"k":"覆盖客户","v":"<M> / <本轮候选总数> 家已各有至少一条计划"},
{"k":"排序依据","v":"影响力 × 亲密度缺口"},
{"k":"想说明理由","v":"不点上面两个按钮,直接在下方输入框写你的意见再提交"}]}
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订
\`crm_coverage.plan\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:排序有没有明显不合理、
有没有遗漏更紧要的缺口节点、理由那一句站不站得住,尽量改得更有说服力;复核后**至少要有
一处实质性变化**(改排序、换动作、补理由,不能原样重发)。
③ **改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改**
(同 PK——\`round_ref\`+\`contact_code\`——覆盖写)。receipt.action 是 S3 那一拍从
plan.action **抄**过来的展示副本(执行回执工单直接读它、只读展示),这里只改了
plan 不改 receipt,S5 那一步人看到的还是驳回前的旧动作——已经实测过:审批来回改了
三稿,receipt 里摆的还是第一稿,推进到执行回执时对不上人刚通过的那一版。
④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如
"已复核并修订(第2稿:调整了 CU-0142 的优先级,补充了理由)请再次审批"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
⑤ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
(发卡挂起)。
**这一步只在这一项里循环,不许把驳回意见带去改别的项**(S1/S2/S3 写下的东西不动)。`,
},
{
item: 'S5_execute_receipt',
intent: '交人执行经营动作,回填执行回执',
writes: [],
reads: ['crm_coverage.receipt'],
view: 'coverage-receipt',
tools: [],
/*
* 拜访 / 技术交流等线下动作 AI 做不到 —— 不给 doneWhen。
* **这一步没有任何记账旗**(2.0.2 退役 receipt_confirmed_total,两轮活单熔断实测):
* 旗是模型写的,S4 一过它顺手写旗就坚信 S5 已办完,连续五拍
* [写旗 + finish 被 worldTouched 拒] 熔断。回执四列全是人在件上填的,
* 收口那道门读的是基座数(`_rows`/`_missing`)—— 模型无账可记,也就无处抢跑。
*/
guide: `计划审批通过了,**该 AM 出场了** —— 这是本活动第二张执行单,零输入。
触达客户的那一下由人做:拜访、技术交流、价值传递这些线下动作 AI 做不到,
工作区摆的是执行回执工单(逐个关键人一行,计划动作只读展示,回执四列可填)。
这一步标了人工激活:发卡就算启动,不必单发 start。**这一步不写任何台账** ——
回执四列由人在件上填、由基座直接落草稿层,你只有两个动作:发卡,和人交回后 finish。
**这一步「人被问过没有」不用你判断 —— 【当前这一步】标题下面那行引擎事实写得明明白白**
(「这一单里已经真的给它发过卡…人已经被问过了」或「这一单里从没给它发过卡」),照它分两条路:
· 那行说**「从没给它发过卡」** —— 你唯一合法的动作就是照下面骨架发卡,发完这一拍结束(挂起)。
**别去轨迹里数 \`[card-reply]\` 自己判断**:S2 的执行单、S4 的决策卡都会在轨迹里留
\`[card-reply]\`,它们不是这一步的 —— 实测两轮连拒都是把 S4 审批的答复当成了 S5 的回执,
凭空宣布「执行回执已交回」。引擎那行事实比你对轨迹的读法准,以它为准。
· 那行说**「人已经被问过了」** —— 人交回后直接 finish 这一项,一个字都不用写
(回执数据人已经在件上填好、基座已落草稿层;**别在这一拍收口写台账** ——
重算覆盖度、写回主数据是下一步的活)。
【照这个骨架发,别少字段】(执行卡零输入,别绑 \`blocks\` 或 \`fields\`)
{"kind":"human","itemId":"S5_execute_receipt","workOrder":"execution","card":{
"title":"经营计划执行回执:共 <N> 项动作",
"ask":"经营计划已审批通过,请执行并回填回执。",
"steps":["每项动作执行后,在工作区的件上逐项回填:完成状态、结果摘要为必填,客户反馈选填",
"关键人变动如实回答「是/否」;若填「是」,请在结果摘要里说清具体变动是什么",
"确实没有变动的,「关键人变动」这一格要写「无变动」,不能留空",
"改完保存,回这张卡提交"],
"standard":"每一项动作的完成状态与结果摘要都填了;关键人变动逐项如实标注,不遗漏。",
"basis":[{"k":"本轮动作数","v":"<N> 项"}]}}
发完这一拍就结束了(发卡挂起)。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着。`,
},
{
item: 'S6_writeback',
intent: '写回主数据:按回执重算覆盖度,更新联系人/客户档案与候选状态',
writes: ['customer.profile', 'customer.contact', 'crm_coverage.candidate'],
reads: ['crm_coverage.receipt', 'crm_coverage.plan', 'customer.profile', 'customer.contact'],
view: 'coverage-ledger-update',
tools: ['crm-relationship-coverage.recompute-score'],
/*
* 四个数全是基座现数:重算分与档案都写齐了才算办完。
* 收口从一项拆成两项(2.0.4 结构治):从前「三拍纪律」写得再明白,模型照样把
* 四张表挤进一串,串尾最费字数的 ledger_update 被截成空数组挨拒(两轮实测同一处)。
* 拆开之后每项的剧本只摊自己那几张表 —— 串想长也长不起来。
*/
doneWhen:
'customer_profile_rows > 0 and customer_profile_missing = 0 and customer_contact_rows > 0 and customer_contact_missing = 0',
guide: `回执都收齐了,**现在写回主数据** —— 固定两拍,别把「调工具」和「写台账」串进
同一拍(工具调用后同拍其余动作作废,结果要下一拍才看得见)。
**这一步不碰 \`crm_coverage.ledger_update\`** —— 留更新明细是下一步(S6_ledger_update)
的活,这一步只管把主数据写对。
【第一拍:重算,只调工具,别的什么都不写】逐个联系人按回执推导新的 relationship/influence
(草稿,先想清楚,还不落库):\`completion_status\` 为 \`done\` 且关系明显推进的,
\`relationship\` 上调(不超过 5);\`partial\`/\`skipped\` 的原样不动或视回执具体内容微调。
**调不出结论就别改** —— 没有回执支持的调整不许写。把本轮涉及的候选客户**一次性打包**,
调一次 \`crm-relationship-coverage.recompute-score\`:
\`decisionNodes\` 照抄 \`customer.profile.decision_chain\` 现有的节点(\`node\`/\`btLevel\`/
\`required\` 三样都要,一个字符不许错);\`contacts\` 是这家客户名下**所有**已知联系人,
**逐人五个字段都要给全**——\`contactCode\`/\`decisionNode\`(按 \`customer.contact
.decision_node\` 填)/\`keyPerson\`(这轮判断的关键人标记)/\`relationship\`(推导后的新值)/
\`influence\`(推导后的新值)。
\`\`\`
{"customers":[{"customerCode":"CU-0142",
"decisionNodes":[{"node":"bt-01","btLevel":"bt-01","required":true}, ...],
"contacts":[{"contactCode":"CU-0142-01","decisionNode":"bt-01","keyPerson":true,
"relationship":4,"influence":"high"}]}]}
\`\`\`
**\`keyPerson\` 这一格漏了,工具会把这个人当成"不存在",这个节点当场判空**——已经实测炸过:
同一批入参只差这一个字段,coverageScore 从 17/33 变成 0,跟台账里明明白白写着的
"bt-01 已覆盖"对不上;模型因此怀疑结果、来回改了又调、调了又改,一单纯为这一步烧了 26 次
工具调用、烧穿了 500k token 预算,\`crm_coverage.ledger_update\` 一行没写成就 exhausted 了。
拿到 \`results\`(逐家 coverageScore/coveredNodes)——**一次调完,不要一家调一次,更不要见
结果与预期不符就反复重调**:它是纯工具,同样的输入永远同样的结论,算出来的覆盖度比预想低,
先检查自己传的 \`contacts\` 是不是漏了字段,而不是重调一遍赌下次会不一样(不会)。
调完这一拍就结束。
【第二拍:只写这两张,别的表这一拍不碰】
· **写 \`customer.contact\`**(对应联系人行,同 PK 覆盖写):**碰过的每一行,
\`influence\` / \`relationship\` / \`key_person\` 三列一列都不许缺**(基座数缺必填
只看本单写下的行 —— 你写了这一行却漏了其中一列,\`customer_contact_missing\`
就大于零,放行门永远打不开):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、
\`relationship\` 写推导后的新值(没变化照现值抄)、\`key_person\` 照这轮判断写;
**本单新建档的联系人**(录入卡只落了姓名/职务/邮箱)这三列由你首评:
\`influence\` 按职务估(总监/VP 级 high、经理级 medium、其余 low)、
\`relationship\` 按实际接触定(还没接触过写 1)、\`key_person\` 如实判断;
\`role\`/\`decision_node\`/\`comm_pref\` 若这轮有新信息一并补;
\`change_log\` 追加一句本轮变动(离职/调岗从回执的 \`key_person_change\` 抓,
没有变动不必写这一列)。
· **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):\`coverage_score\` = 工具
算出的新分数(**必填**);\`decision_chain\` = 更新后的节点覆盖状态(**必填**,
整个数组重写,不是增量合并);\`interaction_timeline\` 追加本轮的互动条目(选填,
没有新增互动的客户不必写这一列)。
· **把办完的候选行状态置 \`covered\`**(写 \`crm_coverage.candidate\`,同 PK 覆盖写,
只带 status 一列):回执里有 \`done\` 动作的客户置 ASCII \`covered\`;
全部 \`skipped\` 的留 \`pending\` 如实 —— 清单上那枚状态徽是给人看的,
一轮跑完还全写着「待覆盖」就是界面在撒谎。
写完这三张,这一步就办完了 —— 不必你 finish,\`doneWhen\` 会在主数据写齐那一刻自动收步,
留更新明细归下一步(S6_ledger_update),这一拍到此为止。`,
},
{
item: 'S6_ledger_update',
intent: '留痕收口:逐条台账更新明细 + 本轮覆盖结论',
writes: ['crm_coverage.ledger_update', 'crm_coverage.verdict'],
reads: ['crm_coverage.receipt', 'customer.profile', 'customer.contact'],
view: 'coverage-ledger-update',
tools: [],
doneWhen:
'crm_coverage_ledger_update_rows > 0 and crm_coverage_ledger_update_missing = 0 and customer_profile_missing = 0 and customer_contact_missing = 0',
guide: `主数据已在上一步写回,**这一步只写两张表、一拍连做**:先写更新明细
(它最费篇幅,放在串首),再写结论。**上一步那三张表(contact/profile/candidate)
这一拍不许再写一遍** —— 再写一遍原值只会被当空转拒掉。
· **写 \`crm_coverage.ledger_update\`,每条主数据更新对应一行**(rows 收数组,
**数组不能是空的** —— 第二拍写的每一处真实变化,这里都要有一行对应):
round_ref、update_seq(**必填**,本轮内从 1 递增)、ledger_label(**必填**,人读名,
如"客户主台账·关系覆盖度")、ledger_address(**必填**,机器地址,如
"customer.profile.coverage_score")、customer_code(**必填**)、contact_code
(选填,客户级字段留空)、field_label(**必填**,人读列名)、before_value(**必填**,
无值写字面"(无)")、after_value(**必填**)、evidence_ref(**必填**,引用具体回执
事实,如"回执 contact_code=CU-0142-01:已完成拜访,亲密度由3提升至4")。
**没有真实变化的字段不必开一行** —— 明细表只记「真改了什么」。
每一处真实变化的依据都在回执与上一步写下的新值里,对照着写,别凭记忆。
· **写 \`crm_coverage.verdict\`**(同一拍接着写):ledger_update_confirmed_total = 1、
covered_customer_total = 这一轮覆盖度提升或已达标的客户数、
key_person_covered_total = 这一轮标记为 key_person 的联系人数、summary
补一句人话(这一轮覆盖了几家客户、覆盖度平均涨了多少)。
写完 finish 这一项 —— 或者不必你 finish,\`doneWhen\` 会在必填列都齐的那一刻自动收步。`,
},
{
item: 'T_release',
intent: '台账已收口,提交放行交 AM Lead 验收;被打回就按意见改完再交',
/* 被打回重做时要改的就是这三张 —— 不声明就写不进去(写的授权面是台账声明) */
writes: ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict'],
reads: ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.receipt'],
view: 'coverage-ledger-update',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `台账更新明细齐了、主数据缺项也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
人已经看过这份成果并把它退回来了,原样递上去他看见的还是刚被他打回的那一份
(基座那道闸也会当场拒:「打回之后台账一个字没改」)。照他的意见改**台账本身**,
改完这一拍再 judge + submit:
· 他说**计划的理由空/不实** → 改 \`crm_coverage.plan\` 的 \`rationale\`(同 PK 覆盖写),
把影响力评级、亲密度差值、决策链缺的是哪个节点写进去,一条一条落实;
动作不具体的连 \`action\`/\`target_date\` 一起改。
· 他说**台账更新没依据 / 数不对** → 改 \`crm_coverage.ledger_update\` 的
\`evidence_ref\`(点名是哪条回执支持它)或 \`before_value\`/\`after_value\`。
· 他说**结论没说清** → 改 \`crm_coverage.verdict.summary\`。
· 他**没写具体意见**(只点了重做)→ 自己把成果复核一遍,挑最站不住的那处改实
(理由最空的那条计划、依据最含糊的那条更新),**至少改对一处**;
实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是数**:同一行覆盖写,别新开行、别把行数改了 —— 那会把收步判据弄假。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="每条台账更新都能在执行回执里找到对应事实依据,不是凭空推算" ——
逐条看 \`crm_coverage.ledger_update\` 的 \`evidence_ref\`:说得出具体哪条回执支持这条
更新就 satisfied=true;有一条说不清来路的,satisfied=**false**,rationale 逐条写清,
交签字的人定。
· judge criterion="更新后的数值(覆盖度、亲密度等)变化合理,没有异常跳变" ——
看每条数值型更新的 before/after 幅度是否与回执的完成程度相称。
· judge criterion="更新内容准确对应回执事实,没有张冠李戴或误更别的客户/联系人" ——
核对 customer_code/contact_code 与回执主体是否一致。
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄,
写别的名字对不上。
这一单没有分支去向 —— 全程线性,只发一口 \`coverage-map\`,不必你选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const COVERAGE_PHASES = [
{ name: '确认覆盖范围', items: ['S1_identify_candidates', 'S2_scope_confirm'] },
{ name: '制定与审批计划', items: ['S3_plan', 'S4_plan_approval'] },
{ name: '执行与更新台账', items: ['S5_execute_receipt', 'S6_writeback', 'S6_ledger_update'] },
];
@@ -0,0 +1,11 @@
/**
* E04 · 关系覆盖与关键人经营 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 两个纯工具 + 三条技能 + 台账声明 + 四张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
export default defineActivity({ name: 'crm-relationship-coverage' });
@@ -0,0 +1,584 @@
/**
* 活动包清单 —— E04 关系覆盖与关键人经营(案例活动)。
*
* 业务出处:`docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md`(7 步业务链条,
* AI 承担信息汇总/记录/初稿生成,人负责判断与决策,收成 2 张执行工单 + 2 张验收/决策工单)、
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-02 客户主台账 /
* L-08 客户联系人台账的读写关系)。
*
* 这是本仓库第一个不属于 `quote-*` A1-A7 报价管线的活动:触发方式是月度例行 / 事件驱动
* 而非来件邮件,核心读写对象是共享主数据 `customer.profile`/`customer.contact`。
*
* 两处与 A 系列的刻意不同,写在这里免得下一个读者当成疏漏:
* ① `customer.profile`/`customer.contact` 已由 A1(quote-intake)声明 `kind:'master'`
* 并持有 `customer_code`/`name` 等基本列 —— 这里只**追加**本活动要用的新列
* (`schema-merge.ts` 的声明合并是纯 add-column,不冲突,不需要重复声明已有列)。
* ② 「验收工单1」(经营计划审批)是**非终局**的通过/驳回+AI重做循环 ——
* `issueAcceptance` 硬绑定终局放行、一个任务只能发一次,不能拿来做中间验收点,
* 所以这里用 `decide` 决策卡实现(S4_plan_approval),真正的终局验收放行是
* 「验收工单2」(T_release,台账更新确认)。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { COVERAGE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, COVERAGE_PHASES, COVERAGE_STEPS } from './case-model.ts';
import { COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE } from './views/report.ts';
const D = 'crm_coverage';
const ACTIVITY_ID = activityIdSchema.parse('crm-relationship-coverage');
/**
* 没有判定表 —— 覆盖度与排序是**连续加权算术**(BT 层级权重 × 覆盖率、
* 影响力权重 × 亲密度缺口),不是离散分支,不适合 DMN 命中表,交给两个纯工具算
* (见 `apps/api/src/app/tools/first-party.ts` 的
* `crm-relationship-coverage.recompute-score` / `.rank-key-persons`)。
* `dmn` 在清单里是必填字段,空表即可 —— 静态审不对 DMN 表数量做任何要求。
*/
const EMPTY_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'E04 · 关系覆盖与关键人经营',
packageVersion: '2.0.9',
category: '客户关系经营',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-crm-relationship-coverage',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 不是来件解析(没有唯一「这封信」),是触发参数。§六 E04 业务说明与
* `Demo-E04-I01-Activity台账关联与设计.html`(L-02/L-08 完整字段表)两份文档核对
* 后按用户明确要求做成**两级下拉 + 一个数组**:
* ① `trigger` 必填 —— 月度例行 / 事件驱动,用户明确要求这一级必须是显式的
* 下拉框(不接受"从数组是否点名反推"这种省略,那样选项与数组状态一旦不一致
* 就是一处会撒谎的地方);
* ② `eventType` 选填,只在 `trigger:'event'` 时在测试台表单里现身(靠
* `showWhen` 这个测试台通用能力),值就是业务说明里那四种事件;
* ③ `customers` 数组,元素两种形状二选一:字符串 = 已有客户的 `customer_code`
* (业务说明里这就是 L-02 的 `customer_id`,同一个概念,本仓库沿用既有列名
* 不改叫法);对象 = **仅 `eventType:'new_customer'` 时使用**,这一刻这家
* 客户还没有编码(新客户建档要等人在录入卡上现场给),数组元素就相当于
* "新建一条 L-02 客户主台账记录所包含的字段"。录入卡会把这些字段落进
* `customer.profile`/`customer.contact` —— 那几列(name/industry/region/
* title/email)本版起**都在下方 declarations.ledger 里显式声明**
* (授权面是台账声明;从前只在说明书里出现、声明里没有,是一处会撒谎的注释)。
* `items` 不声明单一形状 —— 字符串与对象二选一,声明成 object 会让测试台的
* 类型提示(object[])与首选用法(字符串数组)相悖。
*/
data: {
type: 'object',
required: ['trigger'],
properties: {
trigger: {
type: 'string',
enum: ['monthly', 'event'],
enumNames: ['月度例行(常规覆盖轮次)', '事件驱动'],
title: '触发方式',
},
eventType: {
type: 'string',
enum: ['new_customer', 'key_person_change', 'major_deal', 't04_project'],
enumNames: ['新客户建立合作', '关键人离职/调岗', '重大商机启动', '攻坚项目(T04)立项'],
title: '事件类型(仅事件驱动时选)',
showWhen: { field: 'trigger', equals: 'event' },
},
customers: {
type: 'array',
title:
'本轮涉及的客户 —— 已有客户填客户编码字符串;若事件类型是"新客户建立合作",' +
'改填新客户档案对象(公司名等 L-02 字段,不含客户编码——那一格由人在录入卡上现场给)。留空 = 月度例行,AI按台账规则从整个客户库筛选候选',
examples: [
['CU-0142'],
[{ name: '青岛新星电子有限公司', custType: '模组厂', industry: '车规电子', region: '山东', contact: { name: '王芳', title: '采购经理', email: 'wang.fang@example.com' } }],
],
},
note: {
type: 'string',
title: '本轮说明(选填,背景信息 —— 帮AI判断覆盖深度与经营侧重)',
examples: ['客户 CU-0142 启动新项目,商机金额较大,AM Lead 要求本轮优先覆盖其决策链'],
},
},
},
messageAllowed: true,
messageHint: 'AM Lead 可在此说明本轮覆盖的特殊要求、优先客户、或需要重点关注的关键人变动',
},
output: {
/*
* 单一必发口,无分支:E04 全程线性(①→②③④→⑤⑥→⑦),没有像 A1 那样
* 「有没有 C 类」这种要按序求值的岔路。E05/E06 在本仓库尚未实现,暂无消费者 ——
* 按「契约先立、实现后补」的纪律先声明,不因为没有下游就不发。
*/
mandatory: [
{
name: 'coverage-map',
description:
'本轮更新后的关系覆盖地图 —— 供未来 E05互动与拜访 / E06账户计划读取;当前代码库尚未实现下游活动,此口暂无消费者,仅按"载荷=已落地事实"的契约纪律预先声明',
payload: {
type: 'object',
required: ['roundRef', 'coveredCustomerTotal', 'keyPersonCoveredTotal'],
properties: {
roundRef: { type: 'string', title: '覆盖轮次编号' },
coveredCustomerTotal: { type: 'integer', title: '本轮覆盖客户数' },
keyPersonCoveredTotal: { type: 'integer', title: '本轮覆盖关键人数' },
},
},
},
],
selective: [],
},
/*
* 四份必交付物,逐一对应四张工单产出的成果。全部 `editableAtRelease: []`——
* 业务说明明文"验收工单只读展示,不可修改"(与 A1 放行时允许改 type_tag 是刻意对比,
* 不是遗漏:E04 的验收工单从设计上就没有"就地修订"这一环)。
*/
requiredDeliverables: [
{
key: 'coverage-scope',
label: '覆盖范围客户清单',
ledger: `${D}.candidate`,
ledgers: [`${D}.candidate`, `${D}.verdict`],
editableAtRelease: [],
},
{ key: 'coverage-plan', label: '关键人经营计划', ledger: `${D}.plan`, editableAtRelease: [] },
{ key: 'coverage-receipt', label: '执行回执', ledger: `${D}.receipt`, editableAtRelease: [] },
{
key: 'ledger-update',
label: '台账更新明细',
ledger: `${D}.ledger_update`,
ledgers: [`${D}.ledger_update`, `${D}.verdict`],
editableAtRelease: [],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:`_rows`/`_missing` 基座现数,
* 与执行卡的提交闸同一个数同一套算法。L4 三条是纯裁量(有没有依据、数值合不合理、
* 有没有张冠李戴)——机器数不出「这条更新站不站得住」,那是人对结果负责的地方。
*/
acceptanceCriteria: [
/*
* 两条 L1 的「人过了没有」一半仍靠记账旗(scope_confirmed_total / plan_approved_total,
* 只有人回卡之后模型才记 —— 与 A1 的 confirmed_total 同款,规范 §5.3-4 背书的人环凭据);
* 但**每条都必须再钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」
* 第 5 类违反的伪装形 —— 抢跑写旗那单(2026-08-22 活单熔断)正是踩着这半边过的门。
*/
{
key: 'scope-confirmed',
label: '覆盖范围已经 AM Lead 核对确认(执行工单1已交回,清单有行且必填齐)',
level: 'L1',
predicate: `scope_confirmed_total > 0 and ${D}_candidate_rows > 0 and ${D}_candidate_missing = 0`,
},
{
key: 'plan-approved',
label: '关系经营计划已经 AM Lead 审批通过(验收工单1已过,不处在驳回待改状态)',
level: 'L1',
predicate: `plan_approved_total > 0 and ${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
key: 'plan-complete',
label: '经营计划逐条完整:关键人、建议动作、优先级、理由、时间点都有',
level: 'L2',
predicate: `${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
key: 'receipt-matches-plan',
label: '每条计划都收到执行回执,且完成状态、结果摘要、关键人变动均已填写',
level: 'L2',
predicate: `${D}_receipt_rows > 0 and ${D}_receipt_rows = ${D}_plan_rows and ${D}_receipt_missing = 0`,
},
{
key: 'ledger-update-complete',
label: '台账更新明细逐条完整:每条都写明台账、字段、更新前、更新后、依据',
level: 'L2',
predicate: `${D}_ledger_update_rows > 0 and ${D}_ledger_update_missing = 0`,
},
{
key: 'no-missed-update',
label: "每条「已完成」的执行回执至少有一条对应的台账更新(没有漏更)",
level: 'L2',
predicate: `${D}_ledger_update_rows >= ${D}_receipt_completion_status_done`,
},
{
key: 'update-has-evidence',
label: '每条台账更新都能在执行回执里找到对应事实依据,不是凭空推算',
level: 'L4',
},
{ key: 'values-reasonable', label: '更新后的数值(覆盖度、亲密度等)变化合理,没有异常跳变', level: 'L4' },
{ key: 'no-wrong-update', label: '更新内容准确对应回执事实,没有张冠李戴或误更别的客户/联系人', level: 'L4' },
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'owner-release', label: '台账更新入库放行:签下去即正式生效并回写主数据', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-guess-contact', statement: '决策链节点对应哪位联系人不确定时不得臆断 —— 匹配不上就留空,那就是缺口' },
{ key: 'no-skip-evidence', statement: '台账更新明细每条都要有回执依据,不得凭空推算写入主数据' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
],
authorization: {
discretion: [],
preauthorizedActions: [],
/*
* 基线四次(S2/S4通过/S5/T_release)+ 决策卡循环预留两轮(每轮驳回+重提交各算一次
* 人回复,两轮就是 +4)——**基座按 `used >= cap` 判耗尽**(实测栽过:6 次刚好等于
* 6 的上限,「裁决:pass」那次回复本身就是第 6 次,harnessCheck 在 land() 之前
* 就判耗尽,放行卡显示 9/9 达标、验收也点了通过,任务却终态 exhausted、草稿从没
* 合并进正式版)。留够余量,cap 必须严格大于「最后一次拍板」时的实际次数。
*/
humanCallBudget: 12,
},
resources: {
tools: [{ name: 'crm-relationship-coverage.recompute-score' }, { name: 'crm-relationship-coverage.rank-key-persons' }],
skills: COVERAGE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['AM Lead'],
/* 月度关系维护不是 live-inbox 那种时间敏感的单;人环预算同上调宽到 12(见上注) */
budget: { steps: 80, tokens: 500_000, activeMinutes: 90, humanCalls: 12 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm-relationship-coverage.recompute-score' }, { name: 'crm-relationship-coverage.rank-key-persons' }],
ledger: [
/*
* ── 主数据追加列(kind:'master' 显式声明,纯 add-column,与 A1 现有声明并存)──
* 只声明本活动要用的新列;`customer_code` 只是回显/关联用途的重复声明,
* 平台已由 `customer.profile`/`customer.contact` 的全量结构持有主键。
*/
{
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户档案',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* 建档卡会写的那几列(name/industry/region)也要声明 —— **授权面是台账声明**:
* 说明书让录入卡落什么,声明里就得有什么(物理列平台基线本就有,声明是权责面)。
* 与 A1 的 name 声明并存(add-column 合并,同名同型不冲突)。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, label: '所属行业' },
{ name: 'region', type: 'text', primaryKey: false, masked: false, label: '区域' },
/*
* L-02 文档明列的字段,权威维护者是尚未建成的 T02(客户建档评估) —— 平时
* E04 不写它;唯一的例外是"新客户建立合作"这一刻,E04 代替不存在的 T02
* 履行建档职责(与 A1 quote-intake 的 S1_customer 完全同构),此时由数据
* 录入卡收人现场给的值。不带 tally——存文档给的中文枚举字面值,不再造一套
* ASCII 编码来回译(这一列从不参与 data-class-of 徽标染色,没有 ASCII 约束)。
*/
{ name: 'cust_type', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户类型' },
{ name: 'coverage_score', type: 'integer', primaryKey: false, masked: false, required: true, label: '关系覆盖度' },
/* 结构:{nodes:[{node, btLevel, required, contactCode, covered}]} —— 见 case-model.ts 的 guide 注 */
{ name: 'decision_chain', type: 'json', primaryKey: false, masked: false, required: true, label: '决策链地图' },
/* 结构:{entries:[{date, type, contactCode, note}]},append-only(每次整个数组重写) */
{ name: 'interaction_timeline', type: 'json', primaryKey: false, masked: false, expect: true, label: '互动时间线' },
],
},
{
domain: 'customer',
object: 'contact',
kind: 'master',
label: '客户联系人',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* `auto:true` **必须跟 A1 一致**(2026-08-20 修死):`autoKeys()`
* (`apps/api/src/app/routes/desk.ts`)按**当前任务自己所属活动**的声明判断
* 哪几列自增,不是按全仓合并后的物理表结构 —— E04 若只抄字段名不抄这个标记,
* E04 名下的任务发数据录入卡建新联系人时,`contact_code` 就悄悄不再自动生成
* (静默退化成留空,不报错)。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
/* 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏 */
{ name: 'name', 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: 'role', type: 'text', primaryKey: false, masked: false, expect: true, label: '角色' },
{ name: 'decision_node', type: 'text', primaryKey: false, masked: false, expect: true, label: '决策链节点' },
{
name: 'influence',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['high', 'medium', 'low'],
label: '影响力评级',
},
{ name: 'relationship', type: 'integer', primaryKey: false, masked: false, required: true, label: '关系亲密度(1-5)' },
{ name: 'comm_pref', type: 'text', primaryKey: false, masked: false, expect: true, label: '沟通偏好' },
{ name: 'key_person', type: 'boolean', primaryKey: false, masked: false, required: true, label: '关键人标记' },
/* 我方追加的批注(入职/离职/调岗留痕),不标 expect —— 标了会让 _gaps 永远大于零 */
{ name: 'change_log', type: 'text', primaryKey: false, masked: false, label: '变动记录' },
],
},
/* ── E04 自有工作台账(kind:'record',caseKey:'round_ref') ── */
{
domain: D,
object: 'candidate',
kind: 'record',
caseKey: 'round_ref',
label: '候选覆盖客户清单',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, required: true, label: '客户编码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, required: true, label: '客户名称' },
/* 本轮工作快照 —— 有 customer.profile.cust_type 就抄它,没有才按台账信号猜(见 guide) */
{ name: 'cust_type', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户类型' },
{ name: 'bt_level', type: 'text', primaryKey: false, masked: false, expect: true, label: 'BT 层级' },
{ name: 'status', type: 'text', primaryKey: false, masked: false, tally: ['pending', 'covered', 'excluded'], label: '状态' },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, expect: true, label: '纳入依据' },
{ name: 'depth', type: 'text', primaryKey: false, masked: false, expect: true, label: '覆盖深度' },
{ name: 'origin', type: 'text', primaryKey: false, masked: false, label: '来源(ai/human-added)' },
],
},
{
domain: D,
object: 'plan',
kind: 'record',
caseKey: 'round_ref',
label: '关键人经营计划',
fields: [
{ 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: '客户编码' },
{ 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: 'action', type: 'text', primaryKey: false, masked: false, required: true, label: '建议动作' },
{
name: 'priority',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['high', 'medium', 'low'],
label: '优先级',
},
{ name: 'rationale', type: 'text', primaryKey: false, masked: false, required: true, label: '理由' },
{ name: 'influence', type: 'text', primaryKey: false, masked: false, required: true, label: '影响力(写计划那一刻的快照)' },
{ name: 'relationship_gap', type: 'integer', primaryKey: false, masked: false, required: true, label: '亲密度缺口' },
{ name: 'rank_score', type: 'number', primaryKey: false, masked: false, required: true, label: '排序分(模版不排序,须已按此列降序写入)' },
{ name: 'target_date', type: 'date', primaryKey: false, masked: false, required: true, label: '建议动作时间点' },
/*
* 没有 status 列(2.0.0 撤):tally 里的 approved 从没人写过 —— 「计划过没过审」
* 由 plan_approved_total 一个数说了算,同一件事实两处各存一份就会分叉。
*/
],
},
{
/*
* 与 `plan` 分表:回执列若挤进 `plan`,`required` 是对象级全局声明 ——
* S3/S4 阶段的 `_missing` 会把还没执行的回执列也算成「缺」,doneWhen/L2 判据
* 永远打不开。S3 那一步顺手把 action/target_date 回填进来,供 S5 工单只读展示
* (单张表内混排只读+可编辑列,复用工作区渲染器 mine:域.对象 单地址绑定的既有能力)。
*/
domain: D,
object: 'receipt',
kind: 'record',
caseKey: 'round_ref',
label: '经营计划执行回执',
fields: [
{ 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, expect: true, label: '客户编码' },
/* 目标件的「对象」列长「AvtoKom · 产品 VP」—— 客户名与人名都要在,S3 从计划抄 */
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称(承继自计划,只读展示)' },
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' },
{ name: 'action', type: 'text', primaryKey: false, masked: false, label: '计划动作(承继自计划,只读展示)' },
{ name: 'target_date', type: 'date', primaryKey: false, masked: false, label: '建议时间点(承继自计划,只读展示)' },
{
name: 'completion_status',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['done', 'partial', 'skipped'],
label: '完成状态',
},
{ name: 'result_summary', type: 'text', primaryKey: false, masked: false, required: true, label: '结果摘要' },
{ name: 'customer_feedback', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户反馈' },
{
name: 'key_person_change',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['changed', 'unchanged'],
label: '关键人变动',
},
],
},
{
domain: D,
object: 'ledger_update',
kind: 'record',
caseKey: 'round_ref',
label: '台账更新明细',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'update_seq', type: 'integer', primaryKey: true, masked: false, label: '本轮更新序号' },
{ name: 'ledger_label', type: 'text', primaryKey: false, masked: false, required: true, label: '台账(人读名)' },
{ name: 'ledger_address', type: 'text', primaryKey: false, masked: false, required: true, label: '台账地址(机器地址)' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, label: '客户编码' },
{ name: 'contact_code', type: 'text', primaryKey: false, masked: false, expect: 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: 'after_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新后' },
{ name: 'evidence_ref', type: 'text', primaryKey: false, masked: false, required: true, label: '依据(引用具体回执事实)' },
],
},
{
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'round_ref',
label: '本轮覆盖结论',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
/*
* 列名 `trigger_label` 不叫 `trigger`(2.0.0 改):任务视图把草稿列摊平到顶层、
* 与起单入参**同名同位** —— 从前这一列就叫 trigger,S1 写下中文标签之前,
* 视图上 `data-slot="trigger"` 摆的是入参英文码(monthly/event),
* 「报告上不摆系统内英文名」在首帧上破了功。改名后绑定读 trigger_label,
* 写下之前那一格真空着(data-blank),空着是诚实的。
*/
{ name: 'trigger_label', type: 'text', primaryKey: false, masked: false, label: '触发方式(人读标签,非入参英文码)' },
{ name: 'event_type', type: 'text', primaryKey: false, masked: false, label: '事件类型(入参原样抄的人读标签,月度例行留空)' },
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '本轮说明' },
{ name: 'scope_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '覆盖范围已确认' },
{ name: 'plan_approved_total', type: 'integer', primaryKey: false, masked: false, label: '经营计划已审批通过' },
/*
* 没有 receipt_confirmed_total(2.0.2 退役,两轮活单熔断实测):模型自写的
* 「人环走完」旗让它抢跑 —— S5 的门改读基座数(receipt _rows/_missing,
* 回执四列全是人在件上填出来的),被叫的那一方无账可记。
*/
{ name: 'ledger_update_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '台账更新已收口' },
{ name: 'covered_customer_total', type: 'integer', primaryKey: false, masked: false, label: '本轮覆盖客户数' },
{ name: 'key_person_covered_total', type: 'integer', primaryKey: false, masked: false, label: '本轮覆盖关键人数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '本轮覆盖结论' },
],
},
],
listeners: [],
harness: {
memoryWindow: 24,
/* 月度关系维护不是 live-inbox 那种紧迫的单,给够时间让人执行拜访这类线下动作 */
stallAfter: 'PT4H',
nudge: [{ after: 'PT2H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: EMPTY_DMN,
steps: COVERAGE_STEPS,
phases: COVERAGE_PHASES,
},
skills: COVERAGE_SKILLS,
/*
* 四个局面各一张:
* · `coverage-scope` —— S1/S2 摆,候选客户清单,可编辑;
* · `coverage-plan` —— S3/S4 摆,经营计划,只读(业务说明明文"人只做审批,不能修改结论");
* · `coverage-receipt` —— S5 摆,执行回执,混排只读(计划动作/时间点)+ 可编辑(回执四列);
* · `coverage-ledger-update` —— S6/T_release 摆,台账更新明细,只读,收口后即归档件。
*/
viewTemplates: [
{ name: 'coverage-scope', html: COVERAGE_SCOPE },
{ name: 'coverage-plan', html: COVERAGE_PLAN },
{ name: 'coverage-receipt', html: COVERAGE_RECEIPT },
{ name: 'coverage-ledger-update', html: COVERAGE_LEDGER_UPDATE },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本轮关系覆盖:候选客户识别依据、计划审批是否被驳回过(为什么)、执行回执里客户反馈最集中的诉求、覆盖度变化幅度。',
},
],
},
/*
* ── 样例集 —— 五条形状真不同(常备案例即验收案例)─────────────
*
* **第一条必须是满载的那条**(2026-08-23 用户:「询价受理起单时左边都有预填好的入参,
* E04 也要」):测试台落到一个活动上就自动铺 `samples[0]`(activity-run.tsx 的
* useEffect),所以第一条就是人打开这一屏看见的那份入参。从前第一条是月度例行、
* 只有 `{trigger:'monthly'}` 一个键 —— 表单上除了那个下拉全是空的,看着像没预填。
* 现在第一条是**点名客户的事件驱动**:四个字段全有值,起单钮按下去就能跑。
*
* **样例名一律 ≤14 字**:测试台的样例钮超过 14 字就退成「样例 N」(activity-run.tsx),
* 那等于没名字 —— 人得逐个点开才知道哪条是哪条。
*/
samples: [
{
name: '重大商机 · 点名客户',
input: {
trigger: 'event',
eventType: 'major_deal',
customers: ['CU-0142'],
note: '客户 CU-0142(Itelma)启动新项目 MD14.2,商机金额较大,AM Lead 要求本轮优先覆盖其决策链',
},
expect: { ports: ['coverage-map'] },
},
{
/* 月度例行的本分就是不点名(客户格留空 = 由 AI 按台账规则筛)—— 但背景说明要给,不然表单半空 */
name: '月度例行 · 全库筛',
input: {
trigger: 'monthly',
note: '八月例行覆盖轮次:优先看覆盖度陈旧、决策链有空白节点的重点客户',
},
expect: { ports: ['coverage-map'] },
},
{
name: '关键人离职调岗',
input: {
trigger: 'event',
eventType: 'key_person_change',
customers: ['CU-0087'],
note: '客户 CU-0087 采购负责人 Chen Gang 已离职,需要重新识别关键人并更新决策链',
},
expect: { ports: ['coverage-map'] },
},
{
name: '新客户建立合作',
input: {
trigger: 'event',
eventType: 'new_customer',
customers: [
{
name: '青岛新星电子有限公司',
custType: '模组厂',
industry: '车规电子',
region: '山东',
profileTags: '价格敏感 · 首次合作',
contact: { name: '王芳', title: '采购经理', email: 'wang.fang@example.com' },
},
],
note: 'AM Lead 上周展会新结识的客户,想尽快建档并启动关系覆盖',
},
expect: { ports: ['coverage-map'] },
},
{
name: '攻坚项目 T04 立项',
input: {
trigger: 'event',
eventType: 't04_project',
customers: ['CU-0142'],
note: '客户 CU-0142(Itelma)的 MD14.2 项目已升级为 T04 攻坚项目,需要加强决策链覆盖、确保关键节点无缺口',
},
expect: { ports: ['coverage-map'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-crm-relationship-coverage' },
};
@@ -0,0 +1,79 @@
/**
* E04 技能 —— 关系覆盖经营的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const COVERAGE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('crm-coverage.gap-analysis'),
label: '读覆盖现状、找决策链空白节点',
applicability: '识别候选客户之后、拟经营计划之前,要判断这一轮该覆盖谁。',
method: [
'决策链地图(`customer.profile.decision_chain`)按 BT 层级列节点:',
'· BT-01 采购/供应链层、BT-02 技术层(研发/质量)、BT-03 高层(产品总监/VP)。',
'一个节点算「已覆盖」,要同时满足三条:该节点上有联系人对应、那个人标了',
'`key_person=true`、亲密度 `relationship >= 3`。三条少一条都算空白 ——',
'认得这个人不等于覆盖了这个人,亲密度不够的关键人一样是缺口。',
'',
'调 `crm-relationship-coverage.recompute-score` 拿 `gapNodes`,别自己数:',
'节点覆盖是逐条核对的活,人算容易漏,数得出来的事一律让工具数。',
'**批量调**(入参 `customers` 数组,一次多家客户):工具调用后同拍其余动作作废,',
'一家客户调一次就要多截断一拍,而结果是同一份数据算出来的,批量传入不影响精度。',
'',
'`gapNodes` 全员缺口(一个节点都没覆盖到)是正常结果,不是异常 —— 不要回头重调工具',
'确认;缺口本身就是下一步经营计划要处理的东西。',
'',
'缺口分析只认台账现状,不猜将来:客户明说要覆盖某个人,先看 `customer.contact`',
'里有没有这个人的记录,没有的话缺口分析给不出他,只能在计划里写「需先建档」。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['决策链地图', 'BT 层级'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('crm-coverage.plan-drafting'),
label: '按影响力 × 亲密度缺口排经营计划',
applicability: '缺口找齐之后,要给每个关键人排优先级、定动作时。',
method: [
'排序不是拍脑袋,是尺:调 `crm-relationship-coverage.rank-key-persons` 批量算',
'(一次多条,不要一条关键人调一次 —— 拿回来的排序结果本就是同一份数据算出来的)。',
'`influence` 权重高的、`relationship` 缺口大的排前面 —— 一个天天见面的低影响力的人,',
'排序上必然让位给一个见不到面的高影响力的人,这是尺给的结论,不是你临场判断的。',
'',
'建议动作要**具体到能执行**:「拜访」不够,「下月中拜访技术总监,带 XX 认证报告」',
'才算数;每条动作配一个 `target_date`,没有时间点的建议是空话,验收判据③会核这个。',
'',
'理由(`rationale`)要说清「为什么是这个人、这个动作」——',
'引影响力评级与亲密度差值,不要只写「重要客户,建议拜访」这类空话,',
'人审计划时判的就是这句话站不站得住。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['影响力 × 亲密度缺口排序'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('crm-coverage.ledger-update'),
label: '收口写台账、每条更新配依据',
applicability: '执行回执收齐之后,要把结果写回 L-02/L-08 主数据时。',
method: [
'每一条更新都要能在回执里找到对应的事实 —— `evidence_ref` 不是可选的装饰,',
'是验收判据①的唯一抓手:写「回执 contact_code=CU-0142-01:已完成拜访,',
'亲密度由3提升至4」这种能溯源的话,不写「综合判断」这类查无实据的话。',
'',
'覆盖度 `coverage_score` 收口时按执行后的最新数据重算一次(调',
'`crm-relationship-coverage.recompute-score`),不要凭回执的完成条数估一个大概值 ——',
'分数要能重算出同一个数,重算不出来的分数没人信得过。',
'',
'「无变动」也要写清楚:回执里 `key_person_change` 是 `unchanged`(人在卡上选的',
'「无变动」)的联系人,台账更新明细里不必为它单开一行,但不许因为没变动就漏掉',
'这条覆盖度的重算依据。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['台账更新依据'],
version: '1.0.0',
},
];
@@ -0,0 +1,162 @@
/**
* E04 关系覆盖与关键人经营 · 视觉底(**E04 自己的 PRD 目标件,不是借来的家族版面**)。
*
* 上一版这里写着「E04 没有自己的 PRD 目标件」——那是假的:
* `docs/SS01-客户与市场/e04-html/` 下的四份 HTML(执行工单 1/2、验收工单 1/2)正是
* E04 自己的目标件,颜色变量、卡头渐变、判据框、原生 select/textarea 全部逐字量自那四份文件,
* 不是 A1/A2 那一支「浮起卡片 + 渐变徽章 + 头像签名」的家族版面。
*
* 两处**有意**偏离目标件,原因写在这里(别处再改记得回头看):
* 1. 目标件的必填只在表头打一个「*」;本仓有个更早、已验证过的平台级三档占位系统
* (必填红 / 建议填橘 / 选填灰,2026-08-02 定,`docs/HTML报告生成规范.md` 收录),
* 比「只在表头提一句」更能在空格子上直接指路。两者都留着:表头照抄目标件的「*」,
* 空格子再叠一层三档胶囊——不冲突,目标件的例子本来就没有空格子可比对。
* 2. 候选清单「状态」列:目标件那一列是「正式/试用」(客户主数据的状态),
* 本活动的 `candidate.status` 是另一个真实字段(`pending/covered/excluded`,
* 候选行本轮的覆盖生命周期,`declarations.ledger` 里 tally 着),两者恰好同名不同义。
* 留的是本活动这个——它是真在用的数据,目标件那份是另一件事。
*
* 版面照目标件分两层:这份文件是**底**(配色变量、卡壳、头/尾、判据框、说明框、表格基础排版),
* `report.ts` 里的 `BIND_CSS` 是**面**(哪个值决定哪种颜色/文案——三档占位、缺项红带、
* 状态徽、编辑闸开合、行增删这些「数据驱动的开关」,判断在样式表,取值在模版,两处不许混)。
*/
export const PRD_CSS = `
.proto-root {
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4;
--danger:#d83931; --danger-soft:#feeae9;
--warn-accent:#a9711e; --warn-soft:#f6ecd8;
--mono: "SF Mono", Menlo, Consolas, monospace;
--t-fast: .16s;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
font-size: 14px;
line-height: 1.7;
}
.proto-root, .proto-root * { box-sizing: border-box; }
/* ==================== 卡壳(嵌进工作区,不自带浮起——外层已经是一张卡)==================== */
.proto-root .jc-report { background: #fff; overflow: hidden; }
.proto-root .jc-rp-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 2px solid var(--ink); }
.proto-root .jc-report.tone-human .jc-rp-head { background: linear-gradient(120deg, #fffaf1 0%, #fff 55%, #fbf6ea 100%); }
.proto-root .jc-report.tone-ledger .jc-rp-head { background: linear-gradient(120deg, #fdf3f1 0%, #fff 55%, #fbf2f0 100%); }
.proto-root .jc-rp-badge {
flex: none; width: 44px; height: 44px; border-radius: 10px;
display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px;
}
.proto-root .jc-report.tone-human .jc-rp-badge { background: var(--human); box-shadow: 0 6px 16px rgba(169,113,30,.25); }
.proto-root .jc-report.tone-ledger .jc-rp-badge { background: var(--ledger); box-shadow: 0 6px 16px rgba(178,58,46,.25); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { font-style: normal; font-size: 11px; letter-spacing: .08em; color: var(--ink-4); font-weight: 700; }
.proto-root .jc-rp-tit h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; line-height: 1.3; }
.proto-root .jc-rp-sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; }
.proto-root .jc-rp-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
/* 目标件 side 第二行:步骤(静态文案),同目标件 .step 的字号字色 */
.proto-root .jc-rp-step { font-size: 12px; color: var(--ink-3); }
/* 关联任务号:全长 UUID 如实给,排版上截尾 —— 截断是排版,不是数据 */
.proto-root .jc-rp-taskid {
display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: 11px;
}
.proto-root .jc-rp-tag { font-size: 11px; font-weight: 800; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
.proto-root .jc-report.tone-human .jc-rp-tag { color: var(--human); background: var(--human-soft); border-color: var(--human-line); }
.proto-root .jc-report.tone-ledger .jc-rp-tag { color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line); }
.proto-root .jc-rp-wait { font-size: 12px; color: var(--ink-3); }
.proto-root .jc-rp-wait.warn { color: var(--danger); font-weight: 700; }
.proto-root .jc-rp-bd { padding: 20px 22px; }
/* 【做什么】/【交回什么】这类分区小标——括号label + 破折号说明,同一行 */
.proto-root .jc-rp-part { font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-4); margin: 20px 0 8px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part b { color: var(--ink-3); }
/* AI 已完成 / 需要你 —— 说明框,左侧色条按 tone 走 */
.proto-root .jc-derive { border: 1px solid var(--line); border-left: 3px solid; border-radius: 8px; background: var(--soft); padding: 14px 16px; margin-bottom: 16px; }
.proto-root .jc-report.tone-human .jc-derive { border-left-color: var(--human); }
.proto-root .jc-report.tone-ledger .jc-derive { border-left-color: var(--ledger); }
.proto-root .jc-derive p { margin: 0; font-size: 13px; color: var(--ink-2); }
.proto-root .jc-derive p + p { margin-top: 6px; }
.proto-root .jc-derive b { color: var(--ink); }
.proto-root .jc-derive .done { color: var(--ok); font-weight: 700; }
.proto-root .jc-report.tone-human .jc-derive .need { color: var(--human); font-weight: 700; }
.proto-root .jc-report.tone-ledger .jc-derive .need { color: var(--ledger); font-weight: 700; }
/* 验收判据框——目标件里两份验收工单同一色(赭红),不随 tone 变 */
.proto-root .jc-criteria { border: 1px solid var(--ledger-line); border-radius: 10px; background: var(--ledger-soft); padding: 13px 16px; margin-bottom: 18px; }
.proto-root .jc-criteria-t { font-size: 11px; font-weight: 800; letter-spacing: .08em; color: var(--ledger); margin-bottom: 7px; }
.proto-root .jc-criteria ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.proto-root .jc-criteria li { margin: 4px 0; }
.proto-root .jc-criteria b { color: var(--ink); }
/* 表格基础排版(交互态的显隐开关在 BIND_CSS) */
/*
* **表宽超出就得能横着滑**(2026-08-23 用户:「表格已经显示不全了,不能左右滑动」)。
* 从前这里是 overflow: hidden —— 那是为了让圆角裁住表头,可它同时把超出的列
* **直接裁掉且滑不动**:列一多(或某列为了放下下拉框加宽)就看不全,人还没有任何办法。
* quote-* 各包一直是 overflow-x: auto,这两个新包抄版式时把这一条抄丢了。
* (注释里不写反引号 —— 整段 CSS 是模版字符串,一个反引号就把它截断,本轮踩第三次。)
* 竖向仍 hidden:件是整篇铺开跟着外层滚的,表格自己不该再套一层竖滚动条。
*/
.proto-root .jc-tbl-wrap { border: 1px solid var(--line-soft); border-radius: 10px; overflow-x: auto; overflow-y: hidden; margin-bottom: 4px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: collapse; font-size: 13px; }
.proto-root .jc-tbl-in th { text-align: left; background: var(--soft); font-size: 11px; letter-spacing: .04em; font-weight: 800; color: var(--ink-3); padding: 9px 11px; border-bottom: 1px solid var(--line); }
.proto-root .jc-tbl-in th.req::after { content: " *"; color: var(--danger); font-size: 11px; }
.proto-root .jc-tbl-in th.sug::after { content: " 建议"; color: var(--warn-accent); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in th.opt::after { content: " 选填"; color: var(--ink-4); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in td { padding: 9px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in td.jc-rn { width: 38px; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--ink-4); text-align: center; position: relative; }
.proto-root .jc-vcell { display: block; font-size: 12.5px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vcell.cust { font-weight: 700; }
.proto-root .jc-vcell.dim { color: var(--ink-4); text-decoration: line-through; }
.proto-root .jc-diffnew { font-weight: 800; color: var(--ok); }
.proto-root .jc-cell, .proto-root select.jc-cell, .proto-root textarea.jc-cell {
width: 100%; min-width: 60px; border: 1px solid var(--line); border-radius: 6px;
padding: 6px 8px; font: inherit; font-size: 12.5px; color: var(--ink); background: #fff;
}
/* 多行文本照目标件:最小两行高,竖向可拉 */
.proto-root textarea.jc-cell { min-height: 40px; resize: vertical; }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
/* 格内次行小字(依据 / 编码)—— 主行之下,不占独立列 */
.proto-root .jc-sub { display: block; font-size: 11px; color: var(--ink-4); margin-top: 2px; line-height: 1.55; }
.proto-root .jc-sub i { font-style: normal; }
/* 取值位(i)全空的次行整行藏掉;直填文本的次行(如对象格的编码)不归这条管 */
.proto-root .jc-sub:has(i):not(:has(i:not(:empty))) { display: none; }
.proto-root .jc-rowdel {
width: 22px; height: 22px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 14px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-addrow em { font-style: normal; font-size: 11.5px; color: var(--ink-4); }
.proto-root .jc-addrow button {
cursor: pointer; border: 1px solid var(--brand-soft); border-radius: 7px; padding: 5px 12px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--brand-deep); background: var(--brand-wash);
}
.proto-root .jc-addrow button:hover { background: var(--brand-soft); }
/* 本工单只承载内容,提交/审批请在对话区——目标件四份文件都有这一条 */
.proto-root .jc-flow { margin-top: 16px; padding: 9px 12px; border-radius: 8px; background: var(--soft); border: 1px dashed var(--line); font-size: 12px; color: var(--ink-3); }
.proto-root .jc-flow b { color: var(--ink); }
.proto-root .jc-rp-foot { padding: 13px 22px; border-top: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-4); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.proto-root .jc-rp-foot span:last-child { font-family: var(--mono); }
`;
@@ -0,0 +1,551 @@
/**
* E04 关系覆盖与关键人经营的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 四个局面各一张:
* · `coverage-scope` —— S1/S2 摆,候选覆盖客户清单,可编辑(增删改);
* · `coverage-plan` —— S3/S4 摆,关键人经营计划,**只读**(业务说明明文
* "人只做审批,不能修改结论"——通过/驳回的按钮在对话区的决策卡上,不在这份件里);
* · `coverage-receipt` —— S5 摆,执行回执,混排只读(计划动作/对象承继自计划)+
* 可编辑(完成状态/结果摘要/客户反馈/关键人变动四列);
* · `coverage-ledger-update` —— S6/T_release 摆,台账更新明细,**只读**,放行后即归档件。
*
* 版面语言逐字量自 E04 自己的目标件(`docs/SS01-客户与市场/e04-html/` 四份 HTML,
* `prd-style.ts` 头注写了两处有意偏离的地方)。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:crm_coverage.<object>`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺必填红带、状态徽、
* 排序位次全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { PRD_CSS } from './prd-style.ts';
const D = 'crm_coverage';
/* ==================== 绑定期补的样式(数据驱动的开关,判断都在这一层)==================== */
const BIND_CSS = `
/* 空占位三级(全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件 / 只读件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺必填的整行浅红 + 行首红条 —— 由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
/*
* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件。
*
* **只换真有孪生件的那一份**(2026-08-21 用户送来实测截图查出来的坑):从前认
* 「表上有 on 就把这张表里所有 .jc-vcell 全藏起来」,而 coverage-receipt 混排
* 只读列(计划动作/对象里的联系人姓名——承继自计划,声明明写"改不了")与可改列
* (完成状态等),只读列压根没有 .jc-cell 孪生件可换 —— 一开编辑,那格字直接消失,
* 要退出编辑才重新看得见。**只藏「后面紧跟着一个孪生件」的那一份**:
* 没有孪生件的 .jc-vcell 不属于这道开关,编辑态里也照旧显示。
*/
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell:has(+ .jc-cell) { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown / :invalid 认) */
.proto-root .jc-tbl-in.on td.req > select.jc-cell:invalid,
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown),
.proto-root .jc-tbl-in.on tbody tr:has(td.req > select.jc-cell:invalid) { background: #fffbfb; }
/* 铺满工作区,卡壳不再自带外边框(外层容器已经是一张卡) */
.jc-stage { max-width: none; padding: 0 0 4px; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 14px 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 12px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 6px 13px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--human); border-color: var(--human); }
.proto-root .jc-egbtn.save:hover { filter: brightness(1.06); color: #fff; }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--human-soft); border-color: var(--human-line); color: #7c4a12; }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺必填红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px dashed var(--brand-soft); color: var(--ink-3);
font-size: 12px; line-height: 1.6;
}
.proto-root .jc-editbar b { color: var(--ink-2); }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel { display: block; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
/*
* 统一的枚举标签(候选状态 / 优先级 / 完成状态 / 关键人变动 共用同一套机制):
* 值是机器码,人读名与颜色一律由 data-class-of 投影出的 .v-xxx 决定,不写死在取值那一侧。
* 三组值域(pending/covered/excluded、high/medium/low、done/partial/skipped、是/无变动)各自的
* .v-* 类名不会互相碰撞,同一个 .jc-tag 基座够用,不必分三套徽章系统。
*/
.proto-root .jc-tag { font-weight: 800; }
.proto-root .jc-tag::after { content: "待定"; color: var(--ink-4); font-weight: 600; }
.proto-root .jc-tag.v-pending::after { content: "待覆盖"; }
.proto-root .jc-tag.v-pending { color: var(--warn-accent); }
.proto-root .jc-tag.v-covered::after { content: "已覆盖"; }
.proto-root .jc-tag.v-covered { color: var(--ok); }
.proto-root .jc-tag.v-excluded::after { content: "已排除"; }
.proto-root .jc-tag.v-excluded { color: var(--ink-3); }
.proto-root .jc-tag.v-high::after { content: "高"; }
.proto-root .jc-tag.v-high { color: var(--danger); font-family: var(--mono); }
.proto-root .jc-tag.v-medium::after { content: "中"; }
.proto-root .jc-tag.v-medium { color: var(--ink-2); font-family: var(--mono); }
.proto-root .jc-tag.v-low::after { content: "低"; }
.proto-root .jc-tag.v-low { color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-tag.v-done::after { content: "完成"; }
.proto-root .jc-tag.v-done { color: var(--ok); }
.proto-root .jc-tag.v-partial::after { content: "部分完成"; }
.proto-root .jc-tag.v-partial { color: var(--warn-accent); }
.proto-root .jc-tag.v-skipped::after { content: "未执行"; }
.proto-root .jc-tag.v-skipped { color: var(--ink-3); }
/*
* **值域不能用中文**(2026-08-21 用户送来实测截图查出来的坑):classToken()
* 只放行 ASCII 字母数字与横杠(见 view-render.ts 的注释——属性位插值唯一开放
* 的白名单,宁可没有颜色也不给一个错的钩子),中文字符整段被消毒成空字符串,
* data-class-of="key_person_change" 因此永远长不出 v-是/v-无变动 这两个
* class —— 徽章只会落到默认的「待定」,人怎么选、选了多少次都一样,像是保存没生效。
* 机器值改成 ASCII(changed/unchanged),中文只留在这里的 ::after 与
* option 文字里(人看到的字一个没少)。
*/
.proto-root .jc-tag.v-changed::after { content: "是"; }
.proto-root .jc-tag.v-changed { color: var(--danger); }
.proto-root .jc-tag.v-unchanged::after { content: "无变动"; }
.proto-root .jc-tag.v-unchanged { color: var(--ink-4); }
/*
* **覆盖深度同一坑的另一半**(2026-08-21 用户:「编辑的下拉框是中文,显示是英文」)——
* depth 本身不含中文(说明书写死 ASCII deep/standard/light,不撞 classToken 那道墙),
* 但只读那一份从前是 .jc-vcell 直接回显原值,人看到的是英文单词,跟下拉选项(深度/标准/
* 轻度)对不上。换成同一套 data-class-of + .jc-tag 机制,人读的字上下一致。
*/
/*
* **BT 层级同一坑的第三半**(2026-08-23 用户:「BT 层级下拉框显示不全,
* 都看不到具体选了哪个」)。两处一起错:
* · 列宽只有 80px,而选项文字是「BT-01 · 采购/供应链」—— 下拉被挤扁,选了什么看不见
* (覆盖深度那列因为选项只有两个字,一直没暴露);
* · 只读那一份是 data-slot 直显,存的是 ASCII bt-02,人看到的就是 bt-02 ——
* 与编辑态下拉里写的「BT-02 · 技术」对不上。覆盖深度当初专门修过这个坑,
* BT 层级漏了。现在两列同一套:值仍是 ASCII(台账与 recompute-score 的
* btLevel 入参认它),人读的字由这里出。
*/
.proto-root .jc-tag.v-bt-01::after { content: "BT-01 · 采购/供应链"; }
.proto-root .jc-tag.v-bt-02::after { content: "BT-02 · 技术"; }
.proto-root .jc-tag.v-bt-03::after { content: "BT-03 · 高层"; }
.proto-root .jc-tag.v-bt-01, .proto-root .jc-tag.v-bt-02, .proto-root .jc-tag.v-bt-03 { color: var(--ink-2); font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-tag.v-deep::after { content: "深度"; }
.proto-root .jc-tag.v-standard::after { content: "标准"; }
.proto-root .jc-tag.v-light::after { content: "轻度"; }
/* 状态徽承继自引擎产物,不进编辑态的显隐开关——on 时也照旧摆着,当「现在是什么」的参照 */
.proto-root .jc-tbl-in.on td > .jc-tag { display: inline-flex; align-items: center; margin-top: 4px; }
/*
* 「本轮触发 / 本轮说明」这类**值还没写下就整行不摆**的说明行:
* 行里所有取值位(b/span/i)都空着 → 整行藏掉 —— 「本轮说明:」后面跟着一片空白
* 比不摆更糟(像是丢了数据)。值一落(S1 写 verdict),行自己现身。
*/
.proto-root .jc-note-line:not(:has(b:not(:empty), span:not(:empty), i:not(:empty))) { display: none; }
`;
/**
* 三层壳照目标件:少一层样式表全失效。
*
* 卡头/页脚的动态位照目标件 §6.2 补齐(2.0.0):收件人(case:owner)、
* **关联任务**(case:taskId,基座注入的任务号)、**步骤**(step,目标件里每份工单
* 写死的静态文案,如「步骤 ① 启动与覆盖范围确认」)、页脚**单号**(round_ref)。
* 目标件还有一格「最迟交回」—— 本仓没有工单期限这个事实,**不摆假数据**,那一格不做。
*/
const doc = (input: {
tone: 'human' | 'ledger';
badge: string;
tag: string;
kicker: string;
title: string;
titleRef: string;
/** 目标件 side 第二行:静态步骤文案(哪份工单盖哪几步是写清单时定死的) */
step: string;
state: string;
body: string;
foot: string;
}): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report tone-${input.tone}">
<header class="jc-rp-head">
<div class="jc-rp-badge">${input.badge}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <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>
<div class="jc-rp-side">
<span class="jc-rp-tag">${input.tag}</span>
<span class="jc-rp-step">${input.step}</span>
${input.state}
</div>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = ''): string =>
`<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
`<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */
const sign = (note: string, tag: string): string =>
`<span>${note}</span><span>${tag} · <span data-slot="round_ref" data-blank></span></span>`;
/** 编辑闸 —— `label` 是闸上那句话(目标件的措辞各工单不同,如「AI 已识别 N 个客户」) */
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${label}</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
/* ==================== 局面一 · 候选覆盖客户清单(S1/S2)==================== */
const CANDIDATE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th class="req" style="min-width:100px">客户编号</th>
<th class="req" style="min-width:170px">客户名称</th>
<th class="sug" style="min-width:110px">客户类型</th>
<th class="sug" style="width:150px;min-width:150px">BT 层级</th>
<th style="width:80px;min-width:80px">状态</th>
<th class="sug" style="min-width:200px;width:100%">识别依据 / 纳入原因</th>
<th class="sug" style="min-width:104px">覆盖深度</th>
</tr></thead>
<tbody data-rows="mine:${D}.candidate" data-edit="${D}.candidate"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.candidate" title="撤掉这一行">×</button></td>
<td class="req" 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" data-slot="customer_name" data-col="customer_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="BT 层级">
<select class="jc-cell" data-col="bt_level">
<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="bt_level"></span>
</td>
<td data-h="状态"><span class="jc-tag" data-class-of="status"></span></td>
<td class="sug" data-h="识别依据 / 纳入原因"><span class="jc-vcell" data-slot="basis" data-col="basis" data-blank></span></td>
<td class="sug" data-h="覆盖深度">
<select class="jc-cell" data-col="depth">
<option value=""></option>
<option value="deep">深度</option>
<option value="standard">标准</option>
<option value="light">轻度</option>
</select>
<span class="jc-tag" data-class-of="depth"></span>
</td>
</tr></tbody>
</table></div>`;
export const COVERAGE_SCOPE = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 1/2',
title: '覆盖范围确认',
titleRef: 'round_ref',
step: '步骤 ① 启动与覆盖范围确认',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_candidate_rows"></b> 家候选</span>`,
body:
part('做什么', '确认本轮覆盖范围') +
`<div class="jc-derive">
<p><span class="done">AI 已完成</span>:按台账规则(覆盖度陈旧 / 决策链空白节点 / 事件触发)从 <b>L-02 客户主台账</b>与
<b>L-08 联系人台账</b>识别本轮触发口径与覆盖范围,产出下方客户清单(候选基线)。</p>
<p class="jc-note-line">本轮触发:<b data-slot="trigger_label" data-blank></b> <b data-slot="event_type" data-blank></b></p>
<p class="jc-note-line">本轮说明:<span data-slot="note" data-blank></span></p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」直接在清单上增删改 —— <b>加一行 = 额外纳入</b>(AI 未识别但你有价值判断的客户)、
<b>删一行 = 排除</b>(AI 纳入但实际应排除)、<b>改单元格 = 修正</b> AI 识别的内容。没有要补的直接在对话区卡里提交。</p>
</div>` +
part('交回什么', '核对后的覆盖范围客户清单(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_candidate_missing">
<span><b>还差 <span data-slot="${D}_candidate_missing"></span> 行客户编号或客户名称没填</b>
—— <span data-slot="${D}_candidate_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.candidate`,
`<b>AI 已识别 <span data-slot="${D}_candidate_rows"></span> 家候选客户</b>,点「编辑」在清单上增删改`,
) +
`<div class="jc-editbar">
<span>加一行 = 额外纳入,删一行 = 排除,改单元格 = 修正 AI 识别的内容。
<b>客户编号</b>与<b>客户名称</b>是必填,缺一条这一单走不到经营计划那一步。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
CANDIDATE_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${D}.candidate">+ 加一行(额外纳入客户)</button>
<em>新加行的「客户编号」「客户名称」为必填;「识别依据」栏填你纳入它的原因。</em>
</div>` +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 执行工单 1/2 · 覆盖范围确认', '工单类型:执行'),
});
/* ==================== 局面二 · 关键人经营计划(S3/S4,只读)==================== */
const PLAN_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:140px">客户</th>
<th style="min-width:110px">关键人</th>
<th style="min-width:150px">建议动作</th>
<th style="width:92px;min-width:92px">时间点</th>
<th style="width:70px;min-width:70px">优先级</th>
<th style="min-width:200px;width:100%">AI 给出的理由</th>
</tr></thead>
<tbody data-rows="mine:${D}.plan">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="客户"><span class="jc-vcell cust" data-slot="customer_name" data-blank></span></td>
<td class="dimempty" data-h="关键人"><span class="jc-vcell" data-slot="contact_name" data-blank></span></td>
<td class="dimempty" data-h="建议动作"><span class="jc-vcell" data-slot="action" data-blank></span></td>
<td class="dimempty" data-h="时间点"><span class="jc-vcell mono" data-slot="target_date" data-blank></span></td>
<td data-h="优先级"><span class="jc-tag" data-class-of="priority"></span></td>
<td class="dimempty" data-h="AI 给出的理由"><span class="jc-vcell" data-slot="rationale" data-blank></span></td>
</tr>
</tbody>
</table></div>`;
/*
* 比目标件多一列「时间点」(有意偏离):验收判据③让人核「动作有没有时间点」,
* 而目标件的表上没有这一列 —— 判据指向的东西必须摆在卡面上,人才核得了
* (target_date 在台账声明里是必填,数据一直都在,只是从前没摆出来)。
*/
export const COVERAGE_PLAN = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 1/2',
title: '关系经营计划审批',
titleRef: 'round_ref',
step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_plan_rows"></b> 位关键人 · 按影响力 × 亲密度缺口排序</span>`,
body:
part('AI 结论说明', '结论是怎么算出来的') +
`<div class="jc-derive">
<p><b>AI 已完成的计算</b>:读取 L-02 客户画像、决策链地图、关系覆盖度,读取 L-08 联系人角色 / 影响力 / 亲密度;
对照决策链识别空白节点,按「影响力 × 亲密度缺口」排序,得出本轮<b>关系经营计划</b>。</p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这份计划是否合理,请逐项核对', [
'<b>关键人覆盖是否完整</b>:该经营的决策链关键人(BT-01 / BT-02 / BT-03)有没有漏掉;',
'<b>优先级排序是否合理</b>:是否符合「影响力 × 亲密度缺口」的排序逻辑,有没有高优的客户被排低了;',
'<b>动作是否可执行</b>:建议动作是否明确、有没有时间点、可不可落地。',
]) +
part('AI 产出的经营计划', '只读展示') +
PLAN_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 1/2 · 关系经营计划审批', '工单类型:验收'),
});
/* ==================== 局面三 · 执行回执(S5)==================== */
const RECEIPT_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:140px">计划动作</th>
<th style="min-width:150px">对象</th>
<th class="req" style="width:110px;min-width:110px">完成状态</th>
<th class="req" style="min-width:200px">结果摘要</th>
<th class="sug" style="min-width:180px">客户反馈</th>
<th class="req" style="width:100px;min-width:100px">关键人变动</th>
</tr></thead>
<tbody data-rows="mine:${D}.receipt" data-edit="${D}.receipt"><tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="计划动作"><span class="jc-vcell" data-slot="action" data-blank></span></td>
<td data-h="对象">
<span class="jc-vcell"><b class="cust" data-slot="customer_name" data-blank></b> · <i data-slot="contact_name" data-blank></i></span>
<span class="jc-vcell mono jc-sub" data-slot="contact_code" data-col="contact_code" data-blank></span>
</td>
<td class="req" data-h="完成状态">
<select class="jc-cell" data-col="completion_status" required>
<option value=""></option>
<option value="done">完成</option>
<option value="partial">部分完成</option>
<option value="skipped">未执行</option>
</select>
<span class="jc-tag" data-class-of="completion_status"></span>
</td>
<td class="req" data-h="结果摘要">
<span class="jc-vcell" data-slot="result_summary" data-blank></span>
<textarea class="jc-cell" data-col="result_summary" placeholder="见到的关键人、谈了什么、达成什么"></textarea>
</td>
<td class="sug" data-h="客户反馈">
<span class="jc-vcell" data-slot="customer_feedback" data-blank></span>
<textarea class="jc-cell" data-col="customer_feedback" placeholder="客户态度 / 诉求 / 下一步信号"></textarea>
</td>
<td class="req" data-h="关键人变动">
<select class="jc-cell" data-col="key_person_change" required>
<option value=""></option>
<option value="changed">是</option>
<option value="unchanged">无变动</option>
</select>
<span class="jc-tag" data-class-of="key_person_change"></span>
</td>
</tr></tbody>
</table></div>`;
export const COVERAGE_RECEIPT = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 2/2',
title: '经营计划执行回执',
titleRef: 'round_ref',
step: '步骤 ⑤⑥ 执行经营动作',
state: `<span class="jc-rp-wait warn">待回执 <b data-slot="${D}_receipt_missing"></b> / <b data-slot="${D}_receipt_rows"></b> 项</span>`,
body:
part('做什么', '执行计划动作') +
`<div class="jc-derive">
<p><b>经营计划已验收通过</b>。以下是需要你<span class="need">到场执行</span>的动作(物理拜访、技术交流、递送实物 ——
都是 AI 做不到、需要人去干的)。每项动作执行后,在下方回执。</p>
</div>` +
part('交回什么', '逐项回执(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_receipt_missing">
<span><b>还差 <span data-slot="${D}_receipt_missing"></span> 项回执没填齐</b>
—— <span data-slot="${D}_receipt_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.receipt`,
`<b>共 <span data-slot="${D}_receipt_rows"></span> 项计划动作待回执</b>,点「编辑」逐项回填`,
) +
`<div class="jc-editbar">
<span>可填的是<b>完成状态</b>、<b>结果摘要</b>、<b>客户反馈</b>、<b>关键人变动</b>四列;
计划动作与对象是承继自计划的只读展示,改不了。确实没有变动的「关键人变动」请选「无变动」,不能留空。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
RECEIPT_TABLE +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。若「关键人变动」标「是」,将触发台账写变更记录。'),
foot: sign('E04 关系覆盖与关键人经营 · 执行工单 2/2 · 经营计划执行回执', '工单类型:执行'),
});
/* ==================== 局面四 · 台账更新明细(S6/T_release,只读)==================== */
const LEDGER_UPDATE_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:150px">台账</th>
<th style="min-width:200px">字段</th>
<th style="min-width:140px">更新前</th>
<th style="min-width:140px;width:100%">更新后</th>
</tr></thead>
<tbody data-rows="mine:${D}.ledger_update">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="台账">
<span class="jc-vcell mono" data-slot="ledger_label" data-blank></span>
<span class="jc-sub mono"><i data-slot="customer_code" data-blank></i> <i data-slot="contact_code" data-blank></i></span>
</td>
<td class="dimempty" data-h="字段">
<span class="jc-vcell mono" data-slot="field_label" data-blank></span>
<span class="jc-sub">依据:<i data-slot="evidence_ref" data-blank></i></span>
</td>
<td class="dimempty" data-h="更新前"><span class="jc-vcell dim" data-slot="before_value" data-blank></span></td>
<td data-h="更新后"><span class="jc-vcell jc-diffnew" data-slot="after_value" data-blank></span></td>
</tr>
</tbody>
</table></div>`;
/*
* 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 L4 判据
* 让签字的人核「有没有依据 / 有没有张冠李戴」,而 evidence_ref / customer_code 从前
* 压根不在卡面上 —— 人被要求判他看不见的东西。次行小字摆进原格,四列结构不动。
*/
export const COVERAGE_LEDGER_UPDATE = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 2/2',
title: '关系台账更新确认',
titleRef: 'round_ref',
step: '步骤 ⑥⑦ 更新台账',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_ledger_update_rows"></b> 项更新</span>`,
body:
part('AI 结论说明', 'AI 收到回执后做了哪些更新') +
`<div class="jc-derive">
<p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度(coverage_score)、更新决策链地图(decision_chain)、
更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。
本轮覆盖结论:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次台账更新是否无误,请逐项核对', [
'<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);',
'<b>数值是否合理</b>:coverage_score、亲密度等数值变化幅度与回执是否吻合;',
'<b>有无漏更或误更</b>:该更新的字段都更新了、不该动的没动。',
]) +
part('拟更新的台账', '只读展示') +
LEDGER_UPDATE_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'),
});
+20 -1
View File
@@ -154,11 +154,18 @@ export type ToolGateway = {
}; };
/** /**
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
* 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。 * 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔:
* `pass` 才是终局(released);**非 pass = 打回重做,不是终局** ——
* 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型,
* 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22,
* 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有
* 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。
*/ */
export type PendingAcceptance = { export type PendingAcceptance = {
readonly acceptanceCardId: string; readonly acceptanceCardId: string;
readonly verdict?: 'pass' | 'reject'; readonly verdict?: 'pass' | 'reject';
/** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */
readonly comment?: string;
readonly selectedPorts?: Record<string, string>; readonly selectedPorts?: Record<string, string>;
}; };
export type CardGateway = { export type CardGateway = {
@@ -264,6 +271,18 @@ export type ListenerRegistrar = {
}): Promise<{ }): Promise<{
id: string; id: string;
}>; }>;
/**
* **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。
*
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。
* 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执,
* 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 ——
* 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。**
*/
declared(taskId: TaskId): Promise<ReadonlyArray<{
eventType: string;
keyFilter: Record<string, string>;
}>>;
}; };
/** /**
* 视图流控 —— app 注入 `interaction`(L3)。 * 视图流控 —— app 注入 `interaction`(L3)。
+14
View File
@@ -1935,6 +1935,11 @@ export declare const installActivityRoute: {
derived: "derived"; derived: "derived";
}>>; }>>;
caseKey: z.ZodOptional<z.ZodString>; caseKey: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
formatColumn: z.ZodString;
}, z.core.$strip>>;
fields: z.ZodArray<z.ZodObject<{ fields: z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
type: z.ZodEnum<{ type: z.ZodEnum<{
@@ -2187,6 +2192,15 @@ export declare const listActivitiesRoute: {
required: z.ZodBoolean; required: z.ZodBoolean;
label: z.ZodOptional<z.ZodString>; label: z.ZodOptional<z.ZodString>;
format: z.ZodOptional<z.ZodString>; format: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
}, z.core.$strip>>>;
example: z.ZodOptional<z.ZodString>;
showWhen: z.ZodOptional<z.ZodObject<{
field: z.ZodString;
equals: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>; }, z.core.$strip>>;
samples: z.ZodDefault<z.ZodArray<z.ZodObject<{ samples: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
File diff suppressed because it is too large Load Diff
+16
View File
@@ -84,6 +84,7 @@ declare const fileDocSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -221,6 +222,7 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -294,6 +296,7 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -458,6 +461,7 @@ export declare const deliverableSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -531,6 +535,7 @@ export declare const deliverableSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -722,6 +727,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -795,6 +801,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1066,6 +1073,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1139,6 +1147,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1331,6 +1340,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1404,6 +1414,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1647,6 +1658,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1720,6 +1732,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">; kind: z.ZodLiteral<"markdown">;
name: z.ZodString; name: z.ZodString;
md: z.ZodString; md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">; kind: z.ZodLiteral<"excel">;
name: z.ZodString; name: z.ZodString;
@@ -1824,6 +1837,7 @@ export declare const decisionCardSchema: z.ZodObject<{
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">; by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>; note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">; id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime; at: z.ZodISODateTime;
}, z.core.$strip>; }, z.core.$strip>;
@@ -1935,6 +1949,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">; by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>; note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">; id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime; at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
@@ -2067,6 +2082,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">; by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>; note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">; id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime; at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
+5
View File
@@ -98,6 +98,11 @@ export declare const objectDeclarationSchema: z.ZodObject<{
derived: "derived"; derived: "derived";
}>>; }>>;
caseKey: z.ZodOptional<z.ZodString>; caseKey: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
formatColumn: z.ZodString;
}, z.core.$strip>>;
fields: z.ZodArray<z.ZodObject<{ fields: z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
type: z.ZodEnum<{ type: z.ZodEnum<{
+10
View File
@@ -48,6 +48,11 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
derived: "derived"; derived: "derived";
}>>; }>>;
caseKey: z.ZodOptional<z.ZodString>; caseKey: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
formatColumn: z.ZodString;
}, z.core.$strip>>;
fields: z.ZodArray<z.ZodObject<{ fields: z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
type: z.ZodEnum<{ type: z.ZodEnum<{
@@ -530,6 +535,11 @@ export declare const activityManifestSchema: z.ZodObject<{
derived: "derived"; derived: "derived";
}>>; }>>;
caseKey: z.ZodOptional<z.ZodString>; caseKey: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
formatColumn: z.ZodString;
}, z.core.$strip>>;
fields: z.ZodArray<z.ZodObject<{ fields: z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
type: z.ZodEnum<{ type: z.ZodEnum<{
@@ -0,0 +1,131 @@
/**
* 产出的资料**存档** —— 放行那一刻冻住的那一份(2026-08-23 用户:
* 「检查当前产出的附件是否存档,在工作成果里也需要有产出的附件,点击后可以预览」)。
*
* 存档之前的实情:文件是台账那一行现造的,而资料常落在**主数据**上,
* 试跑单放行时主数据只留在活动缓存、不落正式表 —— 于是**放行之后一份都点不开**
* (实测 404)。工作成果恰恰是收工之后才去看的地方,不存这一份,那一屏就是空的。
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { archiveAssetFiles, keyText } from '../asset-archive.ts';
const TASK = 't-1' as TaskId;
/** 记下发生过哪些写 —— SQL 不求值,只看「几笔、带了什么」 */
function fakeDb() {
const writes: string[] = [];
return {
writes,
execute: async (query: unknown) => {
// drizzle 的 sql`` 片段:把它的字面量与参数拼成一句好断言的字符串
const q = query as { queryChunks?: unknown[] };
writes.push(JSON.stringify(q.queryChunks ?? query).slice(0, 4000));
return { rows: [] };
},
};
}
const ids = (() => {
let n = 0;
return {
next: () => {
n += 1;
return `id-${n}`;
},
};
})();
const ASSET = {
domain: 'content',
object: 'asset',
fields: [{ name: 'content_id', primaryKey: true }, { name: 'title' }, { name: 'body_md' }, { name: 'file_format' }],
file: { titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' },
};
describe('keyText —— 主键拼法必须稳定', () => {
it('按列名排序拼,写进去与取出来才对得上', () => {
expect(keyText({ b: '2', a: '1' })).toBe('a=1,b=2');
expect(keyText({ a: '1', b: '2' })).toBe('a=1,b=2');
});
it('单列主键就是一句 `列=值` —— 与件上 data-file 那一串同形状', () => {
expect(keyText({ content_id: 'CT-2026-0001' })).toBe('content_id=CT-2026-0001');
});
});
describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [
{ content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' },
{ content_id: 'CT-2', title: '对照表', body_md: '| a | b |\n| - | - |\n| 1 | 2 |', file_format: 'xlsx' },
],
});
expect(saved).toBe(2);
expect(db.writes).toHaveLength(2);
expect(db.writes.join()).toContain('白皮书');
});
it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
});
expect(saved).toBe(0);
expect(db.writes).toHaveLength(0);
});
it('**主键不全的行不存** —— 存了也按不回那一行,取不回来', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
});
it('没声明 `file` 段的表整张跳过 —— 存档也认那道授权面', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [{ ...ASSET, file: undefined }],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
});
it('格式列填了个不认识的值:退 md,不整份丢掉', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
});
expect(saved).toBe(1);
expect(db.writes.join()).toContain('"md"');
});
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
const db = fakeDb();
expect(
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }),
).toBe(0);
});
});
@@ -0,0 +1,210 @@
/**
* 资料文件现造 + 读回来 —— 两头合起来自证:**造出来的是真文件**。
*
* 断言的重点不是「函数返回了字节」,而是:
* · docx / xlsx 是合法 ZIP,里面那几份 XML 一份不缺(Word / Excel 认的就是它们);
* · 读回来的内容与写进去的一模一样 —— 渲得出来,才谈得上「前端能渲染」;
* · 模型写的字全程转义 —— 正文里带 `<script>` 也只是几个字,不是标签。
*/
import { describe, expect, it } from 'vitest';
import {
columnName,
composeFile,
inlineHtml,
markdownBody,
markdownSheetRows,
parseMarkdown,
safeFileName,
} from '../file-compose.ts';
import { docxBlocks, toFileDoc, xlsxRows } from '../file-doc.ts';
import { readZip } from '../upload-extract.ts';
const MD = [
'# 车规级 MCU 供货能力说明',
'',
'本文面向 **Itelma** 采购与供应链团队,说明我方在车规 MCU 上的供货保障。',
'',
'## 一、产能与库存',
'',
'- 常备安全库存:12 周',
'- 二供已导入,切换周期 4 周',
'',
'## 二、价格与交期',
'',
'| 型号 | 交期(周) | 年用量 |',
'| --- | --- | --- |',
'| NE555P | 8 | 30000 |',
'| DAG2400 | 12 | 25000 |',
'',
'---',
'',
'结论:可承诺 `98%` 的按期交付率。',
].join('\n');
describe('parseMarkdown —— 认得出这几样,认不出的不吞字', () => {
it('标题 / 段落 / 列表 / 表格 / 分隔线各归各位', () => {
const blocks = parseMarkdown(MD);
expect(blocks.map((b) => b.t)).toEqual(['h1', 'p', 'h2', 'ul', 'h2', 'table', 'hr', 'p']);
});
it('表格的分隔行只说对齐,不算一行数据', () => {
const table = parseMarkdown(MD).find((b) => b.t === 'table');
expect(table).toBeDefined();
if (table?.t !== 'table') throw new Error('不是表格块');
expect(table.rows).toEqual([
['型号', '交期(周)', '年用量'],
['NE555P', '8', '30000'],
['DAG2400', '12', '25000'],
]);
});
it('认不出的行当段落原样留着 —— 宁可朴素也不吞内容', () => {
const blocks = parseMarkdown('> 引用还不认\n\n普通段落');
expect(blocks).toEqual([
{ t: 'p', text: '> 引用还不认' },
{ t: 'p', text: '普通段落' },
]);
});
});
describe('转义 —— 模型写的字进不了标签位', () => {
it('正文里的 <script> 只是几个字', () => {
const html = markdownBody('这里有 <script>alert(1)</script> 一段');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>');
});
it('先转义再上行内标记 —— 顺序反了就是把字当标签执行', () => {
expect(inlineHtml('**粗** 与 `码` 与 <b>假标签</b>')).toBe(
'<strong>粗</strong> 与 <code>码</code> 与 &lt;b&gt;假标签&lt;/b&gt;',
);
});
});
describe('md —— 原文即文件', () => {
it('正文没有标题行就补一条:文件得自报家门', () => {
const f = composeFile({ title: '供货能力说明', markdown: '正文直接开始', format: 'md' });
expect(new TextDecoder().decode(f.bytes)).toBe('# 供货能力说明\n\n正文直接开始');
expect(f.fileName).toBe('供货能力说明.md');
});
it('正文自带标题就不再补', () => {
const f = composeFile({ title: 'T', markdown: MD, format: 'md' });
expect(new TextDecoder().decode(f.bytes).startsWith('# 车规级 MCU')).toBe(true);
});
});
describe('html —— 整份文件,离线打开也是这个样子', () => {
const f = composeFile({ title: '供货能力说明', markdown: MD, format: 'html' });
const text = new TextDecoder().decode(f.bytes);
it('是一份完整文档:doctype + 自带样式 + 纸面容器', () => {
expect(text.startsWith('<!doctype html>')).toBe(true);
expect(text).toContain('<style>');
expect(text).toContain('class="doc-paper"');
});
it('表格是真表格,不是一段文字', () => {
expect(text).toContain('<th>型号</th>');
expect(text).toContain('<td>NE555P</td>');
});
});
describe('docx —— 真 OOXML 包', () => {
const f = composeFile({ title: '供货能力说明', markdown: MD, format: 'docx' });
const zip = readZip(f.bytes);
it('该有的几份 XML 一份不缺(少一份 Word 就报文件损坏)', () => {
expect([...zip.keys()].sort()).toEqual(
['[Content_Types].xml', '_rels/.rels', 'word/_rels/document.xml.rels', 'word/document.xml', 'word/styles.xml'].sort(),
);
});
it('MIME 与扩展名是 Word 那一套', () => {
expect(f.fileName).toBe('供货能力说明.docx');
expect(f.mime).toContain('wordprocessingml.document');
});
it('读回来:标题、正文、列表、表格都在,层级没丢', () => {
const xml = new TextDecoder().decode(zip.get('word/document.xml') ?? new Uint8Array());
const blocks = docxBlocks(xml);
expect(blocks[0]).toEqual({ t: 'h1', text: '供货能力说明' });
expect(blocks.some((b) => b.t === 'h2' && b.text === '一、产能与库存')).toBe(true);
const list = blocks.find((b) => b.t === 'ul');
expect(list?.t === 'ul' ? list.items : []).toEqual(['常备安全库存:12 周', '二供已导入,切换周期 4 周']);
// 表格按行摊成 p:列用「 | 」连
expect(blocks.some((b) => b.t === 'p' && b.text === 'NE555P | 8 | 30000')).toBe(true);
});
it('行内标记不带进 Word —— 留着 ** 反而像乱码', () => {
const xml = new TextDecoder().decode(zip.get('word/document.xml') ?? new Uint8Array());
expect(xml).toContain('Itelma');
expect(xml).not.toContain('**Itelma**');
});
});
describe('xlsx —— 真 OOXML 包,表格原样成行', () => {
const f = composeFile({ title: '交期对照表', markdown: MD, format: 'xlsx' });
it('该有的几份 XML 一份不缺', () => {
expect([...readZip(f.bytes).keys()].sort()).toEqual(
['[Content_Types].xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels', 'xl/workbook.xml', 'xl/worksheets/sheet1.xml'].sort(),
);
});
it('读回来:表格三行在,首行是表头', () => {
const sheet = xlsxRows(f.bytes);
expect(sheet?.sheet).toBe('交期对照表');
const rows = sheet?.rows ?? [];
expect(rows[0]).toEqual(['交期对照表']);
expect(rows).toContainEqual(['型号', '交期(周)', '年用量']);
expect(rows).toContainEqual(['NE555P', '8', '30000']);
});
it('正文不丢:列表与段落各占一行', () => {
const rows = markdownSheetRows('T', MD);
expect(rows.some((r) => r[0] === '• 常备安全库存:12 周')).toBe(true);
expect(rows.some((r) => (r[0] ?? '').startsWith('结论:可承诺'))).toBe(true);
});
it('列号:0→A、25→Z、26→AA', () => {
expect([columnName(0), columnName(25), columnName(26)]).toEqual(['A', 'Z', 'AA']);
});
});
describe('toFileDoc —— 前端拿到的就是这份文件读回来的样子', () => {
const one = (format: 'md' | 'html' | 'docx' | 'xlsx') => {
const f = composeFile({ title: '供货能力说明', markdown: MD, format });
return toFileDoc({ name: f.fileName, title: '供货能力说明', format, bytes: f.bytes });
};
it('四种格式各出各的查看形态', () => {
expect(one('md')?.kind).toBe('markdown');
expect(one('html')?.kind).toBe('html');
expect(one('docx')?.kind).toBe('word');
expect(one('xlsx')?.kind).toBe('excel');
});
it('markdown 同时给原文与渲好的那一份(下载拿原文,屏上摆渲染版)', () => {
const doc = one('md');
if (doc?.kind !== 'markdown') throw new Error('不是 markdown');
expect(doc.md).toContain('| NE555P | 8 | 30000 |');
expect(doc.html).toContain('<td>NE555P</td>');
});
it('**读不出来就返回 undefined** —— 不摆一个空壳假装渲染过', () => {
const junk = new TextEncoder().encode('这不是 ZIP');
expect(toFileDoc({ name: 'x.docx', title: 'x', format: 'docx', bytes: junk })).toBeUndefined();
expect(toFileDoc({ name: 'x.xlsx', title: 'x', format: 'xlsx', bytes: junk })).toBeUndefined();
});
});
describe('文件名', () => {
it('路径分隔符这类字符换成下划线 —— 别让标题决定文件落在哪', () => {
expect(safeFileName('a/b:c*d?"e<f>g|h', 'md')).toBe('a_b_c_d__e_f_g_h.md');
});
it('标题整个是空白就退一个兜底名,不出「.docx」这种没名字的文件', () => {
expect(safeFileName(' ', 'docx')).toBe('资料.docx');
});
});
@@ -19,7 +19,7 @@
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。 * 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts'; import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts'; import { missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */ /** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
@@ -68,6 +68,85 @@ describe('件上按保存:按地址收', () => {
}); });
}); });
/**
* **人在件上加的那一行,主键得凑得齐** —— 凑不齐就被静默丢掉,而屏上那一行还在
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。
*
* 三类主键各有各的凑法(2026-08-23,审 I01 需求清单时逮到的):
* · **件上没摆的那一列**(如一单一个的案号):本单现有行全表一致 → 就是它;
* · **件上摆了的**:人填了就用他填的;
* · **序号型(整数主键)**:件上摆着,可「加一行」那一下所有格子都被清空
* (`ViewContainer.addRow` 如此)——各行序号本就互不相同,「全表一致」永远取不出,
* 于是整行被丢。序号的语义就是「本轮内递增」,该由基座续:现有最大值 + 1。
* I01 的需求清单主键正是 `round_ref + req_seq`,不补这一手,人加的需求就消失。
*/
describe('件上加行:主键凑得齐才落得下', () => {
const KEYS = ['round_ref', 'req_seq'];
const TYPES = { round_ref: 'text', req_seq: 'integer' };
const DRAFTS = [
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
{ key: { round_ref: 'CTB-2', req_seq: 2 } },
];
const SAME_ROUND = [
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
{ key: { round_ref: 'CTB-1', req_seq: 2 } },
];
it('老行:件上摆了序号,案号从全表一致里补', () => {
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '2' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '2',
});
});
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '3',
});
});
it('空表上加第一行:序号从 1 起 —— 但案号取不出就还是不猜', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '公司介绍' }, drafts: [], types: TYPES })).toBeUndefined();
expect(
resolveRowKey({ keys: KEYS, cells: { round_ref: 'CTB-9', content_type: '公司介绍' }, drafts: [], types: TYPES }),
).toEqual({ round_ref: 'CTB-9', req_seq: '1' });
});
/*
* **只有一行时,那一行的序号不是「全表一致的常量」**(2026-08-23 浏览器实测逮到:
* 人在只有一行的清单上加一行,新行拿到 req_seq=1,**当场把原来那一行覆盖掉** ——
* 屏上两行、库里一行,而且内容还成了新的那份)。序号逐行不同是它的定义,
* 一致只是行数太少的假象。
*/
it('**表里只有一行:加的新行续成 2,不覆盖原来那一行**', () => {
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '销售工具包' }, drafts: one, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '2',
});
});
it('老行照旧认得出来 —— 件上摆了序号(主键列必挂 data-col,它是行的钥匙)', () => {
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '1', scene: '展会' }, drafts: one, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '1',
});
});
it('案号本身取不出(一单跨了两个号)→ 不猜,如实跳过', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: DRAFTS, types: TYPES })).toBeUndefined();
});
it('**文本主键不续号** —— 序号能续是因为它本就是「第几条」,PN 这种续不出来', () => {
const textKeys = ['rfq_ref', 'pn'];
const textTypes = { rfq_ref: 'text', pn: 'text' };
const drafts = [{ key: { rfq_ref: 'R1', pn: 'A' } }, { key: { rfq_ref: 'R1', pn: 'B' } }];
expect(resolveRowKey({ keys: textKeys, cells: { eau: '100' }, drafts, types: textTypes })).toBeUndefined();
});
});
describe('提交闸:必填没填就交不了', () => { describe('提交闸:必填没填就交不了', () => {
const gate = (rows: Array<Record<string, unknown>>) => const gate = (rows: Array<Record<string, unknown>>) =>
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]); gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
@@ -298,3 +377,64 @@ describe('自增主键:前缀-两位序号', () => {
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' }); expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
}); });
}); });
describe('主键摆不全时认现有行:一格都没认出来的碎片不许命中随便第一行', () => {
const drafts = [
{ key: { round_ref: 'R1', contact_code: 'CU-0142-01' } },
{ key: { round_ref: 'R1', contact_code: 'CU-0087-02' } },
{ key: { round_ref: 'R1', contact_code: 'CU-0087-01' } },
];
const keys = ['round_ref', 'contact_code'];
it('至少一格对得上 → 命中那一行(正常路径)', () => {
expect(findKnownRow(drafts, keys, { contact_code: 'CU-0087-02' })?.key).toEqual({
round_ref: 'R1',
contact_code: 'CU-0087-02',
});
});
it('**一格主键都没给的碎片**(如渲染多嵌一层导致 collect() 拆出的空键分组)→ 不命中第一行,回 undefined', () => {
expect(findKnownRow(drafts, keys, {})).toBeUndefined();
});
it('给了但一个都对不上 → 同样不命中(不是「随便一行」,是「哪行都不是」)', () => {
expect(findKnownRow(drafts, keys, { contact_code: 'CU-9999-01' })).toBeUndefined();
});
});
/**
* **点开一份资料文件时,那一行是怎么认出来的**(2026-08-23)。
*
* 件上传来的 `key` 是一串字符,而"哪一行"是主键说了算。这一段的要害是
* **只认声明里的主键列** —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
*/
describe('parseRowKey —— 件上传来的主键认哪一行', () => {
it('单列主键:直接给值', () => {
expect(parseRowKey('CT-2026-0001', ['content_id'])).toEqual({ content_id: 'CT-2026-0001' });
});
it('多列主键:列=值 以逗号连,顺序无所谓', () => {
expect(parseRowKey('req_seq=2,round_ref=BR-1', ['round_ref', 'req_seq'])).toEqual({
round_ref: 'BR-1',
req_seq: '2',
});
});
it('**声明里没有的列名一律不认** —— 别让一串 key 把别的行取走', () => {
expect(parseRowKey('content_id=A,secret=1', ['content_id'])).toBeUndefined();
});
it('主键给不全:不认(宁可打不开,也不打开错的那一份)', () => {
expect(parseRowKey('round_ref=BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
expect(parseRowKey('round_ref=BR-1,req_seq=', ['round_ref', 'req_seq'])).toBeUndefined();
});
it('多列主键却只给了个裸值:不认(猜不出它是哪一列)', () => {
expect(parseRowKey('BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
});
it('空串 / 没有主键列:不认', () => {
expect(parseRowKey('', ['content_id'])).toBeUndefined();
expect(parseRowKey('X', [])).toBeUndefined();
});
});
+7 -1
View File
@@ -884,7 +884,13 @@ export function normalizeAction(object: unknown, enabled: EnabledSet): AgentActi
* 这里只是不让一个词的叫法差异吃掉一整拍。 * 这里只是不让一个词的叫法差异吃掉一整拍。
*/ */
const kind = String(raw['kind'] ?? raw['action'] ?? ''); const kind = String(raw['kind'] ?? raw['action'] ?? '');
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? ''); /*
* 项号兜底连**进行中**的项一起看(2026-08-23):从前只兜「当下可为」的第一项,
* 而依赖断点那一拍它恰恰是空的 —— 兜出来的是空串,兜底卡于是带着空项号发出去,
* 被引擎自己的门拦下(最后的活路自己被砌了墙)。引擎那一侧已改成「挂不上照发」,
* 这里再挑得准一点:在场的项优先挂上,记账才对得上。
*/
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? enabled.inProgress[0]?.id ?? '');
switch (kind) { switch (kind) {
case 'start': case 'start':
+163
View File
@@ -0,0 +1,163 @@
/**
* 产出的资料文件**存档** —— 放行那一刻把它冻住。
*
* 在办期间文件是台账那一行的投影(现造,见 `file-compose.ts`):台账改一格,文件跟着变,
* 两处永远一致 —— 那时这样最好。**收工之后不该再变**:归档件说的是「他签字那一刻
* 交出去的是什么」,台账后来被下一单改了,归档的那份不能跟着改口。
*
* 还有一条更硬的理由:资料常落在**主数据**上(如 `content.asset` 品牌内容库),
* 而试跑单放行时主数据只留在活动缓存、不落正式表(免得试跑往正式库里塞真资产)——
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
*
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
*/
import { sql } from 'drizzle-orm';
import type { TaskId } from '@contracts';
import { composeFile, isFileFormat, type FileFormat } from './file-compose.ts';
/** 存档里的一份 —— 列表用(不带字节) */
export type ArchivedAssetFile = {
readonly id: string;
readonly address: string;
readonly key: Record<string, string>;
readonly title: string;
readonly format: FileFormat;
readonly name: string;
readonly size: number;
readonly at: string;
};
type Db = { execute(query: unknown): Promise<unknown> };
function rowsOf<T>(result: unknown): T[] {
if (Array.isArray(result)) return result as T[];
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? (rows as T[]) : [];
}
/**
* 主键 → 一个**稳定的**字符串(按列名排序后拼)。
* 稳定是要害:存的时候与取的时候拼法不一样,那一行就永远对不上。
*/
export function keyText(key: Readonly<Record<string, unknown>>): string {
return Object.keys(key)
.sort()
.map((k) => `${k}=${String(key[k] ?? '')}`)
.join(',');
}
/** 这一单已存档的资料(不含字节) */
export async function listArchived(db: Db, taskId: TaskId): Promise<ArchivedAssetFile[]> {
const rows = rowsOf<{
id: string;
address: string;
row_key: unknown;
title: string;
format: string;
name: string;
size: number;
created_at: unknown;
}>(
await db.execute(sql`
select id, address, row_key, title, format, name, size, created_at
from sys.task_asset_file where task_id = ${taskId} order by created_at, name`),
);
return rows.map((r) => ({
id: r.id,
address: r.address,
key: (typeof r.row_key === 'object' && r.row_key !== null ? r.row_key : {}) as Record<string, string>,
title: r.title,
format: isFileFormat(r.format) ? r.format : 'md',
name: r.name,
size: Number(r.size ?? 0),
at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
}));
}
/** 取存档里的那一份字节 —— 按 (单, 地址, 主键) 认 */
export async function readArchived(
db: Db,
taskId: TaskId,
address: string,
key: Readonly<Record<string, string>>,
): Promise<{ bytes: Uint8Array; title: string; format: FileFormat; name: string } | undefined> {
const rows = rowsOf<{ bytes: unknown; title: string; format: string; name: string }>(
await db.execute(sql`
select bytes, title, format, name from sys.task_asset_file
where task_id = ${taskId} and address = ${address} and row_key = ${JSON.stringify(key)}::jsonb
limit 1`),
);
const one = rows[0];
if (one === undefined) return undefined;
const raw = one.bytes;
/*
* **必须拷一份成独立的 Uint8Array** —— pg 取回来的是 Node `Buffer`,
* 而 Buffer 挂在一块**共享的 64KB 内存池**上:`buf.slice()` 是视图不是拷贝,
* `.buffer` 拿到的是整个池子。直接交出去,一份 3KB 的文件会变成 65536 字节
* (后面全是别人的残渣)—— 实测下载三份资料,三份都是 65536。
* `Uint8Array.from` 按元素拷,新数组的 buffer 恰好这么长。
*/
const bytes =
raw instanceof Uint8Array
? Uint8Array.from(raw)
: typeof raw === 'string'
? Uint8Array.from(Buffer.from(raw, 'hex'))
: undefined;
if (bytes === undefined) return undefined;
return { bytes, title: one.title, format: isFileFormat(one.format) ? one.format : 'md', name: one.name };
}
/**
* 把这一单产出的资料**全部存一份**。放行落地之前调 —— 那时草稿还在、人的修订也已落回,
* 存下来的正是他签字那一刻的样子。
*
* 返回存了几份;一份都没有(活动没声明 `file` 段、或一行都没写)时返回 0,不报错 ——
* 大多数活动本来就不产资料文件。
*/
export async function archiveAssetFiles(input: {
readonly db: Db;
readonly taskId: TaskId;
readonly ids: { next(): string };
/** 活动清单声明的台账对象(带 `file` 段的才算) */
readonly declarations: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
}>;
/** 本单在该地址写下的行(正式版 + 本单草稿) */
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
}): Promise<number> {
let saved = 0;
for (const declared of input.declarations) {
const spec = declared.file;
if (spec === undefined) continue;
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
if (keys.length === 0) continue;
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
for (const row of rows) {
const markdown = String(row[spec.bodyColumn] ?? '').trim();
if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
const composed = composeFile({ title, markdown, format });
const address = `${declared.domain}.${declared.object}`;
await input.db.execute(sql`
insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size)
values (${input.ids.next()}, ${input.taskId}, ${address}, ${JSON.stringify(key)}::jsonb,
${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
on conflict (task_id, address, row_key) do update set
title = excluded.title, format = excluded.format, name = excluded.name,
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
saved += 1;
}
}
return saved;
}
+1 -1
View File
@@ -18,7 +18,7 @@ export type AppConfig = {
readonly seedPassword: string; readonly seedPassword: string;
}; };
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai'; const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */ /** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */
const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026'; const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026';
+425
View File
@@ -0,0 +1,425 @@
/**
* 资料文件**现造** —— 进「标题 + Markdown 正文 + 要哪种格式」,出真字节。
*
* 为什么是现造而不是另存一份文件:资料的正身是**内容**,而内容本来就落在台账里。
* 再存一份字节,同一份事实两处各存一份 —— 改了台账那份文件就成了旧的,而没人看得出
* 以哪份为准。现造的这一份永远与台账一致,下载拿到的又是真 .docx / .xlsx
* (Word / Excel 打得开),两头都不将就。
*
* 不引第三方库 —— 与 `upload-extract.ts` 同一条路:docx / xlsx 本质是 ZIP 包着几份 XML,
* 写一个只做 store(不压缩)的 ZIP 打包器就够了,为一件事拉进一整个文档库不值当。
*
* 这一份是**纯计算**:进字符串、出字节,不碰库不碰网,所以能逐条测。
*/
import { crc32Of, zipStore } from './zip-write.ts';
export const FILE_FORMATS = ['md', 'html', 'docx', 'xlsx'] as const;
export type FileFormat = (typeof FILE_FORMATS)[number];
export const isFileFormat = (v: unknown): v is FileFormat =>
typeof v === 'string' && (FILE_FORMATS as readonly string[]).includes(v);
/** 一份造好的文件 —— 字节 + 它该叫什么 + 该以什么 MIME 交出去 */
export type ComposedFile = {
readonly bytes: Uint8Array;
readonly fileName: string;
readonly mime: string;
readonly format: FileFormat;
};
/** 各格式该以什么 MIME 交出去 —— 存档那一路也认它(`desk.ts`) */
export const MIME_OF: Record<FileFormat, string> = {
md: 'text/markdown; charset=utf-8',
html: 'text/html; charset=utf-8',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/* ==================== Markdown → 结构块 ==================== */
export type MdBlock =
| { readonly t: 'h1' | 'h2' | 'h3' | 'p'; readonly text: string }
/* ul / ol 各成一员 —— 合成 `'ul' | 'ol'` 一个成员时,判过这两个分支
TS 也不会把它从联合里剔掉,后面取 `text` 就通不过 */
| { readonly t: 'ul'; readonly items: readonly string[] }
| { readonly t: 'ol'; readonly items: readonly string[] }
| { readonly t: 'table'; readonly rows: ReadonlyArray<readonly string[]> }
| { readonly t: 'hr' };
const TABLE_SPLIT = /\s*\|\s*/;
/** 一行表格 `| a | b |` → ['a','b'](首尾的竖线不算列) */
function tableCells(line: string): string[] {
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return inner.split(TABLE_SPLIT).map((c) => c.trim());
}
const isTableLine = (line: string): boolean => line.trim().startsWith('|') && line.trim().endsWith('|');
/** 分隔行 `|---|:--:|` —— 它只说对齐,不是数据 */
const isTableRule = (line: string): boolean => isTableLine(line) && /^[\s|:-]+$/.test(line);
/**
* Markdown 子集 → 块。**认的就是这几样**:标题(#/##/###)、无序列表(-/*)、
* 有序列表(1.)、表格(GFM)、分隔线(---)、段落。
*
* 为什么只认一个子集:这些文件是给人看的资料(白皮书 / 规格书 / 对照表),
* 上面这几样够写;认得越多、docx/xlsx 那一侧要对应的东西越多,而每多一样
* 都是一处可能与前端渲染不一致的地方。**认不出的一律当段落原样保留**,不吞字。
*/
export function parseMarkdown(md: string): MdBlock[] {
const lines = md.replace(/\r\n?/g, '\n').split('\n');
const blocks: MdBlock[] = [];
let para: string[] = [];
const flushPara = (): void => {
if (para.length === 0) return;
blocks.push({ t: 'p', text: para.join(' ').trim() });
para = [];
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? '';
const trimmed = line.trim();
if (trimmed === '') {
flushPara();
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
flushPara();
blocks.push({ t: 'hr' });
continue;
}
const heading = /^(#{1,3})\s+(.*)$/.exec(trimmed);
if (heading !== null) {
flushPara();
const depth = (heading[1] ?? '#').length;
blocks.push({ t: depth === 1 ? 'h1' : depth === 2 ? 'h2' : 'h3', text: (heading[2] ?? '').trim() });
continue;
}
if (isTableLine(trimmed)) {
flushPara();
const rows: string[][] = [];
while (i < lines.length && isTableLine(lines[i] ?? '')) {
const one = lines[i] ?? '';
if (!isTableRule(one)) rows.push(tableCells(one));
i += 1;
}
i -= 1;
if (rows.length > 0) blocks.push({ t: 'table', rows });
continue;
}
if (/^[-*+]\s+/.test(trimmed)) {
flushPara();
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').trim().replace(/^[-*+]\s+/, ''));
i += 1;
}
i -= 1;
blocks.push({ t: 'ul', items });
continue;
}
if (/^\d+[.)]\s+/.test(trimmed)) {
flushPara();
const items: string[] = [];
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').trim().replace(/^\d+[.)]\s+/, ''));
i += 1;
}
i -= 1;
blocks.push({ t: 'ol', items });
continue;
}
para.push(trimmed);
}
flushPara();
return blocks;
}
/* ==================== 行内:粗体 / 斜体 / 代码 ==================== */
export function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 行内标记 → HTML。**先转义再上标记** —— 顺序反了就是把模型写的字当标签执行 */
export function inlineHtml(text: string): string {
return escapeXml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
}
/** 行内标记**去掉**(docx / xlsx 的文本格里不做富文本,留记号反而像乱码) */
export function inlinePlain(text: string): string {
return text
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
}
/* ==================== md → HTML(前端渲染也用这一份) ==================== */
/**
* 正文骨架 —— 只吐这几种标签,内容全程转义:摆进沙箱是安全的,
* 而且前端 Markdown 查看面与下载到的 .html 长得一模一样(同一段函数出的)。
*/
export function markdownBody(md: string): string {
return parseMarkdown(md)
.map((b) => {
if (b.t === 'hr') return '<hr />';
if (b.t === 'ul') return `<ul>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ul>`;
if (b.t === 'ol') return `<ol>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ol>`;
if (b.t === 'table') {
const [head, ...rest] = b.rows;
const thead =
head === undefined ? '' : `<thead><tr>${head.map((c) => `<th>${inlineHtml(c)}</th>`).join('')}</tr></thead>`;
const tbody = rest.map((r) => `<tr>${r.map((c) => `<td>${inlineHtml(c)}</td>`).join('')}</tr>`).join('');
return `<div class="doc-tbl"><table>${thead}<tbody>${tbody}</tbody></table></div>`;
}
return `<${b.t}>${inlineHtml(b.text)}</${b.t}>`;
})
.join('\n');
}
/** 纸面样式 —— 下载的 .html 与前端查看面共用,两处看着是同一份文件 */
export const DOC_CSS = `
:root { color-scheme: light; }
body { margin: 0; background: #f4f5f7; }
.doc-paper { max-width: 820px; margin: 0 auto; padding: 46px 56px 60px; background: #fff; color: #1f2329;
font: 14px/1.85 "PingFang SC", "Microsoft YaHei", -apple-system, "Segoe UI", sans-serif; }
.doc-paper h1 { font-size: 25px; line-height: 1.35; margin: 0 0 18px; padding-bottom: 12px; border-bottom: 2px solid #1f2329; }
.doc-paper h2 { font-size: 18px; margin: 30px 0 10px; padding-left: 10px; border-left: 3px solid #3370ff; }
.doc-paper h3 { font-size: 15px; margin: 22px 0 8px; color: #3d4046; }
.doc-paper p { margin: 0 0 11px; }
.doc-paper ul, .doc-paper ol { margin: 0 0 12px; padding-left: 22px; }
.doc-paper li { margin: 3px 0; }
.doc-paper hr { border: 0; border-top: 1px solid #dfe1e5; margin: 22px 0; }
.doc-paper code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px;
background: #f2f3f5; padding: 1px 5px; border-radius: 4px; }
.doc-paper strong { font-weight: 700; }
.doc-tbl { overflow-x: auto; margin: 0 0 14px; }
.doc-paper table { border-collapse: collapse; width: 100%; font-size: 13px; }
.doc-paper th, .doc-paper td { border: 1px solid #dfe1e5; padding: 7px 10px; text-align: left; vertical-align: top; }
.doc-paper th { background: #f7f8fa; font-weight: 700; }
@media (max-width: 720px) { .doc-paper { padding: 26px 20px 34px; } }
`.trim();
/** 整份 HTML 文件(下载的那一份 —— 自带样式,离线打开也是这个样子) */
export function markdownHtmlDoc(title: string, md: string): string {
return `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeXml(title)}</title>
<style>${DOC_CSS}</style></head>
<body><article class="doc-paper">${markdownBody(md)}</article></body></html>`;
}
/* ==================== md → docx(真 OOXML) ==================== */
const DOCX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`;
const DOCX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`;
const DOCX_DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
/** 只定义用到的那几个段落样式 —— Word 靠 styleId 认标题层级(导航窗格据此出目录) */
const DOCX_STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:outlineLvl w:val="0"/><w:spacing w:before="280" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:pPr><w:outlineLvl w:val="1"/><w:spacing w:before="240" w:after="100"/></w:pPr><w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:pPr><w:ind w:left="420"/></w:pPr></w:style>
</w:styles>`;
const wRun = (text: string): string =>
`<w:r><w:t xml:space="preserve">${escapeXml(inlinePlain(text))}</w:t></w:r>`;
const wPara = (text: string, style?: string): string =>
`<w:p>${style === undefined ? '' : `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>`}${wRun(text)}</w:p>`;
/** 表格:一行一 `<w:tr>`,首行加底纹当表头 —— Word 里是真表格,能选能改 */
function wTable(rows: ReadonlyArray<readonly string[]>): string {
const cols = Math.max(...rows.map((r) => r.length), 1);
const width = Math.floor(9000 / cols);
const cell = (text: string, head: boolean): string =>
`<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${head ? '<w:shd w:val="clear" w:fill="F2F3F5"/>' : ''}</w:tcPr>` +
`<w:p>${head ? '<w:pPr><w:rPr><w:b/></w:rPr></w:pPr>' : ''}${wRun(text)}</w:p></w:tc>`;
const body = rows
.map(
(r, ri) =>
`<w:tr>${Array.from({ length: cols }, (_, ci) => cell(r[ci] ?? '', ri === 0)).join('')}</w:tr>`,
)
.join('');
return (
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>` +
`<w:tblBorders>${['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map((s) => `<w:${s} w:val="single" w:sz="4" w:color="DFE1E5"/>`)
.join('')}</w:tblBorders></w:tblPr>${body}</w:tbl>`
);
}
export function markdownDocxXml(title: string, md: string): string {
const body = parseMarkdown(md)
.map((b) => {
if (b.t === 'hr') return '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:color="DFE1E5"/></w:pBdr></w:pPr></w:p>';
if (b.t === 'ul') return b.items.map((i) => wPara(`• ${i}`, 'ListParagraph')).join('');
if (b.t === 'ol') return b.items.map((i, n) => wPara(`${n + 1}. ${i}`, 'ListParagraph')).join('');
if (b.t === 'table') return wTable(b.rows);
if (b.t === 'h1') return wPara(b.text, 'Heading1');
if (b.t === 'h2') return wPara(b.text, 'Heading2');
if (b.t === 'h3') return wPara(b.text, 'Heading2');
return wPara(b.text);
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${wPara(title, 'Title')}${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"/></w:sectPr></w:body></w:document>`;
}
/* ==================== md → xlsx(真 OOXML) ==================== */
const XLSX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`;
const XLSX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;
const XLSX_WB_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`;
const xlsxWorkbook = (sheetName: string): string => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="${escapeXml(sheetName).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
/** 列号 0 → A,26 → AA */
export function columnName(index: number): string {
let n = index + 1;
let out = '';
while (n > 0) {
const rem = (n - 1) % 26;
out = String.fromCharCode(65 + rem) + out;
n = Math.floor((n - 1) / 26);
}
return out;
}
/**
* 表格化:**表格块原样成行**,其余块各占一行(标题前空一行)——
* 一份规格书导成 Excel,人要的是那几张对照表,而不是把正文丢掉。
*/
export function markdownSheetRows(title: string, md: string): string[][] {
const rows: string[][] = [[title]];
for (const b of parseMarkdown(md)) {
if (b.t === 'hr') continue;
if (b.t === 'table') {
rows.push([]);
for (const r of b.rows) rows.push(r.map((c) => inlinePlain(c)));
continue;
}
if (b.t === 'ul') {
for (const i of b.items) rows.push([`• ${inlinePlain(i)}`]);
continue;
}
if (b.t === 'ol') {
b.items.forEach((i, n) => rows.push([`${n + 1}. ${inlinePlain(i)}`]));
continue;
}
if (b.t === 'h1' || b.t === 'h2' || b.t === 'h3') rows.push([]);
rows.push([inlinePlain(b.text)]);
}
return rows;
}
/** 值全部按 inlineStr 写 —— 不建共享串表:少一份要对齐的索引,少一处能错的地方 */
function xlsxSheet(rows: ReadonlyArray<readonly string[]>): string {
const body = rows
.map((cells, ri) => {
const tds = cells
.map((v, ci) =>
v === ''
? ''
: `<c r="${columnName(ci)}${ri + 1}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(v)}</t></is></c>`,
)
.join('');
return `<row r="${ri + 1}">${tds}</row>`;
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
}
/* ==================== 出口 ==================== */
/** 文件名里不许出现的字符(跨平台取交集),一律换成下划线 */
export function safeFileName(title: string, format: FileFormat): string {
const base = title.replace(/[\\/:*?"<>|\n\r\t]/g, '_').trim().slice(0, 80) || '资料';
return `${base}.${format}`;
}
export function composeFile(input: {
readonly title: string;
readonly markdown: string;
readonly format: FileFormat;
}): ComposedFile {
const { title, markdown, format } = input;
const enc = new TextEncoder();
const common = { fileName: safeFileName(title, format), mime: MIME_OF[format], format };
if (format === 'md') {
// 原文里没有标题行就补一条 —— 文件得自报家门(台账那一列是标题,正文常常直接从正文起)
const head = /^\s*#\s+/.test(markdown) ? '' : `# ${title}\n\n`;
return { ...common, bytes: enc.encode(head + markdown) };
}
if (format === 'html') return { ...common, bytes: enc.encode(markdownHtmlDoc(title, markdown)) };
if (format === 'docx') {
return {
...common,
bytes: zipStore([
['[Content_Types].xml', enc.encode(DOCX_CONTENT_TYPES)],
['_rels/.rels', enc.encode(DOCX_RELS)],
['word/_rels/document.xml.rels', enc.encode(DOCX_DOC_RELS)],
['word/styles.xml', enc.encode(DOCX_STYLES)],
['word/document.xml', enc.encode(markdownDocxXml(title, markdown))],
]),
};
}
return {
...common,
bytes: zipStore([
['[Content_Types].xml', enc.encode(XLSX_CONTENT_TYPES)],
['_rels/.rels', enc.encode(XLSX_RELS)],
['xl/_rels/workbook.xml.rels', enc.encode(XLSX_WB_RELS)],
['xl/workbook.xml', enc.encode(xlsxWorkbook(title))],
['xl/worksheets/sheet1.xml', enc.encode(xlsxSheet(markdownSheetRows(title, markdown)))],
]),
};
}
export { crc32Of };
+121
View File
@@ -0,0 +1,121 @@
/**
* 造好的资料文件**读回来给前端渲染** —— 进字节,出 `FileDoc`。
*
* 为什么绕这一圈,而不是直接把 Markdown 原文丢给前端按格式摆:
* **那样界面渲的就不是那份文件了**。人下载到的是 .docx,屏上看到的是另一条路
* 拼出来的样子,两边哪天不一致谁也发现不了。这里读的就是 `composeFile` 出的那一份
* 字节:docx 解出段落、xlsx 解出行列 —— 渲得出来,才证明那份文件是真的。
*
* 纯计算:进字节、出结构,不碰库不碰网。
*/
import type { FileDoc } from '@contracts';
import { markdownHtmlDoc, type FileFormat } from './file-compose.ts';
import { readZip, sharedStrings, sheetToRows, unescapeXml } from './upload-extract.ts';
/** docx 的一段:`<w:p>` 里所有 `<w:t>` 连起来就是这一段的文字 */
function docxParagraphs(xml: string): Array<{ style: string; text: string }> {
const out: Array<{ style: string; text: string }> = [];
for (const p of xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)) {
const inner = p[1] ?? '';
const style = /<w:pStyle\s+w:val="([^"]+)"/.exec(inner)?.[1] ?? '';
const text = [...inner.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join('');
out.push({ style, text });
}
return out;
}
/** 查看面认得的块 —— 与 `FileDoc` 的 word 分支同形 */
export type WordBlock = { t: 'h1' | 'h2' | 'p'; text: string } | { t: 'ul'; items: string[] };
/**
* docx → 查看面的块。表格在 `<w:tbl>` 里,而查看面(`FileViewer` 的 word 分支)
* 只认 h1/h2/p/ul —— 表格按行摊成 `p`(用「 | 」连列),**宁可朴素也不丢内容**。
*/
export function docxBlocks(xml: string): WordBlock[] {
const blocks: WordBlock[] = [];
let bullets: string[] = [];
const flush = (): void => {
if (bullets.length === 0) return;
blocks.push({ t: 'ul', items: bullets });
bullets = [];
};
// 表格与段落按它们在文档里的先后走:一次扫过,谁先出现算谁
for (const node of xml.matchAll(/<w:tbl\b[\s\S]*?<\/w:tbl>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/g)) {
const chunk = node[0];
if (chunk.startsWith('<w:tbl')) {
flush();
for (const tr of chunk.matchAll(/<w:tr\b[^>]*>([\s\S]*?)<\/w:tr>/g)) {
const cells = [...(tr[1] ?? '').matchAll(/<w:tc\b[^>]*>([\s\S]*?)<\/w:tc>/g)].map((tc) =>
[...(tc[1] ?? '').matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
);
if (cells.some((c) => c !== '')) blocks.push({ t: 'p', text: cells.join(' | ') });
}
continue;
}
const [para] = docxParagraphs(chunk);
if (para === undefined) continue;
const { style, text } = para;
if (text === '') continue;
if (style === 'ListParagraph') {
bullets.push(text.replace(/^[•\d]+[.、]?\s*/, ''));
continue;
}
flush();
if (style === 'Title' || style === 'Heading1') blocks.push({ t: 'h1', text });
else if (style === 'Heading2') blocks.push({ t: 'h2', text });
else blocks.push({ t: 'p', text });
}
flush();
return blocks;
}
/** xlsx → 行列(第一张表)。读的是**真包**:解不开就如实说,不编一张表出来 */
export function xlsxRows(bytes: Uint8Array): { rows: string[][]; sheet: string } | undefined {
const zip = readZip(bytes);
if (zip.size === 0) return undefined;
const dec = new TextDecoder('utf-8');
const sharedRaw = zip.get('xl/sharedStrings.xml');
const shared = sharedRaw === undefined ? [] : sharedStrings(dec.decode(sharedRaw));
const first = [...zip.keys()].filter((k) => /^xl\/worksheets\/sheet\d+\.xml$/.test(k)).sort()[0];
if (first === undefined) return undefined;
const raw = zip.get(first);
if (raw === undefined) return undefined;
const wbRaw = zip.get('xl/workbook.xml');
const sheet =
wbRaw === undefined ? 'Sheet1' : unescapeXml(/<sheet\s+name="([^"]*)"/.exec(dec.decode(wbRaw))?.[1] ?? 'Sheet1');
return { rows: sheetToRows(dec.decode(raw), shared), sheet };
}
/**
* 字节 → `FileDoc`。**读不出来就返回 undefined** —— 上层据此如实说
* 「这份文件打不开」,不摆一个空壳假装渲染过(界面不撒谎)。
*/
export function toFileDoc(input: {
readonly name: string;
readonly title: string;
readonly format: FileFormat;
readonly bytes: Uint8Array;
}): FileDoc | undefined {
const { name, title, format, bytes } = input;
const dec = new TextDecoder('utf-8');
if (format === 'html') return { kind: 'html', name, html: dec.decode(bytes) };
if (format === 'md') {
const md = dec.decode(bytes);
// 原文与渲好的一起交:查看面摆渲染版,而「下载」拿到的仍是这段原文
return { kind: 'markdown', name, md, html: markdownHtmlDoc(title, md) };
}
if (format === 'xlsx') {
const sheet = xlsxRows(bytes);
if (sheet === undefined) return undefined;
return { kind: 'excel', name, sheet: sheet.sheet, rows: sheet.rows };
}
const zip = readZip(bytes);
const doc = zip.get('word/document.xml');
if (doc === undefined) return undefined;
const blocks = docxBlocks(dec.decode(doc));
if (blocks.length === 0) return undefined;
return { kind: 'word', name, blocks };
}
+2
View File
@@ -30,6 +30,8 @@ const RUN_TABLES = [
*/ */
'sys.task_comm', 'sys.task_comm',
'sys.upload', 'sys.upload',
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
'sys.task_asset_file',
'sys.node_instance', 'sys.node_instance',
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿 // 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
'sys.trace_span', 'sys.trace_span',
+383 -36
View File
@@ -5,26 +5,32 @@
*/ */
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import type { Hono } from 'hono'; import type { Hono } from 'hono';
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts'; import type { ApiError, ChainNode, ChatMessage, DecisionCard, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
import { import {
answerMessageRoute, answerMessageRoute,
chatCardReplySchema, chatCardReplySchema,
confirmTriggerPendingRoute, confirmTriggerPendingRoute,
deliverableIdSchema,
listMessagesRoute, listMessagesRoute,
markMessageReadRoute, markMessageReadRoute,
listTasksRoute, listTasksRoute,
markCommReadRoute, markCommReadRoute,
replyChatCardRoute, replyChatCardRoute,
replyWorkOrderRoute, replyWorkOrderRoute,
openAssetFileRoute,
saveTableRoute, saveTableRoute,
sayRoute, sayRoute,
taskDetailRoute, taskDetailRoute,
staffIdSchema, staffIdSchema,
taskIdSchema, taskIdSchema,
workOrderIdSchema,
workOrderReplySchema, workOrderReplySchema,
} from '@contracts'; } from '@contracts';
import { ReplyRejectedError } from '@modules/human-tool'; import { ReplyRejectedError } from '@modules/human-tool';
import { advanceOrder } from '@modules/process'; import { advanceOrder } from '@modules/process';
import { keyText, listArchived, readArchived } from '../asset-archive.ts';
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
import { toFileDoc } from '../file-doc.ts';
import { displayLog } from './log-lines.ts'; import { displayLog } from './log-lines.ts';
import { pulse } from '../workers/pulse.ts'; import { pulse } from '../workers/pulse.ts';
import type { Container } from '../container.ts'; import type { Container } from '../container.ts';
@@ -60,6 +66,70 @@ const merge = (into: WriteBack, one: WriteBack): void => {
* 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。 * 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。 * 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
*/ */
/**
* **人在件上改的/加的这一行,主键怎么凑齐** —— 凑不齐就落不下去,而屏上那一行还在
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。三类主键三种凑法:
*
* ① 件上摆了、人也填了 → 就用他填的(主键只认行、不改行);
* ② **件上压根没摆的**(A1 的 `rfq_ref`、I01 的 `round_ref`:一单一个,不占版面)——
* 先按行认(`findKnownRow`:件上给了别的主键列就拿它去现有行里找),
* 认不出再看这一列在本单现有行里是不是**全表一致**,是就是它;
* 一单跨了两个号 → 取不出,如实跳过,不猜;
* ③ **序号型(整数主键)**——「加一行」那一下所有格子都被清空
* (`ViewContainer.addRow` 如此),而各行序号本就互不相同,②那条永远取不出,
* 于是整行被丢(2026-08-23 审 I01 需求清单 `round_ref + req_seq` 时逮到)。
* 序号的语义就是「本轮内第几条」,该由基座续:现有最大值 + 1,空表从 1 起。
* **只续新增行**:老行的序号件上摆着、人填着,走①就定了。
*/
export function resolveRowKey(input: {
keys: readonly string[];
cells: Readonly<Record<string, string>>;
drafts: ReadonlyArray<{ key: Record<string, unknown> }>;
types: Readonly<Record<string, string>>;
}): Record<string, string> | undefined {
const { keys, cells, drafts, types } = input;
const key: Record<string, string> = {};
for (const k of keys) {
const v = cells[k];
if (v !== undefined && v.trim() !== '') key[k] = v.trim();
}
if (Object.keys(key).length === keys.length) return key;
const known = findKnownRow(drafts, keys, key);
const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number';
/*
* **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的):
* 表里只有一行时,那一行的序号在「全表一致」眼里就是个常量 —— 人加的新行会拿到
* 同一个号,**当场覆盖掉原来那一行**(屏上两行,库里一行,而且内容还是新的那份)。
* 序号逐行不同是它的定义,一致只是行数太少的假象;它该走下面的续号那一支。
*/
for (const k of keys) {
if (key[k] !== undefined) continue;
if (known === undefined && isSeq(k)) continue;
const v =
known === undefined
? (() => {
const seen = new Set(drafts.map((d) => String(d.key[k] ?? '')));
const only = [...seen][0];
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
})()
: String(known.key[k] ?? '');
if (v !== undefined && v !== '') key[k] = v;
}
if (Object.keys(key).length === keys.length) return key;
// ③ 续号:只对整数型主键,且这一行确实是新增的(前两条都没认出来)
for (const k of keys) {
if (key[k] !== undefined) continue;
if (!isSeq(k)) continue;
const used = drafts
.map((d) => Number(d.key[k]))
.filter((n) => Number.isFinite(n));
key[k] = String((used.length === 0 ? 0 : Math.max(...used)) + 1);
}
return Object.keys(key).length === keys.length ? key : undefined;
}
async function writeEditedRows( async function writeEditedRows(
container: Container, container: Container,
taskId: TaskId, taskId: TaskId,
@@ -71,30 +141,16 @@ async function writeEditedRows(
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);
/* const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
* **件上压根没摆的那几列主键**(A1 的 `rfq_ref` 就是:一单一个单号,不占版面)——
* 本单现有行里那一列若全表一致,就是这一单的那个值,新增行照它填。
* 不一致(一单跨了两个单号)就取不出来:那时新增行认不出自己是谁,如实跳过,不猜。
*/
const constant = (column: string): string | undefined => {
const seen = new Set(drafts.map((d) => String(d.key[column] ?? '')));
const only = [...seen][0];
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
};
for (const cells of lines) { for (const cells of lines) {
/* /*
* 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行, * 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行,
* 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。 * 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。
* 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。 * 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
*/ */
const key: Record<string, string> = {}; const key = resolveRowKey({ keys, cells, drafts, types });
let complete = true;
for (const k of keys) {
const v = cells[k];
if (v === undefined || v.trim() === '') complete = false;
else key[k] = v;
}
/* /*
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。 * **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
* *
@@ -113,22 +169,7 @@ 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 (!complete) { if (key === undefined) continue;
/*
* 主键没摆全 —— 两种情形,先按行认,认不出再按列补:
* · 老行:件上给了 PN,单号那一列没摆 → 拿 PN 去本单现有行里找,取它的单号;
* · **新增行**:PN 是新的,现有行里一个都对不上 → 那几列取全表一致的那个值。
* 少了后一半,人在件上加的行会**静默丢掉**:主键凑不齐,这里一 continue 就没了,
* 而屏上那一行还在 —— 界面说加上了,台账里没有。
*/
const known = drafts.find((d) => keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]));
for (const k of keys) {
if (key[k] !== undefined) continue;
const v = known === undefined ? constant(k) : String(known.key[k] ?? '');
if (v !== undefined && v !== '') key[k] = v;
}
}
if (Object.keys(key).length !== keys.length) continue;
const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k])); const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k]));
if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue; if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue;
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch }); await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
@@ -172,6 +213,27 @@ async function dropEditedRows(
return out; return out;
} }
/**
* **主键摆不全时,认这一行是本单现有的哪一行**(2026-08-21 修死)。
*
* `key` 是这一格已经认出来的那几个主键值(可能一个都没有)。**一格都没认出来的碎片
* 不许命中「随便找到的第一行」**——`.find()` 的谓词对着全 `undefined` 的 `key` 恒真,
* 会把它错配给 `drafts` 里第一条,而那条八成风马牛不相及(手写的 radio/select 组
* 因为渲染嵌套多一层,曾经被 `collect()` 拆成不带主键的碎片,现已在 `ViewContainer`
* 那一侧修死——这里补的是第二道:即便未来哪天又出现类似的欠主键碎片,也不会被这个
* `.find()` 悄悄按到错误的行上)。至少要有一格真对得上,不然交给调用方去按列补
* (`constant`),对不上就整行跳过。
*/
export function findKnownRow<K extends Record<string, unknown>>(
drafts: ReadonlyArray<{ key: K }>,
keys: readonly string[],
key: Readonly<Record<string, string>>,
): { key: K } | undefined {
return drafts.find(
(d) => keys.some((k) => key[k] !== undefined) && keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]),
);
}
/** /**
* **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。 * **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。
* *
@@ -318,6 +380,68 @@ async function landingOf(
}; };
} }
/**
* 这一单产出的**资料文件**一摞 —— 工作成果里与定稿报告并列。
*
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
*/
async function assetFilesOf(
container: Container,
taskId: TaskId,
): Promise<Array<{ address: string; key: string; title: string; format: string; name: string; at?: string; archived: boolean }>> {
const archived = await listArchived(container.db, taskId).catch(() => []);
if (archived.length > 0) {
return archived.map((a) => ({
address: a.address,
key: keyText(a.key),
title: a.title,
format: a.format,
name: a.name,
at: a.at,
archived: true,
}));
}
const declarations = await declarationsOf(container, taskId);
const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = [];
for (const declared of declarations ?? []) {
const spec = declared.file;
if (spec === undefined) continue;
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
if (keys.length === 0) continue;
const page = await container.ledger
.taskView(taskId, {
address: { domain: declared.domain, object: declared.object },
where: {},
orderBy: [],
limit: 500,
offset: 0,
})
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
for (const row of page.rows) {
// 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」
if (String(row[spec.bodyColumn] ?? '').trim() === '') continue;
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue;
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
const format = isFileFormat(rawFormat) ? rawFormat : 'md';
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
out.push({
address: `${declared.domain}.${declared.object}`,
key: keyText(key),
title,
format,
name: safeFileName(title, format),
archived: false,
});
}
}
return out;
}
/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */ /** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */
async function declarationsOf(container: Container, taskId: TaskId) { async function declarationsOf(container: Container, taskId: TaskId) {
const task = await container.task.get(taskId).catch(() => undefined); const task = await container.task.get(taskId).catch(() => undefined);
@@ -326,6 +450,30 @@ async function declarationsOf(container: Container, taskId: TaskId) {
return manifest?.declarations.ledger; return manifest?.declarations.ledger;
} }
/**
* 件上传来的主键 → 一行的主键对。
*
* 两种写法:单列主键直接给值(`CT-2026-0001`),多列主键写 `列=值` 以 `,` 连
* (`round_ref=BR-2026...,req_seq=1`)。**认的是声明里的主键列**,件上说的列名
* 若不在其中一律不认 —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
*/
export function parseRowKey(raw: string, keys: readonly string[]): Record<string, string> | undefined {
if (keys.length === 0) return undefined;
if (!raw.includes('=')) {
const only = keys[0];
return keys.length === 1 && only !== undefined && raw !== '' ? { [only]: raw } : undefined;
}
const out: Record<string, string> = {};
for (const pair of raw.split(',')) {
const at = pair.indexOf('=');
if (at === -1) continue;
const col = pair.slice(0, at).trim();
if (!keys.includes(col)) return undefined;
out[col] = pair.slice(at + 1).trim();
}
return keys.every((k) => out[k] !== undefined && out[k] !== '') ? out : undefined;
}
/** /**
* 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。 * 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。
* *
@@ -725,6 +873,43 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
return gate === undefined ? order : { ...order, gate }; return gate === undefined ? order : { ...order, gate };
}), }),
); );
/*
* **决策卡答完的报告也算工作成果**(2026-08-21,`decisionCardSchema.finalHtml`)。
*
* `decide` 是唯一能循环的中间验收机制(`issueAcceptance` 一个任务只能发一次),
* 而它不是工单 —— 答完那一刻没有 `sys.work_order` 行,工作成果那一屏从前只认
* 工单,人在这张卡上通过的报告(如 E04 的经营计划)永远看不到。这里把答过的
* 决策卡拼成同一形状的条目,**单独一个字段**,不并进 `workOrders`:
* `mode:'acceptance'` 在别处(`ViewRegion.tsx` 的 `signed`)是「终局放行卡」的
* 同义词——那道判断认的是「有一张办结的验收单 + 还没有终态」等于「字签下了正在落地」,
* 前提是 `issueAcceptance` 只发一次、acceptance 恒是那一张。混进去会让 E04 一过
* S4(中间点,离终态还有 S5/S6 一大截)就误亮「正在落地」。前端只在工作成果那一屏
* 把两份列表并显,别处仍只读 `workOrders`。
*
* 三值口径(见 E04 case-model 的注释:`approve`/`reject`/`custom:` 开头)粗分
* 「通过 = pass,别的都算驳回」,只影响列表上那一点点颜色,不影响任何机检或放行 ——
* 那道门永远是 `issueAcceptance` 走的放行卡,这里只是回看用的一条历史记录。
*/
const decided: WorkOrder[] = cards
.filter((c): c is DecisionCard => c.k === 'decision')
.flatMap((c) => {
const { chosen, finalHtml } = c;
if (chosen === undefined || finalHtml === undefined) return [];
return [
{
id: workOrderIdSchema.parse(c.id),
at: c.at,
title: c.ask,
mode: 'acceptance' as const,
basis: c.basis,
deliverable: deliverableIdSchema.parse(c.id),
criteria: [],
fields: [],
finalHtml,
done: { at: chosen.at, by: chosen.by, result: chosen.value === 'approve' ? ('pass' as const) : ('redo' as const) },
},
];
});
/* /*
* 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 / * 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 /
* 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。 * 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。
@@ -839,6 +1024,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
results, results,
comms, comms,
workOrders: gated, workOrders: gated,
decidedDeliverables: decided,
assetFiles: await assetFilesOf(container, taskId),
chat: merged, chat: merged,
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view, view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }), ...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
@@ -859,6 +1046,125 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* **工单不办结**:保存只是保存。放行门的位置一点没变 —— * **工单不办结**:保存只是保存。放行门的位置一点没变 ——
* 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。 * 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。
*/ */
/*
* **点开一份资料文件** —— 件上的资料名点下去,走到这里。
*
* 三道关,一道都不能省:
* ① 会话(是不是这个人);
* ② **授权面 = 活动清单的台账声明** —— 那张表没声明 `file` 段就不给,
* 与「写看台账声明」「立监听看清单声明」同款;声明里没有的列名也不认;
* ③ 行取的是**任务视图**(正式版 + 本单草稿)—— 在办的初稿本来就还在草稿层,
* 只读正式表的话,人在审的这一份恰恰打不开。
*
* 拿到行之后**现造**:标题 + 正文 + 格式 → 真字节。`raw=1` 原样交出去(下载),
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
*/
app.get(openAssetFileRoute.path, async (c) => {
const staff = await me(c.get('account'));
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
}
const taskId = taskIdSchema.parse(c.req.param('taskId'));
const address = c.req.query('address') ?? '';
const rawKey = c.req.query('key') ?? '';
const [domain = '', object = ''] = address.split('.');
/* 单没了就直说单没了 —— 不然下面那句会把「这一单不在了」说成「活动没声明」 */
const task = await container.task.get(taskId).catch(() => undefined);
if (task === undefined) {
const body: ApiError = { code: 'not-found', message: '这一单已经不在了' };
return c.json(body, 404);
}
const declarations = await declarationsOf(container, taskId);
const declared = declarations?.find((d) => d.domain === domain && d.object === object);
if (declared?.file === undefined) {
const body: ApiError = {
code: 'forbidden',
message: `这个活动没有把「${address}」声明成资料文件(清单的 declarations.ledger[].file)——不声明就点不开`,
};
return c.json(body, 403);
}
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
const key = parseRowKey(rawKey, keys);
if (key === undefined) {
const body: ApiError = { code: 'validation-failed', message: `认不出这一行:主键要给全 ${keys.join('、')}` };
return c.json(body, 400);
}
/*
* **先看存档** —— 放行那一刻这一份就冻住了(`asset-archive.ts`)。
*
* 顺序是有讲究的:归档件上点开的必须是**他签字那一刻的那一份**,
* 而不是台账后来被下一单改成的样子。在办期间还没有存档,自然落到下面现造那条路。
*/
const archived = await readArchived(container.db, taskId, address, key).catch(() => undefined);
if (archived !== undefined) {
if (c.req.query('raw') === '1') {
return c.body(archived.bytes.slice().buffer as ArrayBuffer, 200, {
'content-type': MIME_OF[archived.format],
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(archived.name)}`,
});
}
const doc = toFileDoc({ name: archived.name, title: archived.title, format: archived.format, bytes: archived.bytes });
if (doc !== undefined) return c.json({ file: doc, fileName: archived.name, format: archived.format });
}
const page = await container.ledger
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
const row = page.rows.find((r) => keys.every((k) => String(r[k] ?? '') === key[k]));
if (row === undefined) {
/*
* **收了工再来找不到,多半是试跑那条路**(2026-08-23 实测):
* 资料这一行若落在**主数据**上,试跑单放行时它只留在活动缓存、不落正式表
* (免得试跑往品牌内容库里塞真资产,见 ledger 的 `simulation` 那段注释)——
* 于是放行前点得开、放行后点不开。正式单不受此限(放行合并进正式表,照旧查得到)。
* 两种情形说清楚,别拿一句「可能已被撤掉」把人支去翻列表找一行根本不会在的东西。
*/
const body: ApiError = {
code: 'not-found',
message:
task.terminal === undefined
? '这一行不在了 —— 可能已被撤掉,回列表看一眼'
: '这一单已经收工,查不到这一行了 —— 若它是试跑单,放行时主数据只留在活动缓存、不落正式表' +
'(试跑不往正式库里塞真资产),这份资料在放行前查得到;正式单不受此限',
};
return c.json(body, 404);
}
const text = (col: string): string => String(row[col] ?? '').trim();
const title = text(declared.file.titleColumn) || keys.map((k) => key[k]).join('-');
const markdown = text(declared.file.bodyColumn);
const rawFormat = text(declared.file.formatColumn).toLowerCase();
/* 格式列没填 / 填了个不认识的:退 md —— 有正文就该看得见,不因为一格没填就整份打不开 */
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
if (markdown === '') {
const body: ApiError = {
code: 'not-found',
message: `这一份还没有正文(${declared.file.bodyColumn} 是空的)—— 资料还没产出,不是打不开`,
};
return c.json(body, 404);
}
const composed = composeFile({ title, markdown, format });
if (c.req.query('raw') === '1') {
return c.body(composed.bytes.slice().buffer as ArrayBuffer, 200, {
'content-type': composed.mime,
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(composed.fileName)}`,
});
}
const doc = toFileDoc({ name: composed.fileName, title, format, bytes: composed.bytes });
if (doc === undefined) {
const body: ApiError = { code: 'internal', message: `这份 ${format} 造出来了却读不回来 —— 已留痕,请报给开发` };
return c.json(body, 500);
}
return c.json({ file: doc, fileName: composed.fileName, format });
});
app.post(saveTableRoute.path, async (c) => { app.post(saveTableRoute.path, async (c) => {
const staff = await me(c.get('account')); const staff = await me(c.get('account'));
if (staff === undefined) { if (staff === undefined) {
@@ -1012,6 +1318,17 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })), ...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })),
]; ];
const declarations = (await declarationsOf(container, cardTaskId)) ?? []; const declarations = (await declarationsOf(container, cardTaskId)) ?? [];
/*
* **跨段共享主键**(2026-08-20 修死):卡面注释早就写着「两段的 customer_code
* 都留空:人在第一段填了什么,第二段跟着是什么」,但代码从没兑现过这句话 ——
* `also` 段的 `key` 只取卡面写死的字面占位(通常就是那个空字符串),从不看
* 人刚在**上一段**填的值。建一位新客户时,公司档正确落了 `customer_code`
* (它作为一个 `field` 进了 patch),联系人档却带着空字符串的 `customer_code`
* 落库 —— 新联系人从落地那一刻起就没挂在这位客户名下,`autoKeys` 算
* `contact_code` 时也因为拿不到前缀,生成裸序号(`01`)而不是
* `CU-0201-01`。现在按段落定的主键值顺着传给后面的段。
*/
const sharedKey: Record<string, string> = {};
for (const [at, group] of groups.entries()) { for (const [at, group] of groups.entries()) {
const [domain, object] = group.ledger.split('.'); const [domain, object] = group.ledger.split('.');
if (domain === undefined || object === undefined) continue; if (domain === undefined || object === undefined) continue;
@@ -1024,8 +1341,19 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
} }
if (Object.keys(patch).length === 0) continue; if (Object.keys(patch).length === 0) continue;
const declared = declarations.find((l) => l.domain === domain && l.object === object); const declared = declarations.find((l) => l.domain === domain && l.object === object);
const key = { ...group.key, ...(await autoKeys(container, cardTaskId, declared, group.key)) }; // 本段的非自增主键:这一段自己填的(patch)优先,没填就接上更早那段定下的值
const ownKeyFields = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name);
const given: Record<string, string> = { ...group.key };
for (const name of ownKeyFields) {
const value = patch[name] ?? sharedKey[name];
if (value !== undefined && value !== '') given[name] = value;
}
const key = { ...given, ...(await autoKeys(container, cardTaskId, declared, given)) };
await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch }); await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch });
for (const name of ownKeyFields) {
const value = key[name];
if (value !== undefined && value !== '') sharedKey[name] = value;
}
} }
{ {
/* /*
@@ -1036,6 +1364,25 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
*/ */
} }
} }
/*
* **决策卡答完那一刻,把当时的件原样存进这张卡**(与工单同一条道理,见
* `decisionCardSchema.finalHtml` 的注释)。`decide` 是唯一能循环的中间验收
* 机制,E04 的经营计划审批(S4_plan_approval)就是靠它——没有这一段,
* 人在这张卡上通过的报告永远进不了工作成果(那一屏从前只认工单)。
* 快照失败不拦人答卡(留痕即可)。
*/
if (parsed.data.k === 'decision' && card.k === 'decision') {
try {
const shot = await container.interaction.viewOf(cardTaskId);
if (shot?.html !== undefined) {
await container.db.execute(
sql`update sys.chat_card set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.cardId}`,
);
}
} catch {
/* 件还没推过就没有快照 —— 不拦人答卡 */
}
}
/* /*
* 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻, * 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻,
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动, * 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
+46 -2
View File
@@ -720,7 +720,19 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const denied = await governProcess(c.get('account')); const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied)); if (denied) return c.json(denied, denyStatus(denied));
const taskId = taskIdSchema.parse(c.req.param('taskId')); const taskId = taskIdSchema.parse(c.req.param('taskId'));
const task = await container.task.get(taskId); /*
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
* 运行中,没有停下来」查出来的后一半)。这一单被别处清掉(reset-runs / 另一台起单
* 先销毁上一单)之后,这条路由抛的是普通 Error → 兜底成 `500 internal`。
* 而 500 的意思是「服务器出毛病了,等会儿再来」——面板据此**继续举着上一次的状态
* 每 2 秒重试**,屏上就成了「AWAITING-EVENT · 自动刷新」配一条「任务不存在」。
* 状态码说错一格,前端就分不清「该松手」与「该等它回来」。
*/
const task = await container.task.get(taskId).catch(() => undefined);
if (task === undefined) {
const body: ApiError = { code: 'not-found', message: `这一单已经不在了(${taskId})` };
return c.json(body, 404);
}
const memory = await container.memory.window(taskId, 200); const memory = await container.memory.window(taskId, 200);
const drafts = await container.ledger.taskDrafts(taskId); const drafts = await container.ledger.taskDrafts(taskId);
@@ -1246,7 +1258,20 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const faces = await Promise.all( const faces = await Promise.all(
cards.map(async (card) => { cards.map(async (card) => {
const manifest = await container.registry.manifestOf(card.activityId); const manifest = await container.registry.manifestOf(card.activityId);
type FieldSpec = { type?: string; items?: { type?: string }; title?: string; format?: string }; type FieldSpec = {
type?: string;
items?: { type?: string };
title?: string;
format?: string;
/** 枚举值 —— 声明了就该给下拉选,不该让人对着自由文本框猜合法值 */
enum?: unknown[];
/** 枚举的人读名(与 `enum` 同序;JSON Schema 无此标准关键字,借业界通行写法) */
enumNames?: unknown[];
/** JSON Schema 标准关键字:示例值,填不出合法值时给个能抄的样子 */
examples?: unknown[];
/** 非标准扩展:另一个字段等于某值才出现这一格(级联下拉那类场景) */
showWhen?: { field?: string; equals?: string };
};
const data = manifest.contract.input.data as { const data = manifest.contract.input.data as {
required?: string[]; required?: string[];
properties?: Record<string, FieldSpec>; properties?: Record<string, FieldSpec>;
@@ -1256,6 +1281,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 类型的人读写法 —— **数组写成 `T[]`**:声明得看得出是一个还是一串 */ /** 类型的人读写法 —— **数组写成 `T[]`**:声明得看得出是一个还是一串 */
const typeOf = (spec: FieldSpec | undefined): string => const typeOf = (spec: FieldSpec | undefined): string =>
spec?.type === 'array' ? `${spec.items?.type ?? 'any'}[]` : (spec?.type ?? 'unknown'); spec?.type === 'array' ? `${spec.items?.type ?? 'any'}[]` : (spec?.type ?? 'unknown');
/** 枚举 → 下拉选项:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
const optionsOf = (spec: FieldSpec | undefined): { value: string; label: string }[] | undefined => {
if (!Array.isArray(spec?.enum) || spec.enum.length === 0) return undefined;
return spec.enum.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
};
/** 示例值:JSON Schema `examples` 第一项,对象/数组类型序列化成能直接抄的 JSON 串 */
const exampleOf = (spec: FieldSpec | undefined): string | undefined => {
const first = spec?.examples?.[0];
if (first === undefined) return undefined;
return typeof first === 'string' ? first : JSON.stringify(first);
};
const portFields = (payload: unknown) => { const portFields = (payload: unknown) => {
const p = payload as { properties?: Record<string, FieldSpec> } | undefined; const p = payload as { properties?: Record<string, FieldSpec> } | undefined;
return Object.entries(p?.properties ?? {}).map(([name, spec]) => ({ name, type: typeOf(spec) })); return Object.entries(p?.properties ?? {}).map(([name, spec]) => ({ name, type: typeOf(spec) }));
@@ -1270,6 +1306,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
...(spec?.title === undefined ? {} : { label: spec.title }), ...(spec?.title === undefined ? {} : { label: spec.title }),
// `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框 // `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框
...(spec?.format === undefined ? {} : { format: spec.format }), ...(spec?.format === undefined ? {} : { format: spec.format }),
// 声明了枚举就给下拉选 —— 不该让人对着自由文本框猜合法值
...(optionsOf(spec) === undefined ? {} : { options: optionsOf(spec) }),
// 声明了示例就把它摆进占位提示,比裸类型名(`string`)更能抄
...(exampleOf(spec) === undefined ? {} : { example: exampleOf(spec) }),
// 声明了 showWhen 就只在另一格等于某值时才出现(级联下拉)
...(spec?.showWhen?.field === undefined || spec.showWhen.equals === undefined
? {}
: { showWhen: { field: spec.showWhen.field, equals: spec.showWhen.equals } }),
})), })),
// 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通) // 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通)
samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })), samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })),
+23 -15
View File
@@ -30,6 +30,8 @@ const REAL_PACKAGES = [
'quote-pricing', 'quote-pricing',
'quote-negotiate', 'quote-negotiate',
'quote-archive', 'quote-archive',
'crm-relationship-coverage',
'brand-content-toolkit',
] as const; ] as const;
/** /**
@@ -65,21 +67,6 @@ export async function installRealActivities(container: Container): Promise<void>
// 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道 // 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`), (conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
); );
/*
* 一位样例客户 —— 让「老客户认得出」那条路试得出来(见 `MASTER_SAMPLE_ROWS` 的注)。
* 按主键写,重复播种是覆盖不是堆叠;写正式版(不是草稿层),
* 因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
*/
for (const row of MASTER_SAMPLE_ROWS) {
await container.ledger.systemWrite({
address: { domain: row.address.domain, object: row.address.object },
key: { ...row.key },
values: { ...row.values },
cause: { kind: 'governance', ref: 'seed:master-sample' },
});
}
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
const root = activitiesRoot(); const root = activitiesRoot();
for (const dir of REAL_PACKAGES) { for (const dir of REAL_PACKAGES) {
@@ -135,4 +122,25 @@ export async function installRealActivities(container: Container): Promise<void>
log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`); log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`);
} }
} }
/*
* **样例客户 —— 装完全部活动包之后再播种**(2026-08-20 改序,踩过坑):
* `MASTER_SAMPLE_ROWS` 里带着 E04 追加的列(`cust_type`/`decision_chain`…)——
* 这些列要等 `crm-relationship-coverage` 装进 `REAL_PACKAGES` 循环、
* 它的台账声明经 `applyDeclarations` 做过 `add-column` 之后才真的存在于物理表。
* 原来放在装活动**之前**播种,种到这些新列时 Postgres 直接报
* `column "cust_type" of relation "customer__profile" does not exist`——
* 主数据物化(平台基线列)在活动之前没错,但样例数据要用到**活动追加的列**时,
* 必须排在对应活动装完之后。按主键写,重复播种是覆盖不是堆叠;写正式版
* (不是草稿层),因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
*/
for (const row of MASTER_SAMPLE_ROWS) {
await container.ledger.systemWrite({
address: { domain: row.address.domain, object: row.address.object },
key: { ...row.key },
values: { ...row.values },
cause: { kind: 'governance', ref: 'seed:master-sample' },
});
}
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
} }
+139
View File
@@ -175,6 +175,26 @@ export const MASTER_SAMPLE_ROWS = [
payment_terms: 'EXW · CNY 含税', payment_terms: 'EXW · CNY 含税',
profile_tags: '项目制 · 按项目批量询价 · 认证要求严(AEC-Q200)', profile_tags: '项目制 · 按项目批量询价 · 认证要求严(AEC-Q200)',
status: 'active', status: 'active',
/*
* E04(关系覆盖与关键人经营)字段 —— 2026-08-20 补齐,让「客户已经跑过 E04、
* 找缺口」这条分支第一次能真被样例跑通(此前这几列全空,每轮都只能从零起草)。
* decision_chain 只覆盖了 bt-01(采购层),bt-02/bt-03 是刻意留的缺口。
*/
cust_type: 'Tier1 OEM',
coverage_score: 35,
decision_chain: {
nodes: [
{ node: 'bt-01', btLevel: 'bt-01', required: true, contactCode: 'CU-0142-01', covered: true },
{ node: 'bt-02', btLevel: 'bt-02', required: true, contactCode: '', covered: false },
{ node: 'bt-03', btLevel: 'bt-03', required: true, contactCode: '', covered: false },
],
},
interaction_timeline: {
entries: [
{ date: '2026-06-15', type: '邮件报价跟进', contactCode: 'CU-0142-01', note: 'MD14.1 项目 BOM 报价确认' },
{ date: '2026-07-20', type: '电话沟通', contactCode: 'CU-0142-01', note: '交期与付款方式确认' },
],
},
}, },
}, },
{ {
@@ -187,6 +207,13 @@ export const MASTER_SAMPLE_ROWS = [
title: '采购部', title: '采购部',
// **认人的钥匙**:老客户样例的来信就从这个邮箱发,A1 第一步照它认得出 // **认人的钥匙**:老客户样例的来信就从这个邮箱发,A1 第一步照它认得出
email: 'e.volkova@itelma.ru', email: 'e.volkova@itelma.ru',
// E04 字段 —— 对应上面 decision_chain 的 bt-01 节点
role: '采购',
decision_node: 'bt-01',
influence: 'high',
relationship: 3,
comm_pref: 'Email',
key_person: true,
}, },
}, },
/* /*
@@ -205,4 +232,116 @@ export const MASTER_SAMPLE_ROWS = [
status: 'active', status: 'active',
}, },
}, },
/*
* **CU-0087** —— E04(关系覆盖与关键人经营)"关键人离职/调岗"样例专用夹具
* (2026-08-20 补):此前这条样例引用的 CU-0087 在种子数据里根本不存在,
* 是一处遗留 bug。这里种一位客户 + 两位联系人,decision_chain 的 bt-01
* (采购层)当下由陈刚覆盖 —— 样例说的正是"这个人要走了,这个节点要重新覆盖"。
*/
{
address: { domain: 'customer', object: 'profile' },
key: { customer_code: 'CU-0087' },
values: {
customer_code: 'CU-0087',
name: '深圳精密电子有限公司',
grade: 'B',
industry: '工业自动化',
region: '中国·深圳',
payment_terms: 'T/T 30 天',
profile_tags: '批量稳定 · 价格敏感',
status: 'active',
cust_type: '模组厂',
coverage_score: 55,
decision_chain: {
nodes: [
{ node: 'bt-01', btLevel: 'bt-01', required: true, contactCode: 'CU-0087-01', covered: true },
{ node: 'bt-02', btLevel: 'bt-02', required: true, contactCode: 'CU-0087-02', covered: true },
{ node: 'bt-03', btLevel: 'bt-03', required: false, contactCode: '', covered: false },
],
},
interaction_timeline: {
entries: [{ date: '2026-05-10', type: '现场拜访', contactCode: 'CU-0087-01', note: '年度框架协议签署' }],
},
},
},
{
address: { domain: 'customer', object: 'contact' },
key: { customer_code: 'CU-0087', contact_code: 'CU-0087-01' },
values: {
customer_code: 'CU-0087',
contact_code: 'CU-0087-01',
name: 'Chen Gang',
title: '采购部长',
// **样例说的就是这个人**:「关键人离职/调岗」引用的正是这一位
email: 'chen.gang@sz-precision.com',
role: '采购',
decision_node: 'bt-01',
influence: 'high',
relationship: 4,
comm_pref: 'Email',
key_person: true,
},
},
{
address: { domain: 'customer', object: 'contact' },
key: { customer_code: 'CU-0087', contact_code: 'CU-0087-02' },
values: {
customer_code: 'CU-0087',
contact_code: 'CU-0087-02',
name: 'Li Wei',
title: '技术总监',
email: 'li.wei@sz-precision.com',
role: '技术决策人',
decision_node: 'bt-02',
influence: 'medium',
relationship: 3,
comm_pref: 'Email',
key_person: false,
},
},
/*
* I01(品牌形象与销售工具包管理)读的 L-01 宏观市场台账 —— 两条,呼应 I01 的两条
* 事件驱动样例(「进入俄罗斯市场」「E04 请求定向素材」),让「读市场信号 → 拟内容」
* 这条分支第一次有真数据可读,不必每轮都读空手。
*/
{
address: { domain: 'market', object: 'signal' },
key: { signal_id: 'MS-2026-0001' },
values: {
signal_id: 'MS-2026-0001',
signal_type: 'industry_trend',
title: '俄罗斯车规电子本土化替代加速',
content: '受供应链管制影响,俄罗斯本土车企与 Tier1 加快引入非欧美芯片供应商,车规级 AEC-Q200 认证成为准入门槛,采购决策周期缩短、更看重供货稳定性承诺。',
source: '行业报告',
signal_date: '2026-06-01',
market_region: '俄罗斯',
industry: '汽车电子',
chip_category: 'MCU',
importance: 4,
impact_dir: 'opportunity',
signal_status: 'evaluated',
version: '2026-Q2',
review_note: '',
},
},
{
address: { domain: 'market', object: 'signal' },
key: { signal_id: 'MS-2026-0002' },
values: {
signal_id: 'MS-2026-0002',
signal_type: 'customer_market',
title: 'Itelma 系客户新一轮车身控制器项目招标',
content: 'Itelma 及同类俄罗斯 Tier1 客户密集启动车身控制器新项目招标,价值传递素材需突出认证齐全度与批量交付能力,系统方案白皮书是决策链高层最看重的材料形式。',
source: '订阅',
signal_date: '2026-07-10',
market_region: '俄罗斯',
industry: '汽车电子',
chip_category: 'MCU',
importance: 5,
impact_dir: 'opportunity',
signal_status: 'evaluated',
version: '2026-Q2',
review_note: '',
},
},
] as const; ] as const;
+80 -2
View File
@@ -169,6 +169,84 @@ const PLACEHOLDER_TOOL = {
connectionKeys: [] as string[], connectionKeys: [] as string[],
}; };
/**
* E04 覆盖度重算 —— **纯工具**(活动私有作用域):决策链节点比对,同输入必同结论。
* 出参键名与台账列同名(`coverage_score`),模型拿回结果直接 write,不必改名。
*/
const COVERAGE_SCORE_TOOL = {
name: 'crm-relationship-coverage.recompute-score',
displayName: '关系覆盖度重算',
version: '1.1.0',
description:
'按决策链节点比对重算关系覆盖度:节点算已覆盖要同时满足有联系人对应/标了关键人/亲密度≥3。' +
'出参给覆盖率与缺口节点列表,权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。' +
'一次可查一批 —— 传 customers 数组,出参走 results(逐家客户各调一次会多截断一拍)。',
input: {
type: 'object',
properties: {
customers: {
type: 'array',
description: '批量(优先):[{customerCode, decisionNodes:[{node,btLevel,required}], contacts:[{contactCode,decisionNode,keyPerson,influence,relationship}]}]',
},
customerCode: { type: 'string', description: '单条查询' },
decisionNodes: { type: 'array', description: '单条:[{node, btLevel, required}]' },
contacts: { type: 'array', description: '单条:[{contactCode, decisionNode, keyPerson, influence, relationship}]' },
},
},
output: {
type: 'object',
properties: {
customerCode: { type: 'string' },
coverageScore: { type: 'integer' },
coveredNodes: { type: 'array' },
gapNodes: { type: 'array' },
results: { type: 'array', description: '批量查询的逐家结果:每项 {customerCode, coverageScore, coveredNodes, gapNodes}' },
},
},
effect: 'read' as const,
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
idempotent: true,
timeoutMs: 5_000,
retries: 0,
connectionKeys: [] as string[],
pure: true,
ownerActivityId: 'crm-relationship-coverage',
};
/**
* E04 关键人排序 —— **纯工具**(活动私有作用域):影响力 × 亲密度缺口,批量算。
* 出参键名与台账列同名(`priority`/`relationship_gap`/`rank_score`),模型照抄即可。
*/
const RANK_KEY_PERSONS_TOOL = {
name: 'crm-relationship-coverage.rank-key-persons',
displayName: '关键人经营优先级排序',
version: '1.0.0',
description:
'按影响力 × 亲密度缺口批量排序关键人 —— 传 contacts 数组,一次算完,不逐人临场拍。' +
'出参给每人的亲密度缺口、排序分与优先级档(high/medium/low)。',
input: {
type: 'object',
properties: {
contacts: { type: 'array', description: '[{contactCode, influence, relationship}]' },
},
},
output: {
type: 'object',
properties: {
ranked: { type: 'array', description: '[{contactCode, relationshipGap, rankScore, priority}],已按 rankScore 降序' },
counts: { type: 'object' },
},
},
effect: 'read' as const,
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
idempotent: true,
timeoutMs: 5_000,
retries: 0,
connectionKeys: [] as string[],
pure: true,
ownerActivityId: 'crm-relationship-coverage',
};
/** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */ /** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */
const ALLOWLIST: Readonly<Record<string, readonly string[]>> = { const ALLOWLIST: Readonly<Record<string, readonly string[]>> = {
// A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去 // A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去
@@ -211,7 +289,7 @@ export async function seedMvp(container: Container): Promise<void> {
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`); log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
/* ② 功能工具注册两审 + 逐槽位白名单 */ /* ② 功能工具注册两审 + 逐槽位白名单 */
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL]) { for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
const verdict = await container.toolbox.register({ const verdict = await container.toolbox.register({
contract, contract,
samples: [ samples: [
@@ -238,7 +316,7 @@ export async function seedMvp(container: Container): Promise<void> {
for (const [activityId, tools] of Object.entries(ALLOWLIST)) { for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
await container.toolbox.setAllowlist(activityId, tools); await container.toolbox.setAllowlist(activityId, tools);
} }
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 2,逐活动授权)'); log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
/* /*
* ③ 活动装入 —— 两批: * ③ 活动装入 —— 两批:
+81
View File
@@ -207,6 +207,85 @@ const landingRate: ToolHandler = async ({ input }) => {
}; };
}; };
/**
* E04 覆盖度重算 —— **纯工具**:决策链节点比对,同输入必同结论。
*
* 节点算「已覆盖」要同时满足三条:该节点上有联系人对应(`decisionNode` 匹配)、
* 那个人标了 `keyPerson=true`、亲密度 `relationship>=3`。少一条都算空白 ——
* 认得这个人不等于覆盖了这个人。权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。
*
* **一次可查一批**(`customers` 数组):同一批候选客户逐一临场调,每调一次就多截断
* 一拍(工具调用后同拍其余动作作废)——一单 N 家候选就要 N 拍,批量传入只需一拍。
* 契约演进只增不破坏:仍收单条 `{customerCode,decisionNodes,contacts}` 走 legacy 路径,
* 批量给 `customers` 时优先。
*/
const BT_WEIGHT: Record<string, number> = { 'bt-01': 1, 'bt-02': 2, 'bt-03': 3 };
type DecisionNode = { node: string; btLevel: string; required: boolean };
type CoverageContact = { contactCode: string; decisionNode: string; keyPerson: boolean; influence: string; relationship: number };
type CustomerCoverageInput = { customerCode: string; decisionNodes: DecisionNode[]; contacts: CoverageContact[] };
const scoreOne = (customerCode: string, nodes: DecisionNode[], contacts: CoverageContact[]) => {
const required = nodes.filter((n) => n.required === true);
const coveredNodes: string[] = [];
const gapNodes: string[] = [];
let weightTotal = 0;
let weightCovered = 0;
for (const n of required) {
const weight = BT_WEIGHT[n.btLevel] ?? 1;
weightTotal += weight;
const covered = contacts.some((c) => c.decisionNode === n.node && c.keyPerson === true && c.relationship >= 3);
if (covered) {
coveredNodes.push(n.node);
weightCovered += weight;
} else {
gapNodes.push(n.node);
}
}
const coverageScore = weightTotal === 0 ? 0 : Math.round((weightCovered / weightTotal) * 100);
return { customerCode, coverageScore, coveredNodes, gapNodes };
};
const recomputeScore: ToolHandler = async ({ input }) => {
const batch = input['customers'];
if (Array.isArray(batch)) {
const results = (batch as CustomerCoverageInput[]).map((c) =>
scoreOne(c.customerCode, Array.isArray(c.decisionNodes) ? c.decisionNodes : [], Array.isArray(c.contacts) ? c.contacts : []),
);
return { ok: true, output: { results } };
}
const nodes = Array.isArray(input['decisionNodes']) ? (input['decisionNodes'] as DecisionNode[]) : [];
const contacts = Array.isArray(input['contacts']) ? (input['contacts'] as CoverageContact[]) : [];
return { ok: true, output: scoreOne(String(input['customerCode'] ?? ''), nodes, contacts) };
};
/**
* E04 关键人排序 —— **纯工具**:影响力 × 亲密度缺口,批量算,不逐人临场拍。
*
* `relationshipGap = 5 - relationship`(亲密度越低缺口越大);
* `rankScore = 影响力权重 × relationshipGap`;阈值 `>=9→high, >=4→medium, else→low`
* (业务可调常量,不是这一单的临场判断)。
*/
const INFLUENCE_WEIGHT: Record<string, number> = { high: 3, medium: 2, low: 1 };
const rankKeyPersons: ToolHandler = async ({ input }) => {
const contacts = Array.isArray(input['contacts'])
? (input['contacts'] as Array<{ contactCode: string; influence: string; relationship: number }>)
: [];
const ranked = contacts
.map((c) => {
const weight = INFLUENCE_WEIGHT[c.influence] ?? 1;
const relationshipGap = Math.max(0, 5 - Number(c.relationship ?? 0));
const rankScore = weight * relationshipGap;
const priority = rankScore >= 9 ? 'high' : rankScore >= 4 ? 'medium' : 'low';
return { contactCode: c.contactCode, relationshipGap, rankScore, priority };
})
.sort((a, b) => b.rankScore - a.rankScore);
const counts = { high: 0, medium: 0, low: 0 } as Record<string, number>;
for (const r of ranked) counts[r.priority] = (counts[r.priority] ?? 0) + 1;
return { ok: true, output: { ranked, counts } };
};
/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */ /** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */
const mailSend: ToolHandler = async ({ input, connection }) => { const mailSend: ToolHandler = async ({ input, connection }) => {
// 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担 // 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担
@@ -272,4 +351,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
['quote-archive.landing-rate', landingRate], ['quote-archive.landing-rate', landingRate],
['mail.send', mailSend], ['mail.send', mailSend],
['slot.placeholder', slotPlaceholder], ['slot.placeholder', slotPlaceholder],
['crm-relationship-coverage.recompute-score', recomputeScore],
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
]; ];
+8 -4
View File
@@ -9,6 +9,10 @@
* 就能解;为一件读表的活拉进一整个电子表格库,是拿依赖换五十行代码。 * 就能解;为一件读表的活拉进一整个电子表格库,是拿依赖换五十行代码。
* *
* 这一份是**纯计算**:进字节、出文本,不碰库不碰网,所以能逐条测。 * 这一份是**纯计算**:进字节、出文本,不碰库不碰网,所以能逐条测。
*
* 其中 `readZip` / `sheetToRows` 这几件**对外**:资料文件那一侧(`file-doc.ts`)
* 要把造好的 docx / xlsx 再读回来渲染 —— 读的这一套只该有一份实现,
* 两处各写一份迟早在某个边角上分叉(而分叉的症状是「下载能开、界面渲不出」)。
*/ */
import { inflateRawSync } from 'node:zlib'; import { inflateRawSync } from 'node:zlib';
@@ -56,7 +60,7 @@ function cap(text: string, how: string): Extracted {
* 只支持 store(0)与 deflate(8)两种压缩法 —— xlsx 出厂就这两种。 * 只支持 store(0)与 deflate(8)两种压缩法 —— xlsx 出厂就这两种。
* 认不出的条目跳过而不是抛:一份表里混了一条读不了的条目,不该让整份表读不出来。 * 认不出的条目跳过而不是抛:一份表里混了一条读不了的条目,不该让整份表读不出来。
*/ */
function readZip(bytes: Uint8Array): Map<string, Uint8Array> { export function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const files = new Map<string, Uint8Array>(); const files = new Map<string, Uint8Array>();
@@ -104,7 +108,7 @@ function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
} }
/** XML 实体还原 —— 只有这五个是 XML 预定义的 */ /** XML 实体还原 —— 只有这五个是 XML 预定义的 */
function unescapeXml(s: string): string { export function unescapeXml(s: string): string {
return s return s
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
@@ -115,7 +119,7 @@ function unescapeXml(s: string): string {
} }
/** 共享字符串表:`<si>` 一条一个字符串,里面可能被拆成多个 `<t>`(富文本) */ /** 共享字符串表:`<si>` 一条一个字符串,里面可能被拆成多个 `<t>`(富文本) */
function sharedStrings(xml: string): string[] { export function sharedStrings(xml: string): string[] {
return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
[...(m[1] ?? '').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''), [...(m[1] ?? '').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
); );
@@ -135,7 +139,7 @@ function columnOf(ref: string): number {
* 单元格三种取值:`t="s"` 查共享串表、`t="inlineStr"` 取内联 `<t>`、其余取 `<v>` 原样 * 单元格三种取值:`t="s"` 查共享串表、`t="inlineStr"` 取内联 `<t>`、其余取 `<v>` 原样
* (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。 * (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。
*/ */
function sheetToRows(xml: string, shared: readonly string[]): string[][] { export function sheetToRows(xml: string, shared: readonly string[]): string[][] {
const rows: string[][] = []; const rows: string[][] = [];
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) { for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
const cells: string[] = []; const cells: string[] = [];
+68 -1
View File
@@ -37,6 +37,7 @@ import type { TriggerService } from '@modules/trigger';
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel'; import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
import type { StaffId, TaskId } from '@contracts'; import type { StaffId, TaskId } from '@contracts';
import { cardIdSchema, staffIdSchema } from '@contracts'; import { cardIdSchema, staffIdSchema } from '@contracts';
import { archiveAssetFiles } from '../asset-archive.ts';
import { createAgentRunner } from '../agent-runner.ts'; import { createAgentRunner } from '../agent-runner.ts';
import { createMastraAgentRunner } from '../mastra-agent-runner.ts'; import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
import type { Clock, IdGenerator } from '../runtime.ts'; import type { Clock, IdGenerator } from '../runtime.ts';
@@ -673,6 +674,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined); const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined);
const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`; const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`;
const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站'; const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站';
/*
* **这一单的任务号** —— 工单模版卡头「关联任务」那一格(E04 目标件 §6.2 点名的
* 共用动态位)。与归属签同理:任务号是基座的事实,活动写不出也不该写;
* 全长 UUID 如实给,摆多长由模版自己的样式定(截断是排版,不是数据)。
*/
const taskRef = task === undefined ? '' : String(taskId);
/* /*
* **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。 * **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。
* *
@@ -703,6 +710,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
'case:openedAt': openedAt, 'case:openedAt': openedAt,
'case:origin': origin, 'case:origin': origin,
'case:owner': ownerLabel, 'case:owner': ownerLabel,
'case:taskId': taskRef,
...route, ...route,
...own, ...own,
}), }),
@@ -797,7 +805,22 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const declared = manifest.declarations.ledger; const declared = manifest.declarations.ledger;
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的 // 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
const trail = await deps.memory.window(taskId, 400); const trail = await deps.memory.window(taskId, 400);
return { /*
* **开案时刻要真的在 Agent 的作用域里**(2026-08-22,E04 实测逼出来的):
* 说明书写着「round_ref = case:openedAt 按 YYYYMMDDHHmmss 格式化(引擎注入的
* 开案时刻)」,而这个键从前只注进**视图渲染**那一侧,Agent 的任务视图里没有 ——
* 模型只能自己编;正式表里一旦有别单的案号,它就照抄(A2 治过的「从台账借号」
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
*/
const two = (n: number): string => String(n).padStart(2, '0');
const opened = new Date(task.startedAt);
const openedAt = Number.isNaN(opened.getTime())
? ''
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
const enriched: Record<string, unknown> = {
'case:openedAt': openedAt,
...scope, ...scope,
...rowReadings(mine, declared), ...rowReadings(mine, declared),
...rowKeyList(mine, declared), ...rowKeyList(mine, declared),
@@ -806,6 +829,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...toolCallReadings(trail, manifest.declarations.tools), ...toolCallReadings(trail, manifest.declarations.tools),
...mine, ...mine,
}; };
return enriched;
}, },
/** /**
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。 * 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
@@ -955,6 +979,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}); });
return { id: listener.id }; return { id: listener.id };
}, },
/** 立监听的授权面 —— 活动清单声明过的那几种(见 port.ts 的注释) */
async declared(taskId: TaskId) {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.listeners.map((l) => ({ eventType: l.eventType, keyFilter: l.keyFilter }));
},
}; };
/** /**
@@ -2094,6 +2124,43 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 在一张验收单上改几十行是另一件事。 * 在一张验收单上改几十行是另一件事。
*/ */
await applyRevisions(input.taskId); await applyRevisions(input.taskId);
/*
* **产出的资料文件在这儿冻住** —— 修订刚落回草稿层、合并还没发生:
* 存下来的正是他签字那一刻的样子(与工单的 `finalHtml` 快照同一个道理)。
*
* 为什么非存不可,而不是回头照台账现造:资料常落在**主数据**上,
* 而试跑单放行时主数据只留在活动缓存、不落正式表 —— 不存这一份,
* 试跑单一放行,人在工作成果里就什么都点不开了。正式单则是另一头的理由:
* 台账后来被下一单改了,归档件不该跟着改口。
* 存不下来不拦放行(资料是成果的一份,不是放行的前提):记一条痕,人看得见。
*/
try {
const manifest = await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId);
const saved = await archiveAssetFiles({
db,
taskId: input.taskId,
ids,
declarations: manifest.declarations.ledger,
rowsOfAddress: async (address) =>
(
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
).rows as Array<Record<string, unknown>>,
});
if (saved > 0) {
await memoryRecorder.append({
taskId: input.taskId,
kind: 'ledger-write' as never,
title: `产出的资料已存档:${saved} 份(工作成果里点得开)`,
detail: { assetFiles: saved },
});
}
} catch (error) {
await memoryRecorder.append({
taskId: input.taskId,
kind: 'result',
title: `资料存档没做成:${error instanceof Error ? error.message : String(error)}`,
});
}
const outcome = await deps.release.land({ const outcome = await deps.release.land({
taskId: input.taskId, taskId: input.taskId,
ownerId: input.ownerId, ownerId: input.ownerId,
+101
View File
@@ -0,0 +1,101 @@
/**
* 最小 ZIP 打包器 —— docx / xlsx 就是 ZIP 包着几份 XML,写它才写得出真文件。
*
* 只做 **store(不压缩,method 0)**:合法的 ZIP,Word / Excel / macOS 归档工具都认。
* 为什么不压:压缩要 deflate + 一套长度对账,而这些 XML 至多几十 KB ——
* 省下来的那几 KB 换一处能错的地方,不划算。将来真要压,把 `method` 与数据段一起换即可。
*
* 读的那一侧在 `upload-extract.ts`(`readZip`),两边合起来能自证:
* 造出来的包读得回来,读回来的内容与进去的一模一样。
*/
/** CRC-32(IEEE 多项式 0xEDB88320)—— ZIP 每条目都要校验和,写错了解压工具会报损坏 */
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) c = (c & 1) === 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
export function crc32Of(bytes: Uint8Array): number {
let c = 0xffffffff;
for (const b of bytes) c = (CRC_TABLE[(c ^ b) & 0xff] ?? 0) ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
type Entry = readonly [name: string, bytes: Uint8Array];
/**
* 打包。条目顺序即包内顺序 —— `[Content_Types].xml` 习惯放第一条(不少读取器认这个)。
*
* 时间戳一律写 1980-01-01(DOS 时间的下限):同样的入参出同样的字节,
* 这样「文件变没变」就是内容的事,不掺一个每次都不同的时间 —— 也让测试断得住。
*/
export function zipStore(entries: readonly Entry[]): Uint8Array {
const enc = new TextEncoder();
const locals: Uint8Array[] = [];
const centrals: Uint8Array[] = [];
let offset = 0;
for (const [name, data] of entries) {
const nameBytes = enc.encode(name);
const crc = crc32Of(data);
const local = new Uint8Array(30 + nameBytes.length + data.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true); // 本地头签名
lv.setUint16(4, 20, true); // 需要 2.0 才能解
lv.setUint16(6, 0x0800, true); // 位标志:文件名是 UTF-8
lv.setUint16(8, 0, true); // method 0 = store
lv.setUint16(10, 0, true); // 时:0
lv.setUint16(12, 33, true); // 日:1980-01-01
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true); // 压缩后大小 = 原大小(store)
lv.setUint32(22, data.length, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true); // 无 extra
local.set(nameBytes, 30);
local.set(data, 30 + nameBytes.length);
locals.push(local);
const central = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true); // 中央目录签名
cv.setUint16(4, 20, true); // 造它的版本
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, 0, true);
cv.setUint16(14, 33, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true); // 本地头在包里的偏移
central.set(nameBytes, 46);
centrals.push(central);
offset += local.length;
}
const centralSize = centrals.reduce((n, c) => n + c.length, 0);
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true); // 本卷条目数
ev.setUint16(10, entries.length, true); // 总条目数
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true); // 中央目录起点
const total = offset + centralSize + eocd.length;
const out = new Uint8Array(total);
let p = 0;
for (const part of [...locals, ...centrals, eocd]) {
out.set(part, p);
p += part.length;
}
return out;
}
@@ -262,6 +262,68 @@ describe('data-edit / data-col:可改格子的输入孪生件', () => {
}); });
}); });
describe('手写的下拉孪生件:当下的值要标 selected', () => {
const SELECT_ROW =
'<tbody data-rows="rs" data-edit="d.o"><tr><td><select class="jc-cell" data-col="depth">' +
'<option value=""></option><option value="deep">深度</option><option value="standard">标准</option>' +
'<option value="light">轻度</option></select></td></tr></tbody>';
it('当下的值标 selected —— 人一进编辑态就看得见 AI 填了什么', () => {
const out = renderView(SELECT_ROW, { rs: [{ depth: 'standard' }] });
expect(out).toContain('<option value="standard" selected>标准</option>');
});
it('值匹配到具体那个 option,不是随便一个', () => {
const out = renderView(SELECT_ROW, { rs: [{ depth: 'light' }] });
expect(out).toContain('<option value="light" selected>轻度</option>');
expect(out).toContain('<option value="deep">深度</option>');
expect(out).toContain('<option value="standard">标准</option>');
});
it('没有值 —— 照旧留白,不选任何一项(没答=不改的规矩不能被这条改掉)', () => {
const out = renderView(SELECT_ROW, { rs: [{}] });
expect(out).not.toContain('selected');
});
it('值不在选项里 —— 原样留白,不硬凑一个', () => {
const out = renderView(SELECT_ROW, { rs: [{ depth: 'unknown-code' }] });
expect(out).not.toContain('selected');
});
it('没标 data-edit 的表(归档件)一样标好 selected —— 静态归档也该如实呈现当下的值', () => {
const out = renderView(SELECT_ROW.replace(' data-edit="d.o"', ''), { rs: [{ depth: 'deep' }] });
expect(out).toContain('<option value="deep" selected>深度</option>');
});
});
/**
* 手写的多行文本孪生件 —— E04 执行回执的「结果摘要 / 客户反馈」照目标件是 textarea
* (一句话装不下「见到的关键人、谈了什么、达成什么」)。与下拉同款:
* 渲染时把当下的值填进正文,人一进编辑态看得见 AI/上一轮填了什么。
*/
describe('手写的多行文本孪生件:当下的值进正文', () => {
const TEXTAREA_ROW =
'<tbody data-rows="rs" data-edit="d.o"><tr><td>' +
'<textarea class="jc-cell" data-col="result_summary" placeholder="谈了什么"></textarea>' +
'</td></tr></tbody>';
it('当下的值进正文 —— 人一进编辑态就看得见已填的内容', () => {
const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '见到了产品 VP,达成样品测试意向' }] });
expect(out).toContain('data-col="result_summary" placeholder="谈了什么">见到了产品 VP,达成样品测试意向</textarea>');
});
it('没有值 —— 正文真空着,placeholder 才显得出来', () => {
const out = renderView(TEXTAREA_ROW, { rs: [{}] });
expect(out).toContain('placeholder="谈了什么"></textarea>');
});
it('值要转义 —— 正文里混进闭合标签就是逃逸', () => {
const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '</textarea><script>alert(1)</script>' }] });
expect(out).not.toContain('<script>');
expect(out).toContain('&lt;/textarea&gt;');
});
});
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => { describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
it('第二遍渲染不会再长一个 input(属性已被消费)', () => { it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', { const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
@@ -278,3 +340,37 @@ describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
expect(out).not.toMatch(/<script|\son[a-z]+=/i); expect(out).not.toMatch(/<script|\son[a-z]+=/i);
}); });
}); });
describe('data-file-of:这一格点开是一份资料文件', () => {
const TPL =
'<tbody data-rows="mine:content.asset"><tr>' +
'<td><span data-file-of="content_id" data-slot="title"></span></td>' +
'</tr></tbody>';
it('换成认得出行的 data-file="地址/主键值",原属性被消费掉', () => {
const out = renderView(TPL, {
'mine:content.asset': [{ content_id: 'CT-2026-0001', title: '供货能力说明' }],
});
expect(out).toContain('data-file="content.asset/CT-2026-0001"');
expect(out).not.toContain('data-file-of');
expect(out).toContain('供货能力说明');
});
it('**地址去掉 `mine:` 前缀** —— 那是取值的路径,不是台账地址', () => {
const out = renderView(TPL, { 'mine:content.asset': [{ content_id: 'A', title: 'T' }] });
expect(out).toContain('data-file="content.asset/A"');
});
it('那一列还空着:整个属性去掉 —— 没有文件的行不该长得像能点开', () => {
const out = renderView(TPL, { 'mine:content.asset': [{ title: '还没编号' }] });
expect(out).not.toContain('data-file');
expect(out).toContain('还没编号');
});
it('主键值里的引号被转义 —— 一行数据撑不开属性', () => {
const out = renderView(TPL, { 'mine:content.asset': [{ content_id: 'A" onclick="x', title: 'T' }] });
// 引号成了 &quot;,那一串整个还在 data-file 的值里面 —— 没有第二个属性冒出来
expect(out).toContain('data-file="content.asset/A&quot; onclick=&quot;x"');
expect(out).not.toContain('onclick="x"');
});
});
@@ -162,16 +162,44 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。 * 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
*/ */
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null; const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
/*
* 这一组行还是**一份份资料文件**吗 —— 由行路径决定地址(`mine:content.asset`
* → `content.asset`)。行里带 `data-file-of="列名"` 的元素,渲染时换成
* `data-file="地址/主键值"`:点它就是点开那一份文件(外层接住,见 ViewContainer)。
* 模版这一侧照旧只做取值,「点了去哪」不在这份 HTML 里。
*/
const address = path.replace(/^mine:/, '');
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>;
return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one); const twinned = editable ? twinInputs(inner, index) : inner;
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(twinned, one, address), one), one), index), one);
}) })
.join(''); .join('');
return `${open}${body}</${tag}>`; return `${open}${body}</${tag}>`;
}); });
} }
/** `data-file-of="列名"` —— 这一格点开是一份资料文件,文件认哪一行由该列的值定 */
const FILE_OF = /\s*\bdata-file-of\s*=\s*"([^"]+)"/;
/**
* 把 `data-file-of` 换成一个**认得出行的** `data-file="地址/主键值"`,并消费掉原属性。
*
* 值取不到(那一列还空着)就**整个属性去掉** —— 没有文件的行不该长得像能点开,
* 点下去落一句「打不开」比一开始就不给点更糟(界面不撒谎)。
*/
export function fileLinks(inner: string, row: Readonly<Record<string, unknown>>, address: string): string {
return inner.replace(/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-file-of\s*=\s*"[^"]+"[^>]*)>/g, (all, tag: string, attrs: string) => {
const col = FILE_OF.exec(attrs)?.[1];
if (col === undefined) return all;
const value = displayValue(row[col]);
const rest = attrs.replace(FILE_OF, '');
if (value === '' || value === '—') return `<${tag}${rest}>`;
return `<${tag}${rest} data-file="${escapeHtml(address)}/${escapeHtml(value)}">`;
});
}
/** /**
* **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着 * **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着
* 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下 * 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下
@@ -211,6 +239,49 @@ function twinInputs(inner: string, rowIndex: number): string {
}); });
} }
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
/**
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
*
* 文本格子的孪生件(`twinInputs`)一开始就带着 `value=`,人一进编辑态看到的是
* 「AI 已经填了什么」;手写的 `<select>`(完成状态 / 关键人变动 / 覆盖深度这类枚举格)
* 从前一直是净的 `<option value="">`,没人标 `selected`——同一行里,文本格子回显着
* 原值,紧挨着的下拉却空白一片,像是这一格的数据丢了。
*
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
*/
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
return inner.replace(SELECT_CELL, (block, col: string) => {
const raw = resolveSlot(scope, col);
if (raw === undefined || raw === null || raw === '') return block;
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
return optionRe.test(block) ? block.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`) : block;
});
}
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
/**
* **多行文本的孪生件要带上当下的值**(2026-08-22,E04 执行回执):「结果摘要 / 客户反馈」
* 照目标件是 textarea(一句话装不下「见到的关键人、谈了什么、达成什么」),
* 而 textarea 的值在**正文**不在 `value=` 属性 —— `twinInputs` 那条 input 路填不了它。
* 与 `selectCurrent` 同款:值填进正文,没有值就真空着(placeholder 才显得出来)。
* 正文位插值照文本位那四个字符转义 —— 混进 `</textarea>` 就是逃逸。
*/
function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
return inner.replace(TEXTAREA_CELL, (all, attrs: string, col: string) => {
const raw = resolveSlot(scope, col);
if (raw === undefined || raw === null || raw === '') return all;
return `<textarea${attrs}>${escapeHtml(displayValue(raw))}</textarea>`;
});
}
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */ /** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string { export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
return renderTemplate(renderRows(html, scope), scope); return renderTemplate(renderRows(html, scope), scope);
+18 -2
View File
@@ -104,10 +104,26 @@
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真 `mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady) - **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
不再返回 `submitted` 空转 —— 发**验收卡**、立监听、在途验收入状态信封 不再返回 `submitted` 空转 —— 发**验收卡**、立监听、在途验收入状态信封
(`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍 (`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍分岔:
定终局:pass → `finished` + `released: true`(选口随放行带回),reject → 不放行 pass → `finished` + `released: true`(选口随放行带回);**非 pass = 打回重做,
不是终局**(2026-08-22 修死:从前非 pass 也走 `finished` 分支,task 层把
「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,却再没有事件
叫醒它,一单就此卡死)——意见(`PendingAcceptance.comment`,onEvent 从裁决载荷
捕入信封)以「验收被打回重做:…」进轨迹,在途验收清掉,**同一拍照常问模型**;
刚被打回的那一拍不走判齐快道(否则判齐恒真的活动会在意见没人读时原样再发一张卡),
模型修订后重新 submit,新发的验收卡才是下一次裁决的对象
- **onEvent 只记不跑**(三修②):事件到达只登记 occur / 收裁决入信封,存快照即返; - **onEvent 只记不跑**(三修②):事件到达只登记 occur / 收裁决入信封,存快照即返;
推进(可能含一次模型决策)统一发生在 tick —— steps/activeMs/tokens 一个预算口径 推进(可能含一次模型决策)统一发生在 tick —— steps/activeMs/tokens 一个预算口径
- **任务轨迹喂回叙事面**(三修③):`memory.window(taskId)` 的最近轨迹进 - **任务轨迹喂回叙事面**(三修③):`memory.window(taskId)` 的最近轨迹进
【本任务最近轨迹】段,窗口大小 = 活动声明的 `harness.memoryWindow`,Agent 每拍不失忆 【本任务最近轨迹】段,窗口大小 = 活动声明的 `harness.memoryWindow`,Agent 每拍不失忆
- **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场 - **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场
- **打回重做之后,没改过东西就不许再提交**(2026-08-23):`raiseAcceptance` 一处把关,
模型 submit 与判齐快道两条路共用 —— 打回之后台账没动过(写/撤都算)就拒;
软硬两段:头一次拒并指路(改台账 / 发卡问清楚要改什么),已因此拒过一次再提交就放行
(墙要配路,不锁死单子)。起因:打回那一刻案例模型的项常常全是 `Completed`,
模型除了原样重新 submit 无事可做,6 秒就把同一份成果递回来
- **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23):
`ListenerRegistrar.declared(taskId)` 给出声明过的那几种,没声明过的类型当场拒。
与「写的授权面是台账声明」「调工具的授权面是白名单」同一条 ——
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听,
而那个活动一个监听都没声明,单子永久挂在 `awaiting-event`)
@@ -8,6 +8,7 @@ import {
canTransitionItem, canTransitionItem,
IllegalTransitionError, IllegalTransitionError,
initialRuntime, initialRuntime,
markWorldTouched,
mayCompleteCase, mayCompleteCase,
mayTerminateCase, mayTerminateCase,
navigate, navigate,
@@ -134,6 +135,22 @@ describe('生命周期 —— 语义忠实,引擎不得私改迁移拓扑', () =
const next = reenter(runtime, 'T1'); const next = reenter(runtime, 'T1');
expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 }); expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 });
}); });
describe('markWorldTouched —— finish 前的证据(AM 12.2 原理一)', () => {
it('标一个项 —— 其余项原样不动', () => {
const model = parseCmmn(XML);
const next = markWorldTouched(initialRuntime(model), 'T1');
expect(next.items.find((i) => i.id === 'T1')?.worldTouched).toBe(true);
expect(next.items.find((i) => i.id !== 'T1')?.worldTouched).toBeUndefined();
});
it('驳回重新入场是新实例 —— 标记不带过去,得重新证明真触达过', () => {
const model = parseCmmn(XML);
const touched = markWorldTouched(initialRuntime(model), 'T1');
const next = reenter(touched, 'T1');
expect(next.items.find((i) => i.id === 'T1')?.worldTouched).toBeUndefined();
});
});
}); });
describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => { describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => {
@@ -6,7 +6,7 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts'; import type { TaskId } from '@contracts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts'; import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts'; import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts'; import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts'; import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -37,6 +37,12 @@ type Harness = {
readonly pushed: string[]; readonly pushed: string[];
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */ /** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
readonly writesAtPush: number[]; readonly writesAtPush: number[];
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
readonly appended: Array<{ kind: string; title: string }>;
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
readonly toolCalls: string[];
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
readonly listened: Array<{ eventType: string }>;
envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance }; envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance };
}; };
@@ -59,6 +65,12 @@ function harness(
writeNoop?: boolean; writeNoop?: boolean;
/** 撤行时草稿层里根本没有那一行(主键写法对不上) */ /** 撤行时草稿层里根本没有那一行(主键写法对不上) */
dropMisses?: boolean; dropMisses?: boolean;
/** doneWhen 表达式求值的桩(不给就恒真,同旧行为) */
sfeelCondition?: (expr: string, scope: Record<string, unknown>) => boolean;
/** 动态任务视图:按「此刻已写下几行」现算 —— 测「先写后 finish 的 doneWhen 用当下值」要它 */
viewFn?: (written: Harness['written']) => Record<string, unknown>;
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
} = {}, } = {},
): Harness { ): Harness {
const issued: Harness['issued'] = []; const issued: Harness['issued'] = [];
@@ -68,6 +80,9 @@ function harness(
const dropped: Harness['dropped'] = []; const dropped: Harness['dropped'] = [];
const pushed: string[] = []; const pushed: string[] = [];
const writesAtPush: number[] = []; const writesAtPush: number[] = [];
const appended: Harness['appended'] = [];
const toolCalls: string[] = [];
const listened: Array<{ eventType: string }> = [];
const h: Harness = { const h: Harness = {
issued, issued,
saves, saves,
@@ -76,6 +91,9 @@ function harness(
dropped, dropped,
pushed, pushed,
writesAtPush, writesAtPush,
appended,
toolCalls,
listened,
runner: undefined as never, runner: undefined as never,
envelope: undefined as never, envelope: undefined as never,
}; };
@@ -93,9 +111,9 @@ function harness(
}, },
load: async () => h.envelope as never, load: async () => h.envelope as never,
}, },
sfeel: { condition: () => true, expression: () => undefined } as never, sfeel: { condition: over.sfeelCondition ?? (() => true), expression: () => undefined } as never,
data: { data: {
view: async () => over.scope ?? {}, view: async () => (over.viewFn === undefined ? (over.scope ?? {}) : over.viewFn(written)),
writable: async () => [ writable: async () => [
{ domain: 'quote_intake', object: 'record', label: '统一受理记录', keys: ['pn'], fields: [{ name: 'eau', label: 'EAU' }] }, { domain: 'quote_intake', object: 'record', label: '统一受理记录', keys: ['pn'], fields: [{ name: 'eau', label: 'EAU' }] },
], ],
@@ -120,7 +138,10 @@ function harness(
}, },
} as never, } as never,
tools: { tools: {
invoke: async () => ({ ok: true }), invoke: async (input: { tool: string }) => {
toolCalls.push(input.tool);
return { ok: true };
},
isWriteEffect: async () => false, isWriteEffect: async () => false,
registerIntent: async () => undefined, registerIntent: async () => undefined,
busOf: async () => [ busOf: async () => [
@@ -137,9 +158,17 @@ function harness(
return { id: `card-${issued.length}` }; return { id: `card-${issued.length}` };
}, },
} as never, } as never,
listeners: { register: async () => ({ id: 'lsn-1' }) } as never, listeners: {
register: async (input: { eventType: string }) => {
listened.push({ eventType: input.eventType });
return { id: 'lsn-1' };
},
declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }],
} as never,
memory: { memory: {
append: async () => undefined, append: async (entry: { kind: string; title: string }) => {
appended.push({ kind: entry.kind, title: entry.title });
},
loadActivityMemory: async () => [], loadActivityMemory: async () => [],
window: async () => over.trail ?? [], window: async () => over.trail ?? [],
} as never, } as never,
@@ -212,11 +241,166 @@ describe('提交放行 —— 终局唯放行,卡只发一张', () => {
expect(h.saves.at(-1)?.pending).toBeNull(); expect(h.saves.at(-1)?.pending).toBeNull();
}); });
it('裁决 reject → finished 不放行', async () => { /*
const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'reject' } }); * **裁决非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:E04 一单点了「重做」,
* 引擎把在途验收消费成 null、任务重新挂回 awaiting-human —— 人已经说了话,
* 却再没有任何事件会把它叫醒,一单就此卡死)。重做的语义是「带着意见退回去再干一遍」:
* 意见用人话进轨迹、在途验收清掉,**同一拍照常问模型** —— 模型带着意见改成果、
* 改完重新 submit,新发的那张验收卡才是下一次裁决的对象。
*/
it('裁决 redo(非 pass)→ 不终局:意见进轨迹、清在途验收、同一拍照常问模型', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
pending: { acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次,优先级排得不对' },
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
});
const outcome = await h.runner.runTurn(TASK); const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('finished'); expect(outcome.status).not.toBe('finished');
expect(outcome.released).toBe(false); expect(h.decided).toHaveLength(1);
// 在途验收第一时间清掉 —— 留着它,下一拍还会按「已裁决」再走一遍终局
expect(h.saves[0]?.pending).toBeNull();
expect(h.appended.some((e) => e.title.includes('打回重做') && e.title.includes('重做一次,优先级排得不对'))).toBe(true);
});
/*
* **打回重做之后,一个字没改就再提交 = 原样递回,当场拒**(2026-08-22,柴浩实测:
* 点「重做」写了「重做一下」,6 秒后同一份成果原样又递上来 —— 三条裁量判据重判一遍、
* 台账一个字没动。人看见的是「我说了重做,它当没听见」)。
* 案例模型的项这时全是 Completed,模型除了重新 submit 无事可做 —— **措辞治不了的结构治**:
* 提交这道口子自己看一眼「打回之后改过东西没有」,没改就不放行。
* 与「重发已答过的卡」同族的软硬两段:头一次拒(把路指明:改台账、或问清楚要改什么),
* 已经拒过一次还坚持原样提交就放行 —— 人还能再打回,不至于把单子锁死。
*/
const REDO_NOTICE = { kind: 'result', title: '验收被打回重做:优先级排得不对' };
const WROTE = { kind: 'action', title: '写草稿:crm_coverage.plan 1 行(1 行有变化)', detail: { ledger: 'crm_coverage.plan' } };
const REFUSED = { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' };
it('打回后零修改就 submit:当场拒,不发新验收卡', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE] });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('一个字没改'))).toBe(true);
});
it('打回后改过台账再 submit:照常发卡', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, WROTE] });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
});
it('已经因此拒过一次:再提交就放行 —— 墙要配路,不把单子锁死', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, REFUSED] });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('判齐快道同一道闸:打回后没改,引擎也不替模型原样再递一次', async () => {
// READY_MODEL 判齐恒真 —— 这条路不经模型,从前它会在意见没人读时自己再发一张卡
const h = harness({ trail: [REDO_NOTICE] });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(outcome.status).toBe('running');
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(true);
});
it('没有打回过的常路:提交照常,这道闸不出手', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [WROTE] });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('判齐恒真的局面里收到 redo:这一拍不走判齐快道重发卡 —— 先让模型看见意见', async () => {
// READY_MODEL 必做项全无 + autoComplete → 判齐恒真;从前这条快道会不问模型直接再发一张卡
const h = harness({
pending: { acceptanceCardId: 'card-9', verdict: 'reject' },
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
});
await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(1);
// 这一拍引擎没有自己发新验收卡(模型看完意见后自己 submit 才发)
expect(h.issued).toHaveLength(0);
// 没写意见也要说明白:让模型自己复核,不是让它猜
expect(h.appended.some((e) => e.title.includes('打回重做'))).toBe(true);
});
});
/**
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 `itemId` 就是空串,
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下 ——
* **最后的活路自己被拦住,这一单就真没路了**(墙要配路,而这里连路本身都砌了墙)。
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
* 挂不上照发,留痕注明。人工激活那道 worldTouched 门不受影响 ——
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
*/
describe('发卡 —— 项号挂不上不拦', () => {
const CARD = { kind: 'human', itemId: '', card: { title: '需要你判断下一步', ask: '接着走哪一项?' }, workOrder: 'execution' };
it('项号为空:卡照发,留痕注明挂不上', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CARD });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
expect(h.appended.some((e) => e.title.includes('未挂到计划项'))).toBe(true);
});
it('项号编的:同样照发 —— 卡是给人的,不该因为记账挂不上就发不出去', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...CARD, itemId: '我编的项' } });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('项号挂得上:照旧挂上并点亮(worldTouched 那道门要的就是这个)', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: { ...CARD, itemId: 'GATED' } });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
});
});
/**
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23,柴浩实测:
* E04 一单在 S4 审批过后卡在依赖断点 —— 模型没写 `plan_approved_total` 那面旗,
* S5 的入场哨兵永不成立;它于是临场「立一个 data 监听等执行回执」,而 E04 声明的
* 监听是**空的**、这一单也不会有任何 data 事件进来,单子就此永久挂在 awaiting-event,
* 15 分钟一动不动)。与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:
* **没声明过的监听立不得** —— 立了也没人来叫醒它,那不是等待,是失联。
*/
describe('立监听 —— 授权面是清单声明', () => {
const LISTEN = { kind: 'listen', itemId: 'i1', eventType: 'data', keyFilter: { kind: 'receipt' }, deadline: 'P3D' };
it('活动一个监听都没声明:当场拒,并指出该走哪条路', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: LISTEN, declaredListeners: [] });
const outcome = await h.runner.runTurn(TASK);
expect(h.listened).toHaveLength(0);
expect(outcome.status).toBe('running');
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('没声明'))).toBe(true);
});
it('声明过这一类:照立不拦', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: LISTEN,
declaredListeners: [{ eventType: 'data', keyFilter: { kind: 'receipt' } }],
});
const outcome = await h.runner.runTurn(TASK);
expect(h.listened).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
});
it('声明的是别的类(只声明了 time,却要立 data):照样拒', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: LISTEN,
declaredListeners: [{ eventType: 'time', keyFilter: { kind: 'deadline' } }],
});
await h.runner.runTurn(TASK);
expect(h.listened).toHaveLength(0);
}); });
}); });
@@ -234,6 +418,12 @@ describe('onEvent —— 只记不跑', () => {
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9' }); expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9' });
expect(h.decided).toHaveLength(0); expect(h.decided).toHaveLength(0);
}); });
it('redo 裁决连意见一起落进信封 —— 重做那一拍模型要看得见人写了什么', async () => {
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-9', payload: { verdict: 'redo', comment: '重做一次' } });
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次' });
});
}); });
describe('叙事面 —— 任务自身轨迹喂回', () => { describe('叙事面 —— 任务自身轨迹喂回', () => {
@@ -605,6 +795,131 @@ describe('空转的写当场拒', () => {
expect(outcome.status).toBe('running'); expect(outcome.status).toBe('running');
expect(h.written).toHaveLength(1); expect(h.written).toHaveLength(1);
}); });
/*
* **空转写身后还有别的行动:无事发生,串不废**(2026-08-22,E04 活单熔断实测):
* 模型每拍计划「写记账旗 + 发 S5 执行卡」两个动作的串 —— 旗的值没变,第 1 个动作
* 被拒,而「被拒停在谁那儿」把整串弃权,排在后面的发卡从没执行过;连续五拍如此,
* 熔断交人。提示词的 world-touch 事实行与拒回的 blockedOnWorld 指路**都在**,
* 模型照样先写旗 —— 措辞已到头,结构治(设计律第六条):值没变的写什么也没改,
* 后面的行动前提未变,不该陪葬。
*/
it('空转写身后还有行动:无事发生不废串 —— 排在后面的发卡照发', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
actions: [
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'human', itemId: 'GATED', card: { title: '执行回执工单', ask: '请执行并回填' }, workOrder: 'execution' },
],
writeNoop: true,
});
const outcome = await h.runner.runTurn(TASK);
expect(h.issued.filter((c) => c.workOrder === 'execution')).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
// 无事发生也要说出来 —— 模型下一拍得看见「值已在台账里」这个事实
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('写了等于没写'))).toBe(true);
});
/*
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测:模型调
* rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,下一拍补上项号
* 原样重调 —— 那次拒回什么都没保护,白烧一拍。与写行动同款:挂不上不拦,
* 留痕注明,记账挂项是尽力而为,不是门。
*/
it('调工具项号为空/挂不上:照调不拦 —— 授权面是工具白名单,不是计划项', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: { kind: 'tool', itemId: '', tool: 'crm.history-price-read', input: { pns: ['A'] } },
});
const outcome = await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
expect(outcome.status).toBe('running');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
});
/*
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调赌变卦;
* 出参每次原样摆在轨迹里它照调不误,26 次调用烧穿预算的前科在案,而 stalledTurns
* 只数「行动被拒」,成功的重调没有任何兜底)。与「重发已答过的卡」同族的软硬两段:
* 重调的头几次照调、出参标题携带点名警告(不算被拒,模型不撞墙);第 5 次identical
* 才拒(真卡死了,熔断兜底要看得见)。写过/撤过之后世界变了,计数随写清零。
*/
it('同一份入参的重调:出参标题带点名警告 —— 不拒,但话要说到', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
const echo = {
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
};
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: CALL,
trail: [echo, { kind: 'observation', title: '这一拍在往下办事' }, { ...echo }],
});
await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
expect(h.appended.some((e) => e.kind === 'result' && e.title.includes('第 3 次调用') && e.title.includes('别再调了'))).toBe(true);
});
it('同一份入参连调第 5 次(期间没写过东西):当场拒 —— 真卡死了,熔断要看得见', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
const echo = () => ({
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
});
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: CALL,
trail: [echo(), echo(), echo(), echo()],
});
await h.runner.runTurn(TASK);
expect(h.toolCalls).toHaveLength(0);
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
});
it('两次识别之间写过台账:世界变了,重调照放行', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
const echo = {
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
};
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: CALL,
trail: [echo, { ...echo }, { kind: 'action', title: '写草稿:d.o 1 行(1 行有变化)', detail: { ledger: 'd.o' } }],
});
await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
});
it('入参不同的重调不算原地打转:照放行', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['B'] } };
const echo = {
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
};
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CALL, trail: [echo, { ...echo }] });
await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
});
/* 串尾/独苗的空转写仍当场拒 —— 熔断检测(stalledTurns 数「行动被拒」)靠它兜底 */
it('空转写是独苗:照旧当场拒,熔断检测看得见', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE, writeNoop: true });
await h.runner.runTurn(TASK);
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
});
}); });
describe('宣告完成', () => { describe('宣告完成', () => {
@@ -639,16 +954,111 @@ describe('宣告完成', () => {
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 —— * 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。 * 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
*/ */
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => { it('标了人工激活的项:已经真触达过世界,模型显式动它 = 启动它,就地过 Active 再收', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') }); // worldTouched 模拟「更早的拍已经发过卡 / 立过监听」——这里只测「不必再补一次 start」那半条
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, worldTouched: true } : i)) } as never,
action: finish('GATED'),
});
const outcome = await h.runner.runTurn(TASK); const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed'); expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
}); });
/*
* **触达世界的项,没真触达过就不许 finish**(2026-08-21,E04 实测逼出来的:
* S5_execute_receipt 从没给人发过执行回执工单,模型直接 finish 掉了它,
* 人根本没被问过完成状态,台账拿空回执重算了覆盖度)。`manualActivation`
* 声明的正是「完成与否不该由数据形状推出来」——如果 finish 不查有没有真的
* human/entry/decide/listen 之一发生过,这条声明形同虚设,模型可以随口
* 编一个「完成」。
*/
it('标了人工激活的项:从没真触达过世界,finish 被拒——不许凭空宣布人已经办完了', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
});
it('编出来的项收不了', async () => { it('编出来的项收不了', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('我编的项') }); const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('我编的项') });
expect((await h.runner.runTurn(TASK)).status).toBe('running'); expect((await h.runner.runTurn(TASK)).status).toBe('running');
}); });
/*
* **声明了 doneWhen 的项,算出来是假就不许 finish**(2026-08-22,E04 实测逼出来的:
* S1 该写两行候选客户,模型只写成一行就直接 finish——doneWhen 明明为假,这一步照样
* 被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,一路悄悄漏到 S5 提交闸才第一次
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
* 不是让模型自己判断,是让基座去核。
*/
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
sfeelCondition: () => false,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
});
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
sfeelCondition: () => true,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
/*
* **同一串里先写后 finish,doneWhen 要按写完之后的草稿算**(2026-08-22,E04 实测:
* S1 一拍连做「写候选行 + finish」——写已经落进草稿,finish 却按拍首快照判 doneWhen
* 为假被拒;下一拍引擎又用同一份数据自动收步。同一份数据两个裁决,每单白吃一次被拒,
* 而「先写后 finish 一拍连做」正是提示词鼓励的省拍打法,不该被快照误伤。
*/
it('同一串先写后 finish:doneWhen 按写完后的草稿算,不按拍首快照', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
actions: [
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'finish', itemId: 'FREE', rationale: '行已写齐' },
],
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'record_rows > 0' }],
viewFn: (written) => ({ record_rows: written.length }),
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
});
it('doneWhen 表达式算错了(抛错)—— 不拖垮这一拍,原样交给模型的 finish 判断', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: '这不是一个合法表达式(((' }],
sfeelCondition: () => {
throw new Error('写坏了');
},
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
it('没声明 doneWhen 的项:finish 不受这道新门槛影响,照旧收', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('FREE'), sfeelCondition: () => false });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
}); });
/** /**
@@ -873,6 +1283,37 @@ describe('画面不再静默丢帧', () => {
expect(h.writesAtPush).toEqual([2]); expect(h.writesAtPush).toEqual([2]);
}); });
/*
* **这一拍把上一步收了、下一步的卡也发了 —— 画面得跟着走到下一步**
* (2026-08-23 E04 实测:S4 计划审批收掉、S5 执行卡当场发出,而工作区还摆着
* 审批那份只读件。人拿到的卡写着「把回执填到件上」,屏上一个可填的格子都没有,
* 提交闸如实拦住,他无处可填 —— 墙拦住却不给路)。
* 病根与 `liveScope` 那条同源:**拿拍首的局面去决定拍尾的事**。
*/
it('一串行动把案子推到下一步 → 推的是下一步的画面,不是拍首那一步的', async () => {
const TWO: CaseModel = {
name: '两步',
autoComplete: true,
items: [
{ id: 'i1', name: '第一步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'i2', name: '第二步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const h = harness({
parsed: TWO,
runtime: { items: [{ id: 'i1', state: 'Active' }, { id: 'i2', state: 'Available' }] } as never,
templates: ['step1-view', 'step2-view'],
steps: [
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
],
actions: [{ kind: 'finish', itemId: 'i1' }],
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual(['step2-view']);
});
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => { it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
const h = harness({ const h = harness({
parsed: OPEN_MODEL, parsed: OPEN_MODEL,
@@ -76,6 +76,17 @@ export type ItemRuntime = {
readonly instance: number; readonly instance: number;
/** 语义性前提待裁量评估的标记 */ /** 语义性前提待裁量评估的标记 */
readonly awaitingJudgement?: boolean; readonly awaitingJudgement?: boolean;
/**
* **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。
*
* 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否
* 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡
* 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 ——
* 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」,
* 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单,
* 覆盖度收口直接用了空回执重算,人根本没被问过)。
*/
readonly worldTouched?: boolean;
}; };
export type CaseRuntime = { export type CaseRuntime = {
@@ -342,6 +353,11 @@ export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemSta
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) }; return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
} }
/** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */
export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime {
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) };
}
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */ /** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime { export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
return { return {
@@ -30,6 +30,7 @@ import {
criterionKey, criterionKey,
readingsOf, readingsOf,
initialRuntime, initialRuntime,
markWorldTouched,
navigate, navigate,
occur, occur,
publishEnabled, publishEnabled,
@@ -139,6 +140,27 @@ function compact(value: unknown, limit: number = TRAIL_PAYLOAD_LIMIT): string {
* 非法行动、每拍被拓扑拒回来,人在界面上只看到「进行中」一动不动 —— * 非法行动、每拍被拓扑拒回来,人在界面上只看到「进行中」一动不动 ——
* 那不是「在办」,是卡死了。Harness 的活就是在这时候把它交回给人。 * 那不是「在办」,是卡死了。Harness 的活就是在这时候把它交回给人。
*/ */
/**
* 工具入参的短哈希 —— 「同一份入参重调」检查的账。
* 存整份入参不行(轨迹要进提示词,一份 contacts 数组就是几百字);djb2 足够认同一性,
* 碰撞的代价只是一次多余的拦截提示,不是数据错误。
*/
/** 「验收被打回重做」那条痕的抬头 —— 写它与找它的是同一个常量,改一处两处都跟着改 */
const REDO_MARK = '验收被打回重做';
/**
* 「打回之后没改就再提交」那次拒回里的记号句 —— 软硬两段靠它数第二次。
* 认的是**句子**不是抬头:同一条拒回经两条路留痕,抬头各不相同
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
*/
const REFUSE_MARK = '打回之后台账一个字没改';
export function toolInputHash(input: unknown): string {
const text = JSON.stringify(input) ?? '';
let hash = 5381;
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
return `${text.length}:${(hash >>> 0).toString(16)}`;
}
export function stalledTurns(trail: ReadonlyArray<{ kind: string; title: string }>): number { export function stalledTurns(trail: ReadonlyArray<{ kind: string; title: string }>): number {
let count = 0; let count = 0;
for (let i = trail.length - 1; i >= 0; i -= 1) { for (let i = trail.length - 1; i >= 0; i -= 1) {
@@ -348,6 +370,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}; };
} }
/*
* **项号挂不上**(没给 / 编的一个不存在的号)—— 与「这一步已经办完了」是两件事:
* 前者只是记账没对上(写、调工具、发卡都照办,留痕注明),后者是真墙(别往办完的事上派活)。
* 分成两种错误型,调用方才好各按各的处置;混成一句,要么该拦的不拦,要么该放的不放。
*/
class ItemUnattachableError extends Error {}
/** /**
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。 * 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
* *
@@ -372,7 +401,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
.map((i) => i.id); .map((i) => i.id);
// 空字符串不是「不存在的项」,是**没给项号** —— 两件事说成一句,读的人只能猜 // 空字符串不是「不存在的项」,是**没给项号** —— 两件事说成一句,读的人只能猜
const what = itemId.trim() === '' ? '没给计划项号' : `计划项不存在:${itemId}`; const what = itemId.trim() === '' ? '没给计划项号' : `计划项不存在:${itemId}`;
throw new Error(`${what}(可选:${legal.join('、') || '无'})`); throw new ItemUnattachableError(`${what}(可选:${legal.join('、') || '无'})`);
} }
/* /*
* 父阶段没开着就不许在里面干活 —— 不然 CMMN 的阶段门是装饰。 * 父阶段没开着就不许在里面干活 —— 不然 CMMN 的阶段门是装饰。
@@ -498,6 +527,26 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
taskId: TaskId, taskId: TaskId,
rawAction: AgentAction, rawAction: AgentAction,
runtime: CaseRuntime, runtime: CaseRuntime,
/**
* **这一项声明的 doneWhen,现在算出来是真是假**(undefined = 没声明,或算不出来)。
* 只有 `finish` 用得上——`doneWhen` 从前只管「引擎自己收要不要收」,没管过
* 「模型自己 finish 让不让」:一个没有 manualActivation 的普通步骤,模型随时能
* 显式 finish 掉它,doneWhen 是真是假完全不影响这条路(2026-08-22 实测:
* S1 该写两行候选客户,模型只写成一行就直接 finish,doneWhen 明明为假,
* 这一步照样被记成「完成」)。**声明了 doneWhen 就是说「这件事完没完是可以算出来的」**——
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
*/
doneWhenOf?: (itemId: string) => boolean | undefined,
/**
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
* 模型每拍计划「写记账旗 + 发执行卡」,旗的值没变,排在后面的发卡从没执行过,
* 连拒五拍熔断。值没变的写**什么也没改**,后面行动的前提未变,不该陪葬:
* 有后续就降级成「无事发生」放行整串;没有后续(串尾/独苗)仍拒 ——
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
*/
hasFollowUp = false,
): Promise<{ ): Promise<{
runtime: CaseRuntime; runtime: CaseRuntime;
status: TurnOutcome['status']; status: TurnOutcome['status'];
@@ -615,10 +664,56 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
} }
case 'tool': { case 'tool': {
// 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算)
const { parsed: toolModel } = await loaded(taskId); const { parsed: toolModel } = await loaded(taskId);
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它 /*
const working = readyToWork(toolModel, runtime, action.itemId); * **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调
* 赌变卦;出参每次原样摆在轨迹里,它照调不误。26 次调用烧穿 500k 预算的前科在案,
* 而 stalledTurns 只数「行动被拒」,成功的重调它看不见 —— 没有任何兜底)。
* 头两次照调;期间**没有任何写/撤动作**(世界没变,读出来的只会一样)的第 3 次
* identical 调用当场拒 —— 拒回是模型反应最好的通道,也归熔断兜底管。
* 写过之后计数清零:世界变了,重调是合法的。
*/
let sameCalls = 0;
if (!(await deps.tools.isWriteEffect(action.tool))) {
const hash = toolInputHash(action.input);
const recent = await deps.memory.window(taskId, STALL_WINDOW);
for (let i = recent.length - 1; i >= 0; i -= 1) {
const entry = recent[i]!;
if (entry.kind === 'action' && typeof entry.detail?.['ledger'] === 'string') break; // 写/撤过,世界变了
if (entry.kind === 'result' && entry.title.startsWith(`${action.tool} 返回`) && entry.detail?.['inputHash'] === hash) {
sameCalls += 1;
}
}
/*
* 与「重发已答过的卡」同族的软硬两段:重调的头几次**照调**(它是本地纯函数,
* 调用本身不贵,贵的是拍)但出参标题携带点名警告 —— 不算被拒,模型不撞墙;
* 到第 5 次identical 才拒(那时是真卡死了,熔断兜底要看得见它)。
*/
if (sameCalls >= 4) {
throw new Error(
`${action.tool} 用同一份入参已经调过 ${sameCalls} 次,出参就在你的轨迹里,而这期间台账一个字没动 —— ` +
'它是按入参算数的工具,同样的输入永远同样的结论,再调一遍不会变卦。' +
'算出来的数与你预想的不一样,先检查入参是不是漏了字段;确认无误就**按这个数往下办**' +
'(该写的表写上、该收的步收掉),别再调了。',
);
}
}
/*
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测):
* 模型调 rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,
* 下一拍补上项号原样重调 —— 那次拒回什么都没保护,白烧一拍。
* 与写行动同款(「写的授权面是台账声明」):itemId 只是记账,
* 挂不上(空串/项不存在/已收掉)照调不拦,留痕注明;记账挂项尽力而为。
*/
let working = runtime;
let unattached: string | undefined;
try {
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它
working = readyToWork(toolModel, runtime, action.itemId);
} catch (error) {
unattached = error instanceof Error ? error.message : String(error);
}
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地 // 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
if (await deps.tools.isWriteEffect(action.tool)) { if (await deps.tools.isWriteEffect(action.tool)) {
await deps.tools.registerIntent({ await deps.tools.registerIntent({
@@ -651,12 +746,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
await deps.memory.append({ await deps.memory.append({
taskId, taskId,
kind: 'result', kind: 'result',
title: `${action.tool} ${result.ok ? '返回' : '失败'}`, title:
detail: { output: result.output, error: result.error?.message }, `${action.tool} ${result.ok ? '返回' : '失败'}` +
(unattached === undefined ? '' : ` —— 未挂到计划项(${unattached})`) +
// 重调的警告写在出参标题上 —— 它随轨迹进下一拍的上下文,正是模型看的地方
(sameCalls === 0 || !result.ok
? ''
: ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`),
// inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词)
detail: { output: result.output, error: result.error?.message, inputHash: toolInputHash(action.input) },
}); });
return { return {
// 失败才动状态(Failed 是拓扑里的一档);成了就留在 Active 等 Agent 宣告完成 // 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成
runtime: result.ok ? working : transitionItem(working, action.itemId, 'Failed'), runtime: result.ok || unattached !== undefined ? working : transitionItem(working, action.itemId, 'Failed'),
status: 'running', status: 'running',
}; };
} }
@@ -676,7 +778,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* T_release —— 每一拍的补写都被「要先 start」拒回来,连拒五拍熔断交人, * T_release —— 每一拍的补写都被「要先 start」拒回来,连拒五拍熔断交人,
* 而那几行数据本身合法合规。活没干完却没有项可挂,是记账问题,不是授权问题。 * 而那几行数据本身合法合规。活没干完却没有项可挂,是记账问题,不是授权问题。
*/ */
if (action.rows.length === 0) throw new Error('写台账要给行:rows 为空,这一拍什么也没发生'); /*
* **空 rows 的拒回要点名地址,不能只说「什么也没发生」**(2026-08-21 实测:
* E04 一拍连发三个写,前两个成了,第三个 crm_coverage.ledger_update 带着空
* rows 被拒——下一拍模型没有回头补那一张,反倒把前两张已经成功的表原样
* 重写,连拒五拍熔断)。这张表这一拍**一行都没写**,不是「写错了要改」,
* 是「压根没写」——把地址钉在拒回里,下一拍才有目标可对,不会去改别的表。
*/
if (action.rows.length === 0) {
throw new Error(
`写台账要给行:${action.address} 这一下 rows 是空的,这一拍什么也没发生 —— ` +
`这张表这一拍一行都没写(不是内容错了要改,是压根没写),下一拍别去动别的表` +
'(尤其别把已经写成功的表重写一遍,那只会被当空转再拒一次),回来把这张表真正的行写上。',
);
}
const at = action.address.indexOf('.'); const at = action.address.indexOf('.');
if (at <= 0 || at === action.address.length - 1) { if (at <= 0 || at === action.address.length - 1) {
throw new Error(`台账地址要写成「域.对象」:收到 ${action.address || '(空)'}`); throw new Error(`台账地址要写成「域.对象」:收到 ${action.address || '(空)'}`);
@@ -719,11 +834,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍, * 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南, * 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。 * 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
*
* **「该 finish 的项」这半句在一种局面下会把模型指回死路**(2026-08-21
* 实测:E04 finish 被 worldTouched 那道新门拒了之后,模型没去发卡,
* 反倒把同一行 verdict 原样重写了五拍,连拒到熔断)——它其实卡在
* 「有一项标了人工激活,却还没真给人发过卡」,这句泛泛的指路对它不管用。
* 查一眼当下有没有这样的项,点名指过去:该发的是卡,不是再写一遍这张表。
*/ */
const blockedOnWorld = model.items.find((i) => {
if (i.manualActivation !== true) return false;
const state = runtime.items.find((r) => r.id === i.id);
return (state?.state === 'Enabled' || state?.state === 'Active') && state.worldTouched !== true;
});
/*
* **身后还有行动就不拒:无事发生,串照走**(2026-08-22,E04 活单熔断实测)。
* 模型每拍计划「写记账旗 + 发 S5 执行卡」—— 旗的值没变,这里一拒整串弃权,
* 排在后面的发卡从没执行过;提示词的 world-touch 事实行与下面 blockedOnWorld
* 的指路**都在**,它照样先写旗:措辞已到头,结构治。值没变的写什么也没改,
* 后面行动的前提未变,不该陪葬。留痕照说(模型下一拍要看见「值已在台账里」),
* 局面原样交回 —— 无事发生不站上场(与 start 已开项、重发已答卡同族)。
*/
if (hasFollowUp) {
await deps.memory.append({
taskId,
kind: 'observation',
title: `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— 无事发生,这一串后面的行动照走`,
detail: { internal: true, ledger: action.address, itemId: action.itemId },
});
return { runtime, status: 'running' };
}
throw new Error( throw new Error(
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` + `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` + `这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`, `该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次` +
(blockedOnWorld === undefined
? ''
: ` —— 这一单当下卡在 ${blockedOnWorld.id}(标了人工激活,这一单里还没真给它发过卡、` +
'也没为它立过监听):别再往这张表上写了,去照说明书发那一张卡(human/entry/decide)' +
'或立一次监听(listen),这才是当下唯一能往下走的路。'),
); );
} }
await deps.memory.append({ await deps.memory.append({
@@ -875,7 +1023,30 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
} }
} }
const { parsed: cardModel } = await loaded(taskId); const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId); /*
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 itemId 就是空串 ——
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下。
* **最后的活路自己被砌了墙,这一单就真没路了。**
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
* 挂不上照发、留痕注明。人工激活那道 worldTouched 门不受影响 ——
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
*/
let advanced = runtime;
let cardUnattached: string | undefined;
try {
advanced = readyToWork(cardModel, runtime, action.itemId);
} catch (error) {
// 「这一步已经办完/停用」是真墙,原样抛回去(拒回话术会把模型指向当下那一步)
if (!(error instanceof ItemUnattachableError)) throw error;
cardUnattached = error.message;
await deps.memory.append({
taskId,
kind: 'observation',
title: `发卡未挂到计划项(${cardUnattached}) —— 卡照发,记账挂不上不拦人`,
detail: { internal: true, card: action.card },
});
}
const card = await deps.cards.issue({ const card = await deps.cards.issue({
taskId, taskId,
ownerId: context.ownerId, ownerId: context.ownerId,
@@ -889,7 +1060,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: card.id }, keyFilter: { cardId: card.id },
deadline: 'P3D', deadline: 'P3D',
}); });
return { runtime: advanced, status: 'awaiting' }; // 挂不上就没有项可标 —— worldTouched 只记在真挂上的那一项上,那道门因此仍然诚实
return {
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
status: 'awaiting',
};
} }
case 'ask': { case 'ask': {
@@ -932,7 +1107,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: entryCard.id }, keyFilter: { cardId: entryCard.id },
deadline: 'P3D', deadline: 'P3D',
}); });
return { runtime: entryReady, status: 'awaiting' }; return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting' };
} }
case 'decide': { case 'decide': {
@@ -992,10 +1167,31 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: card.id }, keyFilter: { cardId: card.id },
deadline: 'P3D', deadline: 'P3D',
}); });
return { runtime: advanced, status: 'awaiting' }; return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting' };
} }
case 'listen': { case 'listen': {
/*
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23 加,
* 柴浩实测逼出来的:E04 一单卡在依赖断点 —— 模型没写那面记账旗、下一步的哨兵
* 永不成立,它于是临场「立个 data 监听等回执」;而 E04 一个监听都没声明、
* 这一单也不会有任何 data 事件进来,单子就此挂在 awaiting-event 15 分钟一动不动)。
*
* **立了没人叫醒的监听不是等待,是失联** —— 与「写的授权面是台账声明」
* 「调工具的授权面是白名单」同一条纪律。拒回同时把两条真路指出来:
* 要等人办事就发执行工单;卡在断点就回去补那个没写的数。
*/
const declared = await deps.listeners.declared(taskId);
if (!declared.some((d) => d.eventType === action.eventType)) {
const kinds = declared.map((d) => d.eventType).join('、');
throw new Error(
`这个活动没声明过 ${action.eventType} 类监听(声明过的:${kinds || '一个都没有'})—— ` +
'立了也没人会来叫醒它,这一单会一直挂着。要等人去办事就**发执行工单**(human,' +
'人在工作区的件上把回音填回来);当下要是没有项可干(依赖断点),' +
'多半是某个入场前提的数还没写(看「干完的判据」那几行读数,常见的是某面记账旗漏了),' +
'回去把那一笔补上,门自己就开了。',
);
}
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) // AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
await deps.listeners.register({ await deps.listeners.register({
taskId, taskId,
@@ -1009,7 +1205,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
title: `立监听:${action.eventType}(时限 ${action.deadline})`, title: `立监听:${action.eventType}(时限 ${action.deadline})`,
detail: action.keyFilter, detail: action.keyFilter,
}); });
return { runtime, status: 'awaiting' }; return { runtime: markWorldTouched(runtime, action.itemId), status: 'awaiting' };
} }
case 'asked': { case 'asked': {
@@ -1057,6 +1253,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}); });
return { runtime, status: 'running' }; return { runtime, status: 'running' };
} }
/*
* **触达世界的项,没真触达过就不许 finish**(2026-08-21 用户实测逼出来的:
* E04 的 S5_execute_receipt 从未派发执行回执工单,模型一拍之内就把它
* finish 掉了,人根本没被问过完成状态/结果摘要,收口那一步拿空回执重算了
* 覆盖度)。`manualActivation = true` 声明的正是「这一项完成与否**不该由
* 数据形状推出来**」(AM 12.2 原理一)——可 `finish` 从前只查生命周期迁移
* 合不合法,从没查过「人真的被问过吗」。凭空写的完成不是完成,是编造。
*
* 门槛只认 `worldTouched`(见 lifecycle.ts):human / entry / decide / listen
* 四个「触达世界」的动作之一真的针对这一项执行过,才算数——不管是这一拍刚发的,
* 还是更早的拍留下的(reject 之后重发也算,标记不会中途被清)。
*/
const item = finishModel.items.find((i) => i.id === action.itemId);
if (item?.manualActivation === true && runtime.items.find((i) => i.id === action.itemId)?.worldTouched !== true) {
throw new Error(
`${action.itemId} 标了人工激活(触达世界),但这一单里从没给它发过人类工具的卡、` +
'也没为它立过监听 —— finish 不能凭空宣布「人已经办完了」。先照说明书发一张' +
'卡(human/entry/decide)或立一次监听(listen),真收到回音再 finish;' +
'如果这一步其实不需要人(说明书写错了),那是活动包的声明问题,不是这一拍能绕过去的。',
);
}
/*
* **声明了 doneWhen,就不许模型自己一句话绕过去**(2026-08-22 实测:E04 S1
* 该写两行候选客户,模型只写成了一行就直接 finish——doneWhen 明明为假,
* 这一步照样被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,
* 一路悄悄漏到 S5 提交闸才第一次被拦下)。`doneWhen` 是「这件事完没完可以
* 算出来」的声明,算出来是假,finish 就该被拒——不是让模型自己判断,
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
*/
const doneWhenNow = doneWhenOf?.(action.itemId);
if (doneWhenNow === false) {
throw new Error(
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
'基座会自己收,不必你再 finish。',
);
}
const ready = readyToWork(finishModel, runtime, action.itemId); const ready = readyToWork(finishModel, runtime, action.itemId);
await deps.memory.append({ await deps.memory.append({
taskId, taskId,
@@ -1099,6 +1333,34 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/** 验收报告上让人当场改的那几个值(带 AI 拟值与落回地址)—— 见 submit 行动的注释 */ /** 验收报告上让人当场改的那几个值(带 AI 拟值与落回地址)—— 见 submit 行动的注释 */
fields?: ReadonlyArray<{ key: string; label: string; ai?: string; ledger?: string }>, fields?: ReadonlyArray<{ key: string; label: string; ai?: string; ledger?: string }>,
): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status'] }> { ): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status'] }> {
/*
* **人打回了这一份成果,改都没改就不许再递上来**(2026-08-22,柴浩实测:点「重做」
* 写了「重做一下」,6 秒后同一份成果原样又递回 —— 三条裁量判据重判一遍、台账一个字没动。
* 人看见的是「我说了重做,它当没听见」)。
*
* 根子在局面:打回那一刻案例模型的项全是 Completed,模型除了重新 submit 无事可做,
* 拒回文案与说明书都劝不动它(**措辞治不了的结构治**)。所以由提交这道唯一的出口
* 自己看一眼:打回之后台账动过没有(写 / 撤都算)——没动就不放行。
*
* 软硬两段(与「重发已答过的卡」「同一份入参重调」同族):头一次拒,把路指明;
* 已经因此拒过一次还坚持原样提交就放行 —— 人还能再打回,墙要配路,不把单子锁死。
*/
const window = await deps.memory.window(taskId, STALL_WINDOW);
let lastRedo = -1;
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
if (lastRedo >= 0) {
const after = window.slice(lastRedo + 1);
const revised = after.some((e) => e.kind === 'action' && typeof e.detail?.['ledger'] === 'string');
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
if (!revised && !refusedBefore) {
throw new Error(
`人打回了这一份成果,而${REFUSE_MARK} —— 原样再递上去,` +
'他看见的还是刚被他打回的那一份。先按他的意见改台账(write 覆盖写 / drop 撤行都使得),' +
'改完再提交;他没写具体意见的话,自己把成果复核一遍、至少改对一处,' +
'或者发一张卡问清楚他要改什么,别原样重提。',
);
}
}
const context = await deps.tasks.of(taskId); const context = await deps.tasks.of(taskId);
/* /*
* **发验收卡之前先把放行那一页摆好**(2026-07-31)。 * **发验收卡之前先把放行那一页摆好**(2026-07-31)。
@@ -1191,22 +1453,43 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const pending = (await loaded(taskId)).pending; const pending = (await loaded(taskId)).pending;
/* /*
* 在途验收先于一切:裁决到了按它定终局(released 与否都是终局,不再问 Agent); * 在途验收先于一切:没裁就继续等(不重发卡、不空跑模型);裁了按 verdict 分岔:
* 没到就继续等 —— 不重发卡、不空跑模型。 * · `pass` → 终局(released),不再问 Agent;
* · **非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:从前这里也走终局分支,
* task 层把「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,
* 却再没有任何事件会叫醒它,一单就此卡死)。重做 = 带着意见退回去再干一遍:
* 意见用人话进轨迹、在途验收清掉,**这一拍照常往下问模型** ——
* 模型修订台账草稿后重新 submit,新发的验收卡才是下一次裁决的对象。
*/ */
let redone = false;
if (pending !== undefined) { if (pending !== undefined) {
if (pending.verdict === undefined) { if (pending.verdict === undefined) {
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' }; return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
} }
if (pending.verdict === 'pass') {
await deps.state.save(taskId, model, parsed, runtime, null);
return {
runtime,
enabled,
navigation: { kind: 'converged' },
status: 'finished',
released: true,
...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }),
};
}
redone = true;
await deps.state.save(taskId, model, parsed, runtime, null); await deps.state.save(taskId, model, parsed, runtime, null);
return { const comment = pending.comment?.trim() ?? '';
runtime, await deps.memory.append({
enabled, taskId,
navigation: { kind: 'converged' }, kind: 'result',
status: 'finished', title: `${REDO_MARK}${comment === '' ? '(人没写具体意见 —— 自己把成果复核一遍,至少一处实质性修订)' : `:${comment}`}`,
released: pending.verdict === 'pass', detail: {
...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }), hint:
}; '按意见修订台账草稿(write 覆盖写 / drop 撤行都使得),改完重新提交放行(submit)——' +
'新发的验收卡才是下一次裁决的对象;别原样重提,那只会再被打回一次。',
},
});
} }
/* /*
@@ -1215,8 +1498,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 提交要过**落地预检**那道门,预检不过会抛(必交付物缺、意向不可落地、他单改了同一字段)。 * 提交要过**落地预检**那道门,预检不过会抛(必交付物缺、意向不可落地、他单改了同一字段)。
* 抛了不能让回合崩:推进器会一遍遍重来,每遍再撞一次同一道门。 * 抛了不能让回合崩:推进器会一遍遍重来,每遍再撞一次同一道门。
* 正确做法与「行动被拒」一致 —— 拒绝 + 留痕 + 把理由喂回下一拍,让 Agent 自己去补。 * 正确做法与「行动被拒」一致 —— 拒绝 + 留痕 + 把理由喂回下一拍,让 Agent 自己去补。
*
* **刚被打回重做的那一拍不走这条快道**:判齐恒真的活动会在意见没人读的情况下
* 立刻再发一张一模一样的验收卡 —— 人看见的是「我说了重做,它原样又递上来了」。
* 这一拍先交给模型(意见就在轨迹里),它改完自己 submit。
*/ */
if (autoCompleteReady(parsed, runtime)) { if (autoCompleteReady(parsed, runtime) && !redone) {
try { try {
const raised = await raiseAcceptance(taskId, runtime, '必做项全部完成,无进行中项'); const raised = await raiseAcceptance(taskId, runtime, '必做项全部完成,无进行中项');
return { runtime: raised.runtime, enabled, navigation: { kind: 'converged' }, status: raised.status }; return { runtime: raised.runtime, enabled, navigation: { kind: 'converged' }, status: raised.status };
@@ -1431,11 +1718,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const current = const current =
liveStep ?? liveStep ??
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined); [...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
/*
* **触达世界的项,「有没有真的问过人」是一个数,不是一句要模型自己去轨迹里找的话**
* (2026-08-21 用户实测逼出来的:E04 S5_execute_receipt 的 guide 已经写了
* 「动手之前先在轨迹里找 [card-reply]」,finish 被拒的理由也点名了同一件事,
* 模型两次都视而不见,一路把同一行 verdict 重写到连拒五拍熔断)。
* 与「干完的判据」同一条治法:**能算的不问人、也不指望模型自己算**——
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
*/
const currentTouch = ((): string => {
if (current === undefined) return '';
const declared = parsed.items.find((i) => i.id === current.item);
if (declared?.manualActivation !== true) return '';
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
return touched
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
'照下面「人交回之后」那一段办。'
: '这一步标了人工激活(触达世界):这一单里从没给它发过卡(human/entry/decide)、' +
'也没为它立过监听(listen)——**没有别的路,唯一合法的动作是先发那一张卡或立那次监听**,' +
'写台账、finish 都还轮不到,写了也会被拒。';
})();
const staged = const staged =
current === undefined current === undefined
? narrative ? narrative
: [ : [
`【当前这一步 · ${current.item}】${current.intent}`, `【当前这一步 · ${current.item}】${current.intent}`,
currentTouch,
current.guide, current.guide,
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`, current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`, current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
@@ -1630,6 +1939,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
let saved = false; let saved = false;
let framed = false; let framed = false;
let lastAction: AgentAction | undefined; let lastAction: AgentAction | undefined;
/*
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
* `scope` 求值,刚落进草稿的那几行看不见 —— finish 被「算出来是假」误拒,
* 下一拍引擎又用同一份数据自动收步:同一份数据两个裁决,每单白吃一次被拒。
* 写/撤落笔后标脏,轮到 finish 时才重取一次任务视图(不是每写一笔就取)。
*/
let liveScope = scope;
let scopeStale = false;
for (let index = 0; index < planned.length; index += 1) { for (let index = 0; index < planned.length; index += 1) {
const action = planned[index]!; const action = planned[index]!;
@@ -1646,7 +1964,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (action.kind === 'start' && parsed.items.find((i) => i.id === action.itemId) === undefined) { if (action.kind === 'start' && parsed.items.find((i) => i.id === action.itemId) === undefined) {
throw new ForbiddenTerminalError('owner'); throw new ForbiddenTerminalError('owner');
} }
applied = await applyAction(taskId, action, currentRuntime); if (action.kind === 'finish' && scopeStale) {
liveScope = await deps.data.view(taskId);
scopeStale = false;
}
applied = await applyAction(
taskId,
action,
currentRuntime,
(itemId) => {
const step = stepOf(itemId);
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
try {
return deps.sfeel.condition(step.doneWhen, liveScope);
} catch {
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
return undefined;
}
},
index < planned.length - 1,
);
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(error); const reason = error instanceof Error ? error.message : String(error);
await deps.memory.append({ await deps.memory.append({
@@ -1698,10 +2036,36 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 兜一张 `case-overview`(`showView` 再退到它声明的第一张)。 * 兜一张 `case-overview`(`showView` 再退到它声明的第一张)。
* 写了剧本却挑不出 —— 活步都收完了 —— 就一帧不推。 * 写了剧本却挑不出 —— 活步都收完了 —— 就一帧不推。
*/ */
const frame = wanted !== undefined && wanted !== '' ? wanted : steps.length === 0 ? 'case-overview' : undefined; /*
* **挑画面要按「这一拍办完之后」在办的那一步**(2026-08-23,E04 S4→S5 实测)。
*
* `liveStep` 是**拍首**算的,而一串行动常常把案子往前推一步:那一拍收掉 S4
* (经营计划审批)、当场发出 S5 的执行卡,画面却仍按 S4 推 —— 人打开工作台,
* 卡上写着「把回执填到件上」,屏上摆的是审批那份只读件,**一个可填的格子都没有**;
* 提交闸如实拦住「缺完成状态、结果摘要、关键人变动」,而他无处可填。
* 这是设计律第一条「墙要配路」的又一次现形,病根与 `liveScope` 那条同源:
* **拿拍首的局面去决定拍尾的事**。推之前按当下的局面重算一次。
*
* 只在真的算出了一步时才顶掉 `wanted` —— 活步都收完那种情形照旧走老路
* (`framed` 或一帧不推),免得把人刚签过的放行页冲掉。
*/
if (scopeStale) {
liveScope = await deps.data.view(taskId);
scopeStale = false;
}
const settledNow = new Set(
currentRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
);
const enabledNow = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, []);
const stepNow = [...enabledNow.inProgress, ...enabledNow.entries]
.filter((e) => !settledNow.has(e.id))
.map((e) => stepOf(e.id))
.find((x) => x !== undefined);
const wantedNow = stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted;
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
if (!framed && frame !== undefined && frame !== '') { if (!framed && frame !== undefined && frame !== '') {
try { try {
const shown = await showView(taskId, frame, currentRuntime, liveStep?.view); const shown = await showView(taskId, frame, currentRuntime, stepNow?.view ?? liveStep?.view);
if (!shown.pushed) { if (!shown.pushed) {
await deps.memory.append({ await deps.memory.append({
taskId, taskId,
@@ -1742,9 +2106,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (event.kind === 'human' && pending?.acceptanceCardId === event.ref) { if (event.kind === 'human' && pending?.acceptanceCardId === event.ref) {
const verdict = event.payload['verdict'] === 'pass' ? ('pass' as const) : ('reject' as const); const verdict = event.payload['verdict'] === 'pass' ? ('pass' as const) : ('reject' as const);
const ports = event.payload['selectedPorts']; const ports = event.payload['selectedPorts'];
// 打回重做时人写的意见也要落进信封 —— 重做那一拍模型看的就是它
const comment = event.payload['comment'];
nextPending = { nextPending = {
...pending, ...pending,
verdict, verdict,
...(typeof comment === 'string' && comment.trim() !== '' ? { comment: comment.trim() } : {}),
...(typeof ports === 'object' && ports !== null ? { selectedPorts: ports as Record<string, string> } : {}), ...(typeof ports === 'object' && ports !== null ? { selectedPorts: ports as Record<string, string> } : {}),
}; };
} }
+17 -1
View File
@@ -130,11 +130,18 @@ export type ToolGateway = {
/** /**
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
* 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。 * 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔:
* `pass` 才是终局(released);**非 pass = 打回重做,不是终局** ——
* 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型,
* 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22,
* 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有
* 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。
*/ */
export type PendingAcceptance = { export type PendingAcceptance = {
readonly acceptanceCardId: string; readonly acceptanceCardId: string;
readonly verdict?: 'pass' | 'reject'; readonly verdict?: 'pass' | 'reject';
/** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */
readonly comment?: string;
readonly selectedPorts?: Record<string, string>; readonly selectedPorts?: Record<string, string>;
}; };
@@ -209,6 +216,15 @@ export type ListenerRegistrar = {
keyFilter: Record<string, string>; keyFilter: Record<string, string>;
deadline: string; deadline: string;
}): Promise<{ id: string }>; }): Promise<{ id: string }>;
/**
* **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。
*
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。
* 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执,
* 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 ——
* 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。**
*/
declared(taskId: TaskId): Promise<ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>>;
}; };
/** /**
+24 -39
View File
@@ -27,7 +27,6 @@ import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts'; 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 { SURFACE_META, canSeeSurface } from '@web-shared/gov/surfaces.ts';
import { queryKeys } from './query.ts'; import { queryKeys } from './query.ts';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -153,49 +152,33 @@ export function AppShell() {
); );
})} })}
<div className="jc-rail-foot"> <div className="jc-rail-foot">
{/* {/* 轨底只报身份,不再兼职登出入口 —— 退出登录挪到顶栏,免得点头像误触登出 */}
另两面的入口 —— 按授权亮灯:没有治理授权的人根本看不见,不给点了才说没权。 <div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
两枚并排、只留章不留名:轨底还得放身份钮,竖排三件会把左轨撑出滚动条(实测)。
*/}
{(canSeeSurface(session.data, 'design') || canSeeSurface(session.data, 'ops')) && (
<div className="jc-nav-user" style={{ flexDirection: 'row', gap: 6, paddingTop: 0 }}>
{canSeeSurface(session.data, 'design') && (
<a
className="jc-nav-ava"
href={SURFACE_META.design.path}
title="设计中心 —— 定义企业怎么运转"
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#4954e6,#232b6e)' }}
>
设
</a>
)}
{canSeeSurface(session.data, 'ops') && (
<a
className="jc-nav-ava"
href={SURFACE_META.ops.path}
title="运营中心 —— 看企业今天跑得怎样"
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#12b886,#0b7a5c)' }}
>
运
</a>
)}
</div>
)}
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
<button
type="button"
className="jc-nav-user"
title={`${me}(本工作台所有者)· 点击登出`}
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
<span className="jc-nav-ava">{me.slice(0, 1)}</span> <span className="jc-nav-ava">{me.slice(0, 1)}</span>
<span className="jc-nav-name">{me}</span> <span className="jc-nav-name">{me}</span>
</button> </div>
</div> </div>
</nav> </nav>
{module === 'tasks' ? ( <div className="app-main">
<header className="jc-topbar">
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
<div className="jc-topbar-user">
<span className="jc-topbar-name">{me}</span>
<button
type="button"
className="jc-topbar-logout"
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
<Ico name="log-out" s={14} />
退出登录
</button>
</div>
</header>
<div className="app-main-body">
{module === 'tasks' ? (
<div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}> <div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}>
{collapsed && ( {collapsed && (
<button <button
@@ -320,6 +303,8 @@ export function AppShell() {
) : ( ) : (
<ReportDesk /> <ReportDesk />
)} )}
</div>
</div>
</div> </div>
</div> </div>
); );
+69 -3
View File
@@ -476,7 +476,7 @@
.proto-root .jc-sup:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(51,112,255,.12); } .proto-root .jc-sup:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(51,112,255,.12); }
.proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; } .proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; }
/* ===== 左侧一体化导航轨(无顶栏):品牌 → 四大模块 → 轨底动作 + 登录者身份 ===== */ /* ===== 左侧一体化导航轨:品牌 → 四大模块 → 轨底身份(登出已挪去顶栏,见下)===== */
.proto-root .app-nav { padding: 14px 10px 12px; } .proto-root .app-nav { padding: 14px 10px 12px; }
.proto-root .jc-rail-brand { text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-3); margin: -4px 0 6px; } .proto-root .jc-rail-brand { text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-3); margin: -4px 0 6px; }
.proto-root .app-nav button { min-height: 58px; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } .proto-root .app-nav button { min-height: 58px; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); }
@@ -489,6 +489,18 @@
.proto-root .jc-nav-ava { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; flex: 0 0 auto; line-height: 1; color: #fff; font-size: 13px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 2px 6px rgba(14, 159, 216, .35); } .proto-root .jc-nav-ava { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; flex: 0 0 auto; line-height: 1; color: #fff; font-size: 13px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 2px 6px rgba(14, 159, 216, .35); }
.proto-root .jc-nav-name { font-size: 10px; font-weight: 600; color: var(--ink-3); } .proto-root .jc-nav-name { font-size: 10px; font-weight: 600; color: var(--ink-3); }
/* ===== 顶栏:当前模块名 + 身份 + 退出登录 —— 与左轨互不重叠,登出只有这一处入口 ===== */
.proto-root .app-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.proto-root .app-main-body { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; }
.proto-root .jc-topbar { flex: 0 0 auto; height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--line); }
.proto-root .jc-topbar-title { font-size: 14px; font-weight: 700; color: var(--ink); }
.proto-root .jc-topbar-user { display: flex; align-items: center; gap: 12px; }
.proto-root .jc-topbar-name { font-size: 13px; color: var(--ink-3); }
.proto-root .jc-topbar-logout { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); }
.proto-root .jc-topbar-logout:hover { background: var(--danger-soft); color: var(--danger); border-color: #f3c9c6; }
.proto-root .jc-topbar-logout:disabled { opacity: .6; cursor: default; }
/* 工作清单头:渐变图标点缀 + 两级筛选 */ /* 工作清单头:渐变图标点缀 + 两级筛选 */
.proto-root .jc-wl-head { display: flex; align-items: center; justify-content: flex-start; gap: 10px; } .proto-root .jc-wl-head { display: flex; align-items: center; justify-content: flex-start; gap: 10px; }
.proto-root .jc-wl-head > div { flex: 1; min-width: 0; text-align: left; } .proto-root .jc-wl-head > div { flex: 1; min-width: 0; text-align: left; }
@@ -909,16 +921,50 @@
.proto-root .fileview-name svg { color: var(--ink-3); flex: none; } .proto-root .fileview-name svg { color: var(--ink-3); flex: none; }
.proto-root .fileview-name strong { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .proto-root .fileview-name strong { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.proto-root .fileview-kind { margin-left: auto; flex: none; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; } .proto-root .fileview-kind { margin-left: auto; flex: none; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
/* 下载原件 —— 屏上渲的是读回来的样子,这条给的是真字节(.docx 用 Word 打得开) */
.proto-root .fileview-dl { display: inline-flex; align-items: center; gap: 5px; flex: none; height: 28px; padding: 0 11px;
border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2);
font-size: 12px; font-weight: 600; text-decoration: none; transition: all var(--t-fast); }
.proto-root .fileview-dl:hover { border-color: var(--brand-soft); background: var(--brand-wash); color: var(--brand); }
.proto-root .fileview-dl svg { flex: none; }
.proto-root .fileview-kind.k-markdown { color: var(--brand-deep); background: var(--brand-wash); } .proto-root .fileview-kind.k-markdown { color: var(--brand-deep); background: var(--brand-wash); }
.proto-root .fileview-kind.k-excel { color: var(--ok); background: var(--ok-soft); } .proto-root .fileview-kind.k-excel { color: var(--ok); background: var(--ok-soft); }
.proto-root .fileview-kind.k-word { color: var(--st-auto); background: var(--st-auto-soft); } .proto-root .fileview-kind.k-word { color: var(--st-auto); background: var(--st-auto-soft); }
.proto-root .fileview-kind.k-pdf { color: var(--danger); background: var(--danger-soft); } .proto-root .fileview-kind.k-pdf { color: var(--danger); background: var(--danger-soft); }
.proto-root .fileview-body { min-height: 0; padding: 26px 22px; background: radial-gradient(120% 80% at 50% 0%, #eef1f6 0%, #e8ecf1 100%); } /*
* **书桌要跟着纸一样长**(2026-08-23 用户第二次报「灰底滚动后变白」,附截图)。
*
* 这一格是 `.fileview`(`height:100%`)的弹性子项,而弹性子项默认 `flex: 0 1 auto`:
* 它**不长个**,高度被容器(= 看得见的那一屏)封住。里面那张纸比一屏长时,
* 纸溢出这个盒子继续往下排,而**背景只画在盒子里** —— 于是滚到一屏之外,
* 纸两侧的灰书桌没了,底下是面板自己的白。上一轮只治了走沙箱的 markdown / html
* (那两档的底色画在 iframe 里),word / excel 这两档是父文档直接渲的,漏在外面。
*
* `flex: 1 0 auto`:不许被压(`shrink:0` → 盒子至少和内容一样高,背景铺到底),
* 内容比一屏短时又能长满(`grow:1` → 不会在下方留一条白边)。
*/
.proto-root .fileview-body { flex: 1 0 auto; min-height: 0; padding: 26px 22px; background: radial-gradient(120% 80% at 50% 0%, #eef1f6 0%, #e8ecf1 100%); }
/* 文档纸张(word:居中纸张) */ /* 文档纸张(word:居中纸张) */
.proto-root .doc-paper { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid var(--line-soft); border-radius: 8px; box-shadow: 0 16px 44px rgba(31, 35, 41, .14), 0 2px 6px rgba(31, 35, 41, .06); padding: 44px 52px; } .proto-root .doc-paper { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid var(--line-soft); border-radius: 8px; box-shadow: 0 16px 44px rgba(31, 35, 41, .14), 0 2px 6px rgba(31, 35, 41, .06); padding: 44px 52px; }
/* markdown:铺满中间区全渲染,无窄栏/灰边/纸张感 */ /* markdown:铺满中间区全渲染,无窄栏/灰边/纸张感 */
.proto-root .fileview.k-markdown .fileview-body { background: #fff; padding: 0; } /*
* **Markdown 与 HTML 走沙箱之后,外层这一格要跟里面同色**(2026-08-23 用户:
* 「渲染页面向下翻的时候背景的灰色在下半截会变成白色」)。
*
* 这条从前是 `background:#fff; padding:0` —— 那时 markdown 是一整块 `<pre>` 纸面铺满,
* 白是对的。现在里面是一份**自带灰底 + 居中白纸**的文档(与下载到的 .html 同一份),
* iframe 高度按内容算:内容比这一屏矮时,底下那截露出来的就是外层这块白 ——
* 人往下翻,灰底在半路变成白色。同色(与文档自己的 #f4f5f7 一致)之后接缝就没了。
*
* 也不再给内边距:那份文档自己排的版(纸面居中、两侧留白),外层再套一层边距
* 等于两套版面语言叠着。excel / word / pdf 那三种仍走上面的渐变书桌 —— 它们没有自己的底。
*/
.proto-root .fileview.k-markdown .fileview-body,
.proto-root .fileview.k-html .fileview-body { background: #f4f5f7; padding: 0; }
/* 内容比这一屏矮时,让沙箱把这一格铺满 —— 灰底一路到底,不在中途断开 */
.proto-root .fileview.k-markdown .jc-view-frame,
.proto-root .fileview.k-html .jc-view-frame { min-height: 100%; }
.proto-root .doc-paper.doc-full { max-width: none; margin: 0; min-height: 100%; border: 0; border-radius: 0; box-shadow: none; padding: 28px 40px; } .proto-root .doc-paper.doc-full { max-width: none; margin: 0; min-height: 100%; border: 0; border-radius: 0; box-shadow: none; padding: 28px 40px; }
.proto-root .word-paper h1 { font-size: 22px; font-weight: 800; color: var(--ink); margin: 0 0 14px; text-align: center; } .proto-root .word-paper h1 { font-size: 22px; font-weight: 800; color: var(--ink); margin: 0 0 14px; text-align: center; }
.proto-root .word-paper h2 { font-size: 16px; font-weight: 700; color: var(--ink); margin: 20px 0 8px; } .proto-root .word-paper h2 { font-size: 16px; font-weight: 700; color: var(--ink); margin: 20px 0 8px; }
@@ -2132,6 +2178,24 @@
.proto-root .jc-dv { padding: 12px 14px; background: #fff; border: 1px solid var(--cardline); border-left: 3px solid var(--line); border-radius: 11px; box-shadow: var(--card-shadow); } .proto-root .jc-dv { padding: 12px 14px; background: #fff; border: 1px solid var(--cardline); border-left: 3px solid var(--line); border-radius: 11px; box-shadow: var(--card-shadow); }
.proto-root .jc-dv.s-ok { border-left-color: var(--ok); } .proto-root .jc-dv.s-ok { border-left-color: var(--ok); }
.proto-root .jc-dv.s-live { border-left-color: var(--brand); } .proto-root .jc-dv.s-live { border-left-color: var(--brand); }
/* ── 产出的资料(与定稿报告分两摞,2026-08-23)────────────────────────
小标题只是分组条,不是卡片:它要看着比行「轻」,不然一摞里出现三种同重量的块 */
.proto-root .jc-dvs-sub { display: flex; align-items: baseline; gap: 8px; margin: 4px 0 -2px; padding: 0 2px;
font-size: 11.5px; font-weight: 800; letter-spacing: .04em; color: var(--ink-3); }
.proto-root .jc-dvs-sub em { font-style: normal; font-weight: 700; color: var(--ink-4); }
.proto-root .jc-dvs-sub i { margin-left: auto; font-style: normal; font-weight: 600; color: var(--ink-4); }
/* 资料行按格式上色(与件上的格式徽同一套颜色) */
.proto-root .jc-dv.m-file { border-left-color: var(--ink-4); }
.proto-root .jc-dv.m-file.f-docx { border-left-color: #2b5fd9; }
.proto-root .jc-dv.m-file.f-xlsx { border-left-color: #1f7a45; }
.proto-root .jc-dv.m-file.f-html { border-left-color: #b25e15; }
.proto-root .jc-dv-fmt { flex: none; margin-left: auto; padding: 3px 9px; border-radius: 999px;
font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
background: var(--soft); color: var(--ink-3); }
.proto-root .jc-dv-fmt.f-docx { background: #eaf1ff; color: #2b5fd9; }
.proto-root .jc-dv-fmt.f-xlsx { background: #e9f7ee; color: #1f7a45; }
.proto-root .jc-dv-fmt.f-html { background: #fdf0e6; color: #b25e15; }
.proto-root .jc-dv.s-bad { border-left-color: var(--danger); } .proto-root .jc-dv.s-bad { border-left-color: var(--danger); }
.proto-root .jc-dv-h { display: flex; align-items: flex-start; gap: 10px; } .proto-root .jc-dv-h { display: flex; align-items: flex-start; gap: 10px; }
.proto-root .jc-dv-ico { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--ink-4); background: var(--soft); } .proto-root .jc-dv-ico { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--ink-4); background: var(--soft); }
@@ -2842,6 +2906,8 @@
.proto-root .jc-tnav-btn:active:not(:disabled) { transform: scale(.95); } .proto-root .jc-tnav-btn:active:not(:disabled) { transform: scale(.95); }
.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); }
/* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */
.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; }
/* 窄栏:页签藏图标省宽,保持一排 */ /* 窄栏:页签藏图标省宽,保持一排 */
@@ -0,0 +1,45 @@
/**
* 工作区右上角的翻页 —— 2026-08-23 用户三连指出的那几处。
*/
import { describe, expect, it } from 'vitest';
import { pagerFace, pagerText } from '../internal/pager.ts';
describe('pagerText —— 没有可翻的东西就别摆数字', () => {
it('**一件都没有:写「—」,不写 0/0**(0 分之 0 不是一个数)', () => {
expect(pagerText(0, 0)).toBe('—');
});
it('只有一件:照旧 1/1(点位条要定得住,不能忽有忽无)', () => {
expect(pagerText(0, 1)).toBe('1/1');
});
it('多件:报当下这一件', () => {
expect(pagerText(1, 3)).toBe('2/3');
});
it('页码越界(件数变少了而页码还停在后面):夹到最后一件,不报一个不存在的号', () => {
expect(pagerText(5, 2)).toBe('2/2');
});
});
describe('pagerFace —— 翻件与翻页各归各位', () => {
it('一件一页:两组都不出,只留占位', () => {
expect(pagerFace({ items: 1, pages: 1 })).toEqual({ items: false, pages: false, solo: true });
});
it('多件单页:只出翻件', () => {
expect(pagerFace({ items: 3, pages: 1 })).toEqual({ items: true, pages: false, solo: false });
});
it('单件多页:只出翻页(A2 一颗待选型芯片一页)', () => {
expect(pagerFace({ items: 1, pages: 4 })).toEqual({ items: false, pages: true, solo: false });
});
it('**多件各自多页:两组同时出** —— 从前二选一,页就翻不动了', () => {
expect(pagerFace({ items: 2, pages: 3 })).toEqual({ items: true, pages: true, solo: false });
});
it('一件都没有:两组都不出,占位那一格由 pagerText 写「—」', () => {
expect(pagerFace({ items: 0, pages: 1 })).toEqual({ items: false, pages: false, solo: true });
});
});
@@ -21,7 +21,7 @@ const KIND: Readonly<Record<FileDoc['kind'], { label: string; icon: string }>> =
* 二级界面**长在工作区内部**:顶部四个页签始终不变, * 二级界面**长在工作区内部**:顶部四个页签始终不变,
* 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。 * 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。
*/ */
export function FileViewer({ doc }: { doc: FileDoc }) { export function FileViewer({ doc, href }: { doc: FileDoc; href?: string | undefined }) {
const k = KIND[doc.kind]; const k = KIND[doc.kind];
return ( return (
<div className={'fileview k-' + doc.kind}> <div className={'fileview k-' + doc.kind}>
@@ -31,15 +31,33 @@ export function FileViewer({ doc }: { doc: FileDoc }) {
<strong>{doc.name}</strong> <strong>{doc.name}</strong>
</div> </div>
<span className={'fileview-kind k-' + doc.kind}>{k.label}</span> <span className={'fileview-kind k-' + doc.kind}>{k.label}</span>
{/*
**拿得走才算文件**(2026-08-23):屏上这一份是读回来渲的,而这条链接给的是
原字节 —— .docx 用 Word 打得开、.xlsx 用 Excel 打得开。没有下载口的话,
「产出了 Word」这句话就只能靠界面自称。取不到 href 时不摆这个钮(不给死链)。
*/}
{href !== undefined && (
<a className="fileview-dl" href={href} download={doc.name}>
<Ico name="download" s={13} /> 下载原件
</a>
)}
</div> </div>
<div className="fileview-body"> <div className="fileview-body">
{doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />} {doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />}
{doc.kind === 'markdown' && ( {doc.kind === 'markdown' &&
// 富文本渲染(GFM 表格 / 内联 SVG)尚未接入 —— 先以原文纸面呈现,不假装渲染过(见 debt) /*
<article className="doc-paper doc-full"> * **渲好的那一份由基座给**(`html`,2026-08-23):它与下载到的 .html 是
<pre className="md">{doc.md}</pre> * 同一段函数出的,两处看着是同一份文件;渲染走沙箱,与别的 HTML 件同一条路
</article> * (前端拼 DOM 就得 `dangerouslySetInnerHTML`,那是把模型写的字注进父文档)。
)} * `html` 缺席仍退回原文纸面 —— **不假装渲染过**。
*/
(doc.html === undefined ? (
<article className="doc-paper doc-full">
<pre className="md">{doc.md}</pre>
</article>
) : (
<ViewContainer html={doc.html} title={doc.name} />
))}
{doc.kind === 'excel' && <ExcelView doc={doc} />} {doc.kind === 'excel' && <ExcelView doc={doc} />}
{doc.kind === 'word' && <WordView doc={doc} />} {doc.kind === 'word' && <WordView doc={doc} />}
{doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />} {doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />}
@@ -12,7 +12,7 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { markCommReadRoute, saveTableRoute } from '@contracts'; import { markCommReadRoute, openAssetFileRoute, saveTableRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import type { FileDoc, TaskDetail, WorkOrder, WorkOrderReply } from '@contracts'; import type { FileDoc, TaskDetail, WorkOrder, WorkOrderReply } from '@contracts';
import { TERMINAL_STATE_LABEL, isRejection } from '@contracts'; import { TERMINAL_STATE_LABEL, isRejection } from '@contracts';
@@ -20,6 +20,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ViewBlocks, ViewContainer } from '@ui'; import { ViewBlocks, ViewContainer } from '@ui';
import { pagerFace, pagerText } from './pager.ts';
import type { ViewEdits, ViewSave } from '@ui'; 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';
@@ -78,6 +79,59 @@ function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v
} }
} }
/**
* 右上角那对箭头 —— **翻件**与**翻页**共用这一个形状,靠 `mark` 区分。
* 两组同时出现时(多张卡各自多页)才摆那个字,单独出现时保持原样不占地方。
*/
/** 资料格式各用各的图标 —— 一眼看得出点开的是张表还是一份文档 */
const ICO_OF_FORMAT: Readonly<Record<string, string>> = {
xlsx: 'file-spreadsheet',
docx: 'file-type',
html: 'file-text',
md: 'file-text',
};
function Pager({
mark,
pos,
total,
onGo,
what,
}: {
mark?: string;
pos: number;
total: number;
onGo: (next: number) => void;
what: string;
}) {
return (
<span className="jc-tnav-pg" aria-label={what}>
<button
type="button"
className="jc-tnav-btn"
disabled={pos <= 0}
onClick={() => onGo(pos - 1)}
aria-label={`上一${what}`}
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{mark === undefined ? null : <i className="jc-tnav-mk">{mark}</i>}
{pagerText(pos, total)}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= total - 1}
onClick={() => onGo(pos + 1)}
aria-label={`下一${what}`}
>
<Ico name="chevron-right" s={15} />
</button>
</span>
);
}
export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) { export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) {
const [view, setView] = useState<Tab>('open'); const [view, setView] = useState<Tab>('open');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -90,12 +144,37 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
const [docPages, setDocPages] = useState(1); const [docPages, setDocPages] = useState(1);
/** 工作成果:列表里点开的那份定稿报告 */ /** 工作成果:列表里点开的那份定稿报告 */
const [doneSel, setDoneSel] = useState<string | null>(null); const [doneSel, setDoneSel] = useState<string | null>(null);
/**
* **定稿报告的件内翻页**(2026-08-21 用户:「工作成果里面点进一个工作成果也需要
* 这个按钮」)——待处理那份件的翻页只接在 `cur`/`docPage` 上,工作成果点进详情后
* 走的是另一套局面(`doneItem`),从前那对 `[data-page]` 页从没接过 `page`/`onPages`,
* 于是定稿报告里带多页的件(如一颗芯片一页的选型件)在成果详情里会**整份铺开**,
* 待处理那一屏能翻的页,归档后反而翻不了。与 `docPage`/`docPages` 分开一份状态,
* 免得两个局面共用一个页码,来回切换互相冲页。
*/
const [doneDocPage, setDoneDocPage] = useState(0);
const [doneDocPages, setDoneDocPages] = useState(1);
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined); const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
/** 那一份取不回来时的原话 —— 摆在件上方,不弹窗、不静默 */
const [fileError, setFileError] = useState<string | undefined>(undefined);
/** 当下这一份的**原件**地址(`raw=1`)—— 屏上渲的是读回来的样子,下载给的是真字节 */
const [fileHref, setFileHref] = useState<string | undefined>(undefined);
const orders = detail.workOrders; const orders = detail.workOrders;
const openList = orders.filter((o) => o.done === undefined); const openList = orders.filter((o) => o.done === undefined);
/* 未读条数 —— 页签那枚红气泡读的就是它;标记按钮按一下这个数才动 */ /* 未读条数 —— 页签那枚红气泡读的就是它;标记按钮按一下这个数才动 */
const unreadN = detail.comms.filter((m) => m.unread).length; const unreadN = detail.comms.filter((m) => m.unread).length;
/**
* **`awaiting-event` 是「本回合挂起」的统称**,`decide`/`entry`/`listen` 三条路
* 都落这一个值 —— 而 decide/entry 发的是**对话区的卡**(chat_card),不进
* `work_order`,`openList` 因此照旧空。这一屏原样套用「不用盯着,轮到你时
* 工单会带着上下文出现在这里」就成了空话:那张卡已经在等,而且**永远不会**
* 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat`
* 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。
*/
const pendingChatCard = detail.chat.some(
(m) => (m.k === 'decision' && m.chosen === undefined) || (m.k === 'entry' && m.done === undefined),
);
/* /*
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点, * 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
@@ -122,12 +201,41 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }),
}); });
/*
* **点开一份资料文件** —— 件上点了资料名,这里去把那一份取回来。
*
* 为什么要跑一趟服务端而不在前端拼:那份文件是**真造出来的字节**
* (docx / xlsx 是 ZIP 包着 XML),前端拿到的是它读回来的样子 ——
* 屏上渲的与下载到的是同一份东西。取不到就如实弹一句,不摆一个空壳。
*/
const openAssetFile = useMutation({
mutationFn: (ref: { address: string; key: string }) => {
const qs = `address=${encodeURIComponent(ref.address)}&key=${encodeURIComponent(ref.key)}`;
setFileHref(`/api/desk/tasks/${detail.card.id}/file?${qs}&raw=1`);
return call<{ file: FileDoc; fileName: string; format: string }>(openAssetFileRoute, {
params: { taskId: detail.card.id },
query: { address: ref.address, key: ref.key },
});
},
onSuccess: (out) => setOpenFile(out.file),
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
});
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 } }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk'] }),
}); });
const doneList = orders.filter((o) => o.done !== undefined); /*
* **工作成果的「一行一份」不分是工单还是决策卡答出来的**——`decide` 是唯一能循环的
* 中间验收机制(如 E04 的经营计划审批),答完不产生工单行,报告快照走的是
* `detail.decidedDeliverables` 这条单独的口(见后端注释:混进 `workOrders`
* 会连累 `signed` 那处「`mode==='acceptance'` 恒是终局放行卡」的假设)。
* 只在这一屏(工作成果列表)合并显示,`orders`/`openList`/`signed` 一律不动。
*/
const doneList = [...orders.filter((o) => o.done !== undefined), ...detail.decidedDeliverables].sort((a, b) =>
a.at < b.at ? -1 : a.at > b.at ? 1 : 0,
);
/* /*
* **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。 * **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。
* 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。 * 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。
@@ -138,6 +246,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
const pos = Math.min(page, Math.max(0, openList.length - 1)); const pos = Math.min(page, Math.max(0, openList.length - 1));
const cur = openList[pos]; const cur = openList[pos];
const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel); const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel);
/** 成果详情里当下摆的是第几份 —— 翻「份」用它 */
const donePos = Math.max(0, doneList.findIndex((o) => o.id === doneSel));
/** 这一屏该出哪几组翻页(件 / 页 / 都没有) —— 判断抽在 `pager.ts`,有测 */
const face = pagerFace({ items: openList.length, pages: docPages });
/* /*
* 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。 * 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。
@@ -150,25 +262,37 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
setOpenFile(undefined); setOpenFile(undefined);
setView(locate.tab); setView(locate.tab);
setPage(0); setPage(0);
setDocPage(0);
setDoneSel(null); setDoneSel(null);
} }
const hit = orders.find((o) => o.id === locate.orderId); // 决策卡答出来的成果没有工单行(见 doneList 那条注释),定位时一并找
const hit = orders.find((o) => o.id === locate.orderId) ?? detail.decidedDeliverables.find((o) => o.id === locate.orderId);
if (hit !== undefined) { if (hit !== undefined) {
setOpenFile(undefined); setOpenFile(undefined);
if (hit.done !== undefined) { if (hit.done !== undefined) {
setView('done'); setView('done');
setDoneSel(hit.id); setDoneSel(hit.id);
setDoneDocPage(0);
} else { } else {
setView('open'); setView('open');
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId))); setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
setDocPage(0);
} }
} }
} }
/** 换件 —— 页码跟着归零(上一件翻到第 3 页,下一件可能只有一页) */
const goItem = (next: number) => {
setPage(next);
setDocPage(0);
};
const go = (to: Tab) => { const go = (to: Tab) => {
setView(to); setView(to);
setPage(0); setPage(0);
setDocPage(0);
setDoneSel(null); setDoneSel(null);
setDoneDocPage(0);
}; };
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal]; const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
@@ -278,64 +402,84 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。 顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。
*/} */}
{openFile !== undefined ? ( {openFile !== undefined ? (
<button type="button" className="jc-tnav-btn jc-tnav-back" onClick={() => setOpenFile(undefined)}> <button
type="button"
className="jc-tnav-btn jc-tnav-back"
onClick={() => {
setOpenFile(undefined);
setFileHref(undefined);
setFileError(undefined);
}}
>
<Ico name="arrow-left" s={14} /> 返回 <Ico name="arrow-left" s={14} /> 返回
</button> </button>
) : view === 'done' && doneItem !== undefined ? ( ) : view === 'done' && doneItem !== undefined ? (
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}> /*
<Ico name="chevron-left" s={14} /> 返回列表 * 成果详情右上角摆两组翻页 + 返回列表:
</button> * · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
* · **翻份**(2026-08-23 用户:「工作成果点进一个工作成果也需要这个东西」)——
* 从前点进详情只有「返回列表」:想看下一份得先退出去再点一次,
* 而一单常有三五份定稿(执行单快照 / 验收单快照 / 决策卡快照),对着看是常事。
* 换份时页码归零 —— 上一份翻到第 3 页,下一份可能只有一页。
* 各自只在多于一个时现身;都只有一个就照旧只留「返回列表」。
*/
<>
{doneList.length > 1 && (
<Pager
{...(doneDocPages > 1 ? { mark: '份' } : {})}
pos={donePos}
total={doneList.length}
what="份成果"
onGo={(next) => {
const to = doneList[Math.max(0, Math.min(next, doneList.length - 1))];
if (to !== undefined) setDoneSel(to.id);
setDoneDocPage(0);
}}
/>
)}
{doneDocPages > 1 && (
<Pager
{...(doneList.length > 1 ? { mark: '页' } : {})}
pos={doneDocPage}
total={doneDocPages}
what="页"
onGo={setDoneDocPage}
/>
)}
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
<Ico name="chevron-left" s={14} /> 返回列表
</button>
</>
) : view === 'open' ? ( ) : view === 'open' ? (
/* 单卡多页时这对箭头翻**件里的页**(一颗芯片一页);多卡时照旧翻件 */ /*
docPages > 1 && openList.length <= 1 ? ( * **翻件与翻页各归各位**(2026-08-23 用户:「一次叫人不一定只有一个 html
<span className="jc-tnav-pg" aria-label="翻页"> * 模板所以需要翻页,我看现在这个 0/0 显示的不正常」)。从前两者二选一
<button * (`docPages > 1 && openList.length <= 1`):多张卡各自多页时**页就翻不动了**,
type="button" * 人只看得见每张卡的第一页。现在同时出,两组各带一个字区分。
className="jc-tnav-btn" * 一件都没有时(还在跑 / 在等对话区那张卡 / 已收尾)也不再摆「0/0」——
disabled={docPage === 0} * 那不是一个数,是「这儿没有可翻的东西」,写「—」。
onClick={() => setDocPage(docPage - 1)} */
aria-label="上一页" <>
> {face.items && (
<Ico name="chevron-left" s={15} /> <Pager
</button> {...(face.pages ? { mark: '件' } : {})}
<b className="jc-tnav-n jc-mono"> pos={pos}
{docPage + 1}/{docPages} total={openList.length}
</b> what="件"
<button onGo={goItem}
type="button" />
className="jc-tnav-btn" )}
disabled={docPage >= docPages - 1} {face.pages && (
onClick={() => setDocPage(docPage + 1)} <Pager
aria-label="下一页" {...(face.items ? { mark: '页' } : {})}
> pos={docPage}
<Ico name="chevron-right" s={15} /> total={docPages}
</button> what="页"
</span> onGo={setDocPage}
) : ( />
<span className="jc-tnav-pg" aria-label="翻页"> )}
<button {face.solo && <Pager pos={pos} total={openList.length} what="件" onGo={goItem} />}
type="button" </>
className="jc-tnav-btn"
disabled={pos === 0}
onClick={() => setPage(pos - 1)}
aria-label="上一件"
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= openList.length - 1}
onClick={() => setPage(pos + 1)}
aria-label="下一件"
>
<Ico name="chevron-right" s={15} />
</button>
</span>
)
) : null} ) : null}
</div> </div>
@@ -347,7 +491,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
inset={-6} inset={-6}
> >
{openFile !== undefined ? ( {openFile !== undefined ? (
<FileViewer doc={openFile} /> <FileViewer doc={openFile} href={fileHref} />
) : fileError !== undefined ? (
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
<EmptyState ico="file-text" tone="muted" title="这份资料打不开" desc={fileError} />
) : view === 'comms' ? ( ) : view === 'comms' ? (
/* /*
* 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 —— * 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 ——
@@ -455,6 +602,12 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<ViewContainer <ViewContainer
html={doneItem.finalHtml ?? detail.viewHtml ?? ''} html={doneItem.finalHtml ?? detail.viewHtml ?? ''}
title={`${detail.card.activity} · 定稿报告`} title={`${detail.card.activity} · 定稿报告`}
page={doneDocPage}
onPages={setDoneDocPages}
onOpenFile={(ref) => {
setFileError(undefined);
openAssetFile.mutate(ref);
}}
/> />
) : ( ) : (
<article className="jc-mst-doc"> <article className="jc-mst-doc">
@@ -469,9 +622,53 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
/> />
</article> </article>
) )
) : doneList.length > 0 ? ( ) : doneList.length > 0 || detail.assetFiles.length > 0 ? (
/* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */ /* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */
<div className="jc-dvs"> <div className="jc-dvs">
{/*
* **产出的资料另起一摞**(2026-08-23 用户:「工作成果里也需要有产出的附件,
* 点击后可以预览」)。它与定稿报告不是一类东西:报告是「叫了人办的那件事
* 办完长什么样」,资料是**办出来的东西本身**(白皮书 / 对照表 / 落地页)——
* 混在一摞里,「这一单交出了几份成果」这个数就说不清了。
* 点开走的是件上点资料名的同一条路(同一个地址 + 主键)。
*/}
{detail.assetFiles.length > 0 && (
<>
<div className="jc-dvs-sub">
产出的资料
<em>{detail.assetFiles.length} 份</em>
{/* 没存档的是在办中的那几份 —— 如实说,别让人以为已经归了档 */}
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
</div>
{detail.assetFiles.map((f) => (
<button
key={`${f.address}/${f.key}`}
type="button"
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
onClick={() => {
setFileError(undefined);
openAssetFile.mutate({ address: f.address, key: f.key });
}}
>
<span className="jc-dv-h">
<span className="jc-dv-ico">
<Ico name={ICO_OF_FORMAT[f.format] ?? 'file-text'} s={16} />
</span>
<span className="jc-dv-t">
<em>
{f.name}
{f.at === undefined ? '' : ` · 存档 ${atMinute(f.at)}`}
</em>
<b>{f.title}</b>
</span>
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
<Ico name="chevron-right" s={15} />
</span>
</button>
))}
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
</>
)}
{doneList.map((o) => { {doneList.map((o) => {
const res = o.done?.result; const res = o.done?.result;
const bad = res !== undefined && isRejection(res); const bad = res !== undefined && isRejection(res);
@@ -487,7 +684,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
className={ className={
'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe') 'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe')
} }
onClick={() => setDoneSel(o.id)} onClick={() => {
setDoneSel(o.id);
setDoneDocPage(0);
}}
> >
<span className="jc-dv-h"> <span className="jc-dv-h">
<span className="jc-dv-ico"> <span className="jc-dv-ico">
@@ -518,7 +718,47 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
hint="先去「待处理」把手上的事办完" hint="先去「待处理」把手上的事办完"
/> />
) )
) : cur !== undefined ? ( ) : detail.card.terminal !== undefined ? (
<EmptyState
ico="circle-check"
tone="ok"
title="这单已收尾"
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
/>
) : signed ? (
/*
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
*
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
* 于是读起来像跳了一帧。
*
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
* 中间态本来就该说出来,只是要说对。
*/
<EmptyState
ico="hourglass"
tone="brand"
title="字已签下,正在落地"
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
hint="通常几秒钟"
/>
) : detail.card.runtime === 'running' ? (
/*
* **AI 正在算这一拍,不是「已经有一份件摆着」**(2026-08-21 用户:
* 「AI 持续推进中时中间的页面不变成持续推进的页面了,而是卡在 html
* 模版页面」)——这是上面 `detail.viewHtml` 判据改动带出的回归:
* `viewHtml` 是**上一次**推的那一帧,活步收完之后引擎不主动清它
* (「一帧不推,让最后摆上去的那张留在档案里」),于是它在 AI 正埋头
* 算下一拍的这几秒里仍然「有货」——viewHtml 判据排在 running 前面,
* 空待处理就被这份**陈旧**的件抢先占住,推进中动画再也显示不出来。
* **正在跑的这一拍,永远比上一拍摆下的旧件优先**:running 判据挪到
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
*/
<WorkingScene doneN={doneList.length} />
) : detail.viewHtml !== undefined ? (
/* /*
* **工作区只放那份件本身**(2026-08-01 用户定)。 * **工作区只放那份件本身**(2026-08-01 用户定)。
* *
@@ -531,79 +771,75 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**; * **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**;
* 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。 * 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。
* *
* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告: * **这一格判据从前是 `cur !== undefined`(挂在开着的工单上)**(2026-08-21
* 那时工作区总不能是白的。 * 用户送来实测截图查出来的坑):`decide` 循环(如 E04 S4「经营计划审批」)
* 不开工单,活动照旧按 `view:'coverage-plan'` 推了一屏「E04-验收工单-01-
* 关系经营计划.html」——`detail.viewHtml` 明明有货,却因为外层先问
* 「有没有开着的工单」而永远问不到它,工作区落空态,人只能去对话区
* 翻找那份件在说什么。**件推没推,与工单开没开是两件事**——
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
* 呈现,或收边传值(`onField`)时才用得上。
*/ */
detail.viewHtml !== undefined ? ( <ViewContainer
<ViewContainer html={detail.viewHtml}
html={detail.viewHtml} title={`${detail.card.activity} · 汇报报告`}
title={`${detail.card.activity} · 汇报报告`} page={docPage}
page={docPage} onPages={setDocPages}
onPages={setDocPages} onOpenFile={(ref) => {
editable setFileError(undefined);
onEdits={(save) => { openAssetFile.mutate(ref);
/* }}
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`), editable
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是 onEdits={(save) => {
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**, /*
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。 * **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
* * 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走, * 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。 * 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
*/ *
saveTable.mutate(save); * 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
collectEdits(save.tables, cur, onField); * 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
}} * `decide` 循环没有开着的工单(`cur` 恒 undefined)时这条缓冲天然不适用,
* 落库(`saveTable.mutate`)本身不靠它,原样跳过即可。
*/
saveTable.mutate(save);
if (cur !== undefined) collectEdits(save.tables, cur, onField);
}}
/>
) : cur !== undefined ? (
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
<article className="jc-mst-doc">
<WorkOrderReport
order={cur}
detail={detail}
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
blocks={cur.blocks !== undefined && cur.blocks.length > 0 ? cur.blocks : detail.view}
values={values}
onField={onField}
onReply={onReplyOrder}
/> />
) : ( {cur.mode === 'execution' && <GuideCard order={cur} />}
<article className="jc-mst-doc"> </article>
<WorkOrderReport
order={cur}
detail={detail}
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
blocks={cur.blocks !== undefined && cur.blocks.length > 0 ? cur.blocks : detail.view}
values={values}
onField={onField}
onReply={onReplyOrder}
/>
{cur.mode === 'execution' && <GuideCard order={cur} />}
</article>
)
) : ( ) : (
/* /*
* 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种: * 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种:
* 推进中 = AI 在自己转(动画场景);等外部 = 环停着听回音; * 推进中 = AI 在自己转(动画场景,running 已在上面拦下);
* 都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿。 * 等外部 = 环停着听回音;都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿
* (terminal/signed 也已在上面拦下)。这里只剩 awaiting-event 的两条岔与
* 兜底两态。
*
* 「等外部」这一态下还有一条岔:decide/entry 挂起时 `runtime` 与真正等
* 外部回音是同一个值(`awaiting-event`),但那张卡在**对话区**等你,
* 不是在等世界回信 —— `pendingChatCard` 把这条岔认出来单独说话。
*/ */
detail.card.terminal !== undefined ? ( detail.card.runtime === 'awaiting-event' && pendingChatCard ? (
<EmptyState
ico="circle-check"
tone="ok"
title="这单已收尾"
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
/>
) : signed ? (
/*
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
*
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
* 于是读起来像跳了一帧。
*
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
* 中间态本来就该说出来,只是要说对。
*/
<EmptyState <EmptyState
ico="hourglass" ico="hourglass"
tone="brand" tone="brand"
title="字已签下,正在落地" title="有一张卡在等你"
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。" desc="这一步要你在对话区做个决定或补一笔档案 —— 待处理这一页只装执行 / 验收工单,那张卡走的是另一条路,去右边对话区看。"
hint="通常几秒钟" hint="回完那张卡,AI 接着往下推进"
/> />
) : detail.card.runtime === 'running' ? (
<WorkingScene doneN={doneList.length} />
) : detail.card.runtime === 'awaiting-event' ? ( ) : detail.card.runtime === 'awaiting-event' ? (
<EmptyState <EmptyState
ico="hourglass" ico="hourglass"
@@ -0,0 +1,30 @@
/**
* 工作区右上角那对箭头**此刻在翻什么** —— 纯函数,好让「界面不撒谎」这条有测可钉。
*
* 这一屏有两种「翻」,语义不同,不该抢同一个位置(2026-08-23 用户三连指出):
* · **翻件**:待处理里有好几张工单,一张一件;
* · **翻页**:一张工单的件里有好几页(`[data-page]`,如 A2 一颗待选型芯片一页)——
* 「一次叫人不一定只有一个 HTML」说的就是它。
* 从前两者二选一(`docPages > 1 && openList.length <= 1`):**多张卡各自多页时,
* 页就翻不动了** —— 人只能看见每张卡的第一页。现在两组各归各位,同时出。
*
* 还有个数字的事:一件都没有时从前照旧摆一个「0/0」。**0 分之 0 不是一个数**,
* 它是「这儿没有可翻的东西」——那就别拿数字冒充,写「—」。
*/
/** 两组翻页此刻各出不出 —— 都不出时给一个占位(点位条要定得住,不能忽有忽无) */
export function pagerFace(input: { items: number; pages: number }): {
readonly items: boolean;
readonly pages: boolean;
readonly solo: boolean;
} {
const items = input.items > 1;
const pages = input.pages > 1;
return { items, pages, solo: !items && !pages };
}
/** 页码文案 —— 没有可翻的东西不摆「0/0」 */
export function pagerText(pos: number, total: number): string {
if (total <= 0) return '—';
return `${Math.min(pos, total - 1) + 1}/${total}`;
}
@@ -0,0 +1,61 @@
/**
* **取不到不是活着**(2026-08-23,用户:「推进完成了,但测试台还在运行中,没有停下来」)。
*
* 实测现场:面板钉着一个**已经不存在的任务**(别处 reset-runs 把它清了),屏上同时说着
* 两句相反的话 —— 上面「AWAITING-EVENT · 自动刷新 2s」灯还亮着、钮还是「停止仿真」,
* 下面一条橙带「取数失败 —— 任务不存在:fda365fc…」。病根在两处:
* ① `q.data` 在取数失败后保留**上一次成功**的那份,而那份的 `terminal` 是 undefined,
* 于是 `live` 恒真:灯常亮、2s 一直对着一个死号打;
* ② `s === undefined ? 'live'` 这个兜底本身就在撒谎 —— 还没取到 ≠ 正在跑。
*
* 这几条钉的就是「界面不撒谎」:服务端说这一单没了,面板就得当场松手,
* 别再举着上一次的状态说它在跑。
*/
import { describe, expect, it } from 'vitest';
import { observeFace } from '../internal/observe-face.ts';
describe('observeFace —— 面板此刻该怎么说', () => {
it('在跑:灯活、会呼吸、报 runtime、还在自动刷新', () => {
const face = observeFace({ data: { runtime: 'running' } });
expect(face).toMatchObject({ lamp: 'live', breathe: true, word: 'running', gone: false, poll: true });
});
it('等人:灯是 wait,照旧算活着(它在等,不是没了)', () => {
const face = observeFace({ data: { runtime: 'awaiting-human' } });
expect(face.lamp).toBe('wait');
expect(face.gone).toBe(false);
expect(face.poll).toBe(true);
});
it('跑完:终态词上位,灯灭、不呼吸、不再轮询', () => {
const face = observeFace({ data: { runtime: 'terminal', terminal: 'released' } });
expect(face).toMatchObject({ lamp: 'done', breathe: false, gone: false, poll: false });
expect(face.word).toBe('放行完成');
});
/* ↓ 这三条是这次修的正主 */
it('**任务不存在(404):当场认没了** —— 不再说在跑、不再呼吸、停止轮询', () => {
const face = observeFace({ data: { runtime: 'awaiting-event' }, error: { status: 404 } });
expect(face.gone).toBe(true);
expect(face.breathe).toBe(false);
expect(face.poll).toBe(false);
expect(face.lamp).toBe('bad');
expect(face.word).toBe('这一单已不在了');
});
it('别的取数失败(500/断网):不当「没了」,但也不许说在跑', () => {
const face = observeFace({ data: { runtime: 'running' }, error: { status: 500 } });
expect(face.gone).toBe(false);
expect(face.breathe).toBe(false);
expect(face.word).toBe('取数失败');
// 断网是暂时的 —— 照旧轮询,回来了自己接上
expect(face.poll).toBe(true);
});
it('还没取到:说「取数中」,不说在跑(灯不亮呼吸)', () => {
const face = observeFace({});
expect(face.word).toBe('取数中');
expect(face.breathe).toBe(false);
expect(face.gone).toBe(false);
});
});
@@ -14,7 +14,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TERMINAL_STATE_LABEL, testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts'; import { testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
import type { UploadedFile } from '@contracts'; import type { UploadedFile } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
@@ -22,6 +22,7 @@ import { useGovActivities, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { QueryGate } from '@web-shared/gov/primitives.tsx'; 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';
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>;
@@ -89,17 +90,61 @@ export function ActivityRun({
}); });
if (taskId !== undefined) { if (taskId !== undefined) {
return <ActivityObserve taskId={taskId} strip={strip} stopping={stop.isPending} onStop={() => stop.mutate(taskId)} />; return (
<ActivityObserve
taskId={taskId}
strip={strip}
stopping={stop.isPending}
onStop={() => stop.mutate(taskId)}
/* 服务端说这一单没了:不调销毁接口(没有可销毁的),只让上层松手、重取「当下挂着哪一单」 */
onGone={onStopped}
/>
);
} }
const fields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []); const allFields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
// 必填缺一即不可起 —— 门口这道校验与后端同一口径(契约的 required) // 藏起来的格子不算数 —— 没露面的必填(如未选「事件驱动」时的「事件类型」)不该拦起单
const missing = (picked?.contract.inputFields ?? []) const fields = allFields.filter((f) => isVisible(f, data));
.filter((f) => f.required && (data[f.name] ?? '').trim() === '') const missing = fields.filter((f) => f.required && (data[f.name] ?? '').trim() === '').map((f) => f.label ?? f.name);
.map((f) => f.label ?? f.name);
const fireable = picked !== undefined && missing.length === 0; const fireable = picked !== undefined && missing.length === 0;
const ai = staff.find((s) => s.name === 'AI'); const ai = staff.find((s) => s.name === 'AI');
/*
* 改一格顺手清掉因此藏起来的那几格 —— 触发方式从「事件驱动」切回「月度例行」,
* 「事件类型」那格就该跟着消失;它已经填的值若留在 state 里,人切回来又看得见,
* 也会被 `fire()` 悄悄当成这一单的入参一起交上去,而界面上早就看不见它了。
*
* 改的若是一格枚举选择(如「触发方式」「事件类型」),选完顺手看这组枚举组合
* 唯不唯一对得上一条样例 —— 唯一就把那条样例其余格子的内容套进来当默认值
* (「月度例行」/「事件驱动+某事件类型」各自的默认客户与说明,不用另外去点样例按钮)。
* 不唯一(比如刚选了「事件驱动」,「事件类型」还没选)就不动,留给人接着选。
*/
const setField = (name: string, value: string): void => {
setData((prev) => {
const clear = (obj: Record<string, string>): void => {
for (const f of allFields) if (!isVisible(f, obj)) delete obj[f.name];
};
let next: Record<string, string> = { ...prev, [name]: value };
clear(next);
const changed = allFields.find((f) => f.name === name);
if (picked !== undefined && changed?.options !== undefined) {
const idx = matchingSample(picked, next, allFields);
/*
* **整单换成这条样例,不是把它的字段叠上去** —— 样例的 `input` 本就是稀疏的
* (「月度例行」样例只有 `trigger` 一个键),叠加式的 `{...next, ...sampleValues}`
* 加得上样例给的值,却清不掉样例没提的值:从「重大商机」切回「月度例行」,
* 客户与说明会照抄上一条样例的内容原样卡在框里不消失。样例匹配到的这一刻,
* 它就该是这一单入参唯一的真相,照 sampleValues 整份重写。
*/
if (idx !== undefined) {
next = sampleValues(picked, idx);
clear(next); // 套模版也可能改变可见性,按同一条规矩再清一遍
}
}
return next;
});
};
/* /*
* 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象 * 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象
* (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去, * (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去,
@@ -108,8 +153,11 @@ export function ActivityRun({
*/ */
const fire = (card: ActivityCard): void => { const fire = (card: ActivityCard): void => {
const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type])); const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type]));
const visibleNames = new Set(fields.map((f) => f.name));
const shaped: Record<string, unknown> = {}; const shaped: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(data)) { for (const [key, raw] of Object.entries(data)) {
// 藏起来的格子不该跟着交上去 —— 与 `setField` 的清理是同一条规矩,这里再兜一道底
if (!visibleNames.has(key)) continue;
if (raw.trim() === '') continue; if (raw.trim() === '') continue;
const type = typeOf.get(key) ?? 'string'; const type = typeOf.get(key) ?? 'string';
if (!type.endsWith('[]') && type !== 'object') { if (!type.endsWith('[]') && type !== 'object') {
@@ -197,23 +245,36 @@ export function ActivityRun({
<FilePicker <FilePicker
label={f.label ?? f.name} label={f.label ?? f.name}
value={data[f.name] ?? ''} value={data[f.name] ?? ''}
onChange={(next) => setData((prev) => ({ ...prev, [f.name]: next }))} onChange={(next) => setField(f.name, next)}
onError={setError} onError={setError}
/> />
) : f.options !== undefined && f.options.length > 0 ? (
<select
className="tb-pp-i"
value={data[f.name] ?? ''}
onChange={(e) => setField(f.name, e.target.value)}
>
<option value="">{f.required ? '请选择…' : '(不填)'}</option>
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : isLong(f) ? ( ) : isLong(f) ? (
<textarea <textarea
className="tb-pp-i" className="tb-pp-i"
rows={5} rows={5}
value={data[f.name] ?? ''} value={data[f.name] ?? ''}
placeholder={f.type} placeholder={f.example ?? f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))} onChange={(e) => setField(f.name, e.target.value)}
/> />
) : ( ) : (
<input <input
className="tb-pp-i" className="tb-pp-i"
value={data[f.name] ?? ''} value={data[f.name] ?? ''}
placeholder={f.type} placeholder={f.example ?? f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))} onChange={(e) => setField(f.name, e.target.value)}
/> />
)} )}
</label> </label>
@@ -301,8 +362,36 @@ type InputField = {
label?: string | undefined; label?: string | undefined;
/** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */ /** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */
format?: string | undefined; format?: string | undefined;
/** 声明了枚举(契约里的 `enum`)—— 界面给下拉选,不给自由文本框猜合法值 */
options?: { value: string; label: string }[] | undefined;
/** 示例值(契约里的 `examples` 第一项)—— 占位提示用,比裸类型名能抄 */
example?: string | undefined;
/** 声明了 → 只在另一格(`showWhen.field`)当下等于 `showWhen.equals` 时才出现(级联下拉) */
showWhen?: { field: string; equals: string } | undefined;
}; };
/** 这一格该不该露面 —— 没声明 `showWhen` 恒可见,声明了就看那另一格当下的值 */
function isVisible(f: InputField, data: Readonly<Record<string, string>>): boolean {
return f.showWhen === undefined || (data[f.showWhen.field] ?? '') === f.showWhen.equals;
}
/**
* 当前选中的这组枚举字段(有 `options` 且可见的那几格,如「触发方式」「事件类型」)
* 如果唯一对应清单里的一条样例,回它的下标 —— 正是这条样例把其余格子的默认内容定下来。
* 不唯一(比如选了「事件驱动」但「事件类型」还没选,四条候选样例都对不上)就回
* `undefined`,不瞎填一条。这是通用能力:任何"选项 A 决定用哪套默认数据"的活动
* 都靠它自动套模版,不用专门写一次性逻辑。
*/
function matchingSample(card: ActivityCard, data: Readonly<Record<string, string>>, allFields: readonly InputField[]): number | undefined {
const enumFields = allFields.filter((f) => f.options !== undefined && f.options.length > 0 && isVisible(f, data));
if (enumFields.length === 0) return undefined;
const hit = card.contract.samples
.map((s, i) => ({ s, i }))
.filter(({ s }) => enumFields.every((f) => String((s.input as Record<string, unknown>)[f.name] ?? '') === (data[f.name] ?? '')))
.map(({ i }) => i);
return hit.length === 1 ? hit[0] : undefined;
}
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */ /** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
/* ==================== 真文件那一格 ==================== */ /* ==================== 真文件那一格 ==================== */
@@ -484,14 +573,6 @@ function Row({ label, values, empty }: { label: string; values: readonly string[
/* ==================== 观测态 ==================== */ /* ==================== 观测态 ==================== */
const RUNTIME_LAMP: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
running: 'live',
'awaiting-human': 'wait',
'awaiting-event': 'wait',
'landing-blocked': 'bad',
terminal: 'done',
};
/** /**
* 单活动试跑的观测面 —— 一屏看穿这一单。 * 单活动试跑的观测面 —— 一屏看穿这一单。
* *
@@ -504,11 +585,14 @@ function ActivityObserve({
strip, strip,
stopping, stopping,
onStop, onStop,
onGone,
}: { }: {
taskId: string; taskId: string;
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode; strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
stopping: boolean; stopping: boolean;
onStop: () => void; onStop: () => void;
/** 服务端说这一单没了 —— 面板松手(别再钉着一个死号轮询) */
onGone: () => void;
}): ReactNode { }): ReactNode {
const q = useQuery({ const q = useQuery({
queryKey: ['gov', 'testbed', 'activity', taskId], queryKey: ['gov', 'testbed', 'activity', taskId],
@@ -519,17 +603,33 @@ function ActivityObserve({
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。 * 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
* 到终态就停:已经不动的东西没必要一直问。 * 到终态就停:已经不动的东西没必要一直问。
*/ */
refetchInterval: (query) => (query.state.data?.terminal === undefined ? 2000 : false), /*
* **取不到不是活着**(2026-08-23):从前这里只看 `data.terminal`,而取数失败时
* `data` 保留的是上一次成功的那份、`terminal` 恒 undefined —— 于是对着一个
* 已经不存在的任务 2s 一直打,屏上还举着「AWAITING-EVENT · 自动刷新」。
* 现在由 `observeFace` 一处判断该不该继续问(404 与终态都不必再问)。
*/
refetchInterval: (query) =>
observeFace({ data: query.state.data, error: query.state.error as { status?: number } | null ?? undefined }).poll
? 2000
: false,
// 任务没了就别重试 —— 重试只是把同一句「不存在」再问三遍
retry: (count, error) => (error as { status?: number } | undefined)?.status !== 404 && count < 2,
}); });
const s = q.data; const s = q.data;
const live = s !== undefined && s.terminal === undefined; const face = observeFace({ data: q.data, ...(q.error === null ? {} : { error: q.error as { status?: number } }) });
const live = face.breathe;
/* 服务端说没了:松手一次(回装配台或切到当下那一单)—— 不在渲染里直接改父状态 */
useEffect(() => {
if (face.gone) onGone();
}, [face.gone, onGone]);
return ( return (
<> <>
{strip({ {strip({
lamp: s === undefined ? 'live' : (RUNTIME_LAMP[s.runtime] ?? 'live'), lamp: face.lamp,
breathe: live, breathe: face.breathe,
word: s?.terminal === undefined ? (s?.runtime ?? '取数中') : (TERMINAL_STATE_LABEL[s.terminal] ?? s.terminal), word: face.word,
detail: ( detail: (
<> <>
{s?.title ?? taskId.slice(0, 8)} {s?.title ?? taskId.slice(0, 8)}
@@ -0,0 +1,64 @@
/**
* 观测面此刻**该怎么说** —— 纯函数,好让「界面不撒谎」这条有测可钉。
*
* 起因(2026-08-23 用户:「推进完成了,但测试台还在运行中,没有停下来」):
* 面板钉着一个已经不存在的任务(别处把运行数据清了),屏上同时说两句相反的话 ——
* 上面「AWAITING-EVENT · 自动刷新 2s」灯亮着、钮写「停止仿真」,
* 下面一条橙带「取数失败 —— 任务不存在」。两处都错在同一个念头:
* **把「取不到」当成了「还活着」**。
* · `q.data` 取数失败后保留上一次成功的那份,那份 `terminal` 是 undefined → 恒判在跑;
* · `s === undefined ? 'live'` 的兜底 —— 还没取到 ≠ 正在跑。
*
* 现在分三种局面说话:**没了**(服务端 404,当场松手、停轮询)、
* **取数失败**(断网/500,不说在跑,但照旧轮询等它回来)、**取到了**(照实说)。
*/
import { TERMINAL_STATE_LABEL } from '@contracts';
/** 灯:活 / 等 / 完 / 坏 —— 与 `RUNTIME_LAMP` 同一套词 */
export type Lamp = 'idle' | 'live' | 'wait' | 'done' | 'bad';
const RUNTIME_LAMP: Readonly<Record<string, Lamp>> = {
running: 'live',
'awaiting-human': 'wait',
'awaiting-event': 'wait',
'landing-blocked': 'bad',
terminal: 'done',
};
export type ObserveFace = {
readonly lamp: Lamp;
/** 呼吸灯 —— 只有真在跑才亮;取不到、没了、跑完了都不亮 */
readonly breathe: boolean;
readonly word: string;
/** 服务端说这一单不存在了 —— 面板据此松手(回装配台或切到当下那一单) */
readonly gone: boolean;
/** 还要不要 2s 轮询 —— 终态与「没了」都不必再问 */
readonly poll: boolean;
};
export function observeFace(input: {
data?: { runtime: string; terminal?: string | undefined } | undefined;
error?: { status?: number | undefined } | undefined;
}): ObserveFace {
// ① 服务端明说没有这一单 —— 不是一次普通取数失败,是这一单已经不在了
if (input.error?.status === 404) {
return { lamp: 'bad', breathe: false, word: '这一单已不在了', gone: true, poll: false };
}
// ② 取数失败(断网 / 500):不知道它现在怎么样,那就别替它说「在跑」;轮询留着等它回来
if (input.error !== undefined) {
return { lamp: 'bad', breathe: false, word: '取数失败', gone: false, poll: true };
}
// ③ 还没取到:说「取数中」,灯不呼吸 —— 没取到不等于在跑
if (input.data === undefined) {
return { lamp: 'idle', breathe: false, word: '取数中', gone: false, poll: true };
}
const { runtime, terminal } = input.data;
const live = terminal === undefined;
return {
lamp: RUNTIME_LAMP[runtime] ?? 'live',
breathe: live,
word: live ? runtime : ((TERMINAL_STATE_LABEL as Readonly<Record<string, string>>)[terminal] ?? terminal),
gone: false,
poll: live,
};
}
+11 -1
View File
@@ -18,6 +18,8 @@ export class ApiCallError extends Error {
type CallOptions = { type CallOptions = {
readonly params?: Readonly<Record<string, string>>; readonly params?: Readonly<Record<string, string>>;
/** 查询串 —— 路由契约里声明了 `query` 的走这里(值 undefined 的键不发) */
readonly query?: Readonly<Record<string, string | undefined>>;
readonly body?: unknown; readonly body?: unknown;
}; };
@@ -26,8 +28,16 @@ function resolvePath(path: string, params: Readonly<Record<string, string>> = {}
return path.replace(/:([A-Za-z_][\w]*)/g, (_, key: string) => encodeURIComponent(params[key] ?? '')); return path.replace(/:([A-Za-z_][\w]*)/g, (_, key: string) => encodeURIComponent(params[key] ?? ''));
} }
/** `?a=1&b=2` —— 没有查询参数时连问号都不加 */
function queryString(query: Readonly<Record<string, string | undefined>> = {}): string {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(query)) if (v !== undefined) usp.set(k, v);
const s = usp.toString();
return s === '' ? '' : `?${s}`;
}
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> { export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
const response = await fetch(resolvePath(route.path, options.params), { 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' },
credentials: 'same-origin', credentials: 'same-origin',
+25
View File
@@ -0,0 +1,25 @@
-- 产出的资料文件**存档** —— 放行那一刻把它冻在这儿。
--
-- 文件平时是台账那一行的投影(标题 + 正文 + 格式 → 现造字节,见 `file-compose.ts`),
-- 在办期间这样最好:台账改一格,文件跟着变,两处永远一致。
-- 但**收工之后不该再变**:归档件说的是「他签字那一刻交出去的是什么」,
-- 台账后来被下一单改了,归档的那份也不能跟着改口。
--
-- 还有一条更硬的理由:资料常落在**主数据**上(如 `content.asset` 品牌内容库),
-- 而试跑单放行时主数据只留在活动缓存、不落正式表(免得试跑往正式库里塞真资产)——
-- 于是放行前点得开、放行后点不开。存档这一份把两件事一起解决了。
create table sys.task_asset_file (
id text primary key,
task_id text not null,
address text not null, -- 域.对象
row_key jsonb not null, -- 那一行的主键(多列主键原样收)
title text not null default '',
format text not null, -- md | html | docx | xlsx
name text not null, -- 文件名(含扩展名)
bytes bytea not null,
size integer not null,
created_at timestamptz not null default now()
);
create index task_asset_file_task_idx on sys.task_asset_file (task_id, created_at);
-- 同一单同一行只存一份:重跑放行(或补存)覆盖它,不留两份说法不一的档
create unique index task_asset_file_row_idx on sys.task_asset_file (task_id, address, row_key);
+7
View File
@@ -204,6 +204,13 @@
"when": 1785130120176, "when": 1785130120176,
"tag": "0028_view_template", "tag": "0028_view_template",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1785130120177,
"tag": "0029_task_asset_file",
"breakpoints": true
} }
] ]
} }
+2 -2
View File
@@ -6,7 +6,7 @@ services:
environment: environment:
POSTGRES_USER: jcai POSTGRES_USER: jcai
POSTGRES_PASSWORD: jcai POSTGRES_PASSWORD: jcai
POSTGRES_DB: jcai POSTGRES_DB: jcai_wos
# pgcrypto / uuid 由 init 脚本按需启用 # pgcrypto / uuid 由 init 脚本按需启用
TZ: Asia/Shanghai TZ: Asia/Shanghai
ports: ports:
@@ -15,7 +15,7 @@ services:
- jcai-pgdata:/var/lib/postgresql/data - jcai-pgdata:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro - ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U jcai -d jcai'] test: ['CMD-SHELL', 'pg_isready -U jcai -d jcai_wos']
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 20 retries: 20
+20
View File
@@ -113,6 +113,23 @@
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) | | `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
| `data-edit="域.对象"` | 可改表的行容器 | 这组行可就地改;保存按它认地址 | | `data-edit="域.对象"` | 可改表的行容器 | 这组行可就地改;保存按它认地址 |
| `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) | | `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) |
| `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 |
**资料文件**(2026-08-23):一行台账还可以是一份**真文件**。台账声明里写
`file: { titleColumn, bodyColumn, formatColumn }`(它同时是授权面 —— 不声明就点不开),
正文一律 Markdown 原文写在 `bodyColumn`,格式列写 `md` / `html` / `docx` / `xlsx`;
基座按声明**现造**字节(`file-compose.ts`),再读回来渲(`file-doc.ts`)——
下载到的是真 .docx / .xlsx,屏上渲的就是那份字节读回来的样子。
件这一侧只管挂 `data-file-of`,「点了去哪」不在 HTML 里。
**在办中现造,放行那一刻存档**(2026-08-23 补齐):在办期间文件是台账那一行的投影,
台账改一格文件跟着变 —— 那时这样最好,两处永远一致。**放行落地之前**基座把每一份
造成字节冻进 `sys.task_asset_file`,取文件那条路**存档优先**:归档件上点开的必须是
他签字那一刻的那一份,而不是台账后来被下一单改成的样子。
非存不可还有一条硬理由:资料常落在**主数据**上,而试跑单放行时主数据只留在活动缓存、
不落正式表 —— 不存这一份,试跑单一放行,工作成果那一屏就什么都点不开。
工作成果里「产出的资料」自成一摞(与定稿报告分开:报告是「叫人办的那件事办完长什么样」,
资料是**办出来的东西本身**)。
取数约定:`mine:域.对象` = 本单草稿行(成果表**只用它** —— 别单放行过的记录不混进来); 取数约定:`mine:域.对象` = 本单草稿行(成果表**只用它** —— 别单放行过的记录不混进来);
裸键 = 摊平字段 + **基座读数**(`域_对象_rows` / `_missing` / `_missing_keys` / 裸键 = 摊平字段 + **基座读数**(`域_对象_rows` / `_missing` / `_missing_keys` /
@@ -279,3 +296,6 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts`
| 人保存后一行的值被另一行盖掉 | **主键格没挂 `data-col`** —— 收上去的行没有钥匙,服务端按「全表一致列」补键,多行对到同一行 | 主键列必挂 `data-col`(它不是编辑面,是行的钥匙);e2e 走 API 自带主键测不出,只有件上真按保存才现形(A2 走查逮过) | | 人保存后一行的值被另一行盖掉 | **主键格没挂 `data-col`** —— 收上去的行没有钥匙,服务端按「全表一致列」补键,多行对到同一行 | 主键列必挂 `data-col`(它不是编辑面,是行的钥匙);e2e 走 API 自带主键测不出,只有件上真按保存才现形(A2 走查逮过) |
| 逐行卡片克隆成套娃(第二行长进第一行里) | 渲染器按**第一个同名闭合标签**截行内容 —— `<div data-rows>` 的行模版里嵌了别的 `<div>`,行在半截被断开 | **行容器的标签名不得在行模版内部再出现**:容器换 `<section>`/`<tbody>` 这类行内不会嵌的标签(A2 选型档案卡逮过) | | 逐行卡片克隆成套娃(第二行长进第一行里) | 渲染器按**第一个同名闭合标签**截行内容 —— `<div data-rows>` 的行模版里嵌了别的 `<div>`,行在半截被断开 | **行容器的标签名不得在行模版内部再出现**:容器换 `<section>`/`<tbody>` 这类行内不会嵌的标签(A2 选型档案卡逮过) |
| 空格胶囊比同行的数字**往下坠几像素** | 裸 inline-flex 胶囊按 baseline 挂:盒(字 + 内距 + 边框)比字的基线深,行高也随之忽高忽低 | 空格子(`:empty`)转 flex + `align-items:center` + `min-height` 撑住文字那份盒高,胶囊中线对数字中线(A1 逮过,2026-08-03) | | 空格胶囊比同行的数字**往下坠几像素** | 裸 inline-flex 胶囊按 baseline 挂:盒(字 + 内距 + 边框)比字的基线深,行高也随之忽高忽低 | 空格子(`:empty`)转 flex + `align-items:center` + `min-height` 撑住文字那份盒高,胶囊中线对数字中线(A1 逮过,2026-08-03) |
| 查看面的灰底**滚过一屏就变白** | 画背景的那一格是弹性子项(`flex:0 1 auto`),高度被「看得见的那一屏」封住;内容比一屏长时溢出盒子继续排,而**背景只画在盒子里** | `flex: 1 0 auto`(不许被压 → 盒子至少和内容一样高;内容短时能长满 → 下方不留白边)。同一症状可能有两条独立的路(沙箱内的底色、父文档里的底色),治完一条把另一条也走一遍(2026-08-23 用户报了两次) |
| 表宽超出屏宽,列被裁掉且**滑不动** | `.jc-tbl-wrap` 写成 `overflow: hidden`(本意是让圆角裁住表头)—— 横向一并封死;列一多、或某列为放下下拉框加宽,人就再也看不到右边那几列 | `overflow-x: auto; overflow-y: hidden`(件是整篇跟着外层滚的,竖向不该再套一层)。抄别包版式时最容易抄丢这一条 —— quote-* 六包一直是 auto,E04/I01 抄漏(2026-08-23 用户报) |
| 改了 `prd-style.ts`,esbuild 报 `Expected ";" but found …`,报错行离改动处很远 | 整段 CSS 是 **TS 模版字符串**,注释里写了一个反引号就把字符串在那儿截断,后面的 CSS 变成 TS 代码 | **视图文件的注释里不写反引号**(要引 CSS 属性名就裸写 `overflow-x: auto`)。本轮同一处踩了三次 |
@@ -0,0 +1,626 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Demo:E04 关系覆盖与关键人 × I01 品牌与工具包 · 台账设计</title>
<style>
:root { --bg:#fff; --text:#1a202c; --light:#4a5568; --border:#e2e8f0; --card:#f7fafc; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif; color:var(--text); background:var(--bg); line-height:1.7; font-size:14px; max-width:1200px; margin:0 auto; padding:40px 30px; }
.doc-header { text-align:center; margin-bottom:36px; padding-bottom:20px; border-bottom:2px solid #1a365d; }
.doc-header h1 { font-size:24px; color:#1a365d; margin-bottom:8px; }
.doc-header .subtitle { font-size:15px; color:var(--light); }
.doc-header .meta { font-size:12px; color:#a0aec0; margin-top:12px; }
.doc-header .meta span { margin:0 8px; }
h2.section-title { font-size:18px; color:#1a365d; margin:40px 0 16px; padding-bottom:8px; border-bottom:2px solid #1a365d; }
.note-box { background:#fffaf0; border:1px solid #fefcbf; border-radius:6px; padding:12px 16px; margin:16px 0; font-size:13px; color:#975a16; }
.note-box strong { color:#c05621; }
.legend { display:flex; gap:20px; flex-wrap:wrap; margin:12px 0 16px; font-size:12px; color:var(--light); }
.legend-item { display:flex; align-items:center; gap:6px; }
.legend-box { width:22px; height:18px; display:inline-block; text-align:center; line-height:18px; font-weight:600; font-size:11px; border-radius:3px; }
.cell-r { background:#EBF8FF; color:#185FA5; font-weight:600; text-align:center; }
.cell-w { background:#F0FFF4; color:#276749; font-weight:600; text-align:center; }
.cell-rw { background:#FAF5FF; color:#6B46C1; font-weight:700; text-align:center; }
.cell-none { color:#E2E8F0; text-align:center; }
.matrix-wrap { overflow-x:auto; }
table { width:100%; border-collapse:collapse; margin:14px 0; font-size:13px; }
th { background:#1a365d; color:#fff; padding:7px 10px; text-align:left; font-size:12px; white-space:nowrap; }
td { padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
tr:nth-child(even) td { background:#fafbfc; }
.matrix-table { font-size:11px; }
.matrix-table th { text-align:center; padding:5px 3px; }
.matrix-table td { padding:5px 3px; }
.matrix-table td.act-name { text-align:left; white-space:nowrap; font-weight:600; font-size:12px; }
.hl-col { outline:2px solid #C05621; outline-offset:-1px; }
.activity-card { background:var(--card); border:1px solid var(--border); border-left:4px solid #2c5282; border-radius:6px; padding:14px 18px; margin-bottom:16px; }
.activity-card .a-title { font-size:15px; font-weight:700; color:#1a365d; margin-bottom:6px; }
.activity-card .a-desc { font-size:13px; color:var(--light); }
.activity-card .a-tags { margin-top:8px; }
.tag { display:inline-block; font-size:11px; padding:2px 10px; border-radius:10px; margin-right:6px; font-weight:600; }
.tag-blue { background:#EBF8FF; color:#185FA5; }
.tag-orange { background:#FEEBC8; color:#B7791F; }
.ledger-card { border:1px solid var(--border); border-left:4px solid #185FA5; border-radius:6px; margin-bottom:16px; overflow:hidden; }
.ledger-head { background:#EBF8FF; padding:10px 16px; display:flex; justify-content:space-between; align-items:center; }
.ledger-head .l-id { font-size:13px; font-weight:700; color:#185FA5; }
.ledger-head .l-name { font-size:14px; font-weight:600; }
.ledger-head .l-owner { font-size:12px; color:var(--light); }
.ledger-body { padding:12px 16px; }
.ledger-body .l-position { font-size:13px; font-weight:600; margin-bottom:6px; }
.ledger-body .l-desc { font-size:12px; color:var(--light); margin-bottom:10px; }
.field-table td { font-size:12px; }
.fid { font-weight:600; color:#2b6cb0; white-space:nowrap; }
.rel-row { display:flex; gap:14px; flex-wrap:wrap; margin:10px 0; }
.rel-box { flex:1; min-width:280px; background:var(--card); border:1px solid var(--border); border-radius:6px; padding:12px 16px; }
.rel-box .rel-title { font-weight:600; font-size:13px; margin-bottom:6px; }
.rel-box ul { margin-left:18px; font-size:12px; color:var(--light); }
.excel-note { background:#F0FFF4; border:1px solid #C6F6D5; border-radius:6px; padding:12px 16px; margin:14px 0; font-size:13px; }
.excel-note strong { color:#276749; }
.footer { margin-top:48px; padding-top:16px; border-top:1px solid var(--border); font-size:12px; color:#a0aec0; text-align:center; }
/* ===== Work chain (工作链条) styles ===== */
.chain-note {
background:#EBF8FF; border:1px solid #BEE3F8; border-radius:6px;
padding:12px 16px; margin:14px 0; font-size:13px; color:#2C5282;
}
.chain-note strong { color:#2B6CB0; }
.chain-card {
border:1px solid var(--border); border-left:4px solid #185FA5;
border-radius:6px; margin-bottom:20px; overflow:hidden;
}
.chain-head {
background:#F7FAFC; padding:12px 16px; display:flex; align-items:center; gap:12px;
border-bottom:1px solid var(--border);
}
.chain-id {
color:#fff; font-size:13px; font-weight:700; padding:4px 10px; border-radius:4px; flex-shrink:0;
}
.chain-name { font-weight:600; font-size:14px; color:var(--text); }
.chain-meta { display:flex; gap:8px; flex-wrap:wrap; margin-top:5px; }
.chain-tag { font-size:11px; padding:2px 10px; border-radius:10px; font-weight:500; }
.chain-tag.owner { background:#E6FFFA; color:#234E52; }
.chain-tag.trigger { background:#FEFCBF; color:#975A16; }
.chain-tag.p0 { background:#FED7D7; color:#C53030; }
.chain-trigger {
padding:10px 16px; background:#FFFAF0; border-bottom:1px solid var(--border);
font-size:12px; color:#744210; line-height:1.7;
}
.chain-trigger strong { color:#C05621; }
.chain-steps { list-style:none; margin:0; padding:16px 16px 6px 16px; }
.chain-steps li { display:flex; gap:12px; margin-bottom:14px; position:relative; }
.chain-steps li::before {
content:""; position:absolute; left:12px; top:28px; bottom:-14px;
width:2px; background:#E2E8F0;
}
.chain-steps li:last-child::before { display:none; }
.step-num {
flex-shrink:0; width:26px; height:26px; border-radius:50%; background:#185FA5;
color:#fff; font-size:12px; font-weight:700; display:flex; align-items:center;
justify-content:center; z-index:1;
}
.chain-card[style*="2F855A"] .step-num { background:#2F855A; }
.step-body { flex:1; padding-bottom:2px; }
.step-title { font-size:13px; font-weight:600; color:var(--text); margin-bottom:3px; }
.step-role {
font-size:11px; font-weight:500; color:#fff; background:#718096;
padding:1px 8px; border-radius:10px; margin-left:6px;
}
.step-desc { font-size:12px; color:var(--light); line-height:1.7; }
.step-ledger {
font-size:11px; color:#4A5568; margin-top:5px; background:#F7FAFC;
border-left:3px solid #CBD5E0; padding:4px 8px; border-radius:0 4px 4px 0;
}
.chain-end {
padding:10px 16px; background:#F0FFF4; border-top:1px solid var(--border);
font-size:12px; color:#276749; line-height:1.7;
}
.chain-end strong { color:#22543D; }
@media print { body { max-width:none; padding:10px; font-size:12px; } }
</style>
</head>
<body>
<div class="doc-header">
<h1>Demo:E04 关系覆盖与关键人 × I01 品牌与工具包</h1>
<div class="subtitle">CloudCore 云拓芯科 · AI-Native 业务操作系统 · SS01 客户与市场情报子系统 · Activity 台账关联与字段设计</div>
<div class="meta">
<span>版本:v1.0 (Demo)</span>
<span>日期:2026-08-14</span>
<span>作者:丁子乔 (Zane Ding)</span>
<span>基准:台账体系全景 v6.0(44 台账)</span>
</div>
</div>
<!-- ========== 一、Demo 说明 ========== -->
<h2 class="section-title">一、Demo 目标与范围</h2>
<div class="note-box">
<strong>Demo 目的:</strong>选择 2 个 Activity 展示「Activity × 台账」完整链路——先看 Activity 读写哪些台账,再把关联台账的字段结构设计出来,为系统实现提供可落地依据。<br>
<strong>选择逻辑:</strong>E04 与 I01 分别代表两类典型模式——<strong>E04 是"关系经营型"Activity</strong>(核心是客户侧对象主数据 L-02/L-08 的读写),<strong>I01 是"内容生产型"Activity</strong>(核心是内容资产库 L-05 的读写 + 市场洞察 L-01 的输入)。
</div>
<div class="activity-card">
<div class="a-title">E04 关系覆盖与关键人经营 <span class="tag tag-blue">P0</span></div>
<div class="a-desc">人的核心 Activity——围绕客户的关系网络持续经营:关键人识别与覆盖、关系亲密度维护、决策链更新、影响力经营。Richard 强调"你还没开口,你想要的我已经给你了",关系覆盖是预判需求的前提。</div>
<div class="a-tags"><span class="tag tag-blue">读写:L-02 客户主台账(R+W)</span><span class="tag tag-blue">读写:L-08 联系人台账(R+W)</span></div>
</div>
<div class="activity-card" style="border-left-color:#2F855A;">
<div class="a-title">I01 品牌形象与销售工具包管理 <span class="tag tag-blue">P0</span></div>
<div class="a-desc">公司对外呈现的内容资产统一管理:公司介绍、能力声明、销售工具包、案例集、品牌调性标准,多语言版本受控发布。Richard 强调"气质"——对外形象是第一印象的载体。</div>
<div class="a-tags"><span class="tag tag-orange">读:L-01 宏观市场台账(R)</span><span class="tag tag-orange">读写:L-05 品牌内容库(R+W)</span></div>
</div>
<!-- ========== 二、Activity × 台账关联矩阵 ========== -->
<h2 class="section-title">二、Activity × 台账关联矩阵(E04 / I01 抽取)</h2>
<div class="legend">
<div class="legend-item"><span class="legend-box cell-r">R</span> 读取</div>
<div class="legend-item"><span class="legend-box cell-w">W</span> 写入</div>
<div class="legend-item"><span class="legend-box cell-rw">R+W</span> 读写</div>
<div class="legend-item"><span class="legend-box cell-none">—</span> 不涉及</div>
<div class="legend-item"><span class="legend-box" style="background:#C05621;color:#fff;">◉</span> 本 Demo 关联台账(高亮边框)</div>
</div>
<div class="matrix-wrap">
<table class="matrix-table">
<thead>
<tr>
<th style="width:170px;">Activity</th>
<th class="hl-col" class="th-sys">L-01市场</th>
<th class="hl-col" class="th-sys">L-02客户</th>
<th class="th-sys">L-03触达</th>
<th class="th-sys">L-04攻坚</th>
<th class="hl-col" class="th-sys">L-05品牌</th>
<th class="th-sys">L-06知识</th>
<th class="th-sys">L-07情报源</th>
<th class="hl-col" class="th-sys">L-08联系人</th>
<th class="th-sys">P-01需求任务</th>
<th class="th-sys">P-02报价商务</th>
<th class="th-sys">P-03PriceList</th>
<th class="th-sys">P-04框架承诺</th>
<th class="th-sys">P-05客户项目</th>
<th class="th-sys">P-06样品验证</th>
<th class="th-sys">P-07FAE资产</th>
<th class="th-sys">P-08芯片主数据</th>
<th class="th-sys">P-09技术档案</th>
<th class="th-sys">P-10原厂资料</th>
<th class="th-sys">P-11替代批准</th>
<th class="th-sys">P-12技术沉淀</th>
<th class="th-sys">S-01供应链战略</th>
<th class="th-sys">S-02品类覆盖</th>
<th class="th-sys">S-03供应商主数据</th>
<th class="th-sys">S-04询价决策</th>
<th class="th-sys">S-05采购基准</th>
<th class="th-sys">S-06知识库</th>
<th class="th-sys">D-01交易执行</th>
<th class="th-sys">D-02Forecast</th>
<th class="th-sys">D-03库存</th>
<th class="th-sys">D-04回款</th>
<th class="th-sys">D-05RMA</th>
<th class="th-sys">D-06知识</th>
<th class="th-sys">D-07样品库存</th>
<th class="th-sys">D-08质保</th>
<th class="th-sys">D-09条款监控</th>
<th class="th-sys">D-10履约主数据</th>
<th class="th-sys">D-11出口单证</th>
<th class="th-sys">D-12出口退税</th>
<th class="th-sys">D-13制裁筛查</th>
<th class="th-sys">D-14服务水平</th>
<th class="th-sys">D-15采购计划</th>
<th class="th-sys">D-16采购订单</th>
<th class="th-sys">D-17结算核销</th>
<th class="th-sys">D-18采购异常</th>
</tr>
</thead>
<tbody>
<tr>
<td class="act-name">E04 关系覆盖与关键人</td>
<td class="hl-col cell-none">—</td>
<td class="hl-col cell-rw">R+W</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="hl-col cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="hl-col cell-rw">R+W</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
</tr>
<tr>
<td class="act-name">I01 品牌与工具包</td>
<td class="hl-col cell-r">R</td>
<td class="hl-col cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="hl-col cell-rw">R+W</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="hl-col cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
<td class="cell-none">—</td>
</tr>
</tbody>
</table>
</div>
<!-- ========== 三、台账字段设计 ========== -->
<h2 class="section-title">三、关联台账字段设计(4 个台账)</h2>
<div class="ledger-card">
<div class="ledger-head">
<span class="l-id">L-01</span><span class="l-name">宏观市场台账</span>
<span class="l-owner">属主:SS01 · AM | 本 Demo:I01 读(R)</span>
</div>
<div class="ledger-body">
<div class="l-position">定位:市场层数据底座——外部信号与市场评分沉淀</div>
<div class="l-desc">抓取宏观市场数据、行业分析报告、政策趋势情报;I01 品牌内容制作时读取本台账获取市场洞察输入(如俄罗斯市场白皮书的产业数据),T01 目标市场扫描维护(写)。</div>
<table class="field-table">
<tr><th style="width:130px;">字段名</th><th style="width:110px;">中文名</th><th style="width:60px;">类型</th><th>说明</th><th style="width:150px;">维护来源</th></tr>
<tr><td class="fid">signal_id</td><td>信号ID</td><td>文本</td><td>市场信号唯一标识(主键)</td><td>系统自动</td></tr>
<tr><td class="fid">signal_type</td><td>信号类型</td><td>枚举</td><td>宏观经济 / 产业趋势 / 竞争动态 / 政策法规 / 客户市场</td><td>I02 采集</td></tr>
<tr><td class="fid">title / content</td><td>标题 / 详情</td><td>文本</td><td>信号摘要与全文</td><td>I02 采集</td></tr>
<tr><td class="fid">source</td><td>信号来源</td><td>文本</td><td>公开数据 / 行业报告 / 订阅 / 人脉网络</td><td>L-07 情报源</td></tr>
<tr><td class="fid">signal_date</td><td>信号日期</td><td>日期</td><td>信号发生/采集日期</td><td>系统</td></tr>
<tr><td class="fid">market_region</td><td>所属市场</td><td>枚举</td><td>俄罗斯 / 中东 / 东南亚 / 欧洲 / 全球</td><td>人工</td></tr>
<tr><td class="fid">industry</td><td>细分行业</td><td>枚举</td><td>ADAS / 5G / 工业自动化 / 汽车电子 / 消费</td><td>人工</td></tr>
<tr><td class="fid">chip_category</td><td>关联芯片品类</td><td>文本</td><td>关联品类(MCU / 电源 / 模拟等)</td><td>人工</td></tr>
<tr><td class="fid">importance</td><td>重要性评分</td><td>整数</td><td>1-5</td><td>人工评估</td></tr>
<tr><td class="fid">impact_dir</td><td>影响方向</td><td>枚举</td><td>机会 / 威胁 / 中性</td><td>人工评估</td></tr>
<tr><td class="fid">signal_status</td><td>信号状态</td><td>枚举</td><td>待评估 / 已评估 / 已入库 / 已过期</td><td>AM</td></tr>
<tr><td class="fid">version</td><td>版本号</td><td>文本</td><td>季度版本,支持趋势对比</td><td>系统</td></tr>
<tr><td class="fid">review_note</td><td>复盘备注</td><td>长文本</td><td>信号对业务影响的判断记录</td><td>AM</td></tr>
</table>
</div>
</div>
<div class="ledger-card">
<div class="ledger-head">
<span class="l-id">L-02</span><span class="l-name">客户主台账</span>
<span class="l-owner">属主:SS01 · AM Lead | 本 Demo:E04 读写(R+W)</span>
</div>
<div class="ledger-body">
<div class="l-position">定位:唯一客户 ID 锚点——画像 + 决策链 + 全生命周期状态</div>
<div class="l-desc">客户主数据唯一权威源,客户 ID 是全链路锚点(联系人 L-08、需求 P-01、报价 P-02、交易 D-01 等全部通过客户 ID 关联)。E04 关系覆盖与关键人经营直接读写本台账:关系覆盖度、决策链、互动时间线是 E04 的核心产出。</div>
<table class="field-table">
<tr><th style="width:130px;">字段名</th><th style="width:110px;">中文名</th><th style="width:60px;">类型</th><th>说明</th><th style="width:150px;">维护来源</th></tr>
<tr><td class="fid">customer_id</td><td>客户ID</td><td>文本</td><td>全链路唯一客户锚点(主键)</td><td>系统自动</td></tr>
<tr><td class="fid">name_cn/en/local</td><td>客户名称</td><td>文本</td><td>中文 / 英文 / 本地语名称</td><td>T01/T02 建档</td></tr>
<tr><td class="fid">cust_type</td><td>客户类型</td><td>枚举</td><td>Tier1 OEM / 模组厂 / 方案商 / 贸易商 / 终端</td><td>T02 评估</td></tr>
<tr><td class="fid">industry / region</td><td>行业 / 区域</td><td>枚举</td><td>细分行业与国家/区域</td><td>T02 评估</td></tr>
<tr><td class="fid">portfolio_tags</td><td>360°画像标签</td><td>多值</td><td>技术栈 / 品牌偏好 / 价格敏感度 / 文化特征</td><td>E01 维护</td></tr>
<tr><td class="fid">decision_chain</td><td>决策链地图</td><td>结构</td><td>决策链节点,关联 L-08 联系人</td><td>E04 维护</td></tr>
<tr><td class="fid">coverage_score</td><td>关系覆盖度</td><td>整数</td><td>0-100,E04 关系经营产出</td><td>E04 维护</td></tr>
<tr><td class="fid">interaction_timeline</td><td>互动时间线</td><td>结构</td><td>最近互动日期/频率</td><td>E05 回写</td></tr>
<tr><td class="fid">health_score</td><td>健康评分</td><td>整数</td><td>0-100,E07 健康监控回写</td><td>E07 回写</td></tr>
<tr><td class="fid">status</td><td>客户状态机</td><td>枚举</td><td>潜在→已建档→已激活→正式→升降级→退出</td><td>T02/E08 更新</td></tr>
<tr><td class="fid">bt_level</td><td>BT 层级</td><td>枚举</td><td>BT-01 / BT-02 / BT-03</td><td>E03 判定</td></tr>
<tr><td class="fid">coop_history</td><td>合作历史摘要</td><td>长文本</td><td>聚合报价/样品/交易/回款/质量事实</td><td>E01 聚合</td></tr>
<tr><td class="fid">version</td><td>版本号</td><td>文本</td><td>画像版本,支持回溯</td><td>系统</td></tr>
</table>
</div>
</div>
<div class="ledger-card">
<div class="ledger-head">
<span class="l-id">L-05</span><span class="l-name">品牌内容库</span>
<span class="l-owner">属主:SS01 · AM | 本 Demo:I01 读写(R+W)</span>
</div>
<div class="ledger-body">
<div class="l-position">定位:对外呈现内容资产——公司介绍、能力声明、销售工具包、案例集、品牌调性标准</div>
<div class="l-desc">公司对外呈现的所有内容资产统一管理:多语言版本、版本受控、审核发布。I01 品牌形象与销售工具包管理是本台账的唯一读写者;E04 关系覆盖、T03 触达、T04 攻坚从本库调用内容(读)。</div>
<table class="field-table">
<tr><th style="width:130px;">字段名</th><th style="width:110px;">中文名</th><th style="width:60px;">类型</th><th>说明</th><th style="width:150px;">维护来源</th></tr>
<tr><td class="fid">content_id</td><td>内容ID</td><td>文本</td><td>内容资产唯一标识(主键)</td><td>系统自动</td></tr>
<tr><td class="fid">content_type</td><td>内容类型</td><td>枚举</td><td>公司介绍 / 能力声明 / 销售工具包 / 案例集 / 品牌调性标准 / 方案模板</td><td>I01 定义</td></tr>
<tr><td class="fid">title</td><td>内容标题</td><td>文本</td><td></td><td>I01</td></tr>
<tr><td class="fid">language</td><td>语言版本</td><td>枚举</td><td>中文 / English / Русский / العربية</td><td>I01</td></tr>
<tr><td class="fid">version</td><td>版本号</td><td>文本</td><td>版本受控,发布留痕</td><td>I01</td></tr>
<tr><td class="fid">scene</td><td>适用场景</td><td>枚举</td><td>首触 / 方案 / 攻坚 / 展会 / 报价</td><td>I01</td></tr>
<tr><td class="fid">content_status</td><td>内容状态</td><td>枚举</td><td>草稿 / 已审核 / 已发布 / 已废弃</td><td>I01</td></tr>
<tr><td class="fid">brand_tone</td><td>品牌调性标签</td><td>多值</td><td>专业 / 可信赖 / 高效 / 本土化</td><td>I01</td></tr>
<tr><td class="fid">target_customer</td><td>定向客户版本</td><td>文本</td><td>为特定客户定制的版本(关联 L-02)</td><td>I01</td></tr>
<tr><td class="fid">file_ref</td><td>文件位置</td><td>文本</td><td>附件路径/链接</td><td>I01</td></tr>
<tr><td class="fid">reviewer</td><td>审核人</td><td>文本</td><td>发布前审核</td><td>I01</td></tr>
</table>
</div>
</div>
<div class="ledger-card">
<div class="ledger-head">
<span class="l-id">L-08</span><span class="l-name">客户联系人信息台账</span>
<span class="l-owner">属主:SS01 · AM | 本 Demo:E04 读写(R+W)</span>
</div>
<div class="ledger-body">
<div class="l-position">定位:联系人(Contact)明细——一人一记录,一客户多人,挂客户 ID(L-02)</div>
<div class="l-desc">B2B 关系经营的核心对象库:每个客户多个联系人(采购/研发/质量/决策者),记录职位、角色、影响力、关系亲密度、沟通偏好。E04 关系覆盖与关键人经营是核心读写者;E01 画像维护、E02 情报捕捉变动、T03 触达/T04 攻坚调用。联系方式属敏感数据,X02 脱敏管控。</div>
<table class="field-table">
<tr><th style="width:130px;">字段名</th><th style="width:110px;">中文名</th><th style="width:60px;">类型</th><th>说明</th><th style="width:150px;">维护来源</th></tr>
<tr><td class="fid">contact_id</td><td>联系人ID</td><td>文本</td><td>联系人唯一标识(主键)</td><td>系统自动</td></tr>
<tr><td class="fid">customer_id</td><td>所属客户ID</td><td>文本</td><td>关联 L-02 客户主台账</td><td>T02 建档</td></tr>
<tr><td class="fid">name / name_en</td><td>姓名</td><td>文本</td><td>本地语 + 英文名</td><td>T02 调研</td></tr>
<tr><td class="fid">title / department</td><td>职位 / 部门</td><td>文本</td><td>采购 / 研发 / 质量 / 财务 / 管理层</td><td>T02 调研</td></tr>
<tr><td class="fid">role</td><td>角色</td><td>枚举</td><td>采购 / 研发 / 质量 / 决策者 / 技术决策人</td><td>E04 标注</td></tr>
<tr><td class="fid">decision_node</td><td>决策链节点</td><td>文本</td><td>对应 L-02 决策链地图节点</td><td>E04 维护</td></tr>
<tr><td class="fid">influence</td><td>影响力评级</td><td>枚举</td><td>高 / 中 / 低</td><td>E04 评估</td></tr>
<tr><td class="fid">relationship</td><td>关系亲密度</td><td>整数</td><td>1-5,E04 关系经营维护</td><td>E04 维护</td></tr>
<tr><td class="fid">comm_pref</td><td>沟通偏好</td><td>枚举</td><td>Telegram / WhatsApp / Email / LinkedIn / 语言</td><td>E04 维护</td></tr>
<tr><td class="fid">email / phone</td><td>联系方式</td><td>文本</td><td>脱敏管控(X02 隐藏)</td><td>T02 调研</td></tr>
<tr><td class="fid">key_person</td><td>关键人标记</td><td>布尔</td><td>是否关键决策人</td><td>E04 标注</td></tr>
<tr><td class="fid">change_log</td><td>变动记录</td><td>长文本</td><td>入职/离职/调岗留痕</td><td>E02 写入</td></tr>
<tr><td class="fid">version</td><td>版本号</td><td>文本</td><td></td><td>系统</td></tr>
</table>
</div>
</div>
<!-- ========== 四、读写关系 ========== -->
<h2 class="section-title">四、Activity × 台账读写关系与工作链条</h2>
<div class="rel-row">
<div class="rel-box">
<div class="rel-title">E04 关系覆盖与关键人经营</div>
<ul>
<li><strong>读 L-02</strong>:客户画像、决策链地图、关系覆盖现状——判断覆盖缺口</li>
<li><strong>写 L-02</strong>:关系覆盖度(coverage_score)、决策链更新(decision_chain)、互动时间线</li>
<li><strong>读 L-08</strong>:现有联系人清单、角色/影响力/亲密度——识别关键人</li>
<li><strong>写 L-08</strong>:角色标注、影响力评级、关系亲密度、沟通偏好、关键人标记</li>
<li><strong>触发 E02</strong>:关键人变动(离职/调岗)写入 change_log</li>
</ul>
</div>
<div class="rel-box">
<div class="rel-title">I01 品牌形象与销售工具包管理</div>
<ul>
<li><strong>读 L-01</strong>:宏观市场信号、产业趋势——内容创作的市场依据</li>
<li><strong>写 L-05</strong>:内容资产新增/版本更新/状态流转(草稿→审核→发布)</li>
<li><strong>读 L-05</strong>:品牌调性标准、已有内容资产——维护一致性</li>
<li><strong>对外供给</strong>:发布后的内容被 E04/T03/T04 触达与攻坚调用</li>
<li><strong>品牌调性</strong>:Richard 关注的"气质"由 I01 定义并落库为标准</li>
</ul>
</div>
</div>
<!-- ========== 4.2 工作链条 ========== -->
<div class="chain-note">
<strong>工作链条说明:</strong>以下为 E04 / I01 从触发到完结的完整执行链条——标注每步负责人、触发方式、动作内容与台账读写。所有步骤原则上均为"人 + AI 结合"执行,AI 承担信息汇总、记录与初稿生成,人负责判断与决策(具体分工边界后续讨论)。
</div>
<!-- E04 工作链条 -->
<div class="chain-card" style="border-left-color:#185FA5;">
<div class="chain-head">
<span class="chain-id" style="background:#185FA5;">E04</span>
<div>
<div class="chain-name">客户关系覆盖与关键人经营</div>
<div class="chain-meta">
<span class="chain-tag owner">负责:AM Lead(策略)/ AM(执行)</span>
<span class="chain-tag trigger">触发:月度例行 + 事件驱动</span>
<span class="chain-tag p0">优先级 P0</span>
</div>
</div>
</div>
<div class="chain-trigger">
<strong>如何触发:</strong>① 月度例行——每月对重点客户做一轮关系覆盖维护;② 事件驱动——新客户建立合作、关键人离职/调岗、重大商机启动、攻坚项目(T04)立项。触发后由 AM Lead 确认本次覆盖范围与优先级。
</div>
<ol class="chain-steps">
<li>
<span class="step-num">1</span>
<div class="step-body">
<div class="step-title">启动与覆盖范围确认 <span class="step-role">AM Lead</span></div>
<div class="step-desc">确认本次要做的客户清单与场景类型(例行月度维护 / 新客户 / 关键人变动 / 商机联动),判断需要的覆盖深度。</div>
<div class="step-ledger">读:L-02(客户清单与状态)</div>
</div>
</li>
<li>
<span class="step-num">2</span>
<div class="step-body">
<div class="step-title">读取覆盖现状 <span class="step-role">AM Lead + AM</span></div>
<div class="step-desc">调出客户画像、决策链地图(decision_chain)、关系覆盖度(coverage_score)、历史互动时间线,以及联系人清单与各人角色/影响力/亲密度评级——形成当前覆盖全景。</div>
<div class="step-ledger">读:L-02(画像/决策链/覆盖度)、L-08(联系人/角色/影响力/亲密度)</div>
</div>
</li>
<li>
<span class="step-num">3</span>
<div class="step-body">
<div class="step-title">覆盖缺口分析 <span class="step-role">AM Lead</span></div>
<div class="step-desc">对照决策链地图识别每个决策节点的覆盖状态:谁已被覆盖、关系深度如何、谁是空白节点。按 BT 层级确认需要覆盖的角色——BT-01 采购/供应链层、BT-02 技术层(R&amp;D/质量)、BT-03 高层(产品总监/VP)。</div>
<div class="step-ledger">读:L-02(决策链)、L-08(联系人覆盖状态)</div>
</div>
</li>
<li>
<span class="step-num">4</span>
<div class="step-body">
<div class="step-title">制定关系经营计划 <span class="step-role">AM Lead</span></div>
<div class="step-desc">为每个关键人设计经营动作:沟通频率、互动方式(拜访/技术交流/行业活动/价值传递)、传递的价值点。按"影响力 × 亲密度缺口"排序关键人优先级,明确每人的下一步动作与时间点。</div>
<div class="step-ledger">读:L-08(影响力/亲密度评级,用于排序)</div>
</div>
</li>
<li>
<span class="step-num">5</span>
<div class="step-body">
<div class="step-title">执行经营动作 <span class="step-role">AM</span></div>
<div class="step-desc">执行互动:拜访、技术交流、行业活动邀约、价值传递(行业情报、样品支持、案例分享等)。AI 辅助准备沟通素材与纪要,互动过程与客户反馈即时记录。</div>
<div class="step-ledger">写:L-02(互动时间线)</div>
</div>
</li>
<li>
<span class="step-num">6</span>
<div class="step-body">
<div class="step-title">更新关系台账 <span class="step-role">AM</span></div>
<div class="step-desc">重新计算并写入关系覆盖度(coverage_score)、更新决策链地图(decision_chain);更新联系人档案(角色标注、影响力评级、关系亲密度、沟通偏好、关键人标记)。若发现关键人变动(离职/调岗),触发 E02 情报监测写入 change_log。</div>
<div class="step-ledger">写:L-02(coverage_score/decision_chain)、L-08(角色/影响力/亲密度/关键人标记);关键人变动 → 触发 E02 写 change_log</div>
</div>
</li>
<li>
<span class="step-num">7</span>
<div class="step-body">
<div class="step-title">复盘与完结 <span class="step-role">AM Lead</span></div>
<div class="step-desc">覆盖度达标检查:决策链关键节点是否全覆盖、空白是否关闭、计划动作是否全部执行。输出更新后的关系覆盖地图,供给 E05 互动与拜访 / E06 账户计划作为输入。</div>
<div class="step-ledger">输出:关系覆盖地图 → E05 / E06</div>
</div>
</li>
</ol>
<div class="chain-end">
<strong>Activity 完结标志:</strong>决策链上所有关键节点的覆盖状态已更新、本月计划经营动作全部执行完毕、L-02/L-08 台账数据最新、无遗留待办动作。下次触发由月度例行或新事件启动。
</div>
</div>
<!-- I01 工作链条 -->
<div class="chain-card" style="border-left-color:#2F855A;">
<div class="chain-head">
<span class="chain-id" style="background:#2F855A;">I01</span>
<div>
<div class="chain-name">品牌形象与销售工具包管理</div>
<div class="chain-meta">
<span class="chain-tag owner">负责:AM(内容运营)/ AM Lead(调性把关)</span>
<span class="chain-tag trigger">触发:战略调整/新市场/工具过期/使用方请求</span>
<span class="chain-tag p0">优先级 P0</span>
</div>
</div>
</div>
<div class="chain-trigger">
<strong>如何触发:</strong>① 主动——公司战略定位调整、进入新市场、销售工具缺失或过期、月度定期内容更新;② 被动——使用方发起内容请求(T03 触达需要材料、E03 商机需要方案支撑、T04 攻坚需要定制内容、E04 关系经营需要价值传递素材)。触发后由内容运营 AM 受理并判断需求类型与优先级。
</div>
<ol class="chain-steps">
<li>
<span class="step-num">1</span>
<div class="step-body">
<div class="step-title">需求识别与受理 <span class="step-role">AM(内容运营)</span></div>
<div class="step-desc">识别内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求),判断需求类型(公司介绍、能力声明、选型指南、替代对照表、案例集等)与优先级,确认目标客户类型与使用场景。</div>
<div class="step-ledger">无台账读写(需求受理)</div>
</div>
</li>
<li>
<span class="step-num">2</span>
<div class="step-body">
<div class="step-title">市场依据采集 <span class="step-role">AM</span></div>
<div class="step-desc">读取宏观市场信号与产业趋势,作为内容创作的市场依据;确认内容要传递的价值主张与差异化定位(对应 Richard"从你这能得到什么特别想要的东西")。</div>
<div class="step-ledger">读:L-01(宏观市场信号/产业趋势)</div>
</div>
</li>
<li>
<span class="step-num">3</span>
<div class="step-body">
<div class="step-title">内容创作与工具建设 <span class="step-role">AM 创作 + FAE 技术支持</span></div>
<div class="step-desc">按 BT 层级与使用场景创作并适配——BT-01 侧重供货能力/资质文件/渠道政策、BT-02 侧重技术规格书/替代对照表/认证报告、BT-03 侧重系统方案白皮书/参考设计/成功案例;维护多语言版本(俄/英/中)。</div>
<div class="step-ledger">读:L-05(已有资产,避免重复、保持调性一致)</div>
</div>
</li>
<li>
<span class="step-num">4</span>
<div class="step-body">
<div class="step-title">内部审核与调性把关 <span class="step-role">AM Lead + FAE</span></div>
<div class="step-desc">品牌调性与"气质"一致性检查(AM Lead 把关)、技术内容准确性(FAE 审核)、合规检查、多语言校对。未通过则退回修改,通过则进入发布。</div>
<div class="step-ledger">写:L-05(状态流转:草稿 → 审核中 → 待发布)</div>
</div>
</li>
<li>
<span class="step-num">5</span>
<div class="step-body">
<div class="step-title">发布入库 <span class="step-role">AM</span></div>
<div class="step-desc">内容资产正式发布:版本号更新(内容受控)、状态流转为"已发布"、登记适用场景与 BT 标签,供全系统调用。</div>
<div class="step-ledger">写:L-05(发布/版本更新/状态流转)</div>
</div>
</li>
<li>
<span class="step-num">6</span>
<div class="step-body">
<div class="step-title">分发与使用追踪 <span class="step-role">AM</span></div>
<div class="step-desc">内容供给 E04 关系经营 / T03 触达 / T04 攻坚 / E03 方案生成调用;收集使用反馈(使用频次、客户接受度、触达效果),作为迭代输入。</div>
<div class="step-ledger">读:L-05(版本与使用情况)</div>
</div>
</li>
<li>
<span class="step-num">7</span>
<div class="step-body">
<div class="step-title">复盘迭代与完结 <span class="step-role">AM Lead</span></div>
<div class="step-desc">内容效果复盘:使用频次、客户反馈、过时内容识别;失效内容下架或更新版本,新需求进入下一轮内容计划。</div>
<div class="step-ledger">写:L-05(下架/版本更新)</div>
</div>
</li>
</ol>
<div class="chain-end">
<strong>Activity 完结标志:</strong>本次需求的内容已发布入库并登记版本、使用方已取用、无过期内容遗留、复盘结论已沉淀。新需求或定期更新触发下一轮。
</div>
</div>
<!-- ========== 五、Excel 交付 ========== -->
<h2 class="section-title">五、Excel 台账设计文件(4 个)</h2>
<div class="excel-note">
<strong>交付说明:</strong>4 个 Excel 工作簿,每个包含 3 个 sheet——① 台账信息(编号/定位/属主/治理规则)② 字段设计(字段名/类型/必填/主键/说明/示例/维护来源/脱敏等级)③ 示例数据(2 行真实场景样例)。
</div>
<table>
<tr><th style="width:240px;">文件</th><th>内容</th></tr>
<tr><td>L-01-宏观市场台账.xlsx</td><td>18 字段(信号 ID 主键、信号类型/标题/来源/日期、重要性评分、影响方向、信号状态、版本号等)+ 2 行示例</td></tr>
<tr><td>L-02-客户主台账.xlsx</td><td>20 字段(客户 ID 主键、多语言名称、类型/行业/区域、决策链、关系覆盖度、健康评分、状态机、BT 层级等)+ 1 行示例</td></tr>
<tr><td>L-05-品牌内容库.xlsx</td><td>15 字段(内容 ID 主键、类型、语言、版本、状态、调性标签、定向客户、审核人等)+ 2 行示例</td></tr>
<tr><td>L-08-客户联系人信息台账.xlsx</td><td>19 字段(联系人 ID 主键、客户 ID、姓名/职位/角色/影响力/亲密度/沟通偏好/联系方式/关键人标记/变动记录等)+ 2 行示例</td></tr>
</table>
<div class="footer">
CloudCore 云拓芯科 · Demo:E04 关系覆盖与关键人 × I01 品牌与工具包 · 台账设计 v1.0 · 2026-08-14<br>
关联矩阵基准:SS01-02-03-04 台账体系全景 v6.0(44 台账)
</div>
</body>
</html>
@@ -0,0 +1,197 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>执行工单 1 · 覆盖范围确认 · E04 关系覆盖与关键人经营</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.note .done{color:var(--ok);font-weight:700}
.note .need{color:var(--human);font-weight:700}
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
.jc-editgate-t{flex:1 1 auto;min-width:0}
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
.jc-editgate.on{background:var(--human-soft);border-color:var(--human-line);color:#7c4a12}
.jc-editgate.on .eg-open{display:none}
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
th.req::after{content:" *";color:var(--danger);font-size:11px}
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
table.on td>.jc-vcell{display:none}
table.on td>.jc-cell{display:block}
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
table.on td.rn .jc-rn-num{display:none}
table.on td.rn .jc-rowdel{display:block}
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.jc-addrow.show{display:flex;align-items:center;gap:8px}
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
.jc-addrow button:hover{background:var(--brand-soft)}
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">执行</div>
<div class="tit">
<div class="kick">E04 关系覆盖与关键人经营 · 执行工单 1/2</div>
<h1>覆盖范围确认</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM Lead(负责人)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">执行工单</span>
<span class="step" data-slot="meta.phase">步骤 ① 启动与覆盖范围确认</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【做什么】</b>—— 确认本轮覆盖范围</p>
<div class="note">
<p><span class="done">AI 已完成</span>:按台账规则识别本轮触发口径与覆盖范围,产出下方客户清单(<b>候选基线</b>)。</p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」直接在清单上增删改——<b>加一行 = 额外纳入</b>(AI 未识别但你有价值判断的客户)、<b>删一行 = 排除</b>(AI 纳入但实际应排除)、<b>改单元格 = 修正</b> AI 识别的内容。没有要补的直接在对话区卡里提交。</p>
</div>
<p class="zone"><b>【交回什么】</b>—— 核对后的覆盖范围客户清单(*为必填)</p>
<div class="jc-editgate" id="editgate">
<span class="jc-editgate-t"><b data-slot="sd.customer_count">AI 已识别 2 个客户</b>,点「编辑」增删改</span>
<button class="jc-egbtn eg-open" type="button">编辑</button>
<button class="jc-egbtn eg-cancel" type="button">取消</button>
<button class="jc-egbtn eg-save" type="button">保存</button>
</div>
<div class="tbl-wrap">
<table id="tbl">
<thead>
<tr>
<th style="width:38px">#</th>
<th class="req" data-h="客户编号">客户编号</th>
<th class="req" data-h="客户名称">客户名称</th>
<th data-h="客户类型">客户类型</th>
<th data-h="BT 层级">BT 层级</th>
<th data-h="状态">状态</th>
<th data-h="识别依据">识别依据 / 纳入原因</th>
<th data-h="覆盖深度">覆盖深度</th>
</tr>
</thead>
<tbody data-rows="out.customer_list">
<tr>
<td class="rn"><span class="jc-rn-num">1</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
<td data-field="customer_id"><span class="jc-vcell mono">CUS-2025-0012</span><input class="jc-cell mono" value="CUS-2025-0012"></td>
<td data-field="customer_name"><span class="jc-vcell">AvtoKom 汽车电子</span><input class="jc-cell" value="AvtoKom 汽车电子"></td>
<td data-field="cust_type"><span class="jc-vcell mono">Tier1 OEM</span><input class="jc-cell mono" value="Tier1 OEM"></td>
<td data-field="bt_level"><span class="jc-vcell mono">BT-02</span><input class="jc-cell mono" value="BT-02"></td>
<td data-field="status"><span class="jc-vcell">正式</span><input class="jc-cell" value="正式"></td>
<td data-field="basis"><span class="jc-vcell">月度例行 + T04 攻坚联动</span><input class="jc-cell" value="月度例行 + T04 攻坚联动"></td>
<td data-field="coverage_depth"><span class="jc-vcell">决策链全节点</span><input class="jc-cell" value="决策链全节点"></td>
</tr>
<tr>
<td class="rn"><span class="jc-rn-num">2</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
<td><span class="jc-vcell mono">CUS-2025-0027</span><input class="jc-cell mono" value="CUS-2025-0027"></td>
<td><span class="jc-vcell">Boreas 工业控制</span><input class="jc-cell" value="Boreas 工业控制"></td>
<td><span class="jc-vcell mono">模组厂</span><input class="jc-cell mono" value="模组厂"></td>
<td><span class="jc-vcell mono">BT-02</span><input class="jc-cell mono" value="BT-02"></td>
<td><span class="jc-vcell">正式</span><input class="jc-cell" value="正式"></td>
<td><span class="jc-vcell">月度例行</span><input class="jc-cell" value="月度例行"></td>
<td><span class="jc-vcell">维持性覆盖</span><input class="jc-cell" value="维持性覆盖"></td>
</tr>
</tbody>
</table>
</div>
<div class="jc-addrow" id="addrow">
<button type="button">+ 加一行(额外纳入客户)</button>
<em>新加行的「客户编号」「客户名称」为必填;「识别依据」栏填你纳入它的原因</em>
</div>
<div class="flow"><b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。</div>
</div>
<footer class="card-foot">
<span>E04 关系覆盖与关键人经营 · 执行工单 1/2 · 覆盖范围确认</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:执行 · <span data-slot="card.id">WO-E04-01</span></span>
</footer>
</div>
</div>
<script>
(function(){
var gate = document.getElementById('editgate');
var tbl = document.getElementById('tbl');
var addrow = document.getElementById('addrow');
var tbody = tbl.querySelector('tbody');
function setEdit(on){ gate.classList.toggle('on',on); tbl.classList.toggle('on',on); addrow.classList.toggle('show',on); }
gate.querySelector('.eg-open').addEventListener('click', function(){ setEdit(true); });
gate.querySelector('.eg-cancel').addEventListener('click', function(){
tbody.querySelectorAll('tr').forEach(function(tr){ tr.querySelectorAll('td').forEach(function(td){ var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell'); if(v&&c){c.value=v.textContent;} }); });
setEdit(false);
});
gate.querySelector('.eg-save').addEventListener('click', function(){
tbody.querySelectorAll('tr').forEach(function(tr){ tr.querySelectorAll('td').forEach(function(td){ var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell'); if(v&&c){v.textContent=c.value;} }); });
renumber(); setEdit(false);
});
addrow.querySelector('button').addEventListener('click', function(){
var tr=document.createElement('tr');
tr.innerHTML='<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>'+'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="客户编号"></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="客户名称"></td>'+'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="客户类型"></td>'+'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="BT 层级"></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="状态"></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="纳入原因"></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="覆盖深度"></td>';
tbody.appendChild(tr); renumber();
});
tbody.addEventListener('click', function(e){ if(e.target.classList.contains('jc-rowdel')){ e.target.closest('tr').remove(); renumber(); } });
function renumber(){ tbody.querySelectorAll('tr').forEach(function(tr,i){ tr.querySelector('.jc-rn-num').textContent=i+1; }); }
})();
</script>
</body>
</html>
@@ -0,0 +1,150 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>执行工单 2 · 经营计划执行回执 · E04 关系覆盖与关键人经营</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.note .need{color:var(--human);font-weight:700}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
th.req::after{content:" *";color:var(--danger);font-size:11px}
th.opt::after{content:" 选填";color:var(--ink-4);font-size:10px;font-weight:600}
td{padding:10px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.cust{font-weight:700}
input[type=text],textarea,select{font:inherit;font-size:12.5px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;background:#fff;color:var(--ink);width:100%}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash)}
textarea{min-height:40px;resize:vertical}
.fixed{font-size:12.5px;color:var(--ink-2)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">执行</div>
<div class="tit">
<div class="kick">E04 关系覆盖与关键人经营 · 执行工单 2/2</div>
<h1>经营计划执行回执</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM(执行)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">执行工单</span>
<span class="step" data-slot="meta.phase">步骤 ⑤⑥ 执行经营动作</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">2026-08-28</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【做什么】</b>—— 执行计划动作</p>
<div class="note">
<p><b>经营计划已验收通过</b>。以下是需要你<b class="need">到场执行</b>的动作(物理拜访、技术交流、递送实物——都是 AI 做不到、需要人去干的)。每项动作执行后,在下方回执。</p>
</div>
<p class="zone"><b>【交回什么】</b>—— 逐项回执(*为必填)</p>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th style="width:36px">#</th>
<th style="width:140px" data-h="计划动作">计划动作</th>
<th style="width:120px" data-h="对象">对象</th>
<th class="req" style="width:100px" data-h="完成状态">完成状态</th>
<th class="req" data-h="结果摘要">结果摘要</th>
<th class="opt" data-h="客户反馈">客户反馈</th>
<th class="req" style="width:90px" data-h="关键人变动">关键人变动</th>
</tr>
</thead>
<tbody data-rows="out.plan">
<tr>
<td class="num">1</td>
<td data-field="action" class="fixed">拜访 + 方案交流</td>
<td data-field="target" class="cust">AvtoKom · 产品 VP</td>
<td data-field="status"><select><option>完成</option><option>部分完成</option><option>未完成</option></select></td>
<td><textarea placeholder="见到的关键人、谈了什么、达成什么"></textarea></td>
<td><textarea placeholder="客户态度 / 诉求 / 下一步信号"></textarea></td>
<td><select><option>否</option><option>是</option></select></td>
</tr>
<tr>
<td class="num">2</td>
<td class="fixed">技术交流</td>
<td class="cust">AvtoKom · 质量负责人</td>
<td><select><option>完成</option><option>部分完成</option><option>未完成</option></select></td>
<td><textarea placeholder="见到的关键人、谈了什么、达成什么"></textarea></td>
<td><textarea placeholder="客户态度 / 诉求 / 下一步信号"></textarea></td>
<td><select><option>否</option><option>是</option></select></td>
</tr>
<tr>
<td class="num">3</td>
<td class="fixed">价值传递(递样品)</td>
<td class="cust">AvtoKom · 研发负责人</td>
<td><select><option>完成</option><option>部分完成</option><option>未完成</option></select></td>
<td><textarea placeholder="见到的关键人、谈了什么、达成什么"></textarea></td>
<td><textarea placeholder="客户态度 / 诉求 / 下一步信号"></textarea></td>
<td><select><option>否</option><option>是</option></select></td>
</tr>
<tr>
<td class="num">4</td>
<td class="fixed">维持性经营</td>
<td class="cust">Boreas · 采购经理</td>
<td><select><option>完成</option><option>部分完成</option><option>未完成</option></select></td>
<td><textarea placeholder="见到的关键人、谈了什么、达成什么"></textarea></td>
<td><textarea placeholder="客户态度 / 诉求 / 下一步信号"></textarea></td>
<td><select><option>否</option><option>是</option></select></td>
</tr>
</tbody>
</table>
</div>
<div class="flow"><b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。若「关键人变动」标「是」,将触发 E02 写 change_log。</div>
</div>
<footer class="card-foot">
<span>E04 关系覆盖与关键人经营 · 执行工单 2/2 · 经营计划执行回执</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:执行 · <span data-slot="card.id">WO-E04-03</span></span>
</footer>
</div>
</div>
</body>
</html>
@@ -0,0 +1,177 @@
# E04 关系覆盖与关键人经营 · 活动流程与工单说明
> 所属子系统:SS01 客户与市场 | 优先级:P0 | 类型:关系经营型活动
> 本说明总结 E04 活动的整体流程、工单产出的设计逻辑,以及每一步做了什么。配套工单 HTML 见本目录。
---
## 一、活动定位
**E04 关系覆盖与关键人经营**是围绕客户关系网络的持续经营活动:关键人识别与覆盖、关系亲密度维护、决策链更新、影响力经营。核心理念是「你还没开口,你想要的我已经给你了」——关系覆盖是预判需求的前提。
**消费的台账**(读):L-02 客户主台账(画像/决策链/覆盖度)、L-08 联系人台账(角色/影响力/亲密度)。
**产出的台账**(写):L-02 的 coverage_score / decision_chain / interaction_timeline、L-08 的角色标注 / 影响力 / 亲密度 / 关键人标记。
**触发方式**:① 月度例行(每月对重点客户做一轮覆盖维护);② 事件驱动(新客户建档 / 关键人变动 / 重大商机启动 / 攻坚项目 T04 立项)。
---
## 二、整体流程(7 步)
E04 的完整工作链条共 7 步,AI 与人的分工原则是:**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**。
| 步骤 | 名称 | 谁在做 | 干什么 |
|---|---|---|---|
| ① | 启动与覆盖范围确认 | AM Lead(审) | 确认本轮覆盖哪些客户、场景类型、覆盖深度 |
| ② | 读取覆盖现状 | AI 汇总 | 调出客户画像、决策链地图、覆盖度、联系人清单,形成覆盖全景 |
| ③ | 覆盖缺口分析 | AM Lead(审) | 对照决策链识别空白节点,按 BT 层级确定需覆盖的角色 |
| ④ | 制定关系经营计划 | AM Lead(审) | 按「影响力 × 亲密度缺口」排序,草拟每个关键人的经营动作 |
| ⑤ | 执行经营动作 | AM(执行) | 拜访 / 技术交流 / 价值传递等线下动作(AI 做不到) |
| ⑥ | 更新关系台账 | AM(办理) | 重算 coverage_score、更新决策链、更新联系人档案 |
| ⑦ | 复盘与完结(门禁) | AM Lead(审) | 覆盖度达标检查,输出更新后的关系覆盖地图 |
---
## 三、为什么这样产出工单(核心设计逻辑)
7 步不是 7 个工单。按「AI 能不能独立完成」把 7 步收敛成 **2 张执行工单 + 2 张验收工单**:
| 工单 | 覆盖步骤 | 类型 | 依据 |
|---|---|---|---|
| 执行工单 1 | ① | 执行 | AI 识别出了覆盖范围,但「规则之外」的信息需要人来补 |
| 验收工单 1 | ②③④ | 验收 | AI 算出了经营计划,需要人判断计划是否合理 |
| 执行工单 2 | ⑤⑥ | 执行 | 计划要人去线下执行(AI 做不到),执行完要回执 |
| 验收工单 2 | ⑥⑦ | 验收 | AI 收到回执后更新台账,需要人确认无误后入库 |
### 两条工单的本质区别
**执行工单 = AI 产出 + 人填内容**
- AI 先列出它产出的结论(候选基线),人在这基础上**查漏补缺 / 填写内容**(可以不填)。
- 用于「AI 做不到、需要人去做」的事——如物理拜访、线下交流、补充 AI 识别规则之外的信息。
**验收工单 = AI 结论展示 + 人只做审批**
- AI 展示它计算出的结论(只读),人**只做「通过 / 驳回」**,不能修改结论;驳回带意见,AI 重做。
- 用于「AI 算出了结论、需要人拍板」的事——如经营计划是否合理、台账更新是否无误。
### 三条硬约束
1. **工单不承载审批动作**:工单里不放「提交回执 / 通过 / 驳回」按钮,审批动作统一放在对话区的卡(执行卡 / 验收卡)里。
2. **工单要完整**:每个工单必须包含「做什么 / 交回什么 / 期限 / 判据(验收工单)/ 提交后去向」。
3. **交回内容可校验**:执行工单的回执字段标注必填(*)/ 选填,验收工单给明确的判据清单,让人知道凭什么通过或驳回。
---
## 四、每个工单详细说明
### 执行工单 1 · 覆盖范围确认(步骤 ①)
- **做什么**:确认本轮覆盖范围。
- **AI 产出**:按台账规则识别出的候选客户清单(如 AvtoKom、Boreas)+ 场景类型、覆盖深度。
- **人填什么(查漏补缺)**:在 AI 清单上直接增删改——加一行 = 额外纳入(AI 没识别但人了解有价值)、删一行 = 排除(AI 纳入了但实际应排除)、改单元格 = 修正 AI 识别内容。
- **为什么是执行工单**:AI 只能按台账规则判断,有些「数据里没有、但人线下知道」的信息(如某贸易商掌握关键渠道)需要人补充录入。
- **交回**:核对后的覆盖范围客户清单(客户编号、客户名称必填)。
### 验收工单 1 · 关系经营计划审批(步骤 ②③④)
- **做什么**:审批 AI 制定的关系经营计划。
- **AI 产出**:经过「读覆盖现状 → 缺口分析 → 制定计划」三步计算,得出经营计划(经营哪些关键人、建议动作、建议优先级、理由),按「影响力 × 亲密度缺口」排序。
- **人怎么审**:只读展示,不可修改。按判据核对后「通过」或「驳回+意见」(驳回 AI 重做)。
- **验收判据**:① 关键人覆盖是否完整;② 优先级排序是否合理;③ 动作是否可执行、有时间点。
- **为什么是验收工单**:计划是 AI 算出来的结论,人对结论只做肯定或否定,不自己改。
### 执行工单 2 · 经营计划执行回执(步骤 ⑤⑥)
- **做什么**:执行已验收通过的计划动作。
- **AI 产出**:验收通过后的计划动作清单(拜访谁、做什么)。
- **人填什么(回执)**:逐项填完成状态(必填)、结果摘要(必填)、客户反馈(选填)、关键人变动(必填)。
- **为什么是执行工单**:拜访、技术交流、递送实物都是 AI 做不到、需要人到场执行的事;人执行后把回执填回来。
- **交回**:每项计划动作的执行回执。
### 验收工单 2 · 关系台账更新确认(步骤 ⑥⑦)
- **做什么**:确认 AI 拟更新的台账,无误后入库。
- **AI 产出**:收到执行回执后,重算 coverage_score、更新决策链、更新 L-08 联系人档案、追加互动时间线,产出「更新前 → 更新后」明细。
- **人怎么审**:只读展示,不可修改。按判据核对后「通过入库」或「驳回+意见」。
- **验收判据**:① 更新是否有依据(每条更新能否在回执里找到对应事实);② 数值是否合理;③ 有无漏更或误更。
- **为什么是验收工单**:台账更新是 AI 的落账动作,人确认无误后入库,是「终局唯放行」的最后一道确认。
---
## 五、文件对应关系
| 工单文件 | 类型 | 覆盖步骤 | 一句话 |
|---|---|---|---|
| `E04-执行工单-01-覆盖范围确认.html` | 执行 | ① | 人补充 AI 覆盖范围结论之外的信息 |
| `E04-验收工单-01-关系经营计划.html` | 验收 | ②③④ | 人审批 AI 的经营计划(通过/驳回) |
| `E04-执行工单-02-执行回执.html` | 执行 | ⑤⑥ | 人执行计划并填回执 |
| `E04-验收工单-02-台账更新确认.html` | 验收 | ⑥⑦ | 人确认 AI 的台账更新后入库 |
**完整闭环**:
```
① 覆盖范围确认 ──执行工单1(人补信息)──▶ ②③④ AI 制定计划 ──验收工单1(通过/驳回)──▶
⑤⑥ 人去执行计划 ──执行工单2(人填回执)──▶ AI 更新台账 ──验收工单2(确认入库)──▶ 完结
```
---
## 六、动态数据标注(模板绑定位置)
> 这 4 个工单 HTML 是**模板**——里面的数据会根据实例(不同任务 / 客户 / 轮次)加载不同内容。本表标注每个工单里「动态数据」的位置及其绑定地址(命名空间)。设计态示例数据在运行时会被注水器替换。
### 6.1 绑定命名空间速查
| 命名空间 | 含义 | 本活动用到 |
|---|---|---|
| `meta.*` | 任务与活动元数据 | 收件人、任务编号、步骤、活动名 |
| `card.*` | 工单身份专属 | 期限、工单编号、验收判据、回执字段 |
| `out.*` | 输出表单 / 台账映射行集 | 客户清单、经营计划、台账更新明细 |
| `sd.*` | 状态数据读数 | 客户计数、触发口径、覆盖度 |
### 6.2 四个工单共用的动态位置(每个工单头部都有)
| HTML 位置 | 动态数据 | 绑定地址 |
|---|---|---|
| 卡头「收件人」 | 负责人 | `meta.task.owner` |
| 卡头「关联任务」 | 任务编号 | `meta.task.id` |
| 卡头「步骤」 | 当前节点 | `meta.phase` |
| 卡头「最迟交回」 | 期限 | `card.deadline` |
| 页脚「工单编号」 | 工单 ID | `card.id` |
### 6.3 执行工单 1 · 覆盖范围确认
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 【做什么】里「触发口径与覆盖范围」结论 | 本轮触发场景 | `sd.trigger_scene` | 单值 |
| 编辑闸「AI 已识别 2 个客户」 | 识别客户数 | `sd.customer_count` | 单值 |
| 【交回什么】客户清单表 | 候选客户清单 | `out.customer_list` | 行集 |
| └ 客户编号 / 名称 / 类型 / BT层级 / 状态 / 依据 / 覆盖深度 | 各列字段 | `out.customer_list.*` | 字段 |
### 6.4 验收工单 1 · 关系经营计划
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 【验收判据】清单 | 判据项 | `card.criteria[]` | 列表 |
| 【AI 产出的经营计划】表 | 计划行 | `out.plan` | 行集 |
| └ 客户 / 关键人 / 建议动作 / 优先级 / 理由 | 各列字段 | `out.plan.*` | 字段 |
| └ 优先级「高/中」颜色 | 状态着色 | `data-class-of` | 状态类 |
### 6.5 执行工单 2 · 执行回执
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 【交回什么】计划动作回执表 | 计划动作(AI 产出) | `out.plan` | 行集 |
| └ 计划动作 / 对象 | AI 产出的计划 | `out.plan.action / target` | 字段(只读) |
| └ 完成状态 / 结果摘要 / 客户反馈 / 关键人变动 | 人填回执 | `card.fields.*` | 字段(可填) |
### 6.6 验收工单 2 · 台账更新确认
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 【验收判据】清单 | 判据项 | `card.criteria[]` | 列表 |
| 【拟更新的台账】表 | 更新明细行 | `out.ledger_update` | 行集 |
| └ 台账 / 字段 / 更新前 / 更新后 | 各列字段 | `out.ledger_update.*` | 字段 |
---
(完)E04 一个活动,七个步骤,四张工单:**执行工单让人补内容、验收工单让人拍结论**——AI 做得出的算出来给人审,AI 做不到的派出去让人干。数据由服务注水,工单只是模板。
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>验收工单 1 · 关系经营计划审批 · E04 关系覆盖与关键人经营</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
.criteria li{margin:4px 0}
.criteria b{color:var(--ink)}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.cust{font-weight:700}
.prio{font-family:var(--mono);font-weight:800}
.prio.hi{color:var(--danger)}
.prio.mid{color:var(--ink-2)}
.reason{font-size:12.5px;color:var(--ink-2)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">验收</div>
<div class="tit">
<div class="kick">E04 关系覆盖与关键人经营 · 验收工单 1/2</div>
<h1>关系经营计划审批</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM Lead(负责人)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">验收工单</span>
<span class="step" data-slot="meta.phase">步骤 ②③④ 产出</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【AI 结论说明】</b>—— 结论是怎么算出来的</p>
<div class="note">
<p><b>AI 已完成的计算</b>:读取 L-02 客户画像、决策链地图、关系覆盖度,读取 L-08 联系人角色 / 影响力 / 亲密度;对照决策链识别空白节点,按「影响力 × 亲密度缺口」排序,得出本轮<b>关系经营计划</b>。</p>
</div>
<p class="zone"><b>【验收判据】</b>—— 你据此判断「通过 / 驳回」</p>
<div class="criteria">
<div class="c-title">判断这份计划是否合理,请逐项核对</div>
<ul>
<li><b>关键人覆盖是否完整</b>:该经营的决策链关键人(BT-01 / BT-02 / BT-03)有没有漏掉;</li>
<li><b>优先级排序是否合理</b>:是否符合「影响力 × 亲密度缺口」的排序逻辑,有没有高优的客户被排低了;</li>
<li><b>动作是否可执行</b>:建议动作是否明确、有没有时间点、可不可落地。</li>
</ul>
</div>
<p class="zone"><b>【AI 产出的经营计划】</b>—— 只读展示</p>
<div class="tbl-wrap">
<table>
<thead><tr><th style="width:40px">#</th><th style="width:150px" data-h="客户">客户</th><th style="width:130px" data-h="关键人">关键人</th><th style="width:150px" data-h="建议动作">建议动作</th><th style="width:70px" data-h="优先级">优先级</th><th data-h="理由">AI 给出的理由</th></tr></thead>
<tbody data-rows="out.plan">
<tr>
<td class="num">1</td>
<td data-field="customer" class="cust">AvtoKom 汽车电子</td>
<td data-field="key_person">产品 VP</td>
<td data-field="action">拜访 + 方案交流</td>
<td data-field="priority" class="prio hi">高</td>
<td data-field="reason" class="reason">BT-03 高层为空白节点,且 T04 攻坚联动,需优先建立直接关系</td>
</tr>
<tr>
<td class="num">2</td>
<td class="cust">AvtoKom 汽车电子</td>
<td>质量负责人</td>
<td>技术交流</td>
<td class="prio hi">高</td>
<td class="reason">质量节点空白,BT-02 技术层需加深</td>
</tr>
<tr>
<td class="num">3</td>
<td class="cust">AvtoKom 汽车电子</td>
<td>研发负责人</td>
<td>价值传递(样品)</td>
<td class="prio mid">中</td>
<td class="reason">研发关系尚浅,递样品推进测试</td>
</tr>
<tr>
<td class="num">4</td>
<td class="cust">Boreas 工业控制</td>
<td>采购经理</td>
<td>维持性经营</td>
<td class="prio mid">中</td>
<td class="reason">关系稳定,覆盖度 82 / 100,维持即可</td>
</tr>
</tbody>
</table>
</div>
<div class="flow"><b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。</div>
</div>
<footer class="card-foot">
<span>E04 关系覆盖与关键人经营 · 验收工单 1/2 · 关系经营计划审批</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:验收 · <span data-slot="card.id">WO-E04-02</span></span>
</footer>
</div>
</div>
</body>
</html>
@@ -0,0 +1,150 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>验收工单 2 · 关系台账更新确认 · E04 关系覆盖与关键人经营</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
.criteria li{margin:4px 0}
.criteria b{color:var(--ink)}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.ledger{font-family:var(--mono);font-weight:700;white-space:nowrap}
.fld{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.before{color:var(--ink-4);text-decoration:line-through}
.after{font-weight:800;color:var(--ok)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">验收</div>
<div class="tit">
<div class="kick">E04 关系覆盖与关键人经营 · 验收工单 2/2</div>
<h1>关系台账更新确认</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM Lead(负责人)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">验收工单</span>
<span class="step" data-slot="meta.phase">步骤 ⑥⑦ 更新台账</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【AI 结论说明】</b>—— AI 收到回执后做了哪些更新</p>
<div class="note">
<p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度(coverage_score)、更新决策链地图(decision_chain)、更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。</p>
</div>
<p class="zone"><b>【验收判据】</b>—— 你据此判断「通过 / 驳回」</p>
<div class="criteria">
<div class="c-title">判断这次台账更新是否无误,请逐项核对</div>
<ul>
<li><b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);</li>
<li><b>数值是否合理</b>:coverage_score、亲密度等数值变化幅度与回执是否吻合;</li>
<li><b>有无漏更或误更</b>:该更新的字段都更新了、不该动的没动。</li>
</ul>
</div>
<p class="zone"><b>【拟更新的台账】</b>—— 只读展示</p>
<div class="tbl-wrap">
<table>
<thead><tr><th style="width:36px">#</th><th style="width:150px" data-h="台账">台账</th><th style="width:200px" data-h="字段">字段</th><th style="width:140px" data-h="更新前">更新前</th><th data-h="更新后">更新后</th></tr></thead>
<tbody data-rows="out.ledger_update">
<tr>
<td class="num">1</td>
<td data-field="ledger" class="ledger">L-02 客户主台账</td>
<td data-field="field" class="fld">coverage_score</td>
<td data-field="before" class="before">61 / 100</td>
<td data-field="after" class="after">78 / 100</td>
</tr>
<tr>
<td class="num">2</td>
<td class="ledger">L-02 客户主台账</td>
<td class="fld">decision_chain(产品 VP)</td>
<td class="before">空白节点</td>
<td class="after">已建立直接关系</td>
</tr>
<tr>
<td class="num">3</td>
<td class="ledger">L-02 客户主台账</td>
<td class="fld">interaction_timeline</td>
<td class="before">—</td>
<td class="after">追加 3 次互动记录</td>
</tr>
<tr>
<td class="num">4</td>
<td class="ledger">L-08 联系人台账</td>
<td class="fld">质量负责人 · 亲密度</td>
<td class="before">0 / 5</td>
<td class="after">2 / 5</td>
</tr>
<tr>
<td class="num">5</td>
<td class="ledger">L-08 联系人台账</td>
<td class="fld">质量负责人 · 关键人标记</td>
<td class="before">否</td>
<td class="after">是</td>
</tr>
</tbody>
</table>
</div>
<div class="flow"><b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。</div>
</div>
<footer class="card-foot">
<span>E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:验收 · <span data-slot="card.id">WO-E04-04</span></span>
</footer>
</div>
</div>
</body>
</html>
@@ -0,0 +1,186 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>执行工单 1 · 需求受理与信息补充 · I01 品牌形象与销售工具包管理</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.note .done{color:var(--ok);font-weight:700}
.note .need{color:var(--human);font-weight:700}
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
.jc-editgate-t{flex:1 1 auto;min-width:0}
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff}
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
.jc-editgate.on{background:var(--human-soft);border-color:var(--human-line);color:#7c4a12}
.jc-editgate.on .eg-open{display:none}
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
th.req::after{content:" *";color:var(--danger);font-size:11px}
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
table.on td>.jc-vcell{display:none}
table.on td>.jc-cell{display:block}
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
table.on td.rn .jc-rn-num{display:none}
table.on td.rn .jc-rowdel{display:block}
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.jc-addrow.show{display:flex;align-items:center;gap:8px}
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
.jc-addrow button:hover{background:var(--brand-soft)}
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">执行</div>
<div class="tit">
<div class="kick">SS01 客户与市场 · I01 品牌形象与销售工具包管理 · 执行工单 1/3</div>
<h1>需求受理与信息补充</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM(内容运营)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">执行工单</span>
<span class="step" data-slot="meta.phase">步骤 ① 需求识别与受理</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【做什么】</b>—— 确认本轮要生产的内容</p>
<div class="note">
<p><span class="done">AI 已完成</span>:按规则识别本轮内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)与需求类型(公司介绍 / 能力声明 / 销售工具包 / 选型指南 / 替代对照表 / 案例集 / 白皮书等),产出下方需求清单(<b>候选基线</b>)。</p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」在清单上增删改——<b>加一行 = 补充新需求</b>、<b>删一行 = 排除</b>、<b>改单元格 = 修正</b>;尤其要补「目标客户、适用场景、优先级」这些 AI 规则之外的信息(如使用方要一份方案,但你线下知道要针对某客户做定向版)。</p>
</div>
<p class="zone"><b>【交回什么】</b>—— 核对后的内容需求清单(*为必填)</p>
<div class="jc-editgate" id="editgate">
<span class="jc-editgate-t"><b data-slot="sd.requirement_count">AI 已识别 2 个内容需求</b>,点「编辑」增删改</span>
<button class="jc-egbtn eg-open" type="button">编辑</button>
<button class="jc-egbtn eg-cancel" type="button">取消</button>
<button class="jc-egbtn eg-save" type="button">保存</button>
</div>
<div class="tbl-wrap">
<table id="tbl">
<thead>
<tr>
<th style="width:38px">#</th>
<th class="req" data-h="内容类型">内容类型</th>
<th data-h="需求来源">需求来源</th>
<th data-h="目标客户">目标客户</th>
<th data-h="适用场景">适用场景</th>
<th data-h="优先级">优先级</th>
</tr>
</thead>
<tbody data-rows="in.requirement">
<tr>
<td class="rn"><span class="jc-rn-num">1</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
<td data-field="content_type"><span class="jc-vcell">系统方案白皮书</span><input class="jc-cell" value="系统方案白皮书"></td>
<td data-field="source"><span class="jc-vcell">使用方请求(E04 价值传递)</span><input class="jc-cell" value="使用方请求(E04 价值传递)"></td>
<td data-field="target_customer"><span class="jc-vcell">AvtoKom(定向)</span><input class="jc-cell" value="AvtoKom(定向)"></td>
<td data-field="scene"><span class="jc-vcell">攻坚</span><input class="jc-cell" value="攻坚"></td>
<td data-field="priority"><span class="jc-vcell mono">高</span><input class="jc-cell" value="高"></td>
</tr>
<tr>
<td class="rn"><span class="jc-rn-num">2</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
<td data-field="content_type"><span class="jc-vcell">公司介绍</span><input class="jc-cell" value="公司介绍"></td>
<td data-field="source"><span class="jc-vcell">战略调整(进入俄罗斯市场)</span><input class="jc-cell" value="战略调整(进入俄罗斯市场)"></td>
<td data-field="target_customer"><span class="jc-vcell">—(通用)</span><input class="jc-cell" value="—(通用)"></td>
<td data-field="scene"><span class="jc-vcell">首触</span><input class="jc-cell" value="首触"></td>
<td data-field="priority"><span class="jc-vcell mono">中</span><input class="jc-cell" value="中"></td>
</tr>
</tbody>
</table>
</div>
<div class="jc-addrow" id="addrow">
<button type="button">+ 加一行(补充新需求)</button>
<em>新加行的「内容类型」为必填;「目标客户」填定向客户,通用内容可留「—」</em>
</div>
<div class="flow"><b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。</div>
</div>
<footer class="card-foot">
<span>I01 品牌形象与销售工具包管理 · 执行工单 1/3 · 需求受理</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:执行 · <span data-slot="card.id">WO-I01-01</span></span>
</footer>
</div>
</div>
<script>
(function(){
var gate=document.getElementById('editgate'),tbl=document.getElementById('tbl'),addrow=document.getElementById('addrow'),tbody=tbl.querySelector('tbody');
function setEdit(on){gate.classList.toggle('on',on);tbl.classList.toggle('on',on);addrow.classList.toggle('show',on);}
gate.querySelector('.eg-open').addEventListener('click',function(){setEdit(true);});
gate.querySelector('.eg-cancel').addEventListener('click',function(){
tbody.querySelectorAll('tr').forEach(function(tr){tr.querySelectorAll('td').forEach(function(td){var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell');if(v&&c){c.value=v.textContent;}});});
setEdit(false);
});
gate.querySelector('.eg-save').addEventListener('click',function(){
tbody.querySelectorAll('tr').forEach(function(tr){tr.querySelectorAll('td').forEach(function(td){var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell');if(v&&c){v.textContent=c.value;}});});
renumber();setEdit(false);
});
addrow.querySelector('button').addEventListener('click',function(){
var tr=document.createElement('tr');
tr.innerHTML='<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>'+'<td data-field="content_type"><span class="jc-vcell"></span><input class="jc-cell" placeholder="内容类型"></td>'+'<td data-field="source"><span class="jc-vcell"></span><input class="jc-cell" placeholder="需求来源"></td>'+'<td data-field="target_customer"><span class="jc-vcell"></span><input class="jc-cell" placeholder="目标客户"></td>'+'<td data-field="scene"><span class="jc-vcell"></span><input class="jc-cell" placeholder="适用场景"></td>'+'<td data-field="priority"><span class="jc-vcell mono"></span><input class="jc-cell" placeholder="优先级"></td>';
tbody.appendChild(tr);renumber();
});
tbody.addEventListener('click',function(e){if(e.target.classList.contains('jc-rowdel')){e.target.closest('tr').remove();renumber();}});
function renumber(){tbody.querySelectorAll('tr').forEach(function(tr,i){tr.querySelector('.jc-rn-num').textContent=i+1;});}
})();
</script>
</body>
</html>
@@ -0,0 +1,215 @@
# I01 品牌形象与销售工具包管理 · 活动流程与工单说明
> 所属子系统:SS01 客户与市场 | 优先级:P0 | 类型:内容生产型活动
> 本说明总结 I01 活动的整体流程、工单产出的设计逻辑,以及每一步做了什么。配套工单 HTML 见本目录。
---
## 一、活动定位
**I01 品牌形象与销售工具包管理**是公司对外内容资产的统一管理:公司介绍、能力声明、销售工具包、案例集、品牌调性标准,多语言版本受控发布。
**消费的台账**(读):L-01 宏观市场台账(市场信号、产业趋势——内容创作的市场依据)。
**产出的台账**(写):L-05 品牌内容库(内容资产,落账唯一载荷)。
**触发方式**:① 主动——战略调整 / 进入新市场 / 工具过期 / 月度定期内容更新;② 被动——使用方请求(E04 需要价值传递素材、T03 触达需要材料、T04 攻坚需要定制内容、E03 方案需要支撑)。
---
## 二、整体流程(7 步)
分工原则:**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**。AI 的「读」一律**经读数工具查业务数据服务**(不直接碰台账文件),台账是业务数据服务里的数据对象、不是磁盘文件。
### ① 需求识别与受理
- **AI 做什么**:识别内容需求来源与类型,给出目标客户 / 场景 / 优先级的初步建议,产出「内容需求清单」候选基线。
- **AI 怎么做**:
- 读(经读数工具):L-05 已有资产(content_type / title,判断是否已有类似内容)、L-02 客户(customer_id / cust_type,定向客户识别)
- 加工:把触发事件按规则映射到需求类型(如「E04 请求价值传递素材」→ 系统方案白皮书),匹配适用场景与 BT 层级
- 输出:内容需求清单(content_type、source、target_customer 建议、scene 建议、priority 建议)
- **人做什么**:查漏补缺——补 AI 规则之外的目标客户、场景、优先级、定制需求
### ② 市场依据采集
- **AI 做什么**:采集与本内容相关的市场依据,确定价值主张与差异化定位。
- **AI 怎么做**:
- 读(经读数工具):L-01 宏观市场台账(信号类型 / 标题 / 详情、market_region、industry、chip_category、重要性、影响方向)
- 加工:按市场 / 行业 / 芯片品类筛选相关信号,归纳产业趋势与竞争差异
- 输出:市场依据摘要(趋势 + 价值主张 + 差异化定位)
- **人做什么**:基本无需介入(纯 AI 加工态)
### ③ 内容创作与工具建设
- **AI 做什么**:按 BT 层级草拟内容初稿(多语言)。
- **AI 怎么做**:
- 读(经读数工具):步骤②的市场依据(活动自持中间产物)、L-05 已有资产(避免重复、保持调性一致)、L-05 品牌调性标准
- 加工:按 BT 层级套对应结构——BT-01 供货能力 / 资质文件 / 渠道政策、BT-02 技术规格书 / 替代对照表 / 认证报告、BT-03 系统方案白皮书 / 参考设计 / 成功案例;生成多语言版本
- 输出:内容初稿(标题、正文、语言版本、BT 标签、品牌调性标签)
- **人做什么**:FAE 提供 / 核对技术素材(规格书、认证报告)
### ④ 内部审核与调性把关
- **AI 做什么**:准备审核材料,对照调性标准对初稿做预检、标出可疑点。
- **AI 怎么做**:
- 读(经读数工具):L-05 品牌调性标准;内容初稿(活动自持)
- 加工:比对调性标准(专业 / 可信赖 / 高效 / 本土化),标出可能不符处;标出技术数据待核对项
- 输出:审核材料(初稿 + AI 预检标注)
- **人做什么**:AM Lead 调性把关、FAE 技术审核、合规检查、多语校对(**这是验收,不是执行**)
### ⑤ 发布入库
- **AI 做什么**:生成内容 ID、更新版本号、状态流转、登记场景与 BT 标签,产出「拟发布内容」。
- **AI 怎么做**:
- 读:审核通过的初稿(活动自持)
- 加工:生成 content_id、版本号递增、状态流转(待发布 → 已发布)、登记 scene 与 BT 标签
- 输出:拟发布内容(content_id、title、language、version、scene、status)
- **人做什么**:确认发布入库(落账 L-05)
### ⑥ 分发与使用追踪
- **AI 做什么**:把已发布内容分发给使用方,收集使用反馈。
- **AI 怎么做**:
- 读(经读数工具):L-05 已发布内容 + 使用方调用记录
- 加工:按场景 / BT 标签匹配使用方需求分发;汇总使用频次、客户接受度、触达效果
- 输出:使用反馈汇总
- **人做什么**:基本无需介入
### ⑦ 复盘迭代与完结
- **AI 做什么**:复盘内容效果,识别过时 / 失效内容,提出下架 / 更新建议。
- **AI 怎么做**:
- 读(经读数工具):L-05 内容使用频次 / 版本 / 发布日期 + ⑥的使用反馈
- 加工:计算使用频次、识别过期内容(版本旧 / 长期未用)、生成下架 / 更新建议清单
- 输出:复盘结论(下架清单、更新清单)
- **人做什么**:确认复盘结论、完结
---
## 三、工单划分逻辑(生产流程 vs 复盘流程)
I01 的 7 步里混着**两个不同时间段的流程**:
```
【生产流程 —— 被动触发(使用方请求),几天内完成】
① 需求受理 → ② 采集 → ③ 创作 → ④ 审核 → ⑤ 发布
↓ 内容资产上线 L-05,✅ 生产结束
············ 使用期(几周~几个月,内容被 E04/T03/T04/E03 调用) ············
【复盘流程 —— 独立触发(月度定期 / 工具过期 / 战略调整)】
⑥ 分发追踪 → ⑦ 复盘 → 下架/更新(更新则回到①开新一轮生产)
```
**工单只针对生产流程(①→⑤)**,复盘流程独立拎出,不并入。
生产流程内逐步判断:
| 步骤 | 判断 | 结论 |
|---|---|---|
| ① 需求受理 | AI 识别规则之外的信息(目标客户/场景/优先级/定制)需人补充 | **执行工单** |
| ②③ 采集+创作 | AI 草拟内容初稿(纯加工态) | 不派工单 |
| ④ 审核 | AI 产出内容初稿,人审合不合格 | **验收工单** |
| ⑤ 发布 | AI 拟发布,人确认后落账 | **验收工单** |
**最终:1 执行 + 2 验收。**
### 与 E04 的区别
I01 是**内容生产型**,没有 E04「物理拜访」这种 AI 做不到、必须人去线下执行的环节——内容初稿由 AI 草拟,人只审核不执行。所以 I01 少一个「执行回执」工单,执行工单只有「需求受理」一个。
---
## 四、每个工单详细说明
### 执行工单 1 · 需求受理与信息补充(步骤 ①)
- **做什么**:确认本轮要生产的内容。
- **AI 产出**:按规则识别的内容需求清单(来源 + 类型)。
- **人填什么(查漏补缺)**:在清单上增删改——加行=补充新需求、删行=排除、改格=修正;补「目标客户、适用场景、优先级」等 AI 规则之外的信息。
- **为什么执行**:需求来源/类型 AI 能按规则识别,但「给谁、什么场景、多优先、是否定制」需要人补充。
- **交回**:核对后的内容需求清单(内容类型必填)。
### 验收工单 1 · 内容初稿审核(步骤 ②③④)
- **做什么**:审核 AI 草拟的内容初稿。
- **AI 产出**:读 L-01 市场依据 + 按 BT 层级草拟的多语言内容初稿。
- **人怎么审**:只读,不可修改。按判据「通过 / 驳回」,驳回 AI 重做。
- **验收判据**:① 品牌调性是否一致;② 技术内容是否准确;③ 合规与多语言校对是否通过。
- **为什么验收**:内容是 AI 草拟的结论,人只审合格与否。
### 验收工单 2 · 发布确认(步骤 ⑤)
- **做什么**:确认发布内容资产。
- **AI 产出**:拟发布的内容(版本号、状态流转、场景/BT 标签)。
- **人怎么审**:只读,不可修改。按判据「通过入库 / 驳回」。
- **验收判据**:① 版本号与状态是否正确;② 场景与 BT 标签是否登记完整;③ 多语言版本是否齐全。
- **为什么验收**:发布是写 L-05 的落账动作,需要人确认后入库。
---
## 五、文件对应关系
| 工单文件 | 类型 | 覆盖步骤 | 一句话 |
|---|---|---|---|
| `I01-执行工单-01-需求受理.html` | 执行 | ① | 人补内容需求信息 |
| `I01-验收工单-01-内容初稿审核.html` | 验收 | ②③④ | 人审 AI 的内容初稿 |
| `I01-验收工单-02-发布确认.html` | 验收 | ⑤ | 人确认发布入库 |
**完整闭环**:
```
① 需求受理 ──执行工单1(人补信息)──▶ ②③ AI 采集+草拟 ──验收工单1(审初稿)──▶
④ 审核通过 ──验收工单2(确认发布)──▶ 落账 L-05 ──▶ 生产结束
```
---
## 六、动态数据标注(模板绑定位置)
### 6.1 绑定命名空间速查
| 命名空间 | 含义 | 本活动用到 |
|---|---|---|
| `meta.*` | 任务/活动元数据 | 收件人、任务编号、步骤 |
| `card.*` | 工单身份专属 | 期限、工单编号、验收判据 |
| `out.*` | 输出表单/台账映射行集 | 内容初稿、发布明细 |
| `in.*` | 输入表单 | 内容需求 |
| `sd.*` | 状态数据读数 | 需求计数 |
### 6.2 三个工单共用的头部动态位置
| HTML 位置 | 动态数据 | 绑定地址 |
|---|---|---|
| 收件人 | 负责人 | `meta.task.owner` |
| 关联任务 | 任务编号 | `meta.task.id` |
| 步骤 | 当前节点 | `meta.phase` |
| 最迟交回 | 期限 | `card.deadline` |
| 工单编号 | 工单 ID | `card.id` |
### 6.3 执行工单 1 · 需求受理
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 编辑闸「AI 已识别 N 个内容需求」 | 需求计数 | `sd.requirement_count` | 单值 |
| 内容需求表 | 需求行 | `in.requirement` | 行集 |
| └ 内容类型/来源/目标客户/场景/优先级 | 各列字段 | `in.requirement.*` | 字段 |
### 6.4 验收工单 1 · 内容初稿审核
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 验收判据清单 | 判据项 | `card.criteria[]` | 列表 |
| 内容初稿表 | 初稿行 | `out.draft` | 行集 |
| └ 标题/类型/语言/BT标签/调性 | 各列字段 | `out.draft.*` | 字段 |
### 6.5 验收工单 2 · 发布确认
| HTML 位置 | 动态数据 | 绑定地址 | 类型 |
|---|---|---|---|
| 验收判据清单 | 判据项 | `card.criteria[]` | 列表 |
| 拟发布内容表 | 发布行 | `out.publish` | 行集 |
| └ 内容ID/标题/语言/版本/场景/状态 | 各列字段 | `out.publish.*` | 字段 |
---
(完)I01 一个活动,一条生产流程,三张工单:**执行工单让人补需求,验收工单让人审内容、确认发布**——复盘独立在时间轴之后,不混进生产流程。
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>验收工单 1 · 内容初稿审核 · I01 品牌形象与销售工具包管理</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
.criteria li{margin:4px 0}
.criteria b{color:var(--ink)}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.cust{font-weight:700}
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">验收</div>
<div class="tit">
<div class="kick">SS01 客户与市场 · I01 品牌形象与销售工具包管理 · 验收工单 2/3</div>
<h1>内容初稿审核</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM Lead(负责人)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">验收工单</span>
<span class="step" data-slot="meta.phase">步骤 ②③④ 产出</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【AI 结论说明】</b>—— 结论是怎么算出来的</p>
<div class="note">
<p><b>AI 已完成的计算</b>:读取 L-01 宏观市场信号(产业趋势、市场依据),按 BT 层级草拟内容初稿——BT-01 侧重供货能力 / 资质文件、BT-02 侧重技术规格书 / 替代对照表 / 认证报告、BT-03 侧重系统方案白皮书 / 参考设计 / 成功案例;维护多语言版本(中 / 英 / 俄)。</p>
</div>
<p class="zone"><b>【验收判据】</b>—— 你据此判断「通过 / 驳回」</p>
<div class="criteria">
<div class="c-title">判断这份内容初稿是否合格,请逐项核对</div>
<ul>
<li><b>品牌调性是否一致</b>:是否符合品牌「气质」标准(专业 / 可信赖 / 高效 / 本土化);</li>
<li><b>技术内容是否准确</b>:规格、数据、认证信息有无错误;</li>
<li><b>合规与多语言校对</b>:合规检查、多语言校对是否通过。</li>
</ul>
</div>
<p class="zone"><b>【AI 产出的内容初稿】</b>—— 只读展示</p>
<div class="tbl-wrap">
<table>
<thead><tr><th style="width:40px">#</th><th data-h="内容标题">内容标题</th><th data-h="内容类型">内容类型</th><th data-h="语言版本">语言版本</th><th data-h="BT 标签">BT 标签</th><th data-h="品牌调性">品牌调性</th></tr></thead>
<tbody data-rows="out.draft">
<tr>
<td class="num">1</td>
<td data-field="title" class="cust">RS358 车规 MCU 替代方案白皮书</td>
<td data-field="content_type">系统方案白皮书</td>
<td data-field="language" class="mono">中文 · Русский</td>
<td data-field="bt_tag" class="mono">BT-03</td>
<td data-field="brand_tone" class="mono">专业 · 可信赖</td>
</tr>
</tbody>
</table>
</div>
<div class="flow"><b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后进入发布,驳回由 AI 重做。</div>
</div>
<footer class="card-foot">
<span>I01 品牌形象与销售工具包管理 · 验收工单 2/3 · 内容初稿审核</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:验收 · <span data-slot="card.id">WO-I01-02</span></span>
</footer>
</div>
</div>
</body>
</html>
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>验收工单 2 · 发布确认 · I01 品牌形象与销售工具包管理</title>
<style>
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
.criteria li{margin:4px 0}
.criteria b{color:var(--ink)}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.cust{font-weight:700}
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.before{color:var(--ink-4);text-decoration:line-through}
.after{font-weight:800;color:var(--ok)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
</style>
</head>
<body>
<div class="page">
<div class="card">
<header class="card-head">
<div class="badge">验收</div>
<div class="tit">
<div class="kick">SS01 客户与市场 · I01 品牌形象与销售工具包管理 · 验收工单 3/3</div>
<h1>发布确认</h1>
<div class="sub">收件人:<span data-slot="meta.task.owner">AM Lead(负责人)</span> · 关联任务:<span data-slot="meta.task.id">TASK-xxx</span></div>
</div>
<div class="side">
<span class="tag">验收工单</span>
<span class="step" data-slot="meta.phase">步骤 ⑤ 发布入库</span>
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
</div>
</header>
<div class="card-bd">
<p class="zone"><b>【AI 结论说明】</b>—— AI 做了什么</p>
<div class="note">
<p><b>AI 已完成</b>:内容初稿通过审核后,拟正式发布——更新版本号、状态流转(待发布 → 已发布)、登记适用场景与 BT 标签,写入 L-05 品牌内容库。</p>
</div>
<p class="zone"><b>【验收判据】</b>—— 你据此判断「通过 / 驳回」</p>
<div class="criteria">
<div class="c-title">判断这次发布是否无误,请逐项核对</div>
<ul>
<li><b>版本号与状态是否正确</b>:版本号已更新、状态流转(草稿 → 已审核 → 已发布)无误;</li>
<li><b>场景与 BT 标签是否登记完整</b>;</li>
<li><b>多语言版本是否齐全</b>。</li>
</ul>
</div>
<p class="zone"><b>【拟发布的内容】</b>—— 只读展示</p>
<div class="tbl-wrap">
<table>
<thead><tr><th style="width:40px">#</th><th data-h="内容 ID">内容 ID</th><th data-h="标题">标题</th><th data-h="语言版本">语言版本</th><th data-h="版本号">版本号</th><th data-h="适用场景">适用场景</th><th data-h="状态流转">状态流转</th></tr></thead>
<tbody data-rows="out.publish">
<tr>
<td class="num">1</td>
<td data-field="content_id" class="mono">CT-2025-0088</td>
<td data-field="title" class="cust">RS358 车规 MCU 替代方案白皮书</td>
<td data-field="language" class="mono">中文 · Русский</td>
<td data-field="version" class="mono">v1.2</td>
<td data-field="scene">攻坚</td>
<td data-field="status"><span class="before">待发布</span> → <span class="after">已发布</span></td>
</tr>
</tbody>
</table>
</div>
<div class="flow"><b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。通过即落账写入 L-05,本次内容生产结束。</div>
</div>
<footer class="card-foot">
<span>I01 品牌形象与销售工具包管理 · 验收工单 3/3 · 发布确认</span>
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:验收 · <span data-slot="card.id">WO-I01-03</span></span>
</footer>
</div>
</div>
</body>
</html>
+44 -3
View File
@@ -74,11 +74,17 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照
## 工作成果 · 文件 ## 工作成果 · 文件
- **Markdown 只以原文纸面呈现**:富文本渲染(GFM 表格 / 内联 SVG)要 `react-markdown` + - ~~**Markdown 只以原文纸面呈现**~~(2026-08-23 销账):渲染接上了,而且**一个依赖都没引** ——
`remark-gfm` + `rehype-raw` 三个依赖,当前成果文件绝大多数是 HTML,先不引。 基座那一段 `markdownBody`(`file-compose.ts`)把子集(标题 / 段落 / 有序无序列表 /
引入前不假装渲染过 —— FileViewer 里以 `<pre>` 出原文。 GFM 表格 / 分隔线 / 粗体 / 行内代码)转成固定几种标签,全程转义,再走同一个沙箱容器渲。
与下载到的 .html 是同一段函数出的,两处看着是同一份文件。
`FileDoc.markdown.html` 缺席时仍退回 `<pre>` 原文 —— **不假装渲染过**这条没变。
仍不认的:内联 SVG、图片、引用块、代码块。认得越多,docx / xlsx 那一侧要对应的越多。
- **过程成果(deliverable)尚无来源**:活动还不发布 deliverable,所以成果文件目前 - **过程成果(deliverable)尚无来源**:活动还不发布 deliverable,所以成果文件目前
只有活动推的那一屏。装配已接好(`desk.ts` 的 `results`),活动一产出就自动进列表。 只有活动推的那一屏。装配已接好(`desk.ts` 的 `results`),活动一产出就自动进列表。
**注**:2026-08-23 起活动**另有一条产文件的路** —— 台账对象声明 `file` 段,件上挂
`data-file-of`,人在工作区点资料名即开(I01 首用)。那条路不经 `deliverable`:
资料的正身是台账那一行,文件是它的投影。两条路要不要并成一条,等第二个活动要用时再说。
## 沟通记录(占位) ## 沟通记录(占位)
@@ -952,3 +958,38 @@ FAE 从头到尾没被叫过,单子照样 released(去向还对)。案例②③
落草稿层,主键靠行内 `data-col` 的主键格给出 —— 怀疑点在**主键补齐**那一段 落草稿层,主键靠行内 `data-col` 的主键格给出 —— 怀疑点在**主键补齐**那一段
(A2 走查逮过一次「主键格没挂 data-col → 服务端按全表一致列补键 → 两行对到同一行」)。 (A2 走查逮过一次「主键格没挂 data-col → 服务端按全表一致列补键 → 两行对到同一行」)。
复现要碰运气,建议下次带着「保存请求体 + 落库后草稿行」两份留痕对着看。 复现要碰运气,建议下次带着「保存请求体 + 落库后草稿行」两份留痕对着看。
## 2026-08-23 · E04 / I01 的件没有窄屏改版,只能横着滑
**现状**:两包的 `.jc-tbl-wrap` 已改成 `overflow-x: auto`(用户报「表格显示不全、不能左右
滑动」),宽表在 586px 的工作区里滑得动。**但这只是没被裁掉,不是排得好** ——
quote-* 六包各有一段 `@media (max-width: 960px)`:表头收起、**一行变一张卡**(`display:block`
+ `td::before` 摆列名),在工作区那个宽度里根本不需要横滑。E04/I01 **一条媒体查询都没有**。
**为什么先只做滑动**:用户这一轮要的就是「能左右滑」,且两份件的结构是照 `e04-html` /
`i01-html` 目标件 1:1 搬来的(宽表版式)。改成卡片流是版式决策,得先有目标件或用户点头。
**下次做的话**:照 quote-intake §表段那 20 行抄一份(注意 `@media` 要写在基础规则**之后**,
A2 踩过「媒体查询被基础规则盖掉」),两包各自的编辑态孪生件与红带都要跟着验一遍。
## ~~2026-08-23 · 试跑单放行之后,资料文件点不开~~(当日销账)
**现象**:I01 的资料文件在办中点得开(审核那一屏逐份打开、下载都正常),
**试跑单放行之后**再从工作成果里点同一份,404。
**根因**(不是 bug,是两条既有规矩撞在一起):资料这一行落在 `content.asset` 上,
而它是**主数据**;试跑单放行时主数据只留在活动缓存、不落正式表
(ledger 的 `simulation` 那段:免得试跑往品牌内容库里塞真客户/真资产)。
文件是台账那一行的投影 —— 行不在,文件自然造不出来。**正式单不受此限**
(放行合并进正式表,归档件上照旧点得开)。
**已做**:404 的话分两种说法(在办中「可能已被撤掉」/ 已收工「若是试跑单,主数据不落正式表」),
不拿一句话把人支去翻一行根本不会在的东西。
**已治**(当日,用户「检查当前产出的附件是否存档」逼出来的):走的是上面那条 ①——
放行落地**之前**(人的修订刚落回草稿层)把每一份资料现造成字节,存进
`sys.task_asset_file`(迁移 0029),一单一行一份、重复放行覆盖。
取文件那条路**存档优先**:归档件上点开的必须是他签字那一刻的那一份,
而不是台账后来被下一单改成的样子。工作成果那一屏另起一摞「产出的资料」,
点开即预览、可下载原件。冻结这件事就此定了:**归档的资料与台账脱钩** ——
台账后来改了,归档件不改口。
+11
View File
@@ -126,6 +126,17 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
注意 `expect` 的边界:**我方写的注(如备注列)不算** —— 标了它 `_gaps` 恒大于 0, 注意 `expect` 的边界:**我方写的注(如备注列)不算** —— 标了它 `_gaps` 恒大于 0,
那条判据永远绿不了,门就废了。 那条判据永远绿不了,门就废了。
- **一行还可以是一份资料文件**(对象声明的 `file` 段,2026-08-23 加,I01 首用):
`file: { titleColumn, bodyColumn, formatColumn }` —— 正文一律 **Markdown 原文**
落在 `bodyColumn`,格式列写 `md` / `html` / `docx` / `xlsx`。基座按它**现造**真字节
(docx / xlsx 是自己打的 OOXML 包,Word / Excel 打得开),件上给资料名挂
`data-file-of="主键列"` 人就点得开(详见《HTML报告生成规范》§4)。
两条纪律:
· **不另存一份文件** —— 资料的正身是台账那一行,另存即同一份事实两处各存一份;
· 这一段**同时是授权面**,没声明的表点不开(基座 403),与「写看台账声明」同款。
配套把 `bodyColumn` / `formatColumn` 都标 `required`:少一样,那一行就不是文件、
只是一行标题 —— 而 `_missing` 数得出来,收步判据与放行门自然拦得住。
### 3.2 基座读数(机检的唯一合法数源) ### 3.2 基座读数(机检的唯一合法数源)
任务视图顶层永远铺着这些数,**全部由基座现算,模型碰不着**: 任务视图顶层永远铺着这些数,**全部由基座现算,模型碰不着**:
+1
View File
@@ -34,6 +34,7 @@
"drizzle-kit": "^0.31.5", "drizzle-kit": "^0.31.5",
"eslint": "^9.39.0", "eslint": "^9.39.0",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^30.0.1",
"pg": "^8.22.0", "pg": "^8.22.0",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+6
View File
@@ -524,6 +524,12 @@ export const listActivitiesRoute = {
label: z.string().optional(), label: z.string().optional(),
/** `file` = 这一格收**真文件**(界面给文件选择器);缺席 = 普通文本格 */ /** `file` = 这一格收**真文件**(界面给文件选择器);缺席 = 普通文本格 */
format: z.string().optional(), format: z.string().optional(),
/** 声明了枚举 → 界面给下拉选,不给自由文本框猜合法值 */
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
/** 示例值(JSON Schema `examples` 第一项)—— 占位提示用,比裸类型名能抄 */
example: z.string().optional(),
/** 声明了 → 只在另一格(`field`)等于 `equals` 时才出现(级联下拉那类场景) */
showWhen: z.object({ field: z.string(), equals: z.string() }).optional(),
}), }),
), ),
/** /**
+57
View File
@@ -157,6 +157,40 @@ export const taskDetailSchema = z.object({
) )
.default([]), .default([]),
workOrders: z.array(workOrderSchema), workOrders: z.array(workOrderSchema),
/**
* **答过的决策卡,答完那一刻的报告快照**(2026-08-21)。
*
* `decide` 是唯一能循环的中间验收机制(`issueAcceptance` 一个任务只能发一次),
* 它答完不产生工单行 —— 工作成果那一屏要看见这些报告,得走这条**单独的**口:
* 混进 `workOrders` 会连累别处「`mode==='acceptance'` 恒是终局放行卡」的假设
* (如 `signed` 那处「字签下了正在落地」的判断)。呈现时前端把两份列表并显即可。
*/
decidedDeliverables: z.array(workOrderSchema).default([]),
/**
* **这一单产出的资料文件** —— 工作成果里与定稿报告并列的一摞(2026-08-23)。
*
* 它不是工单快照:工单是「叫了人办的一件事」,资料是**办出来的东西本身**
* (白皮书 / 对照表 / 落地页)。两者混进一摞会让「几份成果」这个数说不清。
* 在办期间来自台账现造,放行之后来自存档 —— 两处都按 `address + key` 认,
* 点开走的是同一条路(`openAssetFileRoute`)。
*/
assetFiles: z
.array(
z.object({
/** 台账地址 `域.对象` */
address: z.string(),
/** 主键(多列主键用 `列=值` 以 `,` 连)—— 与 `data-file` 里那一串同一形状 */
key: z.string(),
title: z.string(),
/** md | html | docx | xlsx */
format: z.string(),
name: z.string(),
/** 已存档的带上时刻;在办中的没有 */
at: z.string().optional(),
archived: z.boolean(),
}),
)
.default([]),
chat: z.array(chatMessageSchema), chat: z.array(chatMessageSchema),
view: z.array(viewBlockSchema), view: z.array(viewBlockSchema),
/** 活动推送的视图 HTML —— 容器沙箱渲染,无脚本执行权(AM 07) */ /** 活动推送的视图 HTML —— 容器沙箱渲染,无脚本执行权(AM 07) */
@@ -286,6 +320,29 @@ export const saveTableRoute = {
output: z.object({ ok: z.boolean(), rows: z.number().int().min(0) }), output: z.object({ ok: z.boolean(), rows: z.number().int().min(0) }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/**
* **点开一份资料文件** —— 件上的资料名指向哪一行,这里就把那一行现造成文件交出去。
*
* 为什么按「台账地址 + 主键」取,而不是给文件发一个 id:资料的正身是台账里那一行
* (标题、正文、要哪种格式都在上头),文件是它的投影。发 id 就等于另存一份,
* 同一份事实两处各存一份 —— 台账改了,那个 id 指的还是旧的。
*
* `raw=1` 交原字节(下载,真 .docx / .xlsx);不带就交 `FileDoc`,前端照它渲。
*/
export const openAssetFileRoute = {
method: 'GET',
path: '/api/desk/tasks/:taskId/file',
params: z.object({ taskId: taskIdSchema }),
query: z.object({
/** `域.对象` —— 必须是活动清单里声明过 `file` 段的那张表(授权面) */
address: z.string().min(3),
/** 主键值(多列主键用 `列=值` 以 `,` 连;单列主键直接给值) */
key: z.string().min(1),
raw: z.string().optional(),
}),
output: z.object({ file: fileDocSchema, fileName: z.string(), format: z.string() }),
} as const satisfies RouteContract;
/** 负责人的自由输入 —— 人事件的第二形态 */ /** 负责人的自由输入 —— 人事件的第二形态 */
export const sayRoute = { export const sayRoute = {
method: 'POST', method: 'POST',
+19 -1
View File
@@ -86,7 +86,15 @@ const fileDocSchema = z.discriminatedUnion('kind', [
* 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。 * 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。
*/ */
z.object({ kind: z.literal('html'), name: z.string(), html: z.string() }), z.object({ kind: z.literal('html'), name: z.string(), html: z.string() }),
z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string() }), /**
* Markdown —— `md` 是**原文**(下载得到的就是它),`html` 是基座渲好的那一份。
*
* 为什么渲染要在基座做:HTML 一律经沙箱呈现,而 Markdown 若在前端拼 DOM 就得
* `dangerouslySetInnerHTML`,那是把模型写的字直接注进父文档。基座渲的这一份
* 全程转义、只吐固定几种标签,再经同一个沙箱摆出来,与别的 HTML 件同一条路。
* `html` 缺席不是错误,是「这一份还没渲」—— 查看面照旧摆原文,不假装渲染过。
*/
z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string(), html: z.string().optional() }),
z.object({ z.object({
kind: z.literal('excel'), kind: z.literal('excel'),
name: z.string(), name: z.string(),
@@ -514,6 +522,16 @@ export const decisionCardSchema = z.object({
note: z.string().optional(), note: z.string().optional(),
}) })
.optional(), .optional(),
/**
* **答完那一刻的件快照**(与 `workOrderBase.finalHtml` 同一条道理)。
*
* `decide` 是唯一能循环的验收机制(`issueAcceptance` 一个任务只能发一次,
* 中间验收点只能靠决策卡),但决策卡本身不是工单 —— 没有这个字段的话,
* 人在这张卡上通过的那份报告(如 E04 的经营计划)永远进不了工作成果:
* 那一屏只认 `sys.work_order`,而这一签根本没有工单行。
* 回传那一刻基座把当时渲好的件原样存这里,工作成果拼装时并进同一份列表。
*/
finalHtml: z.string().optional(),
}); });
export type DecisionCard = z.infer<typeof decisionCardSchema>; export type DecisionCard = z.infer<typeof decisionCardSchema>;
+22
View File
@@ -195,6 +195,28 @@ export const objectDeclarationSchema = z.object({
* 安装期就能核:它在不在 fields 里、是不是主键的一部分。 * 安装期就能核:它在不在 fields 里、是不是主键的一部分。
*/ */
caseKey: identifier.optional(), caseKey: identifier.optional(),
/**
* **这张表的每一行还是一份资料文件** —— 哪几列合起来是那份文件(2026-08-23 加)。
*
* 起因:I01 要「真实产出资料文件」(word/excel/md/html,人点开就能看)。文件的
* 正身是**内容**,而内容本来就该落在台账里 —— 再单独存一份文件字节,同一份事实
* 两处各存一份,改一处就分叉。所以这里只声明「这一行的哪一列是标题、哪一列是正文、
* 哪一列说要出哪种格式」,字节由基座按声明**现造**(`composeFile`):
* 下载拿到的是真 .docx/.xlsx(能用 Word/Excel 打开),而它与台账永远一致。
*
* 它同时是**授权面**:没声明这一段的表,谁也点不出文件来 —— 与「写的授权面是
* 台账声明」「立监听的授权面是清单声明」同款。
*/
file: z
.object({
/** 文件名取哪一列(空了退主键) */
titleColumn: identifier,
/** 正文取哪一列 —— 一律 Markdown 原文,四种格式都由它现造 */
bodyColumn: identifier,
/** 出哪种格式取哪一列(值:md / html / docx / xlsx) */
formatColumn: identifier,
})
.optional(),
fields: z.array(fieldDeclarationSchema).min(1), fields: z.array(fieldDeclarationSchema).min(1),
}); });
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>; export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
@@ -0,0 +1,196 @@
/**
* **手写控件嵌套多一层,`collect()` 按行归组不能错行**(2026-08-21 修死)。
*
* 自动孪生的格子是 `<td><input data-col></td>`,往上两层正好是 `<tr>`;
* 手写的三选一(A2 替代等级、A4 决策卡这类)套了一层分组容器
* (`<td><div class="jc-radiogrp"><label class="jc-rop"><input type=radio></label></div></td>`,
* input 到 `<tr>` 隔了 4 层)——`cell.parentElement?.parentElement` 这个定深两层的老算法
* 只能爬到那层 `<div class="jc-radiogrp">`,爬不到真正的行。E04 的执行回执表就是这样
* 栽的:一次改 3 行的完成状态,保存却只留下 1 行(其余两行的单选组各自拆成了
* 不带主键的伪行,服务端按"随便找到的第一行"兜底,写来写去只剩一行生效)。
*
* 修法是 `rowOf`:从格子往上走到 host(`data-edit` 容器)的**直接子元素**为止,
* 不管中间嵌了几层都能对上真正的那一行。这里直接用真实 DOM(jsdom,不是手搭的假对象)
* 复刻这个嵌套形状,断言 `collect()` 认出的是 3 行而不是被拆散的碎片。
*
* 完成状态这一列本身已经从三选一 pill 改成了原生 `<select>`(2026-08-21,见
* `report.ts`),不再嵌那层 `.jc-radiogrp` div——但 `rowOf` 的修法是通用的,
* 不管嵌几层都对;这里的 `.jc-radiogrp` 用例代表仍在用手写三选一的其余活动
* (A2/A4 那几张卡),证明这条路对它们同样成立。
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { collect, fileRefOf, unanswered } from '../src/ViewContainer.tsx';
/** 三行,每行一个手写三选一(照 A2/A4 现存的 `.jc-radiogrp` 结构,input 距 <tr> 四层) */
function radioTable(): Document {
const doc = document.implementation.createHTMLDocument('');
// name 按行缀 __rN(照 view-render.ts 的 uniqueNames())—— 不缀的话 jsdom 与真浏览器
// 一样,把同名 radio 当一组处理,勾选第 2 行会顺手把第 1 行刚勾的那个悄悄取消掉
const row = (code: string, i: number): string => `
<tr>
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
<td class="req">
<div class="jc-radiogrp">
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="done"><span>完成</span></label>
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="partial"><span>部分完成</span></label>
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="skipped"><span>未执行</span></label>
</div>
</td>
</tr>`;
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
${row('CU-0142-01', 0)}${row('CU-0087-02', 1)}${row('CU-0087-01', 2)}
</tbody></table>`;
return doc;
}
describe('collect():三选一嵌了一层分组容器,三行各自的完成状态不许串行/丢主键', () => {
it('三行都点了不同的选项,一次 collect 认出三行,主键各自带对(老算法在这里会把三行拆成更多、且各自缺主键)', () => {
const doc = radioTable();
const rows = Array.from(doc.querySelectorAll('tr'));
(rows[0]!.querySelector('input[value="done"]') as HTMLInputElement).checked = true;
(rows[1]!.querySelector('input[value="partial"]') as HTMLInputElement).checked = true;
(rows[2]!.querySelector('input[value="skipped"]') as HTMLInputElement).checked = true;
const got = collect(doc, 'crm_coverage.receipt');
expect(got).toHaveLength(3);
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
expect(byCode.get('CU-0142-01')?.['completion_status']).toBe('done');
expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('partial');
expect(byCode.get('CU-0087-01')?.['completion_status']).toBe('skipped');
});
it('只点了 1 行的单选,另外两行没点(没答)—— 保存只交出那一行的新值,其余两行原值不动', () => {
const doc = radioTable();
const rows = Array.from(doc.querySelectorAll('tr'));
(rows[1]!.querySelector('input[value="done"]') as HTMLInputElement).checked = true;
const got = collect(doc, 'crm_coverage.receipt');
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('done');
expect(byCode.get('CU-0142-01')).not.toHaveProperty('completion_status');
expect(byCode.get('CU-0087-01')).not.toHaveProperty('completion_status');
});
});
/** 照 report.ts 现在的 RECEIPT_TABLE:完成状态是原生 select,直接是 <td> 的子节点 */
function selectTable(): Document {
const doc = document.implementation.createHTMLDocument('');
const row = (code: string): string => `
<tr>
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
<td class="req">
<select class="jc-cell" data-col="completion_status" required>
<option value=""></option>
<option value="done">完成</option>
<option value="partial">部分完成</option>
<option value="skipped">未执行</option>
</select>
<span class="jc-tag"></span>
</td>
</tr>`;
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
${row('CU-0142-01')}${row('CU-0087-02')}${row('CU-0087-01')}
</tbody></table>`;
return doc;
}
describe('collect() 与 unanswered():下拉换掉三选一之后,同样的多行独立编辑要成立', () => {
it('三行各选一个完成状态,三行都收得到、互不串行', () => {
const doc = selectTable();
const selects = Array.from(doc.querySelectorAll<HTMLSelectElement>('select[data-col="completion_status"]'));
selects[0]!.value = 'done';
selects[1]!.value = 'partial';
selects[2]!.value = 'skipped';
const got = collect(doc, 'crm_coverage.receipt');
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
expect(byCode.get('CU-0142-01')?.['completion_status']).toBe('done');
expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('partial');
expect(byCode.get('CU-0087-01')?.['completion_status']).toBe('skipped');
});
it('下拉停在空占位 → unanswered() 为真,collect() 跳过这一列(没答=不改,不是清空)', () => {
const doc = selectTable();
const select = doc.querySelector<HTMLSelectElement>('select[data-col="completion_status"]')!;
expect(unanswered(select)).toBe(true);
const got = collect(doc, 'crm_coverage.receipt');
expect(got.find((r) => r['contact_code'] === 'CU-0142-01')).not.toHaveProperty('completion_status');
});
it('下拉选了具体值 → unanswered() 为假', () => {
const doc = selectTable();
const select = doc.querySelector<HTMLSelectElement>('select[data-col="completion_status"]')!;
select.value = 'done';
expect(unanswered(select)).toBe(false);
});
it('普通文本输入永远算已答(没有"没答"这一态)', () => {
const doc = selectTable();
const input = doc.querySelector<HTMLInputElement>('input[data-col="contact_code"]')!;
expect(unanswered(input)).toBe(false);
});
});
/** 照 E04 目标件的执行回执:结果摘要 / 客户反馈是 textarea(一句话装不下) */
function textareaTable(): Document {
const doc = document.implementation.createHTMLDocument('');
const row = (code: string): string => `
<tr>
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
<td class="req"><textarea class="jc-cell" data-col="result_summary" placeholder="谈了什么"></textarea></td>
</tr>`;
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
${row('CU-0142-01')}${row('CU-0087-02')}
</tbody></table>`;
return doc;
}
describe('collect():textarea 与文本输入同源同待 —— E04 回执的结果摘要照目标件是多行文本', () => {
it('人在 textarea 里写的话收得到,主键对得上行', () => {
const doc = textareaTable();
const areas = Array.from(doc.querySelectorAll<HTMLTextAreaElement>('textarea[data-col="result_summary"]'));
areas[0]!.value = '见到了产品 VP,达成样品测试意向';
areas[1]!.value = '技术交流完成,对方提了认证问题';
const got = collect(doc, 'crm_coverage.receipt');
const byCode = new Map(got.map((r) => [r['contact_code'], r]));
expect(byCode.get('CU-0142-01')?.['result_summary']).toBe('见到了产品 VP,达成样品测试意向');
expect(byCode.get('CU-0087-02')?.['result_summary']).toBe('技术交流完成,对方提了认证问题');
});
it('textarea 与文本输入同款:永远算已答(空着收空串,语义同 input)', () => {
const doc = textareaTable();
const area = doc.querySelector<HTMLTextAreaElement>('textarea[data-col="result_summary"]')!;
expect(unanswered(area)).toBe(false);
});
});
/**
* **件上的资料名点开哪一份** —— `data-file` 的解析(2026-08-23)。
* 认不出就不认:点不动比发一个取不回来的请求好(界面不撒谎)。
*/
describe('fileRefOf —— data-file 解析', () => {
it('域.对象/主键值 → 地址与主键各归各位', () => {
expect(fileRefOf('content.asset/CT-2026-0001')).toEqual({ address: 'content.asset', key: 'CT-2026-0001' });
});
it('主键值里带斜杠照样归主键 —— 只认第一个斜杠', () => {
expect(fileRefOf('content.asset/round=R/1')).toEqual({ address: 'content.asset', key: 'round=R/1' });
});
it('多列主键写 列=值,值 —— 原样交给服务端认', () => {
expect(fileRefOf('c.a/round_ref=BR-1,req_seq=2')).toEqual({ address: 'c.a', key: 'round_ref=BR-1,req_seq=2' });
});
it('缺一半 / 形状不对 / 空的:一律不认', () => {
expect(fileRefOf(null)).toBeUndefined();
expect(fileRefOf('')).toBeUndefined();
expect(fileRefOf('content.asset')).toBeUndefined();
expect(fileRefOf('content.asset/')).toBeUndefined();
expect(fileRefOf('/CT-1')).toBeUndefined();
expect(fileRefOf('不是地址/CT-1')).toBeUndefined();
});
});
+1
View File
@@ -17,6 +17,7 @@ const GLYPHS: Record<string, React.ReactNode> = {
"sliders-horizontal": (<><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></>), "sliders-horizontal": (<><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></>),
filter: (<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />), filter: (<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />),
"arrow-left": (<><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></>), "arrow-left": (<><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></>),
download: (<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></>),
"file-spreadsheet": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M8 13h2" /><path d="M14 13h2" /><path d="M8 17h2" /><path d="M14 17h2" /></>), "file-spreadsheet": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M8 13h2" /><path d="M14 13h2" /><path d="M8 17h2" /><path d="M14 17h2" /></>),
"file-type": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M9 13v-1h6v1" /><path d="M11 18h2" /><path d="M12 12v6" /></>), "file-type": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M9 13v-1h6v1" /><path d="M11 18h2" /><path d="M12 12v6" /></>),
wrench: (<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />), wrench: (<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />),
+107 -25
View File
@@ -38,6 +38,13 @@ export type ViewContainerProps = {
readonly editable?: boolean; readonly editable?: boolean;
/** 人按下「保存」时把那张表收上来;不传就是纯呈现 */ /** 人按下「保存」时把那张表收上来;不传就是纯呈现 */
readonly onEdits?: (save: ViewSave) => void; readonly onEdits?: (save: ViewSave) => void;
/**
* **件上的资料名被点了** —— 收到 `data-file="域.对象/主键值"` 那一下。
*
* 容器只报「点的是哪一份」,取文件、渲文件都不在这儿:这份组件的本分是
* 把沙箱里发生的事如实交出去,至于跳到哪一屏是工作区的事。
*/
readonly onOpenFile?: (ref: { address: string; key: string }) => void;
/** /**
* **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告, * **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告,
* 合理利用右上角的翻页」):件里带 `data-page` 的可见段各算一页, * 合理利用右上角的翻页」):件里带 `data-page` 的可见段各算一页,
@@ -103,7 +110,12 @@ function morphNode(oldEl: Element, newEl: Element): void {
const bothElements = o.nodeType === Node.ELEMENT_NODE && n.nodeType === Node.ELEMENT_NODE; const bothElements = o.nodeType === Node.ELEMENT_NODE && n.nodeType === Node.ELEMENT_NODE;
if (!bothElements) { if (!bothElements) {
if (o.nodeType !== n.nodeType) (o as ChildNode).replaceWith(n); if (o.nodeType !== n.nodeType) (o as ChildNode).replaceWith(n);
else if (o.textContent !== n.textContent) o.textContent = n.textContent; else if (o.textContent !== n.textContent) {
o.textContent = n.textContent;
// textarea 的拟值在正文:拟值真变了,活值跟上(与 input 的 value 同一条规矩)
const owner = o.parentElement;
if (owner?.tagName === 'TEXTAREA') (owner as HTMLTextAreaElement).value = n.textContent ?? '';
}
continue; continue;
} }
morphNode(o as Element, n as Element); morphNode(o as Element, n as Element);
@@ -154,13 +166,42 @@ function bodyOf(doc: Document, address: string): HTMLElement | null {
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`); return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
} }
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内 */ /**
function cellsOf(doc: Document, address: string): HTMLInputElement[] { * 一个格子所在的那一行 —— **host 的直接子元素**,不管格子本身嵌了几层。
*
* 自动孪生的格子(`twinInputs` 写出来的)是 `<td><input></td>`,往上两层正好是行;
* 手写的三选一(`.jc-radiogrp` 套一层 `<label>`)是 `<td><div><label><input></label></div></td>`,
* 往上两层只到得了那个 radio 组的 `<div>` —— **每个 radio 组各算一行**,同一张表格里
* 三组三行、真正的行反倒被拆成了 N 个碎片,主键那一列(在别的 td 里)一个也进不去这些碎片。
* 一路往上走到 host 的直接子元素为止,不管中间嵌了几层都能对上真正的那一行。
*/
function rowOf(host: Element, cell: Element): Element | null {
let node: Element | null = cell;
while (node !== null && node.parentElement !== host) node = node.parentElement;
return node;
}
/** 可改格子的三种控件 —— 文本输入 / 下拉 / 多行文本,同源同待 */
type EditCell = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内。下拉与多行文本(textarea)与文本输入同源同待 */
function cellsOf(doc: Document, address: string): EditCell[] {
const host = bodyOf(doc, address); const host = bodyOf(doc, address);
if (host === null) return []; if (host === null) return [];
return Array.from(host.querySelectorAll<HTMLInputElement>('input[data-col]')).filter( return Array.from(
(i) => i.closest('.jc-row-gone') === null, host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
); ).filter((i) => i.closest('.jc-row-gone') === null);
}
/**
* **「没答」= 「不改」**——radio 一组都没点、下拉停在空占位,是同一句话的两种写法。
* 三选一没有第四态可以表达「维持原值」,只能靠「没有一个是选中的」;下拉同理,靠
* `<option value="">` 这个空占位表达「还没选」。跳过这一格,原值在台账里原样留着。
*/
export function unanswered(cell: EditCell): boolean {
if (cell.type === 'radio') return !(cell as HTMLInputElement).checked;
if (cell.type === 'select-one') return cell.value === '';
return false;
} }
/** /**
@@ -170,19 +211,21 @@ function cellsOf(doc: Document, address: string): HTMLInputElement[] {
* (删掉第 2 行,余下的仍是 0/2/3 —— 拿它当下标会在中间留一个洞)。 * (删掉第 2 行,余下的仍是 0/2/3 —— 拿它当下标会在中间留一个洞)。
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。 * 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
*/ */
function collect(doc: Document, address: string): Array<Record<string, string>> { export function collect(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address);
if (host === null) return [];
const byRow = new Map<Element, Record<string, string>>(); const byRow = new Map<Element, Record<string, string>>();
for (const cell of cellsOf(doc, address)) { for (const cell of cellsOf(doc, address)) {
const col = cell.dataset['col']; const col = cell.dataset['col'];
const row = cell.parentElement?.parentElement ?? cell.parentElement; const row = rowOf(host, cell);
if (col === undefined || row == null) continue; if (col === undefined || row == null) continue;
/* /*
* **radio 选项:没选中的不算话,一组都没选 = 这一列不动**(2026-08-02, * **radio 选项 / 下拉:没答的不算话,没答 = 这一列不动**(2026-08-02,
* A2 替代等级三选)。radio 的 `value` 是选项值,恒有 —— 照 text 那样读, * A2 替代等级三选;2026-08-20 扩到下拉)。radio 的 `value` 是选项值,恒有;
* 三个选项互相覆盖、没人点过也会交出一个值,把台账里的原值抹掉; * 下拉一旦渲染也恒有一个 `.value`(哪怕是空占位)—— 都照 text 那样读的话,
* 跳过整列则原值保留:「没点」的意思是「不改」,不是「清空」。 * 没人碰过也会交出一个值,把台账里的原值抹掉;跳过则原值保留。
*/ */
if (cell.type === 'radio' && !cell.checked) continue; if (unanswered(cell)) continue;
const one = byRow.get(row) ?? {}; const one = byRow.get(row) ?? {};
one[col] = cell.value; one[col] = cell.value;
byRow.set(row, one); byRow.set(row, one);
@@ -212,10 +255,10 @@ function collect(doc: Document, address: string): Array<Record<string, string>>
*/ */
function mirrorTwins(doc: Document, address: string): void { function mirrorTwins(doc: Document, address: string): void {
for (const cell of cellsOf(doc, address)) { for (const cell of cellsOf(doc, address)) {
// 与 collect 同一条规矩:没选中的 radio 不算话(「没点」= 不改) // 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改)
if (cell.type === 'radio' && !cell.checked) continue; if (unanswered(cell)) continue;
const twin = cell.previousElementSibling; const twin = cell.previousElementSibling;
if (twin === null || twin.tagName === 'INPUT') continue; if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue;
twin.textContent = cell.value; twin.textContent = cell.value;
} }
} }
@@ -248,7 +291,9 @@ function addRow(doc: Document, address: string): boolean {
const fresh = last.cloneNode(true) as HTMLElement; const fresh = last.cloneNode(true) as HTMLElement;
fresh.classList.remove('jc-row-gone'); fresh.classList.remove('jc-row-gone');
fresh.classList.add('jc-row-new'); fresh.classList.add('jc-row-new');
for (const cell of Array.from(fresh.querySelectorAll<HTMLInputElement>('input[data-col]'))) { for (const cell of Array.from(
fresh.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
)) {
cell.value = ''; cell.value = '';
// 序号不再有意义(见 `collect` 的注释),清掉免得两行同号 // 序号不再有意义(见 `collect` 的注释),清掉免得两行同号
delete cell.dataset['row']; delete cell.dataset['row'];
@@ -261,7 +306,23 @@ function addRow(doc: Document, address: string): boolean {
return true; return true;
} }
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, page, onPages }: ViewContainerProps) { /**
* `data-file="域.对象/主键值"` → 取文件要的那两样。
*
* 认第一个斜杠:主键值里再有斜杠也照样归主键(文件名常带 `/`,地址永远不带)。
* 缺一半的一律不认 —— 宁可点不动,也不发一个取不回来的请求。
*/
export function fileRefOf(raw: string | null): { address: string; key: string } | undefined {
const ref = raw ?? '';
const at = ref.indexOf('/');
if (at <= 0 || at === ref.length - 1) return undefined;
const address = ref.slice(0, at);
// 地址必须是「域.对象」两段 —— 形状不对就不是一份资料的地址
if (!/^[a-z][\w]*\.[a-z][\w]*$/i.test(address)) return undefined;
return { address, key: ref.slice(at + 1) };
}
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, onOpenFile, page, onPages }: ViewContainerProps) {
const ref = useRef<HTMLIFrameElement>(null); const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(FALLBACK_HEIGHT); const [height, setHeight] = useState(FALLBACK_HEIGHT);
/* /*
@@ -279,7 +340,13 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
const marked = Array.from(doc.body.querySelectorAll<HTMLElement>('[data-page]')); const marked = Array.from(doc.body.querySelectorAll<HTMLElement>('[data-page]'));
for (const el of marked) el.style.removeProperty('display'); for (const el of marked) el.style.removeProperty('display');
const visible = marked.filter((el) => doc.defaultView?.getComputedStyle(el).display !== 'none'); const visible = marked.filter((el) => doc.defaultView?.getComputedStyle(el).display !== 'none');
onPagesRef.current?.(visible.length); /*
* **一份不分页的件是「一页」,不是「零页」**(2026-08-23 用户:「这个 0/0 显示的
* 不正常」)。绝大多数件压根没写 `data-page`(整篇铺开就是全部内容)——
* 从前这里如实回报 0,外层拿它去写页码,屏上就出「0/0」。
* 页数报的是**这份件有几页**,件在手上,再少也是一页。
*/
onPagesRef.current?.(Math.max(1, visible.length));
// 不传 page(如工作成果的静态归档)= 不分页,全部照常纵排 // 不传 page(如工作成果的静态归档)= 不分页,全部照常纵排
if (pageRef.current === undefined || visible.length <= 1) return; if (pageRef.current === undefined || visible.length <= 1) return;
const current = Math.min(pageRef.current, visible.length - 1); const current = Math.min(pageRef.current, visible.length - 1);
@@ -323,6 +390,8 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
*/ */
const sink = useRef(onEdits); const sink = useRef(onEdits);
sink.current = onEdits; sink.current = onEdits;
const fileSink = useRef(onOpenFile);
fileSink.current = onOpenFile;
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */ /** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
const snapshot = useRef(new Map<string, { values: string[]; rows: number }>()); const snapshot = useRef(new Map<string, { values: string[]; rows: number }>());
@@ -350,10 +419,23 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
* 认能力不认血统:有 `closest` 就当它是元素。 * 认能力不认血统:有 `closest` 就当它是元素。
*/ */
const target = event.target as { closest?: (s: string) => HTMLElement | null } | null; const target = event.target as { closest?: (s: string) => HTMLElement | null } | null;
const hit = if (typeof target?.closest !== 'function') return;
typeof target?.closest === 'function'
? target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]') /*
: null; * **资料名:点开那一份文件** —— 先看这一条,再看编辑闸。
* 两者不会同时命中(一个在表格格子里、一个在闸上),先后只是省一次匹配。
*/
const fileHit = target.closest('[data-file]');
if (fileHit !== null && fileHit !== undefined) {
const ref = fileRefOf(fileHit.getAttribute('data-file'));
if (ref !== undefined) {
event.preventDefault();
fileSink.current?.(ref);
}
return;
}
const hit = target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]');
if (hit === null || hit === undefined) return; if (hit === null || hit === undefined) return;
event.preventDefault(); event.preventDefault();
@@ -380,7 +462,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原 // 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked) // (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
snapshot.current.set(open, { snapshot.current.set(open, {
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? (c.checked ? '1' : '') : c.value)), values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? ((c as HTMLInputElement).checked ? '1' : '') : c.value)),
rows: Array.from(bodyOf(doc, open)?.children ?? []).length, rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
}); });
setOpen(open, true); setOpen(open, true);
@@ -398,7 +480,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
cellsOf(doc, cancel).forEach((c, i) => { cellsOf(doc, cancel).forEach((c, i) => {
const saved = was.values[i]; const saved = was.values[i];
if (saved === undefined) return; if (saved === undefined) return;
if (c.type === 'radio') c.checked = saved === '1'; if (c.type === 'radio') (c as HTMLInputElement).checked = saved === '1';
else c.value = saved; else c.value = saved;
}); });
} }
+307 -4
View File
@@ -32,6 +32,9 @@ importers:
globals: globals:
specifier: ^16.5.0 specifier: ^16.5.0
version: 16.5.0 version: 16.5.0
jsdom:
specifier: ^30.0.1
version: 30.0.1
pg: pg:
specifier: ^8.22.0 specifier: ^8.22.0
version: 8.22.0 version: 8.22.0
@@ -46,7 +49,9 @@ importers:
version: 8.65.0(eslint@9.39.5)(typescript@5.9.3) version: 8.65.0(eslint@9.39.5)(typescript@5.9.3)
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jsdom@30.0.1)(tsx@4.23.1)(yaml@2.9.0)
activities/crm-relationship-coverage: {}
activities/quote-alternative: {} activities/quote-alternative: {}
@@ -109,8 +114,6 @@ importers:
apps/api/src/modules/event: {} apps/api/src/modules/event: {}
apps/api/src/modules/flow-engine: {}
apps/api/src/modules/human-tool: {} apps/api/src/modules/human-tool: {}
apps/api/src/modules/interaction: {} apps/api/src/modules/interaction: {}
@@ -276,6 +279,14 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.23.8 zod: ^3.23.8
'@asamuzakjp/css-color@6.0.7':
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
engines: {node: ^22.13.0 || >=24.0.0}
'@asamuzakjp/dom-selector@8.3.2':
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
engines: {node: ^22.13.0 || >=24.0.0}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -365,6 +376,46 @@ packages:
'@bpmn-io/diagram-js-ui@0.2.4': '@bpmn-io/diagram-js-ui@0.2.4':
resolution: {integrity: sha512-I678ZGtoH7z7FgzFhzzppM9ThsJ3Lh83kW3GjhjG3xyYciIPYHMaHK1MABP8F69H9pFZpViM6t4qUo8dwrVz0w==} resolution: {integrity: sha512-I678ZGtoH7z7FgzFhzzppM9ThsJ3Lh83kW3GjhjG3xyYciIPYHMaHK1MABP8F69H9pFZpViM6t4qUo8dwrVz0w==}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/color-helpers@6.1.1':
resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.3.0':
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.2.0':
resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.8':
resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@drizzle-team/brocli@0.10.2': '@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -1014,6 +1065,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@grpc/grpc-js@1.14.4': '@grpc/grpc-js@1.14.4':
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
engines: {node: '>=12.10.0'} engines: {node: '>=12.10.0'}
@@ -1685,6 +1745,9 @@ packages:
bcrypt-pbkdf@1.0.2: bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bl@4.1.0: bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -1892,9 +1955,17 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1904,6 +1975,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -2083,6 +2157,10 @@ packages:
resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2389,6 +2467,10 @@ packages:
htm@3.1.1: htm@3.1.1:
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
http-errors@2.0.1: http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2478,6 +2560,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'} engines: {node: '>=12'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-promise@4.0.0: is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -2530,6 +2615,15 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true hasBin: true
jsdom@30.0.1:
resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
peerDependencies:
canvas: ^3.2.3
peerDependenciesMeta:
canvas:
optional: true
jsesc@3.1.0: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2671,6 +2765,9 @@ packages:
mdast-util-to-string@4.0.0: mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-typer@1.1.1: media-typer@1.1.1:
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2904,6 +3001,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'} engines: {node: '>=18'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3210,6 +3310,10 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -3390,6 +3494,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tagged-tag@1.0.0: tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3456,6 +3563,13 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tldts-core@7.4.10:
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
tldts@7.4.10:
resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
hasBin: true
tmp@0.2.7: tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
@@ -3467,6 +3581,14 @@ packages:
tokenx@1.6.0: tokenx@1.6.0:
resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==}
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tree-kill@1.2.2: tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true hasBin: true
@@ -3682,11 +3804,31 @@ packages:
jsdom: jsdom:
optional: true optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
watskeburt@4.2.3: watskeburt@4.2.3:
resolution: {integrity: sha512-uG9qtQYoHqAsnT711nG5iZc/8M5inSmkGCOp7pFaytKG2aTfIca7p//CjiVzAE4P7hzaYuCozMjNNaLgmhbK5g==} resolution: {integrity: sha512-uG9qtQYoHqAsnT711nG5iZc/8M5inSmkGCOp7pFaytKG2aTfIca7p//CjiVzAE4P7hzaYuCozMjNNaLgmhbK5g==}
engines: {node: ^18||>=20} engines: {node: ^18||>=20}
hasBin: true hasBin: true
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@17.1.0:
resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
engines: {node: ^22.14.0 || >=24.0.0}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3724,6 +3866,13 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -3865,6 +4014,21 @@ snapshots:
zod: 4.4.3 zod: 4.4.3
zod-to-json-schema: 3.25.2(zod@4.4.3) zod-to-json-schema: 3.25.2(zod@4.4.3)
'@asamuzakjp/css-color@6.0.7':
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.5.2
'@asamuzakjp/dom-selector@8.3.2':
dependencies:
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@@ -3986,6 +4150,34 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- preact-render-to-string - preact-render-to-string
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@csstools/color-helpers@6.1.1': {}
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.1.1
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@drizzle-team/brocli@0.10.2': {} '@drizzle-team/brocli@0.10.2': {}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
@@ -4344,6 +4536,8 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@exodus/bytes@1.15.1': {}
'@grpc/grpc-js@1.14.4': '@grpc/grpc-js@1.14.4':
dependencies: dependencies:
'@grpc/proto-loader': 0.8.1 '@grpc/proto-loader': 0.8.1
@@ -5061,6 +5255,10 @@ snapshots:
dependencies: dependencies:
tweetnacl: 0.14.5 tweetnacl: 0.14.5
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bl@4.1.0: bl@4.1.0:
dependencies: dependencies:
buffer: 5.7.1 buffer: 5.7.1
@@ -5262,12 +5460,26 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
csstype@3.2.3: {} csstype@3.2.3: {}
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -5392,6 +5604,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.3 tapable: 2.3.3
entities@8.0.0: {}
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@@ -5826,6 +6040,12 @@ snapshots:
htm@3.1.1: {} htm@3.1.1: {}
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
transitivePeerDependencies:
- '@noble/hashes'
http-errors@2.0.1: http-errors@2.0.1:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -5890,6 +6110,8 @@ snapshots:
is-plain-obj@4.1.0: {} is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {} is-promise@4.0.0: {}
is-stream@2.0.1: {} is-stream@2.0.1: {}
@@ -5929,6 +6151,32 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
jsdom@30.0.1:
dependencies:
'@asamuzakjp/css-color': 6.0.7
'@asamuzakjp/dom-selector': 8.3.2
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.2
undici: 8.9.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 17.1.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
jsesc@3.1.0: {} jsesc@3.1.0: {}
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
@@ -6106,6 +6354,8 @@ snapshots:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
mdn-data@2.27.1: {}
media-typer@1.1.1: {} media-typer@1.1.1: {}
memoize@10.2.0: memoize@10.2.0:
@@ -6424,6 +6674,10 @@ snapshots:
parse-ms@4.0.0: {} parse-ms@4.0.0: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
@@ -6747,6 +7001,10 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {} scheduler@0.27.0: {}
section-matter@1.0.0: section-matter@1.0.0:
@@ -6941,6 +7199,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
symbol-tree@3.2.4: {}
tagged-tag@1.0.0: {} tagged-tag@1.0.0: {}
tapable@2.3.3: {} tapable@2.3.3: {}
@@ -7046,12 +7306,26 @@ snapshots:
tinyspy@4.0.4: {} tinyspy@4.0.4: {}
tldts-core@7.4.10: {}
tldts@7.4.10:
dependencies:
tldts-core: 7.4.10
tmp@0.2.7: {} tmp@0.2.7: {}
toidentifier@1.0.1: {} toidentifier@1.0.1: {}
tokenx@1.6.0: {} tokenx@1.6.0: {}
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.10
tr46@6.0.0:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {} tree-kill@1.2.2: {}
trough@2.2.0: {} trough@2.2.0: {}
@@ -7244,7 +7518,7 @@ snapshots:
tsx: 4.23.1 tsx: 4.23.1
yaml: 2.9.0 yaml: 2.9.0
vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0): vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jsdom@30.0.1)(tsx@4.23.1)(yaml@2.9.0):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.7 '@vitest/expect': 3.2.7
@@ -7272,6 +7546,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/debug': 4.1.13 '@types/debug': 4.1.13
'@types/node': 22.20.1 '@types/node': 22.20.1
jsdom: 30.0.1
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
@@ -7286,8 +7561,32 @@ snapshots:
- tsx - tsx
- yaml - yaml
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
watskeburt@4.2.3: {} watskeburt@4.2.3: {}
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@17.1.0:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
@@ -7315,6 +7614,10 @@ snapshots:
ws@8.21.1: {} ws@8.21.1: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xtend@4.0.2: {} xtend@4.0.2: {}
xxhash-wasm@1.1.0: {} xxhash-wasm@1.1.0: {}
+4
View File
@@ -17,6 +17,8 @@ import { manifest as quoteNegotiate } from '../activities/quote-negotiate/src/ma
import { manifest as quoteArchive } from '../activities/quote-archive/src/manifest.ts'; import { manifest as quoteArchive } from '../activities/quote-archive/src/manifest.ts';
import { manifest as quoteSourcing } from '../activities/quote-sourcing/src/manifest.ts'; import { manifest as quoteSourcing } from '../activities/quote-sourcing/src/manifest.ts';
import { manifest as quoteAlternative } from '../activities/quote-alternative/src/manifest.ts'; import { manifest as quoteAlternative } from '../activities/quote-alternative/src/manifest.ts';
import { manifest as crmRelationshipCoverage } from '../activities/crm-relationship-coverage/src/manifest.ts';
import { manifest as brandContentToolkit } from '../activities/brand-content-toolkit/src/manifest.ts';
const root = dirname(dirname(fileURLToPath(import.meta.url))); const root = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -27,6 +29,8 @@ const PACKAGES = [
{ dir: 'quote-archive', manifest: quoteArchive }, { dir: 'quote-archive', manifest: quoteArchive },
{ dir: 'quote-sourcing', manifest: quoteSourcing }, { dir: 'quote-sourcing', manifest: quoteSourcing },
{ dir: 'quote-alternative', manifest: quoteAlternative }, { dir: 'quote-alternative', manifest: quoteAlternative },
{ dir: 'crm-relationship-coverage', manifest: crmRelationshipCoverage },
{ dir: 'brand-content-toolkit', manifest: brandContentToolkit },
] as const; ] as const;
for (const pkg of PACKAGES) { for (const pkg of PACKAGES) {
+315 -26
View File
@@ -7,6 +7,8 @@
* node tools/e2e-a2.mjs a6 # 只跑 A6 三案例(客户回音代答 + 决策卡,三口各一条) * node tools/e2e-a2.mjs a6 # 只跑 A6 三案例(客户回音代答 + 决策卡,三口各一条)
* node tools/e2e-a2.mjs a7 # 只跑 A7 三案例(归因代答 + 入库口径决策卡,三档各一条) * node tools/e2e-a2.mjs a7 # 只跑 A7 三案例(归因代答 + 入库口径决策卡,三档各一条)
* node tools/e2e-a2.mjs a1 # 只跑 A1 回归(执行卡零输入 + 提交闸回落) * node tools/e2e-a2.mjs a1 # 只跑 A1 回归(执行卡零输入 + 提交闸回落)
* node tools/e2e-a2.mjs e04 # 只跑 E04 三案例(两张执行单 + 决策卡审批循环)
* node tools/e2e-a2.mjs i01 # 只跑 I01 三案例(一张执行单 + 初稿审核决策卡 + 发布放行)
* node tools/e2e-a2.mjs # 全跑 * node tools/e2e-a2.mjs # 全跑
* node tools/e2e-a2.mjs a2 1 # 只跑 A2 的第 1 条(0 起) * node tools/e2e-a2.mjs a2 1 # 只跑 A2 的第 1 条(0 起)
* *
@@ -292,6 +294,128 @@ const SUITES = {
}, },
], ],
}, },
/*
* E04 关系覆盖与关键人经营 —— 本仓第一个非报价管线的活动,也是第一个
* **一单里有两张不同表的执行单 + 一个决策卡循环**的套件:
* S2 覆盖范围确认(件上改 crm_coverage.candidate,键 customer_code)
* → S4 经营计划审批(决策卡:approve,或先 reject 再 approve 走一轮修订循环)
* → S5 执行回执(件上填 crm_coverage.receipt 四列,键 contact_code —— AI 只写骨架,
* 回执四列一格不写,空手交卷必吃 400,闸真不真每单验一遍)
* → T_release 验收 pass → released,发口 coverage-map。
* 两张执行单表不同 → 用 `stages` 按卡名对号(老套件没有 stages,走 suite 级老路)。
* 回执行数随计划行数走(模型定几位关键人),所以不写死 edits,
* 用 `fillAllBlank` 把回执列扫齐 —— 与真人「逐行填完保存」同一条链。
* 新客户建档路(entry 卡)不进本套件,浏览器走查覆盖它。
*/
e04: {
activityId: 'crm-relationship-coverage',
address: 'crm_coverage.receipt',
blankCol: 'completion_status',
stages: [
{
match: /覆盖范围确认/,
address: 'crm_coverage.candidate',
rowKey: 'customer_code',
// AI 起草的清单必填(编码/名称)本就齐 —— 空手交卷该是 nothing-missing,不设闸断言
expectGateBlock: false,
edits: [],
},
{
match: /执行回执|回执/,
address: 'crm_coverage.receipt',
rowKey: 'contact_code',
blankCol: 'completion_status',
expectGateBlock: true,
edits: [],
fillAllBlank: {
completion_status: 'done',
result_summary: '已完成拜访与交流,关系明显推进,达成下一步动作意向',
customer_feedback: '客户反馈积极,提出了后续技术支持诉求',
key_person_change: 'unchanged',
},
},
],
cases: [
{
// 样例 0 = 满载的那条(测试台落地即预填的也是它)
sample: 0,
name: '事件驱动 · 重大商机点名 CU-0142 · 计划一次通过',
decide: 'approve',
expectPort: 'coverage-map',
},
{
sample: 1,
name: '月度例行 · AI 自选候选 · 计划一次通过',
decide: 'approve',
expectPort: 'coverage-map',
},
{
sample: 4,
name: '攻坚项目 T04 · 决策卡先驳回再通过(修订循环)',
decideSeq: ['reject', 'approve'],
expectDecision: true,
expectPort: 'coverage-map',
},
],
},
/*
* I01 品牌形象与销售工具包管理 —— 内容生产型:一张执行单(需求受理)+ 两道验收
* (内容初稿审核走决策卡循环、发布确认走终局放行)。没有线下执行环节,
* 所以只有一张执行单,且**人不必在件上填什么** —— AI 识别的需求清单本就齐,
* 人的活是「查漏补缺」:这里替他补一格「适用场景」,证明就地改这条链是通的。
*/
i01: {
activityId: 'brand-content-toolkit',
address: 'content_brand.requirement',
stages: [
{
match: /需求受理|内容需求/,
address: 'content_brand.requirement',
rowKey: 'req_seq',
// 内容类型是 AI 写的必填,本就不缺 —— 空手交卷该是 nothing-missing,不设闸断言
expectGateBlock: false,
edits: [],
// 人的查漏补缺:凡场景还空着的行补一个(与真人在件上改同一条链)
fillAllBlank: { scene: '攻坚' },
blankCol: 'scene',
},
],
/*
* **这一单必须真产出资料文件**(2026-08-23 用户定):每一行内容资产都要能
* 按 `域.对象/主键` 取回一份文件,并且渲得出来 —— 渲不出来就不是文件,
* 是一行标题。四种格式各自读回来的形态在 `assertAssetFiles` 里逐条对。
*/
assetFiles: { address: 'content.asset', keyCol: 'content_id', formatCol: 'file_format' },
cases: [
{
sample: 0,
name: '被动触发 · E04 请求定向素材 · 初稿一次通过',
decide: 'approve',
expectPort: 'content-published',
},
{
sample: 1,
name: '主动触发 · 月度定期内容更新 · 初稿一次通过',
decide: 'approve',
expectPort: 'content-published',
},
{
sample: 2,
name: '进入新市场(俄罗斯)· 初稿先驳回再通过(修订循环)',
decideSeq: ['reject', 'approve'],
expectDecision: true,
expectPort: 'content-published',
},
{
sample: 3,
name: '对照表 / 落地页 / 速记 —— 三种形态各挑各的格式',
decide: 'approve',
expectPort: 'content-published',
// 这一单要的就是格式分得开:一封需求里三种形态,不许一律 docx
expectFormats: 2,
},
],
},
a3: { a3: {
activityId: 'quote-sourcing', activityId: 'quote-sourcing',
address: 'quote_sourcing.quote_list', address: 'quote_sourcing.quote_list',
@@ -399,7 +523,7 @@ async function submitExecution(cardId) {
* **断言要分清「闸没拦」与「压根没东西可拦」**,否则它报的失败是假的, * **断言要分清「闸没拦」与「压根没东西可拦」**,否则它报的失败是假的,
* 真失效那天反而没人信它。 * 真失效那天反而没人信它。
*/ */
async function expectGateBlocks(cardId, taskId, suite, kase) { async function expectGateBlocks(cardId, taskId, stage) {
try { try {
await submitExecution(cardId); await submitExecution(cardId);
} catch (error) { } catch (error) {
@@ -411,10 +535,10 @@ async function expectGateBlocks(cardId, taskId, suite, kase) {
* 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。 * 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。
* 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。 * 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。
*/ */
const column = kase.blankCol ?? suite.blankCol ?? kase.fillMissing?.column; const column = stage.blankCol ?? stage.fillMissing?.column;
if (column === undefined) return 'nothing-missing'; if (column === undefined) return 'nothing-missing';
const { body } = await api(`/api/gov/testbed/activity/${taskId}`); const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], suite.address); const rows = draftRows(body.drafts ?? [], stage.address);
const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length; const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;
return blank > 0 ? 'leaked' : 'nothing-missing'; return blank > 0 ? 'leaked' : 'nothing-missing';
} }
@@ -433,8 +557,12 @@ async function answerDecisions(taskId, kase, answered) {
if (card.k !== 'decision' || card.chosen !== undefined || answered.has(card.id)) continue; if (card.k !== 'decision' || card.chosen !== undefined || answered.has(card.id)) continue;
const options = card.options ?? []; const options = card.options ?? [];
if (options.length === 0) continue; if (options.length === 0) continue;
const picked = /*
options.find((o) => o.value === kase.decide) ?? options.find((o) => o.recommended === true) ?? options[0]; * `decideSeq` 按张走(E04 审批循环:第一张驳回、修订后的第二张通过);
* 序列走完取末项兜底。没有 decideSeq 的老套件仍走单值 `decide`。
*/
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];
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: '试跑代答' } }),
@@ -446,33 +574,126 @@ async function answerDecisions(taskId, kase, answered) {
return count; return count;
} }
/** 把案例声明的格子对齐成保存载荷(pn 为空 = 按序发给待填行;fillMissing 兜底扫空) */ /**
function buildEdits(suite, kase, drafts) { * 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
const rows = draftRows(drafts, suite.address); * 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
const pending = rows.filter((r) => String(r[suite.blankCol ?? 'base_price'] ?? '') === ''); * `fillAllBlank` 整行兜底扫空(回执这类「几行由模型定、每行要填一把」的表用它)。
*/
function buildEdits(stage, drafts) {
const rows = draftRows(drafts, stage.address);
const rowKey = stage.rowKey ?? 'pn';
const blankCol = stage.blankCol ?? 'base_price';
const pending = rows.filter((r) => String(r[blankCol] ?? '') === '');
const lines = []; const lines = [];
const taken = new Set(); const taken = new Set();
for (const [i, edit] of kase.edits.entries()) { for (const [i, edit] of (stage.edits ?? []).entries()) {
const { pn, ...cells } = edit; const { [rowKey]: keyed, ...cells } = edit;
const target = pn !== '' ? rows.find((r) => String(r.pn) === pn) : pending[i]; const target = keyed !== '' ? rows.find((r) => String(r[rowKey]) === keyed) : pending[i];
if (!target) { if (!target) {
console.log(` ⚠️ 找不到要填的行(pn=${pn || `待填第${i}行`});现有行:${rows.map((r) => r.pn).join(', ')}`); console.log(` ⚠️ 找不到要填的行(${rowKey}=${keyed || `待填第${i}行`});现有行:${rows.map((r) => r[rowKey]).join(', ')}`);
continue; continue;
} }
taken.add(String(target.pn)); taken.add(String(target[rowKey]));
lines.push({ pn: String(target.pn), ...cells }); lines.push({ [rowKey]: String(target[rowKey]), ...cells });
} }
if (kase.fillMissing) { if (stage.fillMissing) {
const { column, value } = kase.fillMissing; const { column, value } = stage.fillMissing;
for (const r of rows) { for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(String(r.pn))) { if (String(r[column] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ pn: String(r.pn), [column]: value }); lines.push({ [rowKey]: String(r[rowKey]), [column]: value });
}
}
}
if (stage.fillAllBlank) {
for (const r of rows) {
if (String(r[blankCol] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), ...stage.fillAllBlank });
} }
} }
} }
return lines; return lines;
} }
/**
* **资料文件真产出了没有** —— 逐行按台账地址取回来,看它渲成什么。
*
* 断的是三件事,少一件都算没做到:
* ① 每一行都取得回一份文件(取不回 = 那一份根本不存在);
* ② 格式列说的是什么,读回来就该是什么形态(docx→word / xlsx→excel / md→markdown / html→html);
* ③ 里头**真有内容**(word 有块、excel 有行、md/html 有字)——
* 一份空壳也能"渲染成功",那种成功没有意义。
*/
async function assertAssetFiles(taskId, spec) {
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], spec.address);
if (rows.length === 0) return { ok: false, note: '一行内容资产都没写下 —— 谈不上产出资料' };
const KIND = { docx: 'word', xlsx: 'excel', md: 'markdown', html: 'html' };
const seen = new Set();
for (const row of rows) {
const key = String(row[spec.keyCol] ?? '');
const format = String(row[spec.formatCol] ?? '');
if (key === '') return { ok: false, note: `有一行没有主键(${spec.keyCol} 空着),件上点不开` };
if (!(format in KIND)) return { ok: false, note: `${key} 的格式是「${format}」—— 不在 md/html/docx/xlsx 里` };
let got;
try {
got = await api(`/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}`);
} catch (error) {
return { ok: false, note: `${key}(${format})取不回来:${String(error.message).slice(0, 120)}` };
}
const doc = got.body.file;
if (doc?.kind !== KIND[format]) return { ok: false, note: `${key} 说好是 ${format},读回来却是 ${doc?.kind}` };
const empty =
(doc.kind === 'word' && (doc.blocks ?? []).length === 0) ||
(doc.kind === 'excel' && (doc.rows ?? []).length === 0) ||
(doc.kind === 'markdown' && (doc.md ?? '').trim().length < 20) ||
(doc.kind === 'html' && (doc.html ?? '').length < 200);
if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` };
// 原字节也得取得到:下载到的是真 .docx / .xlsx
const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { cookie } });
const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` };
if ((format === 'docx' || format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {
return { ok: false, note: `${key} 的 .${format} 不是 ZIP 包(Word/Excel 打不开)` };
}
seen.add(format);
}
return { ok: true, formats: seen, note: `${rows.length} 份资料全部取得回、渲得出(格式:${[...seen].join(' / ')})` };
}
/**
* 收工之后再看一眼:那几份资料**存档了没有、点得开吗**。
* 查的是工作台任务详情里那一摞(`assetFiles`,工作成果那一屏摆的就是它),
* 逐份按同一条路取回来 —— 与人在界面上点它是同一个接口。
*/
async function assertArchived(taskId) {
const { body } = await api(`/api/desk/tasks/${taskId}`);
const files = body.assetFiles ?? [];
if (files.length === 0) return { ok: false, note: '工作成果里一份资料都没有 —— 放行时没存档' };
const live = files.filter((f) => !f.archived);
if (live.length > 0) return { ok: false, note: `${live.length} 份还标着「在办中」—— 放行那一刻没冻住` };
for (const f of files) {
const url = `/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(f.address)}&key=${encodeURIComponent(f.key)}`;
let got;
try {
got = await api(url);
} catch (error) {
return { ok: false, note: `${f.name} 存档里取不回来:${String(error.message).slice(0, 100)}` };
}
if (got.body.file?.kind === undefined) return { ok: false, note: `${f.name} 取回来是个空壳` };
const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { cookie } });
const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${f.name} 的原件取不到(${raw.status} / ${bytes.length}B)` };
if ((f.format === 'docx' || f.format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {
return { ok: false, note: `${f.name} 存档里那份不是 ZIP 包` };
}
}
return { ok: true, note: `${files.length} 份都已存档、逐份点得开(工作成果那一摞)` };
}
async function runCase(suite, kase) { async function runCase(suite, kase) {
const sample = await loadSample(suite.activityId, kase.sample); const sample = await loadSample(suite.activityId, kase.sample);
console.log(`\n▶ ${suite.activityId} · ${kase.name}`); console.log(`\n▶ ${suite.activityId} · ${kase.name}`);
@@ -487,10 +708,34 @@ async function runCase(suite, kase) {
const retries = new Map(); const retries = new Map();
const decided = new Set(); const decided = new Set();
let gateBlocked = false; let gateBlocked = false;
/** 资料文件那一次检查的结论(在办中验,见下面那段注释) */
let fileCheck;
let gateLeaked = false;
const gateChecked = new Set();
let decisions = 0; let decisions = 0;
const deadline = Date.now() + 20 * 60 * 1000; const deadline = Date.now() + 20 * 60 * 1000;
let last; let last;
/*
* 这张执行卡对应哪一套「表 + 键 + 该填什么」—— 一单里有两张不同表的执行单
* (E04:候选清单 / 执行回执)按卡名对号;老套件没有 stages,整单一套。
*/
const stageOf = (card) => {
if (suite.stages === undefined) {
return {
address: suite.address,
rowKey: suite.rowKey,
blankCol: kase.blankCol ?? suite.blankCol,
edits: kase.edits ?? [],
fillMissing: kase.fillMissing,
expectGateBlock: kase.expectGateBlock === true,
};
}
const hit = suite.stages.find((s) => s.match.test(card.title ?? ''));
if (hit === undefined) console.log(` ⚠️ 认不出这张执行卡属于哪一段:${card.title}`);
return hit;
};
while (Date.now() < deadline) { while (Date.now() < deadline) {
await sleep(3000); await sleep(3000);
const { body } = await api(`/api/gov/testbed/activity/${taskId}`); const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
@@ -505,15 +750,40 @@ async function runCase(suite, kase) {
break; break;
} }
/*
* **资料文件要在「人正在审」那一刻验** —— 而不是放行之后。
*
* 放行那一下草稿被消费掉,而 `content.asset` 是主数据:**试跑一律不落正式表**
* (免得试跑往品牌内容库里塞真资产),于是收工再去找,一行都找不着 ——
* 第一版就是这么误报"一行内容资产都没写下"的,而那一单资料写得好好的。
* 人点资料名恰恰发生在审核那一屏,这里验的就是他那一刻点得开点不开。
*/
/*
* 时机:**等到控制权真的交回人手上那一刻**(有卡等着答),而不是「一见到有行就查」——
* 后者会撞上模型还在逐行写的中途,查到 2 份就收工,而这一单其实产了 3 份
* (实测撞见过一次)。人点资料名恰恰发生在有卡等他的时候,查的就该是那一刻。
*/
const handedOff =
(body.cards ?? []).some((c) => !c.done) ||
((await api(`/api/desk/tasks/${taskId}`)).body.chat ?? []).some((c) => c.k === 'decision' && c.chosen === undefined);
if (suite.assetFiles && fileCheck === undefined && handedOff && (body.drafts ?? []).some((d) => d.address === suite.assetFiles.address)) {
fileCheck = await assertAssetFiles(taskId, suite.assetFiles);
console.log(` 资料文件:${fileCheck.note}`);
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的 // 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided); decisions += await answerDecisions(taskId, kase, decided);
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') {
if (kase.expectGateBlock && !gateBlocked) { const stage = stageOf(card);
const verdict = await expectGateBlocks(card.id, taskId, suite, kase); if (stage === undefined) continue;
gateBlocked = verdict !== 'leaked'; if (stage.expectGateBlock === true && !gateChecked.has(stage.address)) {
gateChecked.add(stage.address);
const verdict = await expectGateBlocks(card.id, taskId, stage);
if (verdict === 'leaked') gateLeaked = true;
else gateBlocked = true;
console.log( console.log(
verdict === 'blocked' verdict === 'blocked'
? ` 闸拦住空手交卷(400)—— 如设计` ? ` 闸拦住空手交卷(400)—— 如设计`
@@ -522,11 +792,11 @@ async function runCase(suite, kase) {
: ` ⚠️ 缺着必填还放过去了:提交闸失效`, : ` ⚠️ 缺着必填还放过去了:提交闸失效`,
); );
} }
const lines = buildEdits(suite, kase, body.drafts ?? []); const lines = buildEdits(stage, body.drafts ?? []);
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',
body: JSON.stringify({ tables: { [suite.address]: lines } }), body: JSON.stringify({ tables: { [stage.address]: lines } }),
}); });
console.log(` 件上保存 ${lines.length} 行(${card.title})`); console.log(` 件上保存 ${lines.length} 行(${card.title})`);
} }
@@ -573,7 +843,7 @@ async function runCase(suite, kase) {
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` + `${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
`决策卡 ${decisions} 张 · 发口 [${ports.join(', ')}]`, `决策卡 ${decisions} 张 · 发口 [${ports.join(', ')}]`,
); );
if (kase.expectGateBlock && !gateBlocked) { if (gateLeaked || (kase.expectGateBlock && !gateBlocked)) {
console.log(' ⚠️ 提交闸没拦住空手交卷 —— 判失败'); console.log(' ⚠️ 提交闸没拦住空手交卷 —— 判失败');
ok = false; ok = false;
} }
@@ -585,6 +855,25 @@ 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 (suite.assetFiles) {
if (fileCheck === undefined) {
console.log(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料');
ok = false;
} else if (!fileCheck.ok) {
ok = false;
} else if (kase.expectFormats && (fileCheck.formats?.size ?? 0) < kase.expectFormats) {
console.log(` ⚠️ 一封需求里三种形态,却只出了 ${[...(fileCheck.formats ?? [])].join('/')} —— 格式没分开`);
ok = false;
}
/*
* **放行之后还得在** —— 工作成果恰恰是收工才去看的地方。
* 资料落在主数据上,而试跑单放行时主数据不落正式表:不存档的话这一刻全是 404
* (这正是 2026-08-23 之前的实情)。所以这条断言查的是**存档**,不是台账。
*/
const archived = await assertArchived(taskId);
console.log(` 资料存档:${archived.note}`);
if (!archived.ok) ok = false;
}
if (!ok) { if (!ok) {
console.log(' —— 轨迹末 12 条 ——'); console.log(' —— 轨迹末 12 条 ——');
for (const t of (last?.trail ?? []).slice(-12)) console.log(` [${t.kind}] ${t.title}`); for (const t of (last?.trail ?? []).slice(-12)) console.log(` [${t.kind}] ${t.title}`);
@@ -596,7 +885,7 @@ async function main() {
const which = process.argv[2] ?? 'all'; const which = process.argv[2] ?? 'all';
const only = process.argv[3] === undefined ? undefined : Number(process.argv[3]); const only = process.argv[3] === undefined ? undefined : Number(process.argv[3]);
await login(); await login();
const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7'] : [which]; const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01'] : [which];
let pass = 0; let pass = 0;
let total = 0; let total = 0;
for (const key of suites) { for (const key of suites) {