From 0aa2fc11b0d2c627ce26d2e84763b2676b12ab95 Mon Sep 17 00:00:00 2001 From: xcs Date: Mon, 24 Aug 2026 10:26:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=96=99=E6=96=87=E4=BB=B6=E7=9C=9F?= =?UTF-8?q?=E4=BA=A7=E5=87=BA=E6=9D=A5=E4=BA=86;E04/I01=20=E8=A1=A5?= =?UTF-8?q?=E5=88=B0=E9=AA=8C=E6=94=B6=E7=BA=BF,=E9=A1=BA=E6=89=8B?= =?UTF-8?q?=E5=A1=AB=E4=BA=86=E5=87=A0=E4=B8=AA=E3=80=8C=E5=A2=99=E6=8B=A6?= =?UTF-8?q?=E4=BD=8F=E5=8D=B4=E4=B8=8D=E7=BB=99=E8=B7=AF=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开) 接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。 文件是台账那一行的投影,不另存一份: · 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开) · 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖) · 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的 · 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件 放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻 那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。 工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。 **Harness** · 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事: 收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形) · 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子) · 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项 · 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算 **界面** · 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0 · 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了) · 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个) · 测试台「取不到」不再当成「还活着」 **活动** E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、 样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。 验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开; 门禁 typecheck/lint/depcruise/1628 测全绿。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX --- CLAUDE.md | 244 +++- .../__tests__/activity.test.ts | 267 ++++ .../__tests__/steps.test.ts | 287 ++++ activities/brand-content-toolkit/package.json | 1 + .../brand-content-toolkit/src/case-model.ts | 464 ++++++ activities/brand-content-toolkit/src/index.ts | 11 + .../brand-content-toolkit/src/manifest.ts | 526 +++++++ .../brand-content-toolkit/src/skills.ts | 80 ++ .../src/views/prd-style.ts | 163 +++ .../brand-content-toolkit/src/views/report.ts | 387 +++++ .../__tests__/activity.test.ts | 538 +++++++ .../__tests__/steps.test.ts | 317 +++++ .../crm-relationship-coverage/package.json | 1 + .../src/case-model.ts | 717 ++++++++++ .../crm-relationship-coverage/src/index.ts | 11 + .../crm-relationship-coverage/src/manifest.ts | 584 ++++++++ .../crm-relationship-coverage/src/skills.ts | 79 + .../src/views/prd-style.ts | 162 +++ .../src/views/report.ts | 551 +++++++ .../api/src/modules/loop-engine/port.d.ts | 21 +- .../contracts/src/api/governance.d.ts | 14 + .../packages/contracts/src/api/workbench.d.ts | 1265 +++++++++++++++++ api-report/packages/contracts/src/card.d.ts | 16 + api-report/packages/contracts/src/ledger.d.ts | 5 + .../packages/contracts/src/manifest.d.ts | 10 + .../src/app/__tests__/asset-archive.test.ts | 131 ++ .../src/app/__tests__/file-compose.test.ts | 210 +++ .../src/app/__tests__/view-table-edit.test.ts | 142 +- apps/api/src/app/agent-runner.ts | 8 +- apps/api/src/app/asset-archive.ts | 163 +++ apps/api/src/app/config.ts | 2 +- apps/api/src/app/file-compose.ts | 425 ++++++ apps/api/src/app/file-doc.ts | 121 ++ apps/api/src/app/reset-runs.ts | 2 + apps/api/src/app/routes/desk.ts | 419 +++++- apps/api/src/app/routes/governance-ops.ts | 48 +- apps/api/src/app/seed-activities.ts | 38 +- apps/api/src/app/seed-mvp-data.ts | 139 ++ apps/api/src/app/seed-mvp.ts | 82 +- apps/api/src/app/tools/first-party.ts | 81 ++ apps/api/src/app/upload-extract.ts | 12 +- apps/api/src/app/wiring/engines.ts | 69 +- apps/api/src/app/zip-write.ts | 101 ++ .../interaction/__tests__/view-render.test.ts | 96 ++ .../internal/domain/view-render.ts | 73 +- apps/api/src/modules/loop-engine/CONTRACT.md | 20 +- .../loop-engine/__tests__/cmmn.test.ts | 17 + .../__tests__/turn-runner-acceptance.test.ts | 465 +++++- .../loop-engine/internal/domain/lifecycle.ts | 16 + .../internal/usecase/turn-runner.ts | 425 +++++- apps/api/src/modules/loop-engine/port.ts | 18 +- apps/web/src/app/app-shell.tsx | 63 +- apps/web/src/app/styles/app.css | 72 +- .../desk-task/__tests__/pager.test.ts | 45 + .../desk-task/internal/FileViewer.tsx | 32 +- .../desk-task/internal/ViewRegion.tsx | 484 +++++-- .../src/features/desk-task/internal/pager.ts | 30 + .../__tests__/observe-face.test.ts | 61 + .../gov-testbed/internal/activity-run.tsx | 150 +- .../gov-testbed/internal/observe-face.ts | 64 + apps/web/src/shared/typed-fetch.ts | 12 +- db/migrations/0029_task_asset_file.sql | 25 + db/migrations/meta/_journal.json | 7 + docker-compose.yml | 4 +- docs/HTML报告生成规范.md | 20 + .../Demo-E04-I01-Activity台账关联与设计.html | 626 ++++++++ .../L-01-宏观市场台账.xlsx | Bin 0 -> 9441 bytes .../E04I01-Demo-台账设计/L-02-客户主台账.xlsx | Bin 0 -> 9680 bytes .../E04I01-Demo-台账设计/L-05-品牌内容库.xlsx | Bin 0 -> 9036 bytes .../L-08-客户联系人信息台账.xlsx | Bin 0 -> 9591 bytes .../E04-执行工单-01-覆盖范围确认.html | 197 +++ .../e04-html/E04-执行工单-02-执行回执.html | 150 ++ .../e04-html/E04-活动流程与工单说明.md | 177 +++ .../E04-验收工单-01-关系经营计划.html | 148 ++ .../E04-验收工单-02-台账更新确认.html | 150 ++ .../i01-html/I01-执行工单-01-需求受理.html | 186 +++ .../i01-html/I01-活动流程与工单说明.md | 215 +++ .../I01-验收工单-01-内容初稿审核.html | 120 ++ .../i01-html/I01-验收工单-02-发布确认.html | 123 ++ docs/debt.md | 47 +- docs/活动开发规范.md | 11 + package.json | 1 + packages/contracts/src/api/governance.ts | 6 + packages/contracts/src/api/workbench.ts | 57 + packages/contracts/src/card.ts | 20 +- packages/contracts/src/ledger.ts | 22 + .../__tests__/view-container-collect.test.ts | 196 +++ packages/ui-kit/src/Ico.tsx | 1 + packages/ui-kit/src/ViewContainer.tsx | 132 +- pnpm-lock.yaml | 311 +++- tools/build-activities.ts | 4 + tools/e2e-a2.mjs | 341 ++++- 92 files changed, 13984 insertions(+), 370 deletions(-) create mode 100644 activities/brand-content-toolkit/__tests__/activity.test.ts create mode 100644 activities/brand-content-toolkit/__tests__/steps.test.ts create mode 100644 activities/brand-content-toolkit/package.json create mode 100644 activities/brand-content-toolkit/src/case-model.ts create mode 100644 activities/brand-content-toolkit/src/index.ts create mode 100644 activities/brand-content-toolkit/src/manifest.ts create mode 100644 activities/brand-content-toolkit/src/skills.ts create mode 100644 activities/brand-content-toolkit/src/views/prd-style.ts create mode 100644 activities/brand-content-toolkit/src/views/report.ts create mode 100644 activities/crm-relationship-coverage/__tests__/activity.test.ts create mode 100644 activities/crm-relationship-coverage/__tests__/steps.test.ts create mode 100644 activities/crm-relationship-coverage/package.json create mode 100644 activities/crm-relationship-coverage/src/case-model.ts create mode 100644 activities/crm-relationship-coverage/src/index.ts create mode 100644 activities/crm-relationship-coverage/src/manifest.ts create mode 100644 activities/crm-relationship-coverage/src/skills.ts create mode 100644 activities/crm-relationship-coverage/src/views/prd-style.ts create mode 100644 activities/crm-relationship-coverage/src/views/report.ts create mode 100644 apps/api/src/app/__tests__/asset-archive.test.ts create mode 100644 apps/api/src/app/__tests__/file-compose.test.ts create mode 100644 apps/api/src/app/asset-archive.ts create mode 100644 apps/api/src/app/file-compose.ts create mode 100644 apps/api/src/app/file-doc.ts create mode 100644 apps/api/src/app/zip-write.ts create mode 100644 apps/web/src/features/desk-task/__tests__/pager.test.ts create mode 100644 apps/web/src/features/desk-task/internal/pager.ts create mode 100644 apps/web/src/features/gov-testbed/__tests__/observe-face.test.ts create mode 100644 apps/web/src/features/gov-testbed/internal/observe-face.ts create mode 100644 db/migrations/0029_task_asset_file.sql create mode 100644 docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html create mode 100644 docs/SS01-客户与市场/E04I01-Demo-台账设计/L-01-宏观市场台账.xlsx create mode 100644 docs/SS01-客户与市场/E04I01-Demo-台账设计/L-02-客户主台账.xlsx create mode 100644 docs/SS01-客户与市场/E04I01-Demo-台账设计/L-05-品牌内容库.xlsx create mode 100644 docs/SS01-客户与市场/E04I01-Demo-台账设计/L-08-客户联系人信息台账.xlsx create mode 100644 docs/SS01-客户与市场/e04-html/E04-执行工单-01-覆盖范围确认.html create mode 100644 docs/SS01-客户与市场/e04-html/E04-执行工单-02-执行回执.html create mode 100644 docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md create mode 100644 docs/SS01-客户与市场/e04-html/E04-验收工单-01-关系经营计划.html create mode 100644 docs/SS01-客户与市场/e04-html/E04-验收工单-02-台账更新确认.html create mode 100644 docs/SS01-客户与市场/i01-html/I01-执行工单-01-需求受理.html create mode 100644 docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md create mode 100644 docs/SS01-客户与市场/i01-html/I01-验收工单-01-内容初稿审核.html create mode 100644 docs/SS01-客户与市场/i01-html/I01-验收工单-02-发布确认.html create mode 100644 packages/ui-kit/__tests__/view-container-collect.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6f557c7..14d3bfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ > 一句话:组织的工作定义为可编排的工作流,工作流由**活动**编排而成;活动是完成一项任务的 > Agent 机制,系统外开发、安装即用。人只在责任点与放行点出现 —— 确定交系统,临场交 AI,落地交人。 -## 现状快照(2026-08-02) +## 现状快照(2026-08-22) - **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/ 大模型/**测试台:三格 —— 流程(投一封信)/ 活动(按契约填入参)/ 日志(历次试跑存档)**)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。 @@ -783,6 +783,248 @@ 截了才加省略号 —— 加了等于说「后面还有」。 实测:一单 8 行、6 行有抽屉、行高 34-36px 齐平,展开那两行 141/103px。 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`(它还是一整块 `
` 铺满时的写法),与里面那份自带灰底的文档
+  不同色;改成同色 `#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` 一并清这张表。
 
 ## 文档权威地图
 
diff --git a/activities/brand-content-toolkit/__tests__/activity.test.ts b/activities/brand-content-toolkit/__tests__/activity.test.ts
new file mode 100644
index 0000000..db59873
--- /dev/null
+++ b/activities/brand-content-toolkit/__tests__/activity.test.ts
@@ -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(/ m[1] ?? '');
+    expect(stepItems).toEqual(cmmnIds);
+  });
+
+  it('六个计划项:1 执行 + 2 验收(S2 执行工单,S4/T_release 验收工单)', () => {
+    const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/ 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(``).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('');
+    const draft = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
+    expect(draft).toContain('');
+  });
+
+  it('宿主拒推含脚本的模版', async () => {
+    const host = createMockHost({ contract: CONTRACT });
+    await expect(host.base.interaction.pushHtml('
')).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; diff --git a/activities/brand-content-toolkit/__tests__/steps.test.ts b/activities/brand-content-toolkit/__tests__/steps.test.ts new file mode 100644 index 0000000..1c276a1 --- /dev/null +++ b/activities/brand-content-toolkit/__tests__/steps.test.ts @@ -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): Record => ({ + ...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)); + 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; + 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): 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'); + }); +}); diff --git a/activities/brand-content-toolkit/package.json b/activities/brand-content-toolkit/package.json new file mode 100644 index 0000000..9a50b3c --- /dev/null +++ b/activities/brand-content-toolkit/package.json @@ -0,0 +1 @@ +{ "name": "@activities/brand-content-toolkit", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } } diff --git a/activities/brand-content-toolkit/src/case-model.ts b/activities/brand-content-toolkit/src/case-model.ts new file mode 100644 index 0000000..46ad41d --- /dev/null +++ b/activities/brand-content-toolkit/src/case-model.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 = ` + + + + + + + + + + + + + + + + update + content_brand_requirement_rows > 0 + + + + update + requirement_confirmed_total > 0 + + + + update + content_asset_rows > 0 and content_asset_missing = 0 + + + + update + draft_approved_total > 0 + + + + update + publish_prepared_total > 0 and content_asset_content_status_published > 0 and content_asset_missing = 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +/** + * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。 + */ +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":"内容初稿已草拟,共 份,请审核。", + "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":"需求受理:本轮共 条内容需求", + "ask":"AI 已按规则识别出 条内容需求,请核对确认。", + "steps":["工作区的件上核对需求清单:内容类型是必填,缺一条这一单走不到内容创作那一步", + "加一行 = 补充新需求(AI 规则之外的目标客户/场景/优先级/定制)", + "删一行 = 排除(AI 识别但实际不需要生产的需求)", + "改单元格 = 修正 AI 识别的内容 —— 尤其是目标客户、适用场景、优先级", + "改完保存,回这张卡提交"], + "standard":"需求清单里每一行内容类型都齐;该补充的都补充、该排除的都排除了。", + "basis":[{"k":"触发口径","v":"<照 content_brand.verdict 刚写的 trigger/trigger_detail 说, + 如「主动触发:月度定期内容更新」或「被动触发:E04 关系覆盖需要价值传递素材」>"}, + {"k":"AI 识别","v":" 条内容需求"}]}} +发完这一拍就结束了(发卡挂起)。 + +【人交回之后】这一步你只做两件事,然后 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":"内容初稿已草拟,共 份,请审核。", + "options":[ + {"value":"approve","label":"通过","recommended":true,"consequence":"内容进入发布准备阶段"}, + {"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按调性标准复核一遍初稿,重新提交审核"}], + "basis":[{"k":"本轮初稿","v":" 份,覆盖 <涉及的内容类型>"}, + {"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'] }, +]; diff --git a/activities/brand-content-toolkit/src/index.ts b/activities/brand-content-toolkit/src/index.ts new file mode 100644 index 0000000..4ec819c --- /dev/null +++ b/activities/brand-content-toolkit/src/index.ts @@ -0,0 +1,11 @@ +/** + * I01 · 品牌形象与销售工具包管理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 + * + * 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里: + * 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 三张 HTML 报告模版。 + * 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— + * 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。 + */ +import { defineActivity } from '@sdk'; + +export default defineActivity({ name: 'brand-content-toolkit' }); diff --git a/activities/brand-content-toolkit/src/manifest.ts b/activities/brand-content-toolkit/src/manifest.ts new file mode 100644 index 0000000..1e62ed5 --- /dev/null +++ b/activities/brand-content-toolkit/src/manifest.ts @@ -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` 里就是一个 ``,不是 `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' }, +}; diff --git a/activities/brand-content-toolkit/src/skills.ts b/activities/brand-content-toolkit/src/skills.ts new file mode 100644 index 0000000..0fff40e --- /dev/null +++ b/activities/brand-content-toolkit/src/skills.ts @@ -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', + }, +]; diff --git a/activities/brand-content-toolkit/src/views/prd-style.ts b/activities/brand-content-toolkit/src/views/prd-style.ts new file mode 100644 index 0000000..4c4b0e8 --- /dev/null +++ b/activities/brand-content-toolkit/src/views/prd-style.ts @@ -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); } +`; diff --git a/activities/brand-content-toolkit/src/views/report.ts b/activities/brand-content-toolkit/src/views/report.ts new file mode 100644 index 0000000..a7b496e --- /dev/null +++ b/activities/brand-content-toolkit/src/views/report.ts @@ -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.` 或 + * `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 => + ` +
+
+
+
${input.badge}
+
+ ${input.kicker} +

${input.title}

+
收件人: · 关联任务:
+
+
+ ${input.tag} + ${input.step} + ${input.state} +
+
+
${input.body}
+
${input.foot}
+
+
`; + +const part = (label: string, aside = ''): string => + `

【${label}】${aside === '' ? '' : ` —— ${aside}`}

`; + +const criteria = (title: string, items: readonly string[]): string => + `
${title}
    ${items.map((i) => `
  • ${i}
  • `).join('')}
`; + +const flow = (text: string): string => `
${text}
`; + +/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */ +const sign = (note: string, tag: string): string => + `${note}${tag} · `; + +/** 编辑闸 —— `label` 是闸上那句话(照目标件:「AI 已识别 N 个内容需求,点「编辑」增删改」) */ +const editGate = (address: string, label: string): string => `
+ ${label} + + + +
`; + +/* ==================== 局面一 · 内容需求清单(S1/S2)==================== */ + +const REQUIREMENT_TABLE = `
+ + + + + + + + + + + + + + + + + + +
序内容类型需求来源目标客户适用场景优先级
`; + +export const REQUIREMENT_LIST = doc({ + tone: 'human', + badge: '执行', + tag: '执行工单', + kicker: 'I01 品牌形象与销售工具包管理 · 执行工单 1/3', + title: '需求受理与信息补充', + titleRef: 'round_ref', + step: '步骤 ① 需求识别与受理', + state: `共 条需求`, + body: + part('做什么', '确认本轮要生产的内容') + + `
+

AI 已完成:按规则识别本轮内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)与 + 需求类型(公司介绍 / 能力声明 / 销售工具包 / 案例集 / 白皮书等),产出下方需求清单(候选基线)。

+

本轮触发:

+

本轮说明:

+

需要你查漏补缺:点「编辑」在清单上增删改 —— 加一行 = 补充新需求、 + 删一行 = 排除、改单元格 = 修正;尤其要补「目标客户、适用场景、优先级」这些 AI 规则之外的信息。

+
` + + part('交回什么', '核对后的内容需求清单(*为必填)') + + `
+ 还差 行内容类型没填 + —— 。 + 按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。 +
` + + editGate( + `${D}.requirement`, + `AI 已识别 个内容需求,点「编辑」增删改`, + ) + + `
+ 加一行 = 补充新需求,删一行 = 排除,改单元格 = 修正 AI 识别的内容。 + 内容类型是必填,缺一条这一单走不到内容创作那一步。 + 改动只落任务草稿层,保存不等于放行。 +
` + + REQUIREMENT_TABLE + + `
+ + 新加行的「内容类型」为必填;「目标客户」填定向客户,通用内容可留空;序号留空即可 —— 系统按本轮现有最大值往下续。 +
` + + flow('审批 / 提交:本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'), + 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 = `
+ + + + + + + + + + + + + + + + + + + + + + +
内容标题资料内容类型语言版本BT 标签品牌调性状态
`; + +export const DRAFT_REVIEW = doc({ + tone: 'ledger', + badge: '验收', + tag: '验收工单', + kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 2/3', + title: '内容初稿审核', + titleRef: 'round_ref', + step: '步骤 ②③④ 产出', + state: `共 份初稿`, + body: + part('AI 结论说明', '结论是怎么算出来的') + + `
+

AI 已完成的计算:读取 L-01 宏观市场信号(产业趋势、市场依据),按 BT 层级草拟内容初稿—— + BT-01 侧重供货能力 / 资质文件、BT-02 侧重技术规格书 / 替代对照表 / 认证报告、 + BT-03 侧重系统方案白皮书 / 参考设计 / 成功案例;维护多语言版本(中 / 英 / 俄)。

+
` + + part('验收判据', '你据此判断「通过 / 驳回」') + + criteria('判断这份内容初稿是否合格,请逐项核对', [ + '品牌调性是否一致:是否符合品牌"气质"标准(专业 / 可信赖 / 高效 / 本土化);', + '技术内容是否准确:规格、数据、认证信息有无错误;', + '合规与多语言校对:合规检查、多语言校对是否通过。', + ]) + + part('AI 产出的内容初稿', '只读展示') + + DRAFT_TABLE + + flow('审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后进入发布,驳回由 AI 重做。'), + foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 2/3 · 内容初稿审核', '工单类型:验收'), +}); + +/* ==================== 局面三 · 拟发布内容(S5/T_release,只读)==================== */ + +const PUBLISH_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + +
内容 ID标题语言版本版本号适用场景BT 标签状态流转
已审核 →
`; + +export const PUBLISH_CONFIRM = doc({ + tone: 'ledger', + badge: '验收', + tag: '验收工单', + kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 3/3', + title: '发布确认', + titleRef: 'round_ref', + step: '步骤 ⑤ 发布入库', + state: `共 份拟发布`, + body: + part('AI 结论说明', 'AI 做了什么') + + `
+

AI 已完成:内容初稿通过审核后,拟正式发布 —— 更新版本号、状态流转(待审核 → 已审核 → 已发布)、 + 登记适用场景与品牌调性标签,写入 L-05 品牌内容库。本轮产出:

+
` + + part('验收判据', '你据此判断「通过 / 驳回」') + + criteria('判断这次发布是否无误,请逐项核对', [ + '版本号与状态是否正确:版本号已更新、状态流转(草稿 → 已审核 → 已发布)无误;', + '场景与 BT 标签是否登记完整;', + '多语言版本是否齐全。', + ]) + + part('拟发布的内容', '只读展示') + + PUBLISH_TABLE + + flow('审批:本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。通过即落账写入 L-05,本次内容生产结束。'), + foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 3/3 · 发布确认', '工单类型:验收 → 完结'), +}); diff --git a/activities/crm-relationship-coverage/__tests__/activity.test.ts b/activities/crm-relationship-coverage/__tests__/activity.test.ts new file mode 100644 index 0000000..4a6d519 --- /dev/null +++ b/activities/crm-relationship-coverage/__tests__/activity.test.ts @@ -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; + 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(' { + 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 [' { + 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 }).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(/'); + } + }); + + 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(''); + const plan = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? ''; + expect(plan).toContain(''); + const receipt = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? ''; + expect(receipt).toContain(''); + }); + + it('宿主拒推含脚本的模版', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.base.interaction.pushHtml('
')).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 是自闭合标签(``,如没有人工激活的 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(/]*?(?:\/>|>[\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(`([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('')))?.[1] ?? ''; + return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&'); +} + +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/crm-relationship-coverage/__tests__/steps.test.ts b/activities/crm-relationship-coverage/__tests__/steps.test.ts new file mode 100644 index 0000000..4c2dc08 --- /dev/null +++ b/activities/crm-relationship-coverage/__tests__/steps.test.ts @@ -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): Record => ({ + ...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)); + 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 = /]*>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(' + + + + + + + + + + +`; + +export const COVERAGE_RECEIPT = doc({ + tone: 'human', + badge: '执行', + tag: '执行工单', + kicker: 'E04 关系覆盖与关键人经营 · 执行工单 2/2', + title: '经营计划执行回执', + titleRef: 'round_ref', + step: '步骤 ⑤⑥ 执行经营动作', + state: `待回执 / 项`, + body: + part('做什么', '执行计划动作') + + `
+

经营计划已验收通过。以下是需要你到场执行的动作(物理拜访、技术交流、递送实物 —— + 都是 AI 做不到、需要人去干的)。每项动作执行后,在下方回执。

+
` + + part('交回什么', '逐项回执(*为必填)') + + `
+ 还差 项回执没填齐 + —— 。 + 按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。 +
` + + editGate( + `${D}.receipt`, + `共 项计划动作待回执,点「编辑」逐项回填`, + ) + + `
+ 可填的是完成状态、结果摘要、客户反馈、关键人变动四列; + 计划动作与对象是承继自计划的只读展示,改不了。确实没有变动的「关键人变动」请选「无变动」,不能留空。 + 改动只落任务草稿层,保存不等于放行。 +
` + + RECEIPT_TABLE + + flow('审批 / 提交:本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。若「关键人变动」标「是」,将触发台账写变更记录。'), + foot: sign('E04 关系覆盖与关键人经营 · 执行工单 2/2 · 经营计划执行回执', '工单类型:执行'), +}); + +/* ==================== 局面四 · 台账更新明细(S6/T_release,只读)==================== */ + +const LEDGER_UPDATE_TABLE = `
+ + + + + + + + + + + + + + + + +
台账字段更新前更新后
+ + + + + 依据: +
`; + +/* + * 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 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: `共 项更新`, + body: + part('AI 结论说明', 'AI 收到回执后做了哪些更新') + + `
+

AI 已完成:收到执行回执后,重算关系覆盖度(coverage_score)、更新决策链地图(decision_chain)、 + 更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。 + 本轮覆盖结论:

+
` + + part('验收判据', '你据此判断「通过 / 驳回」') + + criteria('判断这次台账更新是否无误,请逐项核对', [ + '更新是否有依据:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);', + '数值是否合理:coverage_score、亲密度等数值变化幅度与回执是否吻合;', + '有无漏更或误更:该更新的字段都更新了、不该动的没动。', + ]) + + part('拟更新的台账', '只读展示') + + LEDGER_UPDATE_TABLE + + flow('审批:本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。'), + foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'), +}); diff --git a/api-report/apps/api/src/modules/loop-engine/port.d.ts b/api-report/apps/api/src/modules/loop-engine/port.d.ts index 3683729..0cea4bb 100644 --- a/api-report/apps/api/src/modules/loop-engine/port.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/port.d.ts @@ -154,11 +154,18 @@ export type ToolGateway = { }; /** * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— - * 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。 + * 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔: + * `pass` 才是终局(released);**非 pass = 打回重做,不是终局** —— + * 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型, + * 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22, + * 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有 + * 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。 */ export type PendingAcceptance = { readonly acceptanceCardId: string; readonly verdict?: 'pass' | 'reject'; + /** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */ + readonly comment?: string; readonly selectedPorts?: Record; }; export type CardGateway = { @@ -264,6 +271,18 @@ export type ListenerRegistrar = { }): Promise<{ id: string; }>; + /** + * **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。 + * + * 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。 + * 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执, + * 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 —— + * 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。** + */ + declared(taskId: TaskId): Promise; + }>>; }; /** * 视图流控 —— app 注入 `interaction`(L3)。 diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index a73ff9f..25895f7 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1935,6 +1935,11 @@ export declare const installActivityRoute: { derived: "derived"; }>>; caseKey: z.ZodOptional; + file: z.ZodOptional>; fields: z.ZodArray; format: z.ZodOptional; + options: z.ZodOptional>>; + example: z.ZodOptional; + showWhen: z.ZodOptional>; }, z.core.$strip>>; samples: z.ZodDefault; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -495,6 +496,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -541,6 +543,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -707,6 +710,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -780,6 +784,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1023,6 +1028,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1096,6 +1102,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1167,6 +1174,599 @@ export declare const taskDetailSchema: z.ZodObject<{ }, z.core.$strip>>; finalHtml: z.ZodOptional; }, z.core.$strip>], "mode">>; + decidedDeliverables: z.ZodDefault; + preset: z.ZodOptional>; + ask: z.ZodString; + standard: z.ZodOptional; + steps: z.ZodOptional>; + basedOn: z.ZodOptional>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; + fields: z.ZodArray; + placeholder: z.ZodOptional; + options: z.ZodOptional; + }, z.core.$strip>>>; + group: z.ZodOptional; + hint: z.ZodOptional; + ai: z.ZodOptional; + ledger: z.ZodOptional; + unit: z.ZodOptional; + required: z.ZodOptional; + cols: z.ZodOptional, z.ZodLiteral<2>]>>; + }, z.core.$strip>>; + submitLabel: z.ZodOptional; + gate: z.ZodOptional>; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; + title: z.ZodString; + basis: z.ZodArray>; + stage: z.ZodOptional>; + done: z.ZodOptional; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; + values: z.ZodOptional>; + }, z.core.$strip>>; + finalHtml: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + mode: z.ZodLiteral<"acceptance">; + deliverable: z.core.$ZodBranded; + criteria: z.ZodArray; + pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; + }, z.core.$strip>>; + actions: z.ZodOptional; + detail: z.ZodString; + }, z.core.$strip>>>; + fields: z.ZodDefault; + placeholder: z.ZodOptional; + options: z.ZodOptional; + }, z.core.$strip>>>; + group: z.ZodOptional; + hint: z.ZodOptional; + ai: z.ZodOptional; + ledger: z.ZodOptional; + unit: z.ZodOptional; + required: z.ZodOptional; + cols: z.ZodOptional, z.ZodLiteral<2>]>>; + }, z.core.$strip>>>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; + portChoice: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; + landing: z.ZodOptional; + tables: z.ZodDefault>>; + ports: z.ZodDefault>; + carried: z.ZodDefault>; + }, z.core.$strip>>; + routedPort: z.ZodOptional; + because: z.ZodString; + }, z.core.$strip>>; + passLabel: z.ZodOptional; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; + title: z.ZodString; + basis: z.ZodArray>; + stage: z.ZodOptional>; + done: z.ZodOptional; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; + values: z.ZodOptional>; + }, z.core.$strip>>; + finalHtml: z.ZodOptional; + }, z.core.$strip>], "mode">>>; + assetFiles: z.ZodDefault; + archived: z.ZodBoolean; + }, z.core.$strip>>>; chat: z.ZodArray; text: z.ZodString; @@ -1218,6 +1818,7 @@ export declare const taskDetailSchema: z.ZodObject<{ by: z.core.$ZodBranded; note: z.ZodOptional; }, z.core.$strip>>; + finalHtml: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -1409,6 +2010,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1482,6 +2084,7 @@ export declare const taskDetailSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1723,6 +2326,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1796,6 +2400,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1842,6 +2447,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2008,6 +2614,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2081,6 +2688,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2324,6 +2932,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2397,6 +3006,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2468,6 +3078,599 @@ export declare const taskDetailRoute: { }, z.core.$strip>>; finalHtml: z.ZodOptional; }, z.core.$strip>], "mode">>; + decidedDeliverables: z.ZodDefault; + preset: z.ZodOptional>; + ask: z.ZodString; + standard: z.ZodOptional; + steps: z.ZodOptional>; + basedOn: z.ZodOptional>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; + fields: z.ZodArray; + placeholder: z.ZodOptional; + options: z.ZodOptional; + }, z.core.$strip>>>; + group: z.ZodOptional; + hint: z.ZodOptional; + ai: z.ZodOptional; + ledger: z.ZodOptional; + unit: z.ZodOptional; + required: z.ZodOptional; + cols: z.ZodOptional, z.ZodLiteral<2>]>>; + }, z.core.$strip>>; + submitLabel: z.ZodOptional; + gate: z.ZodOptional>; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; + title: z.ZodString; + basis: z.ZodArray>; + stage: z.ZodOptional>; + done: z.ZodOptional; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; + values: z.ZodOptional>; + }, z.core.$strip>>; + finalHtml: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + mode: z.ZodLiteral<"acceptance">; + deliverable: z.core.$ZodBranded; + criteria: z.ZodArray; + pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; + }, z.core.$strip>>; + actions: z.ZodOptional; + detail: z.ZodString; + }, z.core.$strip>>>; + fields: z.ZodDefault; + placeholder: z.ZodOptional; + options: z.ZodOptional; + }, z.core.$strip>>>; + group: z.ZodOptional; + hint: z.ZodOptional; + ai: z.ZodOptional; + ledger: z.ZodOptional; + unit: z.ZodOptional; + required: z.ZodOptional; + cols: z.ZodOptional, z.ZodLiteral<2>]>>; + }, z.core.$strip>>>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; + portChoice: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; + landing: z.ZodOptional; + tables: z.ZodDefault>>; + ports: z.ZodDefault>; + carried: z.ZodDefault>; + }, z.core.$strip>>; + routedPort: z.ZodOptional; + because: z.ZodString; + }, z.core.$strip>>; + passLabel: z.ZodOptional; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; + title: z.ZodString; + basis: z.ZodArray>; + stage: z.ZodOptional>; + done: z.ZodOptional; + result: z.ZodEnum<{ + redo: "redo"; + revise: "revise"; + submitted: "submitted"; + pass: "pass"; + }>; + values: z.ZodOptional>; + }, z.core.$strip>>; + finalHtml: z.ZodOptional; + }, z.core.$strip>], "mode">>>; + assetFiles: z.ZodDefault; + archived: z.ZodBoolean; + }, z.core.$strip>>>; chat: z.ZodArray; text: z.ZodString; @@ -2519,6 +3722,7 @@ export declare const taskDetailRoute: { by: z.core.$ZodBranded; note: z.ZodOptional; }, z.core.$strip>>; + finalHtml: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -2710,6 +3914,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2783,6 +3988,7 @@ export declare const taskDetailRoute: { kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -2926,6 +4132,64 @@ export declare const saveTableRoute: { rows: z.ZodNumber; }, z.core.$strip>; }; +/** + * **点开一份资料文件** —— 件上的资料名指向哪一行,这里就把那一行现造成文件交出去。 + * + * 为什么按「台账地址 + 主键」取,而不是给文件发一个 id:资料的正身是台账里那一行 + * (标题、正文、要哪种格式都在上头),文件是它的投影。发 id 就等于另存一份, + * 同一份事实两处各存一份 —— 台账改了,那个 id 指的还是旧的。 + * + * `raw=1` 交原字节(下载,真 .docx / .xlsx);不带就交 `FileDoc`,前端照它渲。 + */ +export declare const openAssetFileRoute: { + readonly method: "GET"; + readonly path: "/api/desk/tasks/:taskId/file"; + readonly params: z.ZodObject<{ + taskId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly query: z.ZodObject<{ + address: z.ZodString; + key: z.ZodString; + raw: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + file: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"html">; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + html: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + fileName: z.ZodString; + format: z.ZodString; + }, z.core.$strip>; +}; /** 负责人的自由输入 —— 人事件的第二形态 */ export declare const sayRoute: { readonly method: "POST"; @@ -2987,6 +4251,7 @@ export declare const sayRoute: { by: z.core.$ZodBranded; note: z.ZodOptional; }, z.core.$strip>>; + finalHtml: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 06541fa..22e66ed 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -84,6 +84,7 @@ declare const fileDocSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -221,6 +222,7 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -294,6 +296,7 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -458,6 +461,7 @@ export declare const deliverableSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -531,6 +535,7 @@ export declare const deliverableSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -722,6 +727,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -795,6 +801,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1066,6 +1073,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1139,6 +1147,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1331,6 +1340,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1404,6 +1414,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1647,6 +1658,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1720,6 +1732,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"markdown">; name: z.ZodString; md: z.ZodString; + html: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"excel">; name: z.ZodString; @@ -1824,6 +1837,7 @@ export declare const decisionCardSchema: z.ZodObject<{ by: z.core.$ZodBranded; note: z.ZodOptional; }, z.core.$strip>>; + finalHtml: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>; @@ -1935,6 +1949,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ by: z.core.$ZodBranded; note: z.ZodOptional; }, z.core.$strip>>; + finalHtml: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -2067,6 +2082,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ by: z.core.$ZodBranded; note: z.ZodOptional; }, z.core.$strip>>; + finalHtml: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ diff --git a/api-report/packages/contracts/src/ledger.d.ts b/api-report/packages/contracts/src/ledger.d.ts index 863a8db..ce8cf38 100644 --- a/api-report/packages/contracts/src/ledger.d.ts +++ b/api-report/packages/contracts/src/ledger.d.ts @@ -98,6 +98,11 @@ export declare const objectDeclarationSchema: z.ZodObject<{ derived: "derived"; }>>; caseKey: z.ZodOptional; + file: z.ZodOptional>; fields: z.ZodArray>; caseKey: z.ZodOptional; + file: z.ZodOptional>; fields: z.ZodArray>; caseKey: z.ZodOptional; + file: z.ZodOptional>; fields: z.ZodArray { + // 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); + }); +}); diff --git a/apps/api/src/app/__tests__/file-compose.test.ts b/apps/api/src/app/__tests__/file-compose.test.ts new file mode 100644 index 0000000..dbf7d02 --- /dev/null +++ b/apps/api/src/app/__tests__/file-compose.test.ts @@ -0,0 +1,210 @@ +/** + * 资料文件现造 + 读回来 —— 两头合起来自证:**造出来的是真文件**。 + * + * 断言的重点不是「函数返回了字节」,而是: + * · docx / xlsx 是合法 ZIP,里面那几份 XML 一份不缺(Word / Excel 认的就是它们); + * · 读回来的内容与写进去的一模一样 —— 渲得出来,才谈得上「前端能渲染」; + * · 模型写的字全程转义 —— 正文里带 ` 一段'); + expect(html).toContain('<script>'); + expect(html).not.toContain('' }] }); + expect(out).not.toContain(' + + diff --git a/docs/SS01-客户与市场/e04-html/E04-执行工单-02-执行回执.html b/docs/SS01-客户与市场/e04-html/E04-执行工单-02-执行回执.html new file mode 100644 index 0000000..4349f46 --- /dev/null +++ b/docs/SS01-客户与市场/e04-html/E04-执行工单-02-执行回执.html @@ -0,0 +1,150 @@ + + + + + +执行工单 2 · 经营计划执行回执 · E04 关系覆盖与关键人经营 + + + +
+
+ +
+
执行
+
+
E04 关系覆盖与关键人经营 · 执行工单 2/2
+

经营计划执行回执

+
收件人:AM(执行) · 关联任务:TASK-xxx
+
+
+ 执行工单 + 步骤 ⑤⑥ 执行经营动作 + 最迟交回:2026-08-28 +
+
+ +
+ +

【做什么】—— 执行计划动作

+
+

经营计划已验收通过。以下是需要你到场执行的动作(物理拜访、技术交流、递送实物——都是 AI 做不到、需要人去干的)。每项动作执行后,在下方回执。

+
+ +

【交回什么】—— 逐项回执(*为必填)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#计划动作对象完成状态结果摘要客户反馈关键人变动
1拜访 + 方案交流AvtoKom · 产品 VP
2技术交流AvtoKom · 质量负责人
3价值传递(递样品)AvtoKom · 研发负责人
4维持性经营Boreas · 采购经理
+
+ +
审批 / 提交:本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。若「关键人变动」标「是」,将触发 E02 写 change_log。
+ +
+ +
+ E04 关系覆盖与关键人经营 · 执行工单 2/2 · 经营计划执行回执 + 工单类型:执行 · WO-E04-03 +
+ +
+
+ + diff --git a/docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md b/docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md new file mode 100644 index 0000000..3c113bf --- /dev/null +++ b/docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md @@ -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 做不到的派出去让人干。数据由服务注水,工单只是模板。 diff --git a/docs/SS01-客户与市场/e04-html/E04-验收工单-01-关系经营计划.html b/docs/SS01-客户与市场/e04-html/E04-验收工单-01-关系经营计划.html new file mode 100644 index 0000000..161523c --- /dev/null +++ b/docs/SS01-客户与市场/e04-html/E04-验收工单-01-关系经营计划.html @@ -0,0 +1,148 @@ + + + + + +验收工单 1 · 关系经营计划审批 · E04 关系覆盖与关键人经营 + + + +
+
+ +
+
验收
+
+
E04 关系覆盖与关键人经营 · 验收工单 1/2
+

关系经营计划审批

+
收件人:AM Lead(负责人) · 关联任务:TASK-xxx
+
+
+ 验收工单 + 步骤 ②③④ 产出 + 最迟交回:1 个工作日 +
+
+ +
+ +

【AI 结论说明】—— 结论是怎么算出来的

+
+

AI 已完成的计算:读取 L-02 客户画像、决策链地图、关系覆盖度,读取 L-08 联系人角色 / 影响力 / 亲密度;对照决策链识别空白节点,按「影响力 × 亲密度缺口」排序,得出本轮关系经营计划。

+
+ +

【验收判据】—— 你据此判断「通过 / 驳回」

+
+
判断这份计划是否合理,请逐项核对
+
    +
  • 关键人覆盖是否完整:该经营的决策链关键人(BT-01 / BT-02 / BT-03)有没有漏掉;
  • +
  • 优先级排序是否合理:是否符合「影响力 × 亲密度缺口」的排序逻辑,有没有高优的客户被排低了;
  • +
  • 动作是否可执行:建议动作是否明确、有没有时间点、可不可落地。
  • +
+
+ +

【AI 产出的经营计划】—— 只读展示

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#客户关键人建议动作优先级AI 给出的理由
1AvtoKom 汽车电子产品 VP拜访 + 方案交流高BT-03 高层为空白节点,且 T04 攻坚联动,需优先建立直接关系
2AvtoKom 汽车电子质量负责人技术交流高质量节点空白,BT-02 技术层需加深
3AvtoKom 汽车电子研发负责人价值传递(样品)中研发关系尚浅,递样品推进测试
4Boreas 工业控制采购经理维持性经营中关系稳定,覆盖度 82 / 100,维持即可
+
+ +
审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。
+ +
+ +
+ E04 关系覆盖与关键人经营 · 验收工单 1/2 · 关系经营计划审批 + 工单类型:验收 · WO-E04-02 +
+ +
+
+ + diff --git a/docs/SS01-客户与市场/e04-html/E04-验收工单-02-台账更新确认.html b/docs/SS01-客户与市场/e04-html/E04-验收工单-02-台账更新确认.html new file mode 100644 index 0000000..8a04123 --- /dev/null +++ b/docs/SS01-客户与市场/e04-html/E04-验收工单-02-台账更新确认.html @@ -0,0 +1,150 @@ + + + + + +验收工单 2 · 关系台账更新确认 · E04 关系覆盖与关键人经营 + + + +
+
+ +
+
验收
+
+
E04 关系覆盖与关键人经营 · 验收工单 2/2
+

关系台账更新确认

+
收件人:AM Lead(负责人) · 关联任务:TASK-xxx
+
+
+ 验收工单 + 步骤 ⑥⑦ 更新台账 + 最迟交回:1 个工作日 +
+
+ +
+ +

【AI 结论说明】—— AI 收到回执后做了哪些更新

+
+

AI 已完成:收到执行回执后,重算关系覆盖度(coverage_score)、更新决策链地图(decision_chain)、更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。

+
+ +

【验收判据】—— 你据此判断「通过 / 驳回」

+
+
判断这次台账更新是否无误,请逐项核对
+
    +
  • 更新是否有依据:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);
  • +
  • 数值是否合理:coverage_score、亲密度等数值变化幅度与回执是否吻合;
  • +
  • 有无漏更或误更:该更新的字段都更新了、不该动的没动。
  • +
+
+ +

【拟更新的台账】—— 只读展示

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#台账字段更新前更新后
1L-02 客户主台账coverage_score61 / 10078 / 100
2L-02 客户主台账decision_chain(产品 VP)空白节点已建立直接关系
3L-02 客户主台账interaction_timeline—追加 3 次互动记录
4L-08 联系人台账质量负责人 · 亲密度0 / 52 / 5
5L-08 联系人台账质量负责人 · 关键人标记否是
+
+ +
审批:本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。
+ +
+ +
+ E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认 + 工单类型:验收 · WO-E04-04 +
+ +
+
+ + diff --git a/docs/SS01-客户与市场/i01-html/I01-执行工单-01-需求受理.html b/docs/SS01-客户与市场/i01-html/I01-执行工单-01-需求受理.html new file mode 100644 index 0000000..566621a --- /dev/null +++ b/docs/SS01-客户与市场/i01-html/I01-执行工单-01-需求受理.html @@ -0,0 +1,186 @@ + + + + + +执行工单 1 · 需求受理与信息补充 · I01 品牌形象与销售工具包管理 + + + +
+
+ +
+
执行
+
+
SS01 客户与市场 · I01 品牌形象与销售工具包管理 · 执行工单 1/3
+

需求受理与信息补充

+
收件人:AM(内容运营) · 关联任务:TASK-xxx
+
+
+ 执行工单 + 步骤 ① 需求识别与受理 + 最迟交回:1 个工作日 +
+
+ +
+ +

【做什么】—— 确认本轮要生产的内容

+
+

AI 已完成:按规则识别本轮内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)与需求类型(公司介绍 / 能力声明 / 销售工具包 / 选型指南 / 替代对照表 / 案例集 / 白皮书等),产出下方需求清单(候选基线)。

+

需要你查漏补缺:点「编辑」在清单上增删改——加一行 = 补充新需求、删一行 = 排除、改单元格 = 修正;尤其要补「目标客户、适用场景、优先级」这些 AI 规则之外的信息(如使用方要一份方案,但你线下知道要针对某客户做定向版)。

+
+ +

【交回什么】—— 核对后的内容需求清单(*为必填)

+ +
+ AI 已识别 2 个内容需求,点「编辑」增删改 + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#内容类型需求来源目标客户适用场景优先级
1系统方案白皮书使用方请求(E04 价值传递)AvtoKom(定向)攻坚高
2公司介绍战略调整(进入俄罗斯市场)—(通用)首触中
+
+ +
+ + 新加行的「内容类型」为必填;「目标客户」填定向客户,通用内容可留「—」 +
+ +
审批 / 提交:本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。
+ +
+ +
+ I01 品牌形象与销售工具包管理 · 执行工单 1/3 · 需求受理 + 工单类型:执行 · WO-I01-01 +
+ +
+
+ + + + diff --git a/docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md b/docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md new file mode 100644 index 0000000..d92f754 --- /dev/null +++ b/docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md @@ -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 一个活动,一条生产流程,三张工单:**执行工单让人补需求,验收工单让人审内容、确认发布**——复盘独立在时间轴之后,不混进生产流程。 diff --git a/docs/SS01-客户与市场/i01-html/I01-验收工单-01-内容初稿审核.html b/docs/SS01-客户与市场/i01-html/I01-验收工单-01-内容初稿审核.html new file mode 100644 index 0000000..012ed06 --- /dev/null +++ b/docs/SS01-客户与市场/i01-html/I01-验收工单-01-内容初稿审核.html @@ -0,0 +1,120 @@ + + + + + +验收工单 1 · 内容初稿审核 · I01 品牌形象与销售工具包管理 + + + +
+
+ +
+
验收
+
+
SS01 客户与市场 · I01 品牌形象与销售工具包管理 · 验收工单 2/3
+

内容初稿审核

+
收件人:AM Lead(负责人) · 关联任务:TASK-xxx
+
+
+ 验收工单 + 步骤 ②③④ 产出 + 最迟交回:1 个工作日 +
+
+ +
+ +

【AI 结论说明】—— 结论是怎么算出来的

+
+

AI 已完成的计算:读取 L-01 宏观市场信号(产业趋势、市场依据),按 BT 层级草拟内容初稿——BT-01 侧重供货能力 / 资质文件、BT-02 侧重技术规格书 / 替代对照表 / 认证报告、BT-03 侧重系统方案白皮书 / 参考设计 / 成功案例;维护多语言版本(中 / 英 / 俄)。

+
+ +

【验收判据】—— 你据此判断「通过 / 驳回」

+
+
判断这份内容初稿是否合格,请逐项核对
+
    +
  • 品牌调性是否一致:是否符合品牌「气质」标准(专业 / 可信赖 / 高效 / 本土化);
  • +
  • 技术内容是否准确:规格、数据、认证信息有无错误;
  • +
  • 合规与多语言校对:合规检查、多语言校对是否通过。
  • +
+
+ +

【AI 产出的内容初稿】—— 只读展示

+
+ + + + + + + + + + + + +
#内容标题内容类型语言版本BT 标签品牌调性
1RS358 车规 MCU 替代方案白皮书系统方案白皮书中文 · РусскийBT-03专业 · 可信赖
+
+ +
审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后进入发布,驳回由 AI 重做。
+ +
+ +
+ I01 品牌形象与销售工具包管理 · 验收工单 2/3 · 内容初稿审核 + 工单类型:验收 · WO-I01-02 +
+ +
+
+ + diff --git a/docs/SS01-客户与市场/i01-html/I01-验收工单-02-发布确认.html b/docs/SS01-客户与市场/i01-html/I01-验收工单-02-发布确认.html new file mode 100644 index 0000000..7b56900 --- /dev/null +++ b/docs/SS01-客户与市场/i01-html/I01-验收工单-02-发布确认.html @@ -0,0 +1,123 @@ + + + + + +验收工单 2 · 发布确认 · I01 品牌形象与销售工具包管理 + + + +
+
+ +
+
验收
+
+
SS01 客户与市场 · I01 品牌形象与销售工具包管理 · 验收工单 3/3
+

发布确认

+
收件人:AM Lead(负责人) · 关联任务:TASK-xxx
+
+
+ 验收工单 + 步骤 ⑤ 发布入库 + 最迟交回:1 个工作日 +
+
+ +
+ +

【AI 结论说明】—— AI 做了什么

+
+

AI 已完成:内容初稿通过审核后,拟正式发布——更新版本号、状态流转(待发布 → 已发布)、登记适用场景与 BT 标签,写入 L-05 品牌内容库。

+
+ +

【验收判据】—— 你据此判断「通过 / 驳回」

+
+
判断这次发布是否无误,请逐项核对
+
    +
  • 版本号与状态是否正确:版本号已更新、状态流转(草稿 → 已审核 → 已发布)无误;
  • +
  • 场景与 BT 标签是否登记完整;
  • +
  • 多语言版本是否齐全。
  • +
+
+ +

【拟发布的内容】—— 只读展示

+
+ + + + + + + + + + + + + +
#内容 ID标题语言版本版本号适用场景状态流转
1CT-2025-0088RS358 车规 MCU 替代方案白皮书中文 · Русскийv1.2攻坚待发布 → 已发布
+
+ +
审批:本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。通过即落账写入 L-05,本次内容生产结束。
+ +
+ +
+ I01 品牌形象与销售工具包管理 · 验收工单 3/3 · 发布确认 + 工单类型:验收 · WO-I01-03 +
+ +
+
+ + diff --git a/docs/debt.md b/docs/debt.md index 0555e3e..91261fe 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -74,11 +74,17 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照 ## 工作成果 · 文件 -- **Markdown 只以原文纸面呈现**:富文本渲染(GFM 表格 / 内联 SVG)要 `react-markdown` + - `remark-gfm` + `rehype-raw` 三个依赖,当前成果文件绝大多数是 HTML,先不引。 - 引入前不假装渲染过 —— FileViewer 里以 `
` 出原文。
+- ~~**Markdown 只以原文纸面呈现**~~(2026-08-23 销账):渲染接上了,而且**一个依赖都没引** ——
+  基座那一段 `markdownBody`(`file-compose.ts`)把子集(标题 / 段落 / 有序无序列表 /
+  GFM 表格 / 分隔线 / 粗体 / 行内代码)转成固定几种标签,全程转义,再走同一个沙箱容器渲。
+  与下载到的 .html 是同一段函数出的,两处看着是同一份文件。
+  `FileDoc.markdown.html` 缺席时仍退回 `
` 原文 —— **不假装渲染过**这条没变。
+  仍不认的:内联 SVG、图片、引用块、代码块。认得越多,docx / xlsx 那一侧要对应的越多。
 - **过程成果(deliverable)尚无来源**:活动还不发布 deliverable,所以成果文件目前
   只有活动推的那一屏。装配已接好(`desk.ts` 的 `results`),活动一产出就自动进列表。
+  **注**:2026-08-23 起活动**另有一条产文件的路** —— 台账对象声明 `file` 段,件上挂
+  `data-file-of`,人在工作区点资料名即开(I01 首用)。那条路不经 `deliverable`:
+  资料的正身是台账那一行,文件是它的投影。两条路要不要并成一条,等第二个活动要用时再说。
 
 ## 沟通记录(占位)
 
@@ -952,3 +958,38 @@ FAE 从头到尾没被叫过,单子照样 released(去向还对)。案例②③
 落草稿层,主键靠行内 `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),一单一行一份、重复放行覆盖。
+取文件那条路**存档优先**:归档件上点开的必须是他签字那一刻的那一份,
+而不是台账后来被下一单改成的样子。工作成果那一屏另起一摞「产出的资料」,
+点开即预览、可下载原件。冻结这件事就此定了:**归档的资料与台账脱钩** ——
+台账后来改了,归档件不改口。
diff --git a/docs/活动开发规范.md b/docs/活动开发规范.md
index 28b4d9f..5531ec8 100644
--- a/docs/活动开发规范.md
+++ b/docs/活动开发规范.md
@@ -126,6 +126,17 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in
   注意 `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 基座读数(机检的唯一合法数源)
 
 任务视图顶层永远铺着这些数,**全部由基座现算,模型碰不着**:
diff --git a/package.json b/package.json
index 843f3b3..59d3e4d 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "drizzle-kit": "^0.31.5",
     "eslint": "^9.39.0",
     "globals": "^16.5.0",
+    "jsdom": "^30.0.1",
     "pg": "^8.22.0",
     "tsup": "^8.5.0",
     "typescript": "^5.9.3",
diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts
index b2ab50f..ab090cc 100644
--- a/packages/contracts/src/api/governance.ts
+++ b/packages/contracts/src/api/governance.ts
@@ -524,6 +524,12 @@ export const listActivitiesRoute = {
               label: z.string().optional(),
               /** `file` = 这一格收**真文件**(界面给文件选择器);缺席 = 普通文本格 */
               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(),
             }),
           ),
           /**
diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts
index 0d46690..200c2a0 100644
--- a/packages/contracts/src/api/workbench.ts
+++ b/packages/contracts/src/api/workbench.ts
@@ -157,6 +157,40 @@ export const taskDetailSchema = z.object({
     )
     .default([]),
   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),
   view: z.array(viewBlockSchema),
   /** 活动推送的视图 HTML —— 容器沙箱渲染,无脚本执行权(AM 07) */
@@ -286,6 +320,29 @@ export const saveTableRoute = {
   output: z.object({ ok: z.boolean(), rows: z.number().int().min(0) }),
 } 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 = {
   method: 'POST',
diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts
index 95d3fa8..e916918 100644
--- a/packages/contracts/src/card.ts
+++ b/packages/contracts/src/card.ts
@@ -86,7 +86,15 @@ const fileDocSchema = z.discriminatedUnion('kind', [
    * 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。
    */
   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({
     kind: z.literal('excel'),
     name: z.string(),
@@ -514,6 +522,16 @@ export const decisionCardSchema = z.object({
       note: z.string().optional(),
     })
     .optional(),
+  /**
+   * **答完那一刻的件快照**(与 `workOrderBase.finalHtml` 同一条道理)。
+   *
+   * `decide` 是唯一能循环的验收机制(`issueAcceptance` 一个任务只能发一次,
+   * 中间验收点只能靠决策卡),但决策卡本身不是工单 —— 没有这个字段的话,
+   * 人在这张卡上通过的那份报告(如 E04 的经营计划)永远进不了工作成果:
+   * 那一屏只认 `sys.work_order`,而这一签根本没有工单行。
+   * 回传那一刻基座把当时渲好的件原样存这里,工作成果拼装时并进同一份列表。
+   */
+  finalHtml: z.string().optional(),
 });
 export type DecisionCard = z.infer;
 
diff --git a/packages/contracts/src/ledger.ts b/packages/contracts/src/ledger.ts
index 0d0a76e..44ddfaa 100644
--- a/packages/contracts/src/ledger.ts
+++ b/packages/contracts/src/ledger.ts
@@ -195,6 +195,28 @@ export const objectDeclarationSchema = z.object({
    * 安装期就能核:它在不在 fields 里、是不是主键的一部分。
    */
   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),
 });
 export type ObjectDeclaration = z.infer;
diff --git a/packages/ui-kit/__tests__/view-container-collect.test.ts b/packages/ui-kit/__tests__/view-container-collect.test.ts
new file mode 100644
index 0000000..61fd18b
--- /dev/null
+++ b/packages/ui-kit/__tests__/view-container-collect.test.ts
@@ -0,0 +1,196 @@
+/**
+ * **手写控件嵌套多一层,`collect()` 按行归组不能错行**(2026-08-21 修死)。
+ *
+ * 自动孪生的格子是 ``,往上两层正好是 ``;
+ * 手写的三选一(A2 替代等级、A4 决策卡这类)套了一层分组容器
+ * (`
`, + * input 到 `` 隔了 4 层)——`cell.parentElement?.parentElement` 这个定深两层的老算法 + * 只能爬到那层 `
`,爬不到真正的行。E04 的执行回执表就是这样 + * 栽的:一次改 3 行的完成状态,保存却只留下 1 行(其余两行的单选组各自拆成了 + * 不带主键的伪行,服务端按"随便找到的第一行"兜底,写来写去只剩一行生效)。 + * + * 修法是 `rowOf`:从格子往上走到 host(`data-edit` 容器)的**直接子元素**为止, + * 不管中间嵌了几层都能对上真正的那一行。这里直接用真实 DOM(jsdom,不是手搭的假对象) + * 复刻这个嵌套形状,断言 `collect()` 认出的是 3 行而不是被拆散的碎片。 + * + * 完成状态这一列本身已经从三选一 pill 改成了原生 ` + +
+ + + +
+ + `; + doc.body.innerHTML = ` + ${row('CU-0142-01', 0)}${row('CU-0087-02', 1)}${row('CU-0087-01', 2)} +
`; + 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,直接是 的子节点 */ +function selectTable(): Document { + const doc = document.implementation.createHTMLDocument(''); + const row = (code: string): string => ` + + + + + + + `; + doc.body.innerHTML = ` + ${row('CU-0142-01')}${row('CU-0087-02')}${row('CU-0087-01')} +
`; + return doc; +} + +describe('collect() 与 unanswered():下拉换掉三选一之后,同样的多行独立编辑要成立', () => { + it('三行各选一个完成状态,三行都收得到、互不串行', () => { + const doc = selectTable(); + const selects = Array.from(doc.querySelectorAll('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('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('select[data-col="completion_status"]')!; + select.value = 'done'; + expect(unanswered(select)).toBe(false); + }); + + it('普通文本输入永远算已答(没有"没答"这一态)', () => { + const doc = selectTable(); + const input = doc.querySelector('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 => ` + + + + `; + doc.body.innerHTML = ` + ${row('CU-0142-01')}${row('CU-0087-02')} +
`; + return doc; +} + +describe('collect():textarea 与文本输入同源同待 —— E04 回执的结果摘要照目标件是多行文本', () => { + it('人在 textarea 里写的话收得到,主键对得上行', () => { + const doc = textareaTable(); + const areas = Array.from(doc.querySelectorAll('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('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(); + }); +}); diff --git a/packages/ui-kit/src/Ico.tsx b/packages/ui-kit/src/Ico.tsx index b274660..38679e3 100644 --- a/packages/ui-kit/src/Ico.tsx +++ b/packages/ui-kit/src/Ico.tsx @@ -17,6 +17,7 @@ const GLYPHS: Record = { "sliders-horizontal": (<>), filter: (), "arrow-left": (<>), + download: (<>), "file-spreadsheet": (<>), "file-type": (<>), wrench: (), diff --git a/packages/ui-kit/src/ViewContainer.tsx b/packages/ui-kit/src/ViewContainer.tsx index f2e7343..2fbc5fe 100644 --- a/packages/ui-kit/src/ViewContainer.tsx +++ b/packages/ui-kit/src/ViewContainer.tsx @@ -38,6 +38,13 @@ export type ViewContainerProps = { readonly editable?: boolean; /** 人按下「保存」时把那张表收上来;不传就是纯呈现 */ readonly onEdits?: (save: ViewSave) => void; + /** + * **件上的资料名被点了** —— 收到 `data-file="域.对象/主键值"` 那一下。 + * + * 容器只报「点的是哪一份」,取文件、渲文件都不在这儿:这份组件的本分是 + * 把沙箱里发生的事如实交出去,至于跳到哪一屏是工作区的事。 + */ + readonly onOpenFile?: (ref: { address: string; key: string }) => void; /** * **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告, * 合理利用右上角的翻页」):件里带 `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; if (!bothElements) { 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; } morphNode(o as Element, n as Element); @@ -154,13 +166,42 @@ function bodyOf(doc: Document, address: string): HTMLElement | null { return doc.querySelector(`[data-edit="${CSS.escape(address)}"]`); } -/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内 */ -function cellsOf(doc: Document, address: string): HTMLInputElement[] { +/** + * 一个格子所在的那一行 —— **host 的直接子元素**,不管格子本身嵌了几层。 + * + * 自动孪生的格子(`twinInputs` 写出来的)是 ``,往上两层正好是行; + * 手写的三选一(`.jc-radiogrp` 套一层 `