人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
+10 -5
View File
@@ -17,13 +17,18 @@ export type ActivityOutcome =
export type ActivityDefinition = {
readonly name: string;
/** 范式只存在于引擎层;此处声明是给装载器看的 */
readonly paradigm: 'loop' | 'flow';
/**
* 一个回合的推进。由**引擎**驱动调用 —— 活动不自转、不自设 maxSteps;
* 预算与熔断由基座在环外强制。
* 一个回合的推进 —— **可选**。
*
* 2026-07-29 范式退役后,活动一律案例模型:推进由 Loop 驱动(定位 → 择向 → 决断 →
* 行动),行动落在基座上,不经活动代码。所以绝大多数活动**不需要 run** ——
* 它的行为写在清单里(案例模型 + 说明书 + DMN + 技能 + 台账声明),
* 「系统不执行代码即知包为何物」。
*
* 留着它是为确定性子序列(结晶片段)将来有个落点;不写就是不写,不是缺件。
* 活动不自转、不自设 maxSteps;预算与熔断由基座在环外强制。
*/
run(base: ActivityBase): Promise<ActivityOutcome>;
run?(base: ActivityBase): Promise<ActivityOutcome>;
};
export function defineActivity(definition: ActivityDefinition): ActivityDefinition {
+17 -1
View File
@@ -128,5 +128,21 @@ export function createMockHost(options: HostOptions): MockHost {
},
};
return { base, recording, run: (definition) => definition.run(base) };
return {
base,
recording,
/*
* 跑一个回合。**没有 `run` 的活动不是残包** —— 案例活动的推进由 Loop 驱动,
* 行为写在清单里(案例模型 + 说明书 + DMN + 技能 + 台账声明),代码入口只被静态审扫描。
* 所以这里如实报错,不静默返回一个假的 outcome:那会让自测「过了」而什么也没测。
*/
run: (definition) => {
if (definition.run === undefined) {
return Promise.reject(
new Error(`活动 ${definition.name} 没有 run —— 案例活动的推进由 Loop 驱动,自测请测清单与资产,不要跑回合`),
);
}
return definition.run(base);
},
};
}
+16 -2
View File
@@ -27,7 +27,21 @@ export type {
export type { HostOptions, HostRecording, MockHost } from './host.ts';
export { createMockHost } from './host.ts';
export type { Tone } from './view.ts';
export { kvCard, noteCard, stagesCard, tableCard, viewShell } from './view.ts';
export type { ColStyle, FactTone, Stamp, Tone } from './view.ts';
export {
actionList,
bigCount,
factCards,
letter,
meter,
noteOnly,
ruleTable,
section,
stagesOnly,
statsBand,
tableOnly,
verdict,
viewShell,
} from './view.ts';
export const SDK_VERSION = '0.1.0-m5';
+666 -72
View File
@@ -3,111 +3,705 @@
*
* 模版是**内容不是代码**:这里产出的一律是无脚本、无事件处理器、无外链的静态 HTML;
* 数据由基座在推送时填(`data-slot` 取值、`data-rows` 逐条展开)。
* 容器是沙箱 iframe(`sandbox=""`),因此**样式必须自带** —— 页面不继承工作台的 CSS。
* 容器是沙箱 iframe(`sandbox=""`),因此**样式必须自带** —— 页面不继承工作台的 CSS,
* 也**不能引外部字体**(净化门拦 `src`/`href` 的 `//`),所以只用系统字族。
*
* ── 版面取正式汇报件(2026-07-31 重做,对齐 PRD 原型 `jc-report`)────────────
*
* 从前这里是「一个色条 + 几张灰卡」:能读,但不像一份**交得出去的件**。
* 人在这一屏上要签字、要拿去对账,它就该长成一份汇报件的样子:
*
* 题头(图标徽章 · 类型行 · 标题 · 状态章)
* → 元信息一行(编号 / 客户 / 编制 …,竖线分隔,不做格子)
* → 关键指标带(判据摊上台面,洗蓝渐变格)
* → 编号章节(一 / 二 / 三,方徽 + 标题 + 细线 + 右侧结论签)
* → 落款(署名 + 时刻)
*
* **色彩纪律:结构用品牌蓝,数据才用语义色。** 整屏只有一处渐变(题头徽章),
* 绿/橙/红只出现在「这个数达标没达标」上 —— 满屏彩色等于没有重点。
*
* 图标一律**内联 SVG**(lucide 线形,1.75 描边):沙箱里取不到图标字体,
* 而 emoji 当图标是另一回事 —— 它在不同系统上是不同的画,还带情绪。
*
* 六个活动共用这一套外壳:汇报面貌一致,不是每个活动各自发明一套。
*/
export type Tone = 'brand' | 'ok' | 'warn' | 'danger' | 'case' | 'archive';
/** 主色一档一个 —— 题头徽章与结构线取它 */
const ACCENT: Readonly<Record<Tone, string>> = {
brand: '#3370ff',
ok: '#0aa294',
warn: '#ff8800',
danger: '#e5484d',
ok: '#278717',
warn: '#de7802',
danger: '#d83931',
case: '#7f3bf5',
archive: '#46586a',
};
/** 一套克制的排版;所有尺寸用相对单位,窄栏也不横向溢出 */
const BASE_CSS = (accent: string): string => `
:root { color-scheme: light; }
/** 题头徽章的渐变收尾色 —— 与 PRD 的 `--ai-grad` 同一手法(蓝 → 紫) */
const ACCENT_B: Readonly<Record<Tone, string>> = {
brand: '#7f3bf5',
ok: '#0aa294',
warn: '#f54a45',
danger: '#a3231d',
case: '#3370ff',
archive: '#6a7590',
};
/* ==================== 图标 ==================== */
/**
* 内联 lucide 线形图标 —— **只收这套模版真正用得上的**。
*
* 不做成「全量图标库」:模版是随包冻结的字符串,塞一百个图标就是给每一份报告
* 都背上一百个 path。要新图标就往这里加一条,加的时候顺手想想是不是真的需要。
*/
const ICONS: Readonly<Record<string, string>> = {
'file-text':
'<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
'list-checks':
'<path d="M3 5h1l1.5 1.5L8 4"/><path d="M3 12h1l1.5 1.5L8 10"/><path d="M3 19h1l1.5 1.5L8 17"/><path d="M12 5h9"/><path d="M12 12h9"/><path d="M12 19h9"/>',
database:
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
coins:
'<circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>',
handshake:
'<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.9-3.9a2 2 0 0 1 0-2.8L17 5"/><path d="M21 3 9.6 14.4a2 2 0 0 1-2.8 0l-2.4-2.4a2 2 0 0 1 0-2.8L7 6"/>',
archive:
'<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
'shield-check': '<path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V6l8-3 8 3Z"/><path d="m9 12 2 2 4-4"/>',
'alert-triangle':
'<path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'circle-check': '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m2 7 8.9 5.3a2 2 0 0 0 2.2 0L22 7"/>',
workflow:
'<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
package:
'<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
users:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/>',
'mail-open':
'<path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/>',
building:
'<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/>',
user: '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
boxes:
'<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
sheet:
'<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>',
'git-branch': '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
};
/** 一枚图标 —— 认不出的名字回一个空盒,不摆一个错的图标充数 */
function ico(name: string, size = 14): string {
const path = ICONS[name];
if (path === undefined) return '';
return `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
}
/* ==================== 骨架样式 ==================== */
/** 版面取正式汇报件;尺寸一律相对单位,窄栏也不横向溢出 */
const BASE_CSS = (a: string, b: string): string => `
/*
* ── 版面**照 PRD 目标件的数值抄**(2026-08-01 重做)────────────────────
*
* ~/coding/JCAI_PRD/docs/活动执行页/ 那两份是这个活动「办完该长什么样」的答案。
* 上一版我把「块」都搬齐了,却用了自己那套外壳:题头下多一段灰字摘要、
* 多一条元信息带、章节头是彩色方块编号 + 大标题、字号用 clamp 响应式。
* 结果是**块齐了、版面是两套**,用户一眼就说「跟我给你的完全不一样」。
*
* 所以这一版把数值逐条抄过来:题头 16/20/13、章节头 11px/800/.12em + 细线 + 右签、
* 正文 12px、表头 10.5px、落款 13/20/15。**字号一律固定** ——
* clamp 会让同一份件在不同宽度下长得不一样,而目标件只有一个样子。
*/
:root {
color-scheme: light;
--a: ${a}; --a-b: ${b};
--a-wash: color-mix(in srgb, ${a} 7%, #fff);
--a-soft: color-mix(in srgb, ${a} 20%, #fff);
--brand: #3370ff; --brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb; --cardline: #e1e6ec;
--ok: #278717; --ok-soft: #e8f8e4; --ok-line: #bfedb4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9; --danger-line: #f9c1be;
--teal: #0aa294;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51,112,255,.22);
}
* { box-sizing: border-box; }
/*
* **报告本体就是工作区** —— 不在里面再画一张居中的小卡片。
* 容器(.jc-view-frame)已经是 100% 宽、白底、带圆角的那张卡。
*/
body {
margin: 0; padding: 18px;
font: 13px/1.6 -apple-system, "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
color: #1f2933; background: #f7f8fa;
margin: 0; padding: 0;
font: 13px/1.6 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
color: var(--ink); background: #fff;
-webkit-font-smoothing: antialiased;
}
.rp { width: 100%; background: #fff; overflow: hidden; }
.ic { flex: none; vertical-align: -.14em; }
/* ── 题头:徽章 + 类型行 + 标题 + 状态;下缘粗细双线 = 正式件 ── */
.hd { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px;
padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
.hd::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
.hd::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px;
background: radial-gradient(circle, color-mix(in srgb, var(--a) 10%, transparent), transparent 70%); pointer-events: none; }
.hd > * { position: relative; z-index: 1; }
.hd .bdg { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center;
color: #fff; background: linear-gradient(135deg, var(--a), var(--a-b)); box-shadow: var(--shadow-brand); }
.hd .tt { flex: 1; min-width: 0; }
.hd .kick { font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.hd h1 { margin: 4px 0 0; font-size: 18px; font-weight: 800; letter-spacing: -.01em; line-height: 1.3; overflow-wrap: anywhere; }
.hd h1 .ref { font-family: var(--mono); font-size: .88em; letter-spacing: -.02em; }
.hd .stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor;
border-radius: 9px; padding: 5px 9px 4px 14px; font-size: 14px; font-weight: 900;
letter-spacing: .3em; color: var(--ok); opacity: .9; }
.hd .stamp.bad { color: var(--danger); }
.hd .live { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px;
padding: 4px 12px; border-radius: 999px; font-size: 11px; font-weight: 700;
color: var(--a); background: var(--a-wash); border: 1px solid var(--a-soft); }
/* ── 正文区与章节头:**小标签 + 细线 + 右端小签**,没有编号方块 ── */
.bd { padding: 18px 20px 12px; }
.sh { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 22px 0 11px; }
.s:first-child .sh { margin-top: 0; }
.sh b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.sh .ln { flex: 1; height: 1px; background: var(--line-soft); }
.sh .aside { flex: none; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono);
background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.sh .aside.ok { color: var(--ok); background: var(--ok-soft); }
.sh .aside.bad { color: var(--danger); background: var(--danger-soft); }
/* ── 关键指标带(要用才用;两份目标件都没有)── */
.stats { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line-soft); border: 1px solid var(--line-soft); border-radius: 10px; overflow: hidden; }
.stat { flex: 1 1 140px; min-width: 0; padding: 10px 14px 11px; background: #fff; }
.stat i { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .09em; color: var(--ink-4); }
.stat b { display: block; margin-top: 3px; font-size: 14px; font-weight: 800; line-height: 1.5; overflow-wrap: anywhere; }
/* ── 来件原文:青色左脊 = 收进来的东西 ── */
.lt { border: 1px solid var(--cardline); border-left: 3px solid var(--teal); border-radius: 12px;
padding: 14px 16px 15px; background: #fff; box-shadow: 0 1px 2px rgba(31,35,41,.05); }
.lt .lh { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; }
.lt .dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800;
padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.lt .lh .own { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px;
background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 10.5px; color: #245bdb; }
.lt .lh .own b { font-weight: 600; }
.lt .lh .own i { font-style: normal; opacity: .55; }
.lt .lh .own:has(b:empty) { display: none; }
.lt .lh .at { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.lt .lm { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px;
border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.lt .lm p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.lt .lm i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.lt .lm b { font-weight: 400; font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.lt h4 { margin: 0 0 9px; font-size: 14px; font-weight: 800; line-height: 1.45; overflow-wrap: anywhere; }
/* 正文原文照录:pre-wrap 保原始换行,不截断、不折叠 */
.lt .lb { margin: 0; font: inherit; font-size: 12.5px; line-height: 1.72; color: var(--ink-2);
white-space: pre-wrap; overflow-wrap: anywhere; }
.lt .atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.lt .att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft);
border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: #245bdb; }
.lt .att b { font-family: var(--mono); font-weight: 400; font-size: 10.5px; }
.lt .att span { color: var(--ink-4); }
/* ── 要素卡:顶缘色棱 + 右上光晕 ── */
.facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.fact { position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
--t: var(--brand); --t-soft: rgba(51,112,255,.13); }
.fact::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--t); }
.fact::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px;
background: radial-gradient(circle, var(--t-soft), transparent 70%); pointer-events: none; }
.fact > * { position: relative; z-index: 1; }
.fact.t-teal { --t: var(--teal); --t-soft: rgba(10,162,148,.15); }
.fact.t-ok { --t: var(--ok); --t-soft: rgba(39,135,23,.14); }
.fact.t-warn { --t: var(--warn-accent); --t-soft: rgba(255,136,0,.12); }
.fact .k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--t); }
.fact .k i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center;
background: var(--t-soft); color: var(--t); }
.fact .v { font-size: 14px; font-weight: 800; line-height: 1.35; overflow-wrap: anywhere; }
.fact .v.mono { font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--ink-2);
background: var(--t-soft); border-radius: 8px; padding: 3px 9px; display: inline-block; }
.fact .n { margin-top: auto; font-size: 10.5px; line-height: 1.55; color: var(--ink-4); overflow-wrap: anywhere; }
.fact .n b { color: var(--ink-2); font-weight: 700; }
.fact .row { display: flex; align-items: center; gap: 10px; min-width: 0; }
.fact .row > .col { min-width: 0; flex: 1; }
.ava { flex: none; width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center;
color: #fff; background: linear-gradient(135deg, var(--a), var(--a-b)); border-radius: 12px;
box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
.ava.person { border-radius: 999px; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9;
box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 大号计数 —— 照目标件:32px 等宽 */
.big { display: flex; align-items: baseline; gap: 6px; }
.big > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; letter-spacing: -.04em; }
.big > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一条数据一格,按值上色 */
.meter { display: flex; gap: 3px; margin: 0; padding: 0; list-style: none; min-height: 7px; }
.meter > li { flex: 1 1 0; min-width: 4px; height: 7px; border-radius: 2px; background: var(--line); }
.meter > li.v-a { background: var(--ok); }
.meter > li.v-b { background: var(--brand); }
.meter > li.v-c { background: var(--warn-accent); }
.meter > li.v-0 { background: var(--ok); }
.meter > li.v-1, .meter > li.v-2, .meter > li.v-3 { background: var(--danger); }
.meter:empty { height: 7px; border-radius: 3px; background: repeating-linear-gradient(45deg, var(--line-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--line); }
.mlg { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 8px; font-size: 11px; color: var(--ink-3); }
.mlg span { display: inline-flex; align-items: center; gap: 6px; }
.mlg i { width: 9px; height: 9px; border-radius: 2px; }
.mlg i.v-a { background: var(--ok); } .mlg i.v-b { background: var(--brand); } .mlg i.v-c { background: var(--warn-accent); }
.mlg b { font-weight: 800; color: var(--ink); font-family: var(--mono); }
/* ── 表 ── */
.tw { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
th { text-align: left; font-weight: 800; font-size: 10.5px; letter-spacing: .03em; color: var(--ink-3);
padding: 8px 9px; background: var(--soft); border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
td { padding: 6px 9px; border-bottom: 1px solid var(--line-soft); color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: #f7f9fd; }
/* 序号列由 CSS 计数器排 —— 模版里没有循环变量 */
tbody { counter-reset: rn; }
td.rn { position: relative; width: 38px; padding-left: 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
td.rn::before { counter-increment: rn; content: counter(rn); }
td.num { white-space: nowrap; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
td.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); overflow-wrap: break-word; }
/* 空格子自己说话:必填红、选填灰;缺必填的整行浅红 + 行首红条 */
td.req:empty::after, td.opt:empty::after {
display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; line-height: 1;
padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
td.req:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
td.opt:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
tbody tr:has(td.req:empty) td { background: #fffbfb; }
tbody tr:has(td.req:empty):hover td { background: #fff5f5; }
tbody tr:has(td.req:empty) td.rn { color: var(--danger); font-weight: 800; }
tbody tr:has(td.req:empty) td.rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
/* 短标签出药丸,按值分档上色(data-class-of 投影) */
td.pill { white-space: nowrap; }
td.pill > span { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px;
font-size: 11px; font-weight: 800; font-family: var(--mono);
background: var(--soft); color: var(--ink-3); border: 1px solid var(--line); }
td.pill > span.v-a { background: var(--ok-soft); color: var(--ok); border-color: var(--ok-line); }
td.pill > span.v-b { background: var(--brand-wash); color: #245bdb; border-color: var(--brand-soft); }
td.pill > span.v-c { background: var(--warn-soft); color: var(--warn); border-color: #ffd9a8; }
td.pill > span.v-true { background: var(--ok-soft); color: var(--ok); border-color: var(--ok-line); width: auto; padding: 0 9px; }
td.pill > span.v-false { width: auto; padding: 0 9px; }
.mono { font-family: var(--mono); }
.tag { display: inline-block; padding: 1.5px 9px; border-radius: 999px; font-size: 11px; font-weight: 700;
background: var(--a-wash); color: var(--a); border: 1px solid var(--a-soft); }
/* ── 说明:蓝底一条,摆在数据旁边 ── */
.note { display: flex; gap: 8px; align-items: flex-start; margin: 11px 0 0; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: #245bdb; }
.note svg { flex: none; margin-top: 3px; }
.note b { font-weight: 700; }
/* 剔除行说明:虚线框、灰底 —— 与蓝底那条分得开(它说的是「没做什么」) */
.drop { display: flex; gap: 8px; align-items: flex-start; margin: 10px 0 0; padding: 9px 12px; border-radius: 9px;
border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; line-height: 1.6; color: var(--ink-3); }
.drop svg { flex: none; margin-top: 3px; color: var(--ink-4); }
.drop b, .drop code { color: var(--ink-2); font-weight: 700; }
.drop code { font-family: var(--mono); font-size: 10.5px; }
/* ── 分步 / 阶段 ── */
.steps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 7px; }
.steps li { display: flex; gap: 9px; align-items: flex-start; font-size: 12px; line-height: 1.65; color: var(--ink-2); }
.steps .n { flex: none; width: 19px; height: 19px; margin-top: 1px; border-radius: 999px; display: grid;
place-items: center; font-size: 10.5px; font-weight: 800; color: var(--a);
background: var(--a-wash); border: 1px solid var(--a-soft); font-family: var(--mono); }
.steps .t { font-weight: 700; }
.steps .d { color: var(--ink-4); font-size: 11px; }
/* ── 按序求值的规则表 ── */
.rules { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.rules > div { display: grid; grid-template-columns: 26px minmax(0, 1fr) minmax(0, 160px) minmax(0, 68px); gap: 10px;
align-items: center; padding: 10px 14px; font-size: 12px; color: var(--ink-2); }
.rules > div + div { border-top: 1px solid var(--line-soft); }
.rules > .h { background: var(--soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
.rules .no { font-family: var(--mono); font-weight: 800; color: var(--ink-4); }
.rules .go { font-weight: 700; }
.rules .go code { font-family: var(--mono); font-size: 11px; color: var(--a); margin-right: 5px; }
.rules .cnt { font-family: var(--mono); font-weight: 800; text-align: right; }
/* ── 落地动作 ── */
.acts { margin: 0; padding: 0; list-style: none; border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.acts li { display: flex; align-items: flex-start; gap: 10px; padding: 11px 14px; font-size: 12px; line-height: 1.65; color: var(--ink-3); }
.acts li + li { border-top: 1px solid var(--line-soft); }
.acts .m { flex: none; width: 20px; height: 20px; margin-top: 1px; border-radius: 999px; display: grid; place-items: center;
color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-line); }
.acts b { display: block; font-weight: 800; color: var(--ink); margin-bottom: 2px; }
/* ── 结论章 ── */
.verdict { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 11px 0 0;
padding: 11px 14px; border-radius: 10px; background: var(--ok-soft);
border: 1px solid var(--ok-line); color: var(--ok); font-size: 13px; font-weight: 800; }
.verdict.bad { background: var(--danger-soft); border-color: var(--danger-line); color: var(--danger); }
.verdict.wait { background: var(--warn-soft); border-color: #f7d9ab; color: var(--warn); }
.verdict span { font-weight: 500; font-size: 11.5px; color: var(--ink-2); }
/* ── 落款 ── */
.foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd;
font-size: 10.5px; color: var(--ink-4); }
.foot svg { color: var(--a); }
.foot b { font-size: 11.5px; font-weight: 700; color: var(--ink-2); }
.foot .ftag { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; padding: 6px 13px;
border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--a);
background: #fff; border: 1px solid var(--a-soft); }
@media (max-width: 1040px) { .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) {
.facts { grid-template-columns: 1fr; }
.hd, .bd, .foot { padding-left: 13px; padding-right: 13px; }
}
.wrap { max-width: 760px; margin: 0 auto; }
.hd { display: flex; align-items: stretch; gap: 12px; margin-bottom: 14px; }
.hd .bar { width: 4px; border-radius: 2px; background: ${accent}; flex: none; }
.hd .kick { font-size: 11px; letter-spacing: .08em; color: #6a7686; }
.hd h1 { margin: 2px 0 0; font-size: 17px; font-weight: 650; letter-spacing: .01em; }
.card { background: #fff; border: 1px solid #e6e9ee; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
.card h2 { margin: 0 0 10px; font-size: 12px; font-weight: 600; color: #6a7686; letter-spacing: .04em; }
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px 18px; }
.kv > div { min-width: 0; }
.kv em { display: block; font-style: normal; font-size: 11px; color: #8b95a3; margin-bottom: 2px; }
.kv b { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.tbl-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { text-align: left; font-weight: 600; color: #6a7686; padding: 6px 8px; border-bottom: 1px solid #e6e9ee; white-space: nowrap; }
td { padding: 7px 8px; border-bottom: 1px solid #f0f2f5; overflow-wrap: anywhere; }
tr:last-child td { border-bottom: 0; }
.mono { font-family: "SF Mono", Menlo, Consolas, monospace; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
background: ${accent}1a; color: ${accent}; }
.note { font-size: 11px; color: #8b95a3; margin-top: 10px; line-height: 1.75; }
.steps { list-style: none; margin: 0; padding: 0; }
.steps li { position: relative; padding: 0 0 12px 20px; border-left: 2px solid #e6e9ee; }
.steps li:last-child { padding-bottom: 0; border-left-color: transparent; }
.steps li::before { content: ""; position: absolute; left: -5px; top: 5px; width: 8px; height: 8px;
border-radius: 50%; background: ${accent}; }
.steps .t { font-weight: 600; }
.steps .d { color: #8b95a3; font-size: 11.5px; }
`;
/** 页头 + 卡片区 —— 每张模版都长这样,只换标题与内容 */
/* ==================== 外壳 ==================== */
/** 状态章:办结盖印 / 在办亮灯 —— 两者都不写就没有 */
export type Stamp =
| { readonly kind: 'stamp'; readonly text: string; readonly bad?: boolean }
| { readonly kind: 'live'; readonly text: string };
/**
* 一份汇报件的外壳 —— 题头 + 元信息 + 正文 + 落款。
*
* `icon` 认 `ICONS` 里的名字(file-text / list-checks / database / search / coins /
* handshake / archive / shield-check / package / gauge / mail / workflow / users / send);
* 不给就按 tone 落一个默认的。
*/
export function viewShell(input: {
title: string;
kicker: string;
tone: Tone;
body: string;
/**
* 标题后面那半截**由数据填**的取值路径 —— 一般是单号。
* 「询价受理需求」是件的名字,「MD14.1」才说得出是哪一单。
*/
titleRef?: string;
/** 题头徽章的图标名 */
icon?: string;
/** 右上角状态章 */
stamp?: Stamp;
/** 落款一句(署名与出处);不给就落一句通用的 */
sign?: string;
/**
* 落款右端那枚小签 —— 这份件在流里是什么位置(「A1 受理归一 · 交下游」)。
* **写位置,不写状态**:归档件与在办件是同一张模版。
*/
footTag?: string;
}): string {
const accent = ACCENT[input.tone];
return `<style>${BASE_CSS(accent)}</style>
<div class="wrap">
<div class="hd"><span class="bar"></span><div><div class="kick">${input.kicker}</div><h1>${input.title}</h1></div></div>
${input.body}
const a = ACCENT[input.tone];
const b = ACCENT_B[input.tone];
const badge = ico(input.icon ?? DEFAULT_ICON[input.tone], 18);
const stamp =
input.stamp === undefined
? ''
: input.stamp.kind === 'stamp'
? `<span class="stamp${input.stamp.bad === true ? ' bad' : ''}">${input.stamp.text}</span>`
: `<span class="live">${ico('gauge', 12)}${input.stamp.text}</span>`;
return `<style>${BASE_CSS(a, b)}</style>
<div class="rp">
<div class="hd">
<span class="bdg">${badge}</span>
<div class="tt">
<div class="kick">${input.kicker}</div>
<h1>${input.title}${input.titleRef === undefined ? '' : ` <span class="ref" data-slot="${input.titleRef}"></span>`}</h1>
</div>
${stamp}
</div>
<div class="bd">${input.body}</div>
<div class="foot">${ico('file-text', 12)}<b>${input.sign ?? '本页由活动按当下局面生成'}</b> · 放行前的一切都还改得动${
input.footTag === undefined ? '' : `<span class="ftag">${ico('git-branch', 12)}${input.footTag}</span>`
}</div>
</div>`;
}
/** 键值卡:`[标签, 取值路径]`;`mono` 用等宽显示编号类字段 */
export function kvCard(title: string, items: ReadonlyArray<readonly [string, string, ('mono' | 'tag')?]>): string {
const cells = items
.map(([label, path, style]) => {
const cls = style === undefined ? '' : ` class="${style === 'mono' ? 'mono' : 'tag'}"`;
return `<div><em>${label}</em><b${cls} data-slot="${path}"></b></div>`;
})
.join('');
return `<div class="card"><h2>${title}</h2><div class="kv">${cells}</div></div>`;
const DEFAULT_ICON: Readonly<Record<Tone, string>> = {
brand: 'file-text',
ok: 'circle-check',
warn: 'alert-triangle',
danger: 'alert-triangle',
case: 'workflow',
archive: 'archive',
};
/* ==================== 件 ==================== */
/**
* 关键指标带 —— **判据摊上台面**。
* 摆的是人做判断要看的那三五个数,不是所有数;摆多了就没有「关键」可言。
*/
export function statsBand(items: ReadonlyArray<readonly [string, string]>): string {
return `<div class="stats">${items
.map(([label, path]) => `<div class="stat"><i>${label}</i><b data-slot="${path}"></b></div>`)
.join('')}</div>`;
}
/** 逐条表:行数不写死,由 `data-rows` 展开 */
export function tableCard(
title: string,
/**
* 章节头 —— **小号加粗标签 + 一条细线 + 右端小签**,照 PRD 目标件。
*
* 从前这里是「彩色方块编号(一/二/三)+ 大标题」。那是我自己发明的一套:
* 编号在一份两三屏的件里没有指路作用(人不会说「翻到第四节」),
* 而方块 + 大标题把每一节都拔成一个新的开始 —— 一份件被切成了五份。
* 目标件用的是**分隔而非分段**:一行小字压住,右端一枚 mono 小签说这一段的口径。
*/
export function section(title: string, body: string, aside?: { readonly text: string; readonly tone?: 'ok' | 'bad' }): string {
const pill =
aside === undefined
? ''
: `<span class="aside${aside.tone === undefined ? '' : ` ${aside.tone}`}">${aside.text}</span>`;
return `<div class="s"><div class="sh"><b>${title}</b><span class="ln"></span>${pill}</div>${body}</div>`;
}
export type ColStyle =
| 'mono'
| 'num'
| 'pill'
| 'req'
| 'opt'
| 'req-mono'
| 'opt-mono'
| 'req-num'
| 'opt-num';
const COL_CLASS: Readonly<Record<ColStyle, string>> = {
mono: 'mono',
num: 'num',
pill: 'pill',
req: 'req',
opt: 'opt',
'req-mono': 'req mono',
'opt-mono': 'opt mono',
'req-num': 'req num',
'opt-num': 'opt num',
};
/**
* 逐条表:行数不写死,由 `data-rows` 展开。
*
* `numbered` 给一列序号 —— 由 CSS 计数器排,模版里没有循环变量。
* 有了行号,「第 9 条缺年用量」才指得出是哪一行。
*/
export function tableOnly(
rowsPath: string,
columns: ReadonlyArray<readonly [string, string, ('mono')?]>,
note?: string,
columns: ReadonlyArray<readonly [string, string, ColStyle?]>,
options?: { readonly numbered?: boolean },
): string {
const head = columns.map(([label]) => `<th>${label}</th>`).join('');
const cells = columns
.map(([, path, style]) => `<td${style === 'mono' ? ' class="mono"' : ''} data-slot="${path}"></td>`)
.join('');
return `<div class="card"><h2>${title}</h2><div class="tbl-wrap"><table>
<thead><tr>${head}</tr></thead>
<tbody data-rows="${rowsPath}"><tr>${cells}</tr></tbody>
</table></div>${note === undefined ? '' : `<p class="note">${note}</p>`}</div>`;
const numbered = options?.numbered === true;
const head =
(numbered ? '<th class="rn"></th>' : '') +
columns
.map(([label, , style]) => `<th${style !== undefined && style.startsWith('req') ? ' class="req"' : ''}>${label}</th>`)
.join('');
const cells =
(numbered ? '<td class="rn"></td>' : '') +
columns
.map(([, path, style]) => {
// 药丸要有个套得住的壳:槽位填的是文本,`td` 自己当不了药丸;颜色按值分档
if (style === 'pill') return `<td class="pill"><span data-class-of="${path}" data-slot="${path}"></span></td>`;
const cls = style === undefined ? '' : ` class="${COL_CLASS[style]}"`;
// 必填 / 选填这两档要**真的空**才标得出来,所以带 data-blank
const blank = style !== undefined && (style.startsWith('req') || style.startsWith('opt')) ? ' data-blank' : '';
return `<td${cls}${blank} data-slot="${path}"></td>`;
})
.join('');
return `<div class="tw"><table><thead><tr>${head}</tr></thead><tbody data-rows="${rowsPath}"><tr>${cells}</tr></tbody></table></div>`;
}
/** 说明卡:把「为什么这么做」摆在数据旁边 —— 人要在数据上作判断 */
export function noteCard(title: string, html: string): string {
return `<div class="card"><h2>${title}</h2><p class="note" style="margin-top:0">${html}</p></div>`;
/** 只要那一条说明 */
export function noteOnly(html: string): string {
return `<div class="note">${ico('shield-check', 13)}<span>${html}</span></div>`;
}
/** 阶段时间线:loop 按案例模型的阶段与里程碑组织汇报(AM 07) */
export function stagesCard(title: string, rowsPath: string, note?: string): string {
return `<div class="card"><h2>${title}</h2><ul class="steps" data-rows="${rowsPath}">
<li><span class="t" data-slot="name"></span><span class="d" data-slot="state"></span></li>
</ul>${note === undefined ? '' : `<p class="note">${note}</p>`}</div>`;
export function stagesOnly(rowsPath: string): string {
return `<ul class="steps" data-rows="${rowsPath}"><li><span class="n">·</span><span><span class="t" data-slot="name"></span> <span class="d" data-slot="state"></span></span></li></ul>`;
}
/**
* **来件原文** —— 这一单是被什么叫起来的。
*
* 归一出来的每一个字段都得回溯得到出处;把原文摆在归一结果**上面**,
* 人核对时不必切页签、不必去翻沟通记录 —— 一屏之内看得见「客户原话是这么写的」。
* 正文 `pre-wrap` 原样照录:不摘要、不折叠。摘要过的原文就不是原文了。
*/
export function letter(input: {
/** 谁发来的(取值路径) */
from: string;
/** 主题(取值路径) */
subject: string;
/** 收件人(取值路径)—— 投给哪个信箱;不给就不摆这一行 */
to?: string;
/** 正文(取值路径) */
body: string;
/** 附件数组的取值路径 —— 每件取 `name` 与 `note` */
attachments?: string;
/** 左上角那枚方向签,默认「收件」 */
direction?: string;
}): string {
const atts =
input.attachments === undefined
? ''
: `<div class="atts" data-rows="${input.attachments}"><span class="att">${ico('sheet', 12)}<b data-slot="name"></b><span data-slot="note"></span></span></div>`;
/*
* 入站时刻由基座填(`case:openedAt`,起单即入站)——
* 一封信摆在这儿而不说几时到的,人核 SLA 时得另开一屏去找。
*/
const to = input.to === undefined ? '' : `<p><i>收件人</i><b data-slot="${input.to}"></b></p>`;
/*
* 归属签 —— 这一单**怎么来的、归了谁**(`case:origin` / `case:owner`,基座填)。
* 活动写不出也不该写:让被指派的人自己说自己是谁,那不叫归属解析。
*/
return `<div class="lt">
<div class="lh"><span class="dir">${ico('mail-open', 12)}${input.direction ?? '收件'}</span><span class="own"><b data-slot="case:origin" data-blank></b><i>→</i><b data-slot="case:owner" data-blank></b></span><code class="at" data-slot="case:openedAt" data-blank></code></div>
<div class="lm"><p><i>发件人</i><b data-slot="${input.from}"></b></p>${to}</div>
<h4 data-slot="${input.subject}"></h4>
<p class="lb" data-slot="${input.body}"></p>
${atts}
</div>`;
}
/**
* **分段刻度条** —— 一条数据一格,按值上色(`data-class-of`)。
*
* 原型里那条是「按条数分宽的分布条」,宽度得算,而模版不许算。
* 改成一条料一格:同类相邻自然连成一段,看着等价,而且每一格都对得上清单里的一行 ——
* 数得出来,不是一根示意的色条。
*
* `legend` 是图例:`[色档, 标签, 取值路径]`,把「A 5 条」这类计数摆在条子下面。
*/
export function meter(
rowsPath: string,
valueField: string,
legend?: ReadonlyArray<readonly [string, string, string]>,
): string {
/*
* 格子里**不填字**:只用 `data-class-of` 把值投影成颜色。
* 带上 `data-slot` 的下场实测见过 —— 九个格子里排着「0 0 0 0 0 0 0 0 1」,
* 一条刻度条成了一串数字,目标件那条是纯色块。
*/
const bar = `<ul class="meter" data-rows="${rowsPath}"><li data-class-of="${valueField}"></li></ul>`;
if (legend === undefined || legend.length === 0) return bar;
const keys = legend
.map(
([tone, label, path]) =>
`<span><i class="v-${tone}"></i>${label} <b data-slot="${path}"></b></span>`,
)
.join('');
return `${bar}<div class="mlg">${keys}</div>`;
}
/** 大号计数 —— 一屏之内最该被一眼看见的那个数(`unit` 是它的分母或量纲) */
export function bigCount(path: string, unit: string): string {
return `<span class="big"><b data-slot="${path}"></b><span>${unit}</span></span>`;
}
/**
* **按序求值的规则表** —— 三条互斥条件与各自的去向,连着「本单是几」一起摆。
*
* 命中哪一条由编排层求值,这里**不画对勾替它宣布**;但把条件、去向、本单的计数
* 摆在同一行上,人自己一眼就对得出来 —— 这比一句「三条按序求值」有用得多。
*/
export function ruleTable(
rules: ReadonlyArray<{ readonly no: string; readonly when: string; readonly node: string; readonly to: string; readonly count: string }>,
): string {
const head = '<div class="h"><span>序</span><span>条件(按序求值)</span><span>去向</span><span>本单</span></div>';
const rows = rules
.map(
(r) =>
`<div><span class="no">${r.no}</span><span>${r.when}</span>` +
`<span class="go"><code>${r.node}</code>${r.to}</span><span class="cnt" data-slot="${r.count}"></span></div>`,
)
.join('');
return `<div class="rules">${head}${rows}</div>`;
}
/** 要素卡的色调 —— 静态,不随数据变(数据判不了达标的事,别让颜色替它说) */
export type FactTone = 'brand' | 'teal' | 'ok' | 'warn';
/**
* **要素带** —— 这一单的几件事,一件一张卡。
*
* 与关键指标带的分别:指标带摆的是**数**(判据),要素带摆的是**谁与什么**
* (客户、联系人、属性、成品量)。人接手一单先认这几件,再看数对不对。
*/
export function factCards(
items: ReadonlyArray<{
/** 卡头那一行小字 */
readonly label: string;
/** 主值的取值路径 */
readonly value: string;
/** 卡脚一句(HTML,可含 `data-slot`) */
readonly note?: string;
readonly icon?: string;
readonly tone?: FactTone;
/** 主值用等宽(邮箱、编码这类) */
readonly mono?: boolean;
/**
* 左侧圆标:`org` 是圆角方(组织)、`person` 是圆(人)—— 方与圆一眼分得开。
* 标里放的是**图标不是首字母**:首字母得算,而模版不许算;
* 画一个算错的缩写,不如画一个说得清类别的图标。
*/
readonly avatar?: 'org' | 'person';
/** 给了就把主值摆成大号计数,这里是它的分母或量纲(如「/ 10 行 BOM」) */
readonly big?: string;
/** 主值下面再挂一件(刻度条这类) */
readonly extra?: string;
}>,
): string {
return `<div class="facts">${items
.map((f) => {
const value =
f.big === undefined
? `<span class="v${f.mono === true ? ' mono' : ''}" data-slot="${f.value}"></span>`
: bigCount(f.value, f.big);
const body =
f.avatar === undefined
? value
: `<div class="row"><span class="ava${f.avatar === 'person' ? ' person' : ''}">` +
`${ico(f.avatar === 'person' ? 'user' : 'building', 17)}</span><div class="col">${value}</div></div>`;
return (
`<div class="fact${f.tone === undefined || f.tone === 'brand' ? '' : ` t-${f.tone}`}">` +
`<span class="k"><i>${ico(f.icon ?? 'file-text', 11)}</i>${f.label}</span>` +
`${body}${f.extra ?? ''}` +
`${f.note === undefined ? '' : `<span class="n">${f.note}</span>`}</div>`
);
})
.join('')}</div>`;
}
/**
* **落地动作** —— 放行那一刻系统替人做了什么,一条一行。
*
* 放行是草稿态与事实态之间的那道门。门后发生的事人看不见(合并、发事件、起下游单),
* 而他要为这些事负责 —— 所以得逐条写出来,不能只留一句「已放行」。
*/
export function actionList(items: ReadonlyArray<{ readonly title: string; readonly detail: string }>): string {
return `<ul class="acts">${items
.map((a) => `<li><span class="m">${ico('circle-check', 12)}</span><span><b>${a.title}</b>${a.detail}</span></li>`)
.join('')}</ul>`;
}
/** 结论章 —— 一屏只该有一个 */
export function verdict(text: string, tone: 'ok' | 'bad' | 'wait', detail?: string): string {
const cls = tone === 'ok' ? '' : ` ${tone}`;
const mark = tone === 'ok' ? 'circle-check' : tone === 'bad' ? 'alert-triangle' : 'gauge';
return `<p class="verdict${cls}">${ico(mark, 15)}${text}${detail === undefined ? '' : `<span>${detail}</span>`}</p>`;
}
+259 -5
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import type { RouteContract } from './http.ts';
import { disableCheckSchema, grantSchema, positionSchema, staffSchema, workGroupSchema } from '../org.ts';
import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts';
import { ledgerObjectKindSchema } from '../ledger.ts';
import { approvalTemplateSchema, closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts';
import { controlSemanticSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
import { activityManifestSchema } from '../manifest.ts';
@@ -230,7 +231,10 @@ export const govTaskLogRoute = {
path: '/api/gov/tasks/:taskId/log',
params: z.object({ taskId: taskIdSchema }),
output: z.object({
log: z.array(z.object({ id: z.string(), at: z.string(), sideLabel: z.string(), title: z.string() })),
// 与工作台同一份日志:章是四字动作,内容带主语(见 workbench 的 log 项)
log: z.array(
z.object({ id: z.string(), at: z.string(), sideLabel: z.string(), action: z.string(), title: z.string() }),
),
}),
} as const satisfies RouteContract;
@@ -511,7 +515,23 @@ export const listActivitiesRoute = {
*/
contract: z.object({
requiredInputs: z.array(z.string()),
inputFields: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })),
inputFields: z.array(
z.object({
name: z.string(),
type: z.string(),
required: z.boolean(),
/** 人读名(清单里的 title)—— 表单上摆中文,不摆字段名 */
label: z.string().optional(),
/** `file` = 这一格收**真文件**(界面给文件选择器);缺席 = 普通文本格 */
format: z.string().optional(),
}),
),
/**
* 包自带的样例集 —— 试跑一个活动时**照它预填**。
* 「首装审以静态审 + 样例集替代回放审」的那份样例,本来就是这个活动
* 「一单长什么样」的权威说法;让人对着空表单自己编,编出来的多半跑不通。
*/
samples: z.array(z.object({ name: z.string(), input: z.record(z.string(), z.unknown()) })).default([]),
outputPorts: z.array(
z.object({
name: z.string(),
@@ -559,7 +579,24 @@ export const listLedgerObjectsRoute = {
path: '/api/gov/data/objects',
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
output: z.object({
objects: z.array(z.object({ domain: z.string(), object: z.string(), label: z.string().optional() })),
objects: z.array(
z.object({
domain: z.string(),
object: z.string(),
label: z.string().optional(),
/**
* 这张表的**性质**(单据 / 主数据 / 派生)—— 取自装在库里的活动清单声明。
*
* 物化出来的物理表本身不知道自己是什么:表里只有列和主键。
* 而「该不该管它、谁有权改它」全看性质,所以这一维必须摆到治理台上 ——
* 从前没有它,一屏 19 张表看着一模一样,人分不出哪几张是要治理的主数据。
* 缺省 = 库里有这张表,却没有哪个在装的活动声明它(多半是退役活动留下的壳)。
*/
kind: ledgerObjectKindSchema.optional(),
/** 单据的单标识列名 —— 呈现上摆出来,人一眼看得出「这张表按什么分单」 */
caseKey: z.string().optional(),
}),
),
}),
} as const satisfies RouteContract;
@@ -713,6 +750,31 @@ export const testRunRoute = {
output: z.object({ workflowId: z.string(), state: workflowStateSchema }),
} as const satisfies RouteContract;
/**
* 仿真测试台 · 试跑**一个活动** —— 与试跑流程包并列的第二种试法。
*
* 为什么要单有一条:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
* 走流程包试,前面几个节点的噪音全得先趟一遍,而且每改一次活动就要重跑整条流。
*
* 起出来的是**真任务**(不挂工作流),照旧落到被指派人的工作台、照旧过 Harness 与放行门 ——
* 试跑不等于假跑:假跑试不出任何东西。区别只在它带 `origin='testbed'`,可以连根拔掉。
*/
export const testRunActivityRoute = {
method: 'POST',
path: '/api/gov/testbed/run-activity',
body: z.object({
activityId: z.string().min(1),
/** 起单入参 —— 按该活动契约的 `input.data` 填;可用包自带样例预填 */
data: z.record(z.string(), z.unknown()).default({}),
/** 委托消息(可空)—— 契约允许时才有意义 */
message: z.string().default(''),
/** 派给谁;留空 = 操作者自己(试跑不该派到别人工作台上) */
ownerId: z.string().default(''),
}),
output: z.object({ taskId: z.string(), title: z.string() }),
} as const satisfies RouteContract;
/**
* 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。
* 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。
@@ -720,15 +782,207 @@ export const testRunRoute = {
export const testStopRoute = {
method: 'POST',
path: '/api/gov/testbed/stop',
body: z.object({ workflowId: z.string() }),
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
body: z.object({ workflowId: z.string().default(''), taskId: z.string().default('') }),
output: z.object({ destroyed: z.boolean(), tasks: z.number().int() }),
} as const satisfies RouteContract;
/** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */
/**
* 当前这一台仪器上的试跑单(测试台一次只跑一条)—— 刷新页面也认得回来。
*
* **返回还在跑的那一单,以及刚跑完的那一单** —— 两者要分得开:
* `live` 为真才锁旋钮(在跑的不许换台);为假表示它已收尾,观测面照旧摆着结果给人看,
* 但可以再起一单了。从前不分,已终结的单照样被当成「在跑」——
* 跑完之后旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
*/
export const testCurrentRoute = {
method: 'GET',
path: '/api/gov/testbed/current',
output: z.object({ workflowId: z.string().optional(), subject: z.string().optional() }),
output: z.object({
workflowId: z.string().optional(),
subject: z.string().optional(),
/** 单活动试跑:没有工作流,认的是任务 */
taskId: z.string().optional(),
activityId: z.string().optional(),
/** 还在跑(未终结)—— 只有它为真才算「占着这台仪器」 */
live: z.boolean().default(false),
/** 已收尾时的终态名(released / blocked / terminated …),给观测面收灯用 */
terminal: z.string().optional(),
}),
} as const satisfies RouteContract;
/**
* 单活动试跑的**观测面** —— 一屏看穿这一单:
* 轨迹(每一拍干了什么)· 台账草稿(成果落到哪儿了)· 卡(问了谁什么)· 预算读数。
*
* 流程包那侧看的是 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**,
* 拿 trace 树看不出「第三拍为什么被拒」。
*/
export const testActivityStateRoute = {
method: 'GET',
path: '/api/gov/testbed/activity/:taskId',
params: z.object({ taskId: z.string() }),
output: z.object({
taskId: z.string(),
activityId: z.string(),
title: z.string(),
runtime: taskRuntimeStateSchema,
terminal: terminalStateSchema.optional(),
reason: z.string().optional(),
used: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }),
caps: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }),
/** 每一拍的轨迹:干了什么、拿回了什么、被拒的理由 */
trail: z.array(
z.object({
at: z.string(),
kind: z.string(),
title: z.string(),
detail: z.record(z.string(), z.unknown()).optional(),
}),
),
/** 本单写下的台账草稿 —— 成果就是这些行 */
drafts: z.array(
z.object({
address: z.string(),
key: z.record(z.string(), z.unknown()),
patch: z.record(z.string(), z.unknown()),
}),
),
/** 待答与已答的卡 */
cards: z.array(
z.object({
id: z.string(),
mode: z.enum(['execution', 'acceptance']),
title: z.string(),
done: z.boolean(),
result: z.string().optional(),
}),
),
}),
} as const satisfies RouteContract;
/* ── 试跑日志:运行数据不过夜,观测记录留下来 ─────────────────────────── */
/* ── 上传件:附件要能真正进活动,不能只剩一个文件名 ────────────────── */
/**
* 上传一个文件 —— 走 base64 而不是 multipart。
*
* 这一层所有路由都由同一套 zod 契约与 typed-fetch 收着,multipart 得在它旁边另开一条
* 不受契约管的路;而这里要传的是 BOM、规格书这类几十 KB 的件,base64 那点膨胀
* 换来「照旧一条契约管到底」是划算的。真大的件不该走界面上传,该走通道。
*/
export const uploadFileRoute = {
method: 'POST',
path: '/api/gov/upload',
body: z.object({
name: z.string().min(1),
contentType: z.string().default(''),
/** 文件字节的 base64 —— 上限 8MB(超了当场拒,不静默截断) */
dataBase64: z.string().min(1),
}),
output: z.object({
ref: z.string(),
name: z.string(),
contentType: z.string(),
size: z.number().int(),
/** 读出来的文本;缺席 = 这类文件读不出文本(理由在 note 里) */
text: z.string().optional(),
note: z.string(),
}),
} as const satisfies RouteContract;
export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
/** 取回原件 —— 存的是真字节,人点得开才算真留住了 */
export const downloadFileRoute = {
method: 'GET',
path: '/api/gov/upload/:id',
params: z.object({ id: z.string() }),
output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }),
} as const satisfies RouteContract;
/**
* 一次试跑的存档 —— **销毁那一刻拍的快照**。
*
* 「停止仿真 = 连根拔掉」这条不变(试跑起的是真任务,不该在别人工作台上过夜);
* 拔掉之前把这一单的观测面存下来。开发活动是反复试的活,「上一版跑了几拍、
* 卡在哪一步、台账写成什么样」正是要拿来比对的东西 —— 拔干净等于每改一次
* 就把上一次的账一并烧了。
*/
export const testLogRowSchema = z.object({
id: z.string(),
kind: z.enum(['activity', 'package']),
subject: z.string(),
/** 试的是谁:activityId 或 packageId */
target: z.string(),
taskId: z.string().optional(),
workflowId: z.string().optional(),
/** 终态名;缺省 = 人中途停的 */
terminal: z.string().optional(),
reason: z.string().optional(),
used: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }).partial(),
caps: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }).partial(),
/** 列表页只要计数,详情才给正文 —— 一次试跑的轨迹有上百条 */
trailCount: z.number().int(),
draftCount: z.number().int(),
cardCount: z.number().int(),
startedAt: z.string().optional(),
archivedAt: z.string(),
});
export type TestLogRow = z.infer<typeof testLogRowSchema>;
export const listTestLogsRoute = {
method: 'GET',
path: '/api/gov/testbed/logs',
output: z.object({ runs: z.array(testLogRowSchema) }),
} as const satisfies RouteContract;
/** 一次试跑的正文 —— 逐拍轨迹 / 台账草稿 / 发过的卡 */
export const testLogDetailRoute = {
method: 'GET',
path: '/api/gov/testbed/logs/:id',
params: z.object({ id: z.string() }),
output: testLogRowSchema.extend({
trail: z.array(
z.object({
at: z.string(),
kind: z.string(),
title: z.string(),
detail: z.record(z.string(), z.unknown()).optional(),
}),
),
drafts: z.array(
z.object({
address: z.string(),
key: z.record(z.string(), z.unknown()),
patch: z.record(z.string(), z.unknown()),
}),
),
cards: z.array(
z.object({
id: z.string(),
mode: z.string(),
title: z.string(),
done: z.boolean(),
result: z.string().optional(),
}),
),
}),
} as const satisfies RouteContract;
/** 删一条 / 清空 —— 日志是给人比对用的,留多久由人说了算 */
export const deleteTestLogRoute = {
method: 'DELETE',
path: '/api/gov/testbed/logs/:id',
params: z.object({ id: z.string() }),
output: z.object({ deleted: z.number().int() }),
} as const satisfies RouteContract;
export const clearTestLogsRoute = {
method: 'DELETE',
path: '/api/gov/testbed/logs',
output: z.object({ deleted: z.number().int() }),
} as const satisfies RouteContract;
/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */
+125 -1
View File
@@ -53,9 +53,21 @@ export const taskCardSchema = z.object({
title: z.string(),
runtime: taskRuntimeStateSchema,
terminal: terminalStateSchema.optional(),
/** 它在工作流上的序位 */
/** 它在工作流上的序位 —— **与下面的阶段进度不是一回事**:这是「第几个活动」 */
position: z.number().int().min(1),
total: z.number().int().min(1),
/**
* 活动**自己**的阶段进度 —— 进度条读的是它。
*
* 从前进度条读的是上面那对序位:A1 在七节点流里是 1/7,一开跑就画成 14%,
* 跑到收口还是 14%(节点没换,序位当然不动);单活动试跑更荒唐 —— 恒 1/1,
* 起单那一刻就满格。序位答的是「这单在整条流的哪一环」,不是「这单办到哪了」。
*
* 活动没声明阶段就没有这个键:界面照此不画进度条,不编一个出来。
*/
phase: z
.object({ index: z.number().int().min(1), total: z.number().int().min(1), name: z.string() })
.optional(),
workflowSubject: z.string(),
workflowState: workflowStateSchema,
startedAt: z.string(),
@@ -111,6 +123,39 @@ export const taskDetailSchema = z.object({
file: fileDocSchema,
}),
),
/**
* 沟通记录 —— 跟客户/供应商往来的**原件**,按单归档。
* 进来的那一封是开案的那份料(活动的入参就是它);出去的是放行后系统真发的件。
*/
comms: z
.array(
z.object({
id: z.string(),
at: z.string(),
direction: z.enum(['in', 'out']),
channel: z.string(),
party: z.string(),
subject: z.string(),
body: z.string(),
attachments: z.array(
z.object({
name: z.string(),
size: z.number().int().optional(),
ref: z.string().optional(),
/** 基座读出来的说明:读出多少字 / 为什么读不出 —— 界面照抄,不改写 */
note: z.string().optional(),
readable: z.boolean().optional(),
}),
),
note: z.string(),
/**
* **未读**(2026-07-31)——「看没看过」由人按按钮说了算,不由打开页签自动清。
* 自动清等于替人宣布「你看过了」,而他可能只是路过那个页签。
*/
unread: z.boolean().default(false),
}),
)
.default([]),
workOrders: z.array(workOrderSchema),
chat: z.array(chatMessageSchema),
view: z.array(viewBlockSchema),
@@ -122,6 +167,17 @@ export const taskDetailSchema = z.object({
at: z.string(),
side: attributionSideSchema,
sideLabel: z.string(),
/**
* 这一条**是谁干的** —— 日志只认三档:`owner` 负责人 · `agent` 智能体 · `system` 系统。
*
* 与 `side`(五种归侧)分工不同:那一维是**度量**用的(自主率要把「等外面」
* 与「机器干的」分开);这一维是**呈现**用的 —— 人扫这一屏只问
* 「这一步是我办的、AI 办的,还是流程自己走到的」,三档各一个颜色。
*/
who: z.enum(['owner', 'agent', 'system']),
/** 类型章:**四字短语**,与对话区同词(触发事件 / 阶段结论 / 数据录入 / 员工确认 …) */
action: z.string(),
/** 内容,**带主语**:`任务助手:…` / `柴浩:…` —— 日志不写没头脑的流水账 */
title: z.string(),
summary: z.string().optional(),
}),
@@ -155,6 +211,74 @@ export const replyChatCardRoute = {
output: z.object({ ok: z.boolean() }),
} as const satisfies RouteContract;
/**
* 标记沟通记录已读 / 未读 —— **气泡计数跟着这一下走**。
* 不做「打开即已读」:那是替人宣布他看过了。已读还能改回未读(看了一半想留个记号)。
*/
export const markCommReadRoute = {
method: 'POST',
path: '/api/desk/comms/:commId/read',
params: z.object({ commId: z.string() }),
body: z.object({ read: z.boolean() }),
output: z.object({ ok: z.boolean(), unread: z.boolean() }),
} as const satisfies RouteContract;
/**
* **就地改完按下「保存」** —— 把件上那张表写回**本单草稿层**,工单不办结。
*
* 为什么非要立刻落库、而不是攒到提交那一下:件是**服务端投影**。
* 人改了几格,只读那份跟着变了(那是他自己敲的),而由数据算出来的东西
* ——类徽 A/B/C、分布条、结论横幅那条去向——全是渲染那一刻的旧值。
* 于是屏上一半新一半旧:**人把三条料全改成 A,底下的去向还写着「去选型」**
* (用户实测)。那不是没更新,那是界面在撒谎。
*
* 落库之后前端重取任务投影,服务端拿新数据重渲染整份件 —— 算出来的那些跟着一起变,
* 去向也由同一次谓词求值重算。**放行门的位置没变**:写的仍是草稿层,
* 合并进正式版依旧只发生在人签字那一刻。
*
* ── 按**地址**收,不按「当下那张卡的第几块」收(2026-08-02 用户实测逼出来)──────
*
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由**那张工单的 `blocks[0]`**
* 给出,于是「这份件能不能改」被绑在了「当下开着的那张卡长什么样」上。
* 实测的下场:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
* 而屏上摆着的正是受理记录那份件,闸也在、编辑也进得去 —— 人把那条料的年用量补上、
* 按了保存,前端找不到匹配的块,**一格都没往下送**。他要补的恰恰就是把这一单
* 卡在那儿的那个必填格(判据 `quote_intake_record_missing = 0`),
* 而屏上什么反应都没有 —— 界面提供了一个落不了地的编辑口。
*
* 现在收的是件自己说的话:每张可改的表挂着 `data-edit="域.对象"`,格子挂着
* `data-col="台账列名"` —— 地址与列名**本来就在件里**,不必再绕一趟工单。
* 授权面改由**活动清单的台账声明**把关(不在声明里的地址一概不写),
* 比「当下那张卡碰巧带了哪一块」既严格又稳定。
*
* **改、增、删是同一件事的三种样子**(2026-08-02 用户:「编辑列表应该还能增删行」):
* 人手改本来就是**兜 AI 的错**的那一手 —— 而错不只有「这一格填错了」,
* 还有「这一行根本不该在」(客户点名本轮跳过的料被写了进来)与「漏了一行」。
* 只给改格子,前两种就没有出口:那一单会一路走到放行门被判据拦住,
* 而人明明一眼就看出问题在哪。
*/
export const saveTableRoute = {
method: 'POST',
path: '/api/desk/tasks/:taskId/table',
params: z.object({ taskId: taskIdSchema }),
body: z.object({
/** `{ '域.对象': [ { 台账列名: 值 } ] }` —— 主键列用来认行,不参与改行;台账里没有的行就是**新增** */
tables: z.record(z.string(), z.array(z.record(z.string(), z.string()))),
/**
* **人在件上删掉的那几行** —— 摆主键,一行一条。
*
* 为什么删要单独说,而不是「`tables` 里没有的就当删了」:那种口径把**一次不完整的
* 回传**变成一次删表 —— 件上摆的是不是整张表,服务端看不见(模版绑哪条路径是活动的事)。
* 说出来的删才删:回传缺了什么一律当「这次没提到」,不当「要撤掉」。
*
* 撤的只是**本单草稿层**那一笔(`draftDrop`),正式版一个字都动不了、不抛事件 ——
* 与 Agent 的 `drop` 同一条路子。放行门的位置没变。
*/
drops: z.record(z.string(), z.array(z.record(z.string(), z.string()))).optional(),
}),
output: z.object({ ok: z.boolean(), rows: z.number().int().min(0) }),
} as const satisfies RouteContract;
/** 负责人的自由输入 —— 人事件的第二形态 */
export const sayRoute = {
method: 'POST',
+233 -5
View File
@@ -64,6 +64,14 @@ export const formFieldSchema = z.object({
hint: z.string().optional(),
/** AI 拟值 —— 验收工单落地时已填好,人可改全部字段 */
ai: z.string().optional(),
/**
* 这个值改完**落回哪个台账地址**(`域.对象`)—— 只有验收单的修订字段需要。
*
* 没有它,人在验收报告上改的值只留在工单留痕里,落地时用的还是 AI 的原值:
* 「验收报告可编辑」就成了摆设(第 12 章③)。
* 口径:只改**本单在该对象的那一行**;该对象有多行时不猜是哪一行,如实拒改。
*/
ledger: z.string().optional(),
unit: z.string().optional(),
required: z.boolean().optional(),
cols: z.union([z.literal(1), z.literal(2)]).optional(),
@@ -126,6 +134,23 @@ export const viewBlockSchema = z.discriminatedUnion('t', [
rows: z.array(z.array(z.string())),
editable: z.boolean().optional(),
editableCols: z.array(z.number().int()).optional(),
/*
* ── 绑台账:**行由基座铺,不由活动塞**(2026-08-01)────────────────────
*
* 给了 `ledger` + `fields`,发卡那一刻基座就从这一单的草稿层把行铺进来
* (`rows` 写不写都会被铺出来的行盖掉)。
*
* 为什么必须这样:人要在卡上改的是**那份清单本身**(PRD 的执行页就是整份 9 行
* 逐格可改),而让活动把 9 行塞进回复里有两处死结 ——
* · 回复有长度上限,铺长了半途截断,整拍作废(实测);
* · 那些行**台账里已经有了**,让模型再抄一遍是拿 token 换一次抄错的机会。
*
* `fields` 与 `cols` 一一对应:`cols` 是给人看的列名,`fields` 是台账列名。
* 人提交之后,基座按同样的对应关系把改动写回草稿层并留痕 —— AI 不经手
* (与数据录入卡同一条规矩:该由人亲手落下的,就别在中间加一道传话)。
*/
ledger: z.string().optional(),
fields: z.array(z.string()).optional(),
linkDonut: z
.object({
col: z.number().int(),
@@ -301,6 +326,28 @@ export const executionWorkOrderSchema = z.object({
blocks: z.array(viewBlockSchema).optional(),
fields: z.array(formFieldSchema),
submitLabel: z.string().optional(),
/**
* **必填还空着就按不下提交**(2026-08-02 用户定)—— 由基座按台账声明**现算**。
*
* 起因是实测的一单:受理需求那张卡上有一条料缺年用量(必填),人照样交了回来;
* 这一单于是一路走到放行门,被 L1 机检 `quote_intake_record_missing = 0` 拦住,
* 模型连拒五拍熔断 —— **同一件事,在提交那一刻拦下比在第 10 拍拦下便宜得多**:
* 那时缺的是哪一格就摆在他眼前;到了放行门,他只看见一条红判据和一个停住的单。
*
* 「必填」是**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)——
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
* 按钮照常能按:**没有依据的时候不拦人**。
*/
gate: z
.object({
/** 能交了吗 */
ready: z.boolean(),
/** 交不了的话,一句话说清差在哪 */
why: z.string(),
/** 差在哪几行哪一格 —— 摆主键 + 中文列名,人照着就能找到那一行 */
blanks: z.array(z.string()).default([]),
})
.optional(),
});
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
@@ -308,6 +355,20 @@ export const acceptanceCriterionCheckSchema = z.object({
label: z.string(),
level: acceptanceLevelSchema,
pass: z.boolean(),
/**
* 裁量项(L3/L4)**裁过没有** —— 机检项与 L5 不带这一维(它们不是裁出来的)。
*
* 「裁了但裁成不满足」与「压根没裁」是两件完全不同的事:前者是 AI 办过、
* 办不到,把判断交给人;后者是活没干完。从前卡上只有一个 `pass`,
* 两者长得一模一样(都是红叉「未达标」)—— 人看到一条永远不绿的项,
* 既不知道 AI 有没有办过,也不知道它为什么办不到,只能凭感觉签。
*/
settled: z.boolean().optional(),
/**
* 裁断的理由 —— **裁成「不满足」的那一条,这句话才是签字的人要看的东西**。
* 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。
*/
rationale: z.string().optional(),
});
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
@@ -334,10 +395,21 @@ export const acceptanceWorkOrderSchema = z.object({
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
fields: z.array(formFieldSchema).default([]),
/**
* 选择组(活动契约的 selective 出口):**恰发一口,随放行一并确认**(AM 1.2)。
* 人选的那一口就是这次运行的分支结论(如议价的 成交/有异议/拒绝),
* 下游异或网关判的正是它 —— 所以放行时必须恰选一个,选口即裁分支。
* 没有选择组的活动此字段缺省,验收单照旧只有通过/重做。
* 验收页的正文块 —— 与执行工单同一套件(可含**绑台账的可编辑表**)。
*
* 验收是这一单最重的一道门:人得能在**成果本身**上改,而不是只在几个整单级字段上改。
* 绑了台账的表由基座铺行、由基座写回(`blocks[].ledger` + `fields`),AI 不经手 ——
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
*/
blocks: z.array(viewBlockSchema).optional(),
/**
* 选择组(活动契约的 selective 出口)—— **只在算不出去向时才摆给人选**。
*
* 去向能算就别问:契约里每个出口都写了 `when` 谓词时,基座按序求值(首个命中即出),
* 结论随放行一并落定,卡上只如实**告诉**人走哪一口、凭什么(见 `routedPort`)。
* 「这一单往哪走」是分类表算出来的事实,不是一道让人凭印象点的选择题 ——
* 让人选,等于把一个可回放的判定改成一次不可回放的手感。
* 只有当出口没写全谓词(算不出来)时,才退回这张选择题。
*/
portChoice: z
.object({
@@ -345,6 +417,42 @@ export const acceptanceWorkOrderSchema = z.object({
ports: z.array(z.object({ name: z.string(), label: z.string().optional() })).min(2),
})
.optional(),
/**
* **这一签下去,世界上发生了什么**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
*
* 只在办结之后才有。放行卡上摆的是**签字前要核的东西**(判据);
* 签完之后人关心的换成了另一件事:落了几行、进了哪几张表、转去哪儿了。
* 从前这两件事共用一屏判据表 —— 签完再回看,那一摞对勾说的还是签之前的话。
*
* 数据来自放行那一刻的结构化留痕(逐表计数 + 发出的口),不是把日志句子再解析一遍。
*/
landing: z
.object({
/** 合并进正式版的总行数 */
rows: z.number().int().min(0),
/** 试跑挡下的主数据行数 —— 非 0 就得说,不然「合并 N 行」是假的 */
simulated: z.number().int().min(0).default(0),
/** 逐表:进了哪张表、几行 */
tables: z.array(z.object({ label: z.string(), rows: z.number().int().min(0) })).default([]),
/** 转去哪儿了 —— 摆出口的人读名;一个都没有就是这条流到此为止 */
ports: z.array(z.string()).default([]),
/** 带了哪些字段过去 —— 下游活动读到的就是这几个(摆名字不摆值) */
carried: z.array(z.string()).default([]),
})
.optional(),
/**
* **算出来的去向** —— 契约的出口谓词按序求值,首个命中即出。
* 卡上只告诉人「走哪一口、凭哪一条」,不给他改;放行时按它发口。
*/
routedPort: z
.object({
group: z.string(),
name: z.string(),
label: z.string().optional(),
/** 命中的那条谓词原文 —— 人要回核「凭什么走这条」 */
because: z.string(),
})
.optional(),
passLabel: z.string().optional(),
});
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
@@ -406,7 +514,17 @@ export const elicitationCardSchema = z.object({
ask: z.string(),
basis: basisSchema.optional(),
fields: z.array(
z.object({ key: z.string(), label: z.string(), placeholder: z.string().optional() }),
z.object({
key: z.string(),
label: z.string(),
placeholder: z.string().optional(),
/**
* 预填值 —— AI 从来件里读得出的先替人填上(公司名、联系人姓名)。
* 人核对一眼、补上填不出的那个即可。**读不出来的别给**:
* 填错了人得先擦掉再写,比空着更费事。
*/
value: z.string().optional(),
}),
),
done: z
.object({
@@ -420,6 +538,99 @@ export const elicitationCardSchema = z.object({
});
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
/**
* **数据录入卡** —— 人类工具的第五类(2026-07-31 用户定)。
*
* 与补全卡的差别是**谁写库**:
* · 补全卡:人填的值进轨迹 → **AI 再转写进台账**。中间隔一道传话,
* 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。
* · 数据录入卡:**人按下确认那一刻就是写入** —— 卡上写着落哪张表、哪个主键,
* 确认即落草稿层。AI 只负责把能填的先填好,填错了人当场改。
*
* 所以它适合「建档」这类事:客户档案是主数据,一旦建错后面每一单都跟着错;
* 那一笔该由人亲手落下,不该经 AI 的手转述一遍。
*
* 仍守铁律②:确认写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。
*/
/** 录入卡的一格 —— 主段与 `also` 各段同一形状 */
const entryFieldSchema = z.object({
key: z.string(),
label: z.string(),
/** AI 预填的值 —— 人可改;填不出的留空,界面标「待填写」 */
value: z.string().optional(),
/** 必填的空着不让确认 —— 缺了这一格这张档就是残的 */
required: z.boolean().default(false),
placeholder: z.string().optional(),
});
export const dataEntryCardSchema = z.object({
...chatBase,
k: z.literal('entry'),
ask: z.string(),
basis: basisSchema.optional(),
/** 落哪张表(`域.对象`)—— 确认那一下写的就是它 */
ledger: z.string().min(1),
/**
* 那张表**给人看的名字**(台账登记的表注释,如「客户档案」)。
*
* 卡面上摆的是它,不是 `customer.profile` —— 界面不摆系统内的英文名
* (用户 2026-08-02:「这种卡片里说:确认后写入 xxx数据库」)。
* 登记里没有人读名时不给这一项,卡面退回对象地址:宁可笼统,不可编。
*/
ledgerLabel: z.string().optional(),
/** 主键值:确认时按它定位那一行 */
key: z.record(z.string(), z.string()),
/** 主键那几列的中文列名 —— 卡面摆它,不摆 `customer_code`(界面不摆系统内的英文名) */
keyLabels: z.record(z.string(), z.string()).optional(),
fields: z.array(entryFieldSchema),
/**
* **这一段有几列是基座自己生成的** —— 摆一句话告诉人「这个不用你填」。
*
* 声明里标了 `auto` 的列(如联系人编码)压根不进 `fields`:
* 让人填是白占一格他答不上来的表单。但**不能一声不吭地拿掉** ——
* 那样他会以为这张档少了一格。
*/
autoNote: z.string().optional(),
/**
* **同一次确认还要落的另几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
*
* 建一位新客户落的是**两条主数据**:公司档(`customer.profile`)与
* 联系人档(`customer.contact`)。从前卡上只有公司那一半,联系人由 AI 自己写 ——
* 而联系人档里的 `contact_code` 是它**编**出来的,人一眼没看过。
* 主数据编错一次,后面每一单都跟着错,这正是数据录入卡存在的理由。
*
* 分成两张卡也能办,但那是把**一件事**(建档)拆成两次打断;
* 一张卡两段,人一次看全、一次确认,基座一次落两行。
*
* 值按段隔开:`values` 的键是 `段号.列名`(主段是 `0.`)—— 两张表都有 `name` 这一列,
* 平铺就会互相盖掉,而盖掉之后没人看得出来。
*/
also: z
.array(
z.object({
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
title: z.string(),
ledger: z.string().min(1),
ledgerLabel: z.string().optional(),
key: z.record(z.string(), z.string()),
keyLabels: z.record(z.string(), z.string()).optional(),
fields: z.array(entryFieldSchema),
autoNote: z.string().optional(),
}),
)
.optional(),
done: z
.object({
at: z.iso.datetime(),
by: staffIdSchema,
/** 键是 `段号.列名`(主段 `0.`)—— 见 `also` 的注释 */
values: z.record(z.string(), z.string()),
})
.optional(),
});
export type DataEntryCard = z.infer<typeof dataEntryCardSchema>;
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
export const ratingCardSchema = z.object({
...chatBase,
@@ -439,6 +650,7 @@ export type RatingCard = z.infer<typeof ratingCardSchema>;
export const chatCardSchema = z.discriminatedUnion('k', [
decisionCardSchema,
dataEntryCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
@@ -461,8 +673,14 @@ export const chatMessageSchema = z.discriminatedUnion('k', [
k: z.literal('system'),
type: z.enum(['data', 'time', 'human', 'control']),
text: z.string(),
/**
* 这条系统消息带一个「查看邮件」的去处 —— 原件在沟通记录里,一步跳过去。
* **点不点都不挡路**:开场白之后 AI 照常开跑,不等人按。
*/
openComms: z.boolean().optional(),
}),
decisionCardSchema,
dataEntryCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
@@ -504,6 +722,16 @@ export const chatCardReplySchema = z.discriminatedUnion('k', [
/** 「没法填」旁路 */
reason: z.string().optional(),
}),
/**
* 数据录入卡的确认 —— **这一下就是写入**。
* 基座按卡上声明的 `ledger` + `key` 把 `values` 落进草稿层,并留一条痕:
* 谁、何时、往哪张表写了什么。AI 不经手。
*/
z.object({
k: z.literal('entry'),
cardId: cardIdSchema,
values: z.record(z.string(), z.string()),
}),
z.object({
k: z.literal('rating'),
cardId: cardIdSchema,
+84
View File
@@ -24,6 +24,27 @@ export const contractInputSchema = z.object({
/** message:委托消息(自然语言),可为空;**不得携带扩大授权的效力** */
messageAllowed: z.boolean().default(true),
messageHint: z.string().optional(),
/**
* **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件。
*
* 声明了,基座在起单那一刻就把它归进任务的**沟通记录**(工作台那一屏),
* 原件与附件一并存下:谈过什么、什么时候谈的,回头查得到。
*
* 为什么是**声明**而不是活动跑起来之后再做一遍:这件事没有临场判断的成分 ——
* 「入参的 subject 就是来件主题」是活动作者写清单时就知道的事实。
* 交给模型每单认一遍,是拿一整拍的 token 去换一个常量。
* 值填的是**入参里的字段名**,不是字面内容。
*/
inboundComm: z
.object({
channel: z.string().default('mail'),
/** 对方是谁 —— 填入参里那个字段的名字(如 `customerEmail`) */
partyField: z.string().min(1),
subjectField: z.string().optional(),
bodyField: z.string().optional(),
attachmentsField: z.string().optional(),
})
.optional(),
});
export type ContractInput = z.infer<typeof contractInputSchema>;
@@ -34,6 +55,19 @@ export const outputPortSchema = z.object({
/** 交付物 schema —— 调用方仅凭口名与 schema 即可衔接下游 */
payload: jsonSchemaSchema,
description: z.string().optional(),
/**
* **这一口什么时候走** —— 对任务视图求值的 S-FEEL 谓词(选择组内**按序求值,首个命中即出**)。
*
* 组内每一口都写了 `when`,去向就是**算出来的**:放行时基座自己判,
* 卡上只如实告诉人走哪一口、凭哪一条,不摆成选择题。
* 「这一单往哪走」多半是台账里那几个数的函数(有没有 C 类、有没有 B 类)——
* 让人凭印象点一下,等于把一个可回放的判定换成一次不可回放的手感;
* 让模型选,又多一处它可以说了算的地方。
*
* 有一口没写,整组就算不出来,退回「人选一口」那条老路(不是所有分支都算得出来:
* 议价的 成交/有异议/拒绝 就得人裁)。
*/
when: z.string().optional(),
});
export type OutputPort = z.infer<typeof outputPortSchema>;
@@ -59,6 +93,35 @@ export const requiredDeliverableSchema = z.object({
label: z.string(),
/** 缺一不得提交放行 —— 验收工单「成果清单」的来源 */
schema: jsonSchemaSchema.optional(),
/**
* 「有没有产出」的**可机检依据**:台账地址 `域.对象`,该地址在任务视图里有行即算有。
*
* 不给依据的话,「缺一不得提交放行」就只能靠活动自报 —— 自报等于没有门。
* 声明了地址,预检就能自己数行数:活动说交付了、台账里一行没有,当场拒收。
*/
ledger: z.string().optional(),
/**
* **这份成果由哪几张表承载**(2026-08-02 用户当场指出:「料号分类表和受理结论
* 不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)。
*
* 一份成果常常不止一张表:A1 交出的是**一份询价受理记录**,而它由受理记录、
* 料号分类表(判据来源)、受理结论(结论栏)三张表共同承载。
* 从前三张表各占一条 `requiredDeliverables`,人在成果面上就看到三样东西 ——
* 而在业务上他只拿到一份。
*
* 声明了它,落地那一屏按**交付物**点名,三张表的行数并成一行;
* 不声明就退回 `ledger` 那一张 —— 老清单不必改一个字。
*/
ledgers: z.array(z.string()).optional(),
/**
* **验收页上让人就地改的那几列** —— 台账列名,顺序即列序。
*
* 验收是这一单最重的一道门:人得能在**成果本身**上改,而不只是在旁边几个整单级字段上改。
* 给了这几列,基座在放行卡上铺一张绑着 `ledger` 的可编辑表(整份行都在),
* 人改完由基座写回草稿层、随放行合并 —— AI 不经手。
* 不给就没有这张表:哪些列该让人动,是写清单时就该定的事,不是临场发挥。
*/
editableAtRelease: z.array(z.string()).optional(),
});
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
@@ -133,6 +196,27 @@ export const sevenPartContractSchema = z.object({
output: outputPortsSchema,
requiredDeliverables: z.array(requiredDeliverableSchema).default([]),
acceptanceCriteria: z.array(acceptanceCriterionSchema).default([]),
/**
* **放行时人可当场改的值** —— 验收报告「AI 已填好、可编辑」改的就是这几个。
*
* 它是**设计期的事实**,和验收判据一样:哪几个值该让负责人有机会覆盖、
* 改完落回哪个台账地址,由做这个活动的人一次定死,过安装审。
* 让 Agent 每单临场重述一遍是不可靠的 —— 实测它会漏(说了「可直接编辑」而没给字段),
* 而漏掉的后果是验收报告只能看不能改,人工修订形同虚设。
*
* `ai` 不在这儿:拟值是**运行期**的事实 —— 放行前由基座从台账现读活动写下的那个值,
* 不让模型重述一遍(重述就会和台账里的值不一致,而以哪个为准说不清)。
*/
revisableAtRelease: z
.array(
z.object({
key: z.string().min(1),
label: z.string(),
/** 改完落回哪儿:`域.对象`;只改本单在该对象的那一行 */
ledger: z.string().min(1),
}),
)
.default([]),
prohibitions: z.array(prohibitionSchema).default([]),
authorization: authorizationBoundarySchema,
resources: resourceDeclarationSchema,
+111
View File
@@ -64,13 +64,124 @@ export const fieldDeclarationSchema = z.object({
primaryKey: z.boolean().default(false),
/** 治理:脱敏 / 保留期 / 可学习标记(OS 8.1 治理先行) */
masked: z.boolean().default(false),
/**
* **这一列缺了就是活没干完** —— 于是基座数得出「本单这张表缺必填的有几行」。
*
* 不建 NOT NULL:草稿层的本分就是「过程中允许缺」,缺口要摆给人看、由人补,
* 而不是让写入当场炸掉。这一维只служ两件事:
* · 读数 `域_对象_missing` / `_missing_keys` —— 基座数出来的数,判据可以放心读;
* · 报告模版按它标红胶囊(A1 那张清单的「必填 / 选填」两色就是它)。
*
* 为什么要有它(2026-08-01,连跑十轮里三轮死在这):A1 的
* 「必填齐(PN / 年用量)」是 L1 机检,读的却是**活动自己写的** `missing_key_total` ——
* 人在卡上补了年用量、基座写回了草稿层,而那个汇总列要模型自己重算;
* 它算漏一次,这条判据就永远绿不了,连拒五拍熔断。
* **机检判据只许读基座数得出来的数** —— 这一维就是让基座数得出来的那把尺。
*
* 用 `optional` 不用 `default(false)`:后者会把它变成出参必填,
* 六个活动两百来行字段声明都得补一个 `required: false` —— 那是给纪律收噪音税。
* 不写就是不必填,读的那一侧一律 `=== true` 才当真。
*/
required: z.boolean().optional(),
/**
* **这一列是分类列** —— 给出它的取值域,基座就按值现数本单每一类几行。
*
* 出的读数是 `域_对象_<列>_<值>`(值消毒成小写标识符),例如
* `quote_intake_record_type_tag_a`。**没出现的值也铺 0** —— 取值域是声明给的,
* 所以数得全;零条也是一个数,判据才算得下去。
*
* 为什么非要基座来数(2026-08-01 用户当场指出):三分类的条数从前是活动自己写的
* `type_a_total / type_b_total / type_c_total`,而分布条、放行时的去向谓词、验收判据
* 读的全是它们。于是**人在放行页把九条料全改成 A,类徽变了,分布条还是 5/3/1、
* 去向还是「去选型」** —— 逐行数据变了,汇总数没人重算。
* 而**手工修改的全部意义就是纠正 AI 的判断**:改了类标而流向不跟着变,
* 这个兜底功能就是假的。能由数据算出来的,一律别让活动自报。
*/
tally: z.array(z.string().min(1)).optional(),
/**
* **这一列缺了不拦单,但那一问不能省** —— 基座据此数「还有几行缺选填」。
*
* 与 `required` 的分工:必填缺一条都不许放行(它是验收标准);
* 选填缺照样往下走,只是**必须经过人**——问过客户、或放行时确认可以先受理。
*
* 为什么也得基座数(2026-08-02 用户实测:「我这次补了目标价,为啥 L4 还在那乱判」):
* 那条 L4 裁的依据是活动自己写的 `missing_minor_total`。人在件上补了目标价、
* 基座已经写回草稿层,而那个数要模型自己重算 —— 它没算,于是照旧裁「不满足」,
* 人明明补齐了却被告知还缺。**裁量项可以由人来判,但它判的事实必须是真的。**
*/
expect: z.boolean().optional(),
/**
* **这一列由基座生成,既不问人也不让模型编**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
*
* 用在**内部流水号**那一类主键上:联系人编码、行号 —— 它们不承载业务含义,
* 只是「同一位客户下的第几位」。让人填是白占一格他答不上来的表单
* (他哪知道这家客户已经有几位联系人);让模型编更糟,编重了就是覆盖别人那一行。
*
* 生成规则是**声明出来的一条**,不是每处各写一套:
* 取同组其余主键的值作前缀,后面跟两位序号 —— `CU-9001` + `-01`、`-02`……
* 序号数的是**同组已有的行**(正式版 + 本单草稿),所以同一单里加两位联系人也不撞。
* 没有其余主键(它自己就是唯一那把键)时退回纯序号 `01`。
*
* 落在哪一层:人按下确认那一刻由基座补上 —— 与「该由人亲手落下的别经 AI 转述」
* 同一条规矩的另一半:**人答不上来的,也别摆到他面前假装他该答**。
*/
auto: z.boolean().optional(),
});
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
/**
* 台账对象的**性质** —— 这条数据归谁管、活多久、谁有权改它。
*
* 从前台账只有「地址 + 列」,没有这一维,于是**没人需要回答「这张表是什么」**——
* 后果不是不好看,是主键写歪了还没人发现:A4 的报价草案本该一单一份,
* 主键却只有 `pn`,第二单报同一个料号直接 upsert 覆盖上一单
* (实测:跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
* 而主键带了单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少)。
*
* 分三类,判据是**机械的**,不靠感觉:
*
* · `record` 单据 —— 一单一份的过程与结论。主键**必含单标识**(`caseKey`),
* 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出。
* · `master` 主数据 —— 跨单积累的经营资产(客户、供应商、料号规格、历史价)。
* 主键是**业务实体**,会被反复更新,下一单读它做判断。治理都在这一类上:
* 谁能改、依据是什么、怎么去重、多久算过期。
* · `derived` 派生 —— 能由前两类算出来的指标(落地率、命中率)。
* **本不该手写**:手写的派生值能和它的来源打架,而没人会发现。
* 标出来至少让它可被重算校验。
*/
export const LEDGER_OBJECT_KINDS = ['record', 'master', 'derived'] as const;
export const ledgerObjectKindSchema = z.enum(LEDGER_OBJECT_KINDS);
export type LedgerObjectKind = z.infer<typeof ledgerObjectKindSchema>;
export const LEDGER_KIND_LABEL: Readonly<Record<LedgerObjectKind, string>> = {
record: '单据',
master: '主数据',
derived: '派生',
};
export const LEDGER_KIND_DESC: Readonly<Record<LedgerObjectKind, string>> = {
record: '一单一份的过程与结论 —— 放行即定稿,只增不改;查得到、回放得出就够了',
master: '跨单积累的经营资产 —— 会被反复更新,下一单读它做判断;治理都在这一类上',
derived: '能由单据与主数据算出来的指标 —— 不该手写,手写的派生值会和来源打架',
};
export const objectDeclarationSchema = z.object({
domain: identifier,
object: identifier,
label: z.string().optional(),
/**
* 这张表是什么性质 —— 缺省 `record`,因为活动写下的绝大多数是单据。
* 缺省不是「随便」:安装静态审会按 kind 各自核一遍(见 `ledgerKindFindings`)。
*/
kind: ledgerObjectKindSchema.default('record'),
/**
* 单标识字段名 —— **只有 `record` 需要**,且必须是它主键的一部分。
*
* 为什么要显式声明而不去猜:猜的规则(比如「主键里得有个 `_ref` 结尾的列」)
* 迟早猜错,而错的那一次是**静默覆盖数据**。让活动自己说清哪一列是「单」,
* 安装期就能核:它在不在 fields 里、是不是主键的一部分。
*/
caseKey: identifier.optional(),
fields: z.array(fieldDeclarationSchema).min(1),
});
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
+93
View File
@@ -95,12 +95,102 @@ export const dmnModelSchema = z.object({
});
export type DmnModel = z.infer<typeof dmnModelSchema>;
/**
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
*
* 为什么要有它(2026-07-31 加,起因是 A1 反复空转):
* 从前每一拍喂给模型的是**整本说明书 + 全部工具 + 全部可写台账**,
* 模型得自己从六步里认出「我现在在第几步」。认对了还好,认岔了就开始转圈 ——
* 实测 S1 已经把八字段写齐了,而说明书里 S3 写着「把八字段补到齐」,
* 模型于是把同样三行又写一遍,被「写了等于没写」拒回,下一拍再写一遍,如此往复。
*
* 治法不是把说明书写得更啰嗦,是**把状态挪进引擎**:
* ① `doneWhen` 让引擎自己算得出「这一步的成果已经在那儿了」—— 是就直接收掉,
* **不问模型**(能算的不问 LLM:同输入必同结论、零 token);
* ② `guide` / `writes` / `tools` 让每一拍的上下文**只装当前这一步要用的东西** ——
* 上下文小了,模型跑偏的余地也就小了。
*
* 不写剧本的活动照旧跑(整本说明书 + 全部工具),只是没有这两样好处。
*/
export const planStepSchema = z.object({
/** 对应 CMMN 里的计划项 id —— 对不上的条目安装期即拒 */
item: z.string().min(1),
/** 一句话:这一步交出什么 */
intent: z.string().min(1),
/** 这一步的做法(从说明书里搬过来的那一段)—— 只在轮到它时进上下文 */
guide: z.string().min(1),
/** 这一步可写哪几个台账地址「域.对象」;空 = 这一步不写台账 */
writes: z.array(z.string()).default([]),
/**
* 这一步**要读哪几个台账地址** —— 与 `writes` 对称,同样是「域.对象」。
*
* 为什么非声明不可:`域.对象` 整表那一段进提示词前会**按字符截断**
* (整表里躺着历史所有已放行单的行,只会越来越长,不截会把上下文吃光)。
* 截断是盲的 —— 砍掉的可能恰是这一步要用的那张表。实测:A1 第一步要拿发件邮箱
* 去 \`customer.contact\` 认客户,而这一单的来函正文与 BOM 附件就占了近 2000 字,
* 客户联系人表整个落在刀口外;模型看不见,只能报「主数据里没找到」,
* 于是老客户被当成新客户 —— **而库里那一行一直好端端在**。
*
* 声明了的表**排在最前且不参与截断**:这一步该看见的,一定看得见。
*/
reads: z.array(z.string()).default([]),
/**
* 这一步的**工作区画面**(视图模版名)—— 人办这一步时看的那份件。
*
* 不写就由模型临场挑。而「这一步该看哪张」是**写清单时就定死的事**:
* 阶段二派执行单,人要看的是那份询价受理需求;模型挑了「缺口清单」,
* 人拿到的就不是那份件了(实测:两份目标件都在包里,推出来的却是旧的那张)。
* 画面归活动定,不归模型临场发挥 —— 模型仍可用 `say.view` 换画面,
* 但**没换的时候按这里推**,而不是不推或推上一张。
*/
view: z.string().optional(),
/** 这一步可调哪几件工具;空 = 这一步不调工具 */
tools: z.array(z.string()).default([]),
/**
* **这一步算干完了没有** —— 对任务视图求值的 S-FEEL 谓词。
*
* 真了引擎就自己把这一项收掉,不问模型。所以它必须是「成果已存在」的判据,
* 不是「该不该做」的判据:写成 `missing_key_total = 0` 这种**开局就为真**的式子,
* 会让那一步在还没干之前就被收掉。
*
* **`X != null` 是个坑**:S-FEEL 里未绑定的名字 `!= null` 求值为 **true**,
* 写它等于让那一步开局即收。要判「这个键写过没有」,用正向式子(`X >= 0`);
* 布尔列更要绕开 —— 它合法值含 false,拿它分不清「没写」与「写了 false」,
* 该改判同一步写下的计数列。
*/
doneWhen: z.string().optional(),
});
export type PlanStep = z.infer<typeof planStepSchema>;
/**
* 阶段 —— **给人看的进度刻度**,引擎一眼都不看。
*
* 计划项是给引擎的:一件事拆到能判「干完没有」的粒度,A1 有六项。
* 可六格进度条对人没有意义 —— 「查历史价 / 打标 / 定去向」在业务上是**一件事**
* (判这单该怎么走),人关心的是「到哪一步了」,不是引擎拆了几项。
* 所以另立一层:几个计划项合成一个阶段,进度条按阶段走。
*
* **只声明激活态的阶段**。结束态不在这里 —— 每个活动都以放行收口(铁律③),
* 那是系统的规矩不是活动的阶段;进度条自己在末尾加这一格。
*/
export const casePhaseSchema = z.object({
/** 阶段名 —— 进度条上显示的就是它 */
name: z.string().min(1),
/** 这一阶段含哪几个计划项(CMMN item id);**全收完** = 这一阶段过了 */
items: z.array(z.string().min(1)).min(1),
});
export type CasePhase = z.infer<typeof casePhaseSchema>;
export const loopModelSchema = z.object({
/** 案例模型 · 机器面:CMMN XML,语义引擎以它为运行结构 */
cmmnXml: z.string().min(1),
/** 案例模型 · 叙事面:说明书,与 XML **同版本发布,不允许分叉**(AM 12.2) */
narrative: z.string().min(1),
dmn: dmnModelSchema,
/** 步骤剧本:一个计划项一条(见 `planStepSchema`);**不写照旧跑**(整本说明书 + 全部工具) */
steps: z.array(planStepSchema).optional(),
/** 阶段:进度条的刻度(见 `casePhaseSchema`);**不写就没有进度条**,不编 */
phases: z.array(casePhaseSchema).optional(),
});
export type LoopModel = z.infer<typeof loopModelSchema>;
@@ -186,6 +276,9 @@ export const staticAuditFindingSchema = z.object({
'dynamic-eval',
'ledger-type-conflict',
'acceptance-level-misreported',
'acceptance-predicate-unbound',
'deliverable-address-unbound',
'ledger-kind-invalid',
]),
severity: z.enum(['error', 'warn']),
message: z.string(),
+21 -3
View File
@@ -94,15 +94,33 @@ export const WORKFLOW_STATE_LABEL: Readonly<Record<WorkflowState, string>> = {
/* ── 归侧四标签(OS 8.1):度量的口径来源,零埋点 ─────────────────────── */
export const ATTRIBUTION_SIDES = ['machine', 'human', 'human-verdict', 'world-wait'] as const;
export const ATTRIBUTION_SIDES = ['machine', 'human', 'human-verdict', 'world-wait', 'trigger'] as const;
export const attributionSideSchema = z.enum(ATTRIBUTION_SIDES);
export type AttributionSide = z.infer<typeof attributionSideSchema>;
export const ATTRIBUTION_SIDE_LABEL: Readonly<Record<AttributionSide, string>> = {
machine: '机器产出',
human: '人产出',
/*
* 「员工动作」而不是「人产出」(2026-07-31 用户定)。
* 枚举键 `human` 是协议不动;改的是给人看的那一句 ——
* 「产出」听着像交付物,而这一类装的是**人做了什么动作**:
* 填了工单、看了来件、留了一句话。
*/
human: '员工动作',
'human-verdict': '人裁决',
'world-wait': '世界等待',
/*
* 「系统等待」而不是「世界等待」(2026-07-31 用户定)。
* 枚举键 `world-wait` 是协议不动 —— 改键要动所有留痕数据;
* 改的是**给人看的那一句**:人在工作日志里读到的是"在等谁",
* 「世界」太玄,他要的答案是"系统这边挂着在等"。
*/
'world-wait': '系统等待',
/*
* 触发事件 —— 这一单是被什么叫起来的(一封信到了、时限到了、人登记了)。
* 单列一类不是分得细:它**既不是机器产出也不是员工动作**,
* 混进任何一边都会把自主率算歪(分母里多一条谁也没干的事)。
*/
trigger: '触发事件',
};
/* ── 验收分级 L1–L5(AM 10.2) ──────────────────────────────────────── */
+50 -5
View File
@@ -23,13 +23,29 @@ export type ViewBlocksProps = {
/** 报告级「修改」态:表 / 邮件就地进入编辑(存与弃由外层报告统一管) */
readonly editing?: boolean | undefined;
readonly onOpenFile?: ((file: FileDoc) => void) | undefined;
/**
* 表被改了 —— **改完的行要交出去**(第几块、整张表的行)。
*
* 从前没有这个口子:可编辑表的修改只活在 `TableBlock` 自己的 `setRows` 里,
* 人填完提交,回传载荷里一个格子都没有。实测后果最难看 ——
* 活动派了一张「空的地方标红待填写」的执行单,人老老实实填了 13 个格子、提交,
* Agent 收到的回传是空的,于是认为没人答,**又发了一张一模一样的单**。
* 人以为自己白填了一遍,而系统看起来在原地打转。
*/
readonly onTableChange?: ((index: number, rows: readonly (readonly string[])[]) => void) | undefined;
};
export function ViewBlocks({ blocks, editing = false, onOpenFile }: ViewBlocksProps) {
export function ViewBlocks({ blocks, editing = false, onOpenFile, onTableChange }: ViewBlocksProps) {
return (
<>
{blocks.map((block, i) => (
<Block key={i} b={block} editing={editing} onOpenFile={onOpenFile} />
<Block
key={i}
b={block}
editing={editing}
onOpenFile={onOpenFile}
{...(onTableChange === undefined ? {} : { onTableChange: (rows: readonly (readonly string[])[]) => onTableChange(i, rows) })}
/>
))}
</>
);
@@ -39,10 +55,12 @@ function Block({
b,
editing,
onOpenFile,
onTableChange,
}: {
b: ViewBlock;
editing: boolean;
onOpenFile?: ((file: FileDoc) => void) | undefined;
onTableChange?: ((rows: readonly (readonly string[])[]) => void) | undefined;
}) {
switch (b.t) {
case 'callout':
@@ -82,7 +100,7 @@ function Block({
);
case 'table':
return <TableBlock b={b} editing={editing} />;
return <TableBlock b={b} editing={editing} {...(onTableChange === undefined ? {} : { onChange: onTableChange })} />;
case 'bars':
return (
@@ -218,13 +236,26 @@ function Section({ title, children }: { title?: string | undefined; children: Re
}
/** 可编辑表:报告「修改」态下就地编辑;数据源一换即复位(渲染期调整 state,不用 effect) */
function TableBlock({ b, editing }: { b: Extract<ViewBlock, { t: 'table' }>; editing: boolean }) {
function TableBlock({
b,
editing,
onChange,
}: {
b: Extract<ViewBlock, { t: 'table' }>;
editing: boolean;
onChange?: ((rows: readonly (readonly string[])[]) => void) | undefined;
}) {
const [rows, setRows] = useState<readonly (readonly string[])[]>(b.rows);
const [srcRows, setSrcRows] = useState(b.rows);
if (srcRows !== b.rows) {
setSrcRows(b.rows);
setRows(b.rows);
}
/** 改一格就往外交一次 —— 外层报告拿它拼回传载荷,不必等「保存」那一下 */
const put = (next: readonly (readonly string[])[]): void => {
setRows(next);
onChange?.(next);
};
const live = editing && b.editable === true;
const canEditCol = (c: number) => b.editableCols === undefined || b.editableCols.includes(c);
@@ -259,12 +290,26 @@ function TableBlock({ b, editing }: { b: Extract<ViewBlock, { t: 'table' }>; edi
{r.map((cell, ci) => (
<td key={ci} className={ci === 0 ? 'jc-mono' : ''}>
{live && canEditCol(ci) ? (
/*
* 空格子**自己喊「待填写」**(红字占位)。
*
* 一张全可编辑的表摆在人面前,哪几格是空的、哪几格本来就没有,
* 光看是分不出来的 —— 尤其这份表大半列都填着值,空的那两格
* 在一片文字里根本不显眼。人于是签完了才发现漏填。
* 占位字 + 红边把「这里等你」摆到脸上,填上就恢复常态。
*/
<input
className={cell.trim() === '' ? 'jc-tbl-todo' : undefined}
placeholder="待填写"
aria-label={`${b.cols[ci] ?? ''}${cell.trim() === '' ? '(待填写)' : ''}`}
value={cell}
onChange={(e) =>
setRows(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x)))
put(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x)))
}
/>
) : cell.trim() === '' ? (
/* 只读态也别留一片空白:没填就是没填,如实说 */
<em className="jc-tbl-empty">未填</em>
) : (
cell
)}
+257 -5
View File
@@ -9,20 +9,130 @@
* **高度随内容长**:HTML 报告既是独立文件,也是交互视图的组成部分 ——
* 它得像页面里的一段正文那样整篇铺开、跟着外层一起滚,而不是缩在一个定高小窗里自己滚。
* 定高会把长报告拦腰截断,人得在两层滚动条之间来回找。
*
* ── 就地改:默认锁着,按「编辑」才开(2026-08-01 用户定)────────────────
* 待处理那一屏摆的**只有这份件**,而件本身要能改 —— 于是编辑态长在件里面:
* 每个可改的格子在渲染时就配了一个 `<input class="jc-cell">` 孪生件
* (见 `view-render.ts` 的 `twinInputs`),平时收着,开了闸才现身。
*
* **看与改是两件事**:进这一屏整份件就是编辑态的话,人还没决定要改什么,
* 屏上已经是一张待填的表(A1 那份是 54 个输入框一起亮着)。所以每张可改的表
* 各带一道闸(`[data-edit-for]`):锁着 → 「编辑」;开了 → 「取消 / 保存」。
*
* 外层怎么开、怎么收:`allow-same-origin` 让父文档**读得到也改得动** iframe 的 DOM——
* · 挂事件、加 class、读 input,全在父文档这一侧;
* · **iframe 里始终一行脚本都没有**:`allow-scripts` 永远不给,两者同时给才是破沙箱。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
/** 一张可改的表收上来的样子:`{ 台账地址: [{ 列名: 值 }] }` */
export type ViewEdits = Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
/** 按一次保存交出去的东西:现在这张表长什么样(`tables`)+ 人删掉了哪几行(`drops`) */
export type ViewSave = { readonly tables: ViewEdits; readonly drops: ViewEdits };
export type ViewContainerProps = {
readonly html: string;
readonly title?: string;
/** 这一屏的件**可以改** —— 闸出现(默认仍是锁着的,要按「编辑」才开) */
readonly editable?: boolean;
/** 人按下「保存」时把那张表收上来;不传就是纯呈现 */
readonly onEdits?: (save: ViewSave) => void;
};
/** 量不到高度时的兜底,避免塌成 0 高 */
const FALLBACK_HEIGHT = 420;
export function ViewContainer({ html, title = '活动视图' }: ViewContainerProps) {
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */
function bodyOf(doc: Document, address: string): HTMLElement | null {
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
}
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内 */
function cellsOf(doc: Document, address: string): HTMLInputElement[] {
const host = bodyOf(doc, address);
if (host === null) return [];
return Array.from(host.querySelectorAll<HTMLInputElement>('input[data-col]')).filter(
(i) => i.closest('.jc-row-gone') === null,
);
}
/**
* 把一张表读成 `[{列: 值}]` —— **按行元素归行**,不按 `data-row` 序号归行。
*
* 序号是渲染那一刻钉上去的;人删一行、加一行之后,它就不再是「第几行」了
* (删掉第 2 行,余下的仍是 0/2/3 —— 拿它当下标会在中间留一个洞)。
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
*/
function collect(doc: Document, address: string): Array<Record<string, string>> {
const byRow = new Map<Element, Record<string, string>>();
for (const cell of cellsOf(doc, address)) {
const col = cell.dataset['col'];
const row = cell.parentElement?.parentElement ?? cell.parentElement;
if (col === undefined || row == null) continue;
const one = byRow.get(row) ?? {};
one[col] = cell.value;
byRow.set(row, one);
}
return [...byRow.values()];
}
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
function collectGone(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address);
if (host === null) return [];
return Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone')).map((row) => {
const one: Record<string, string> = {};
for (const cell of Array.from(row.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
const col = cell.dataset['col'];
if (col !== undefined) one[col] = cell.value;
}
return one;
});
}
/**
* **在表尾添一行空的** —— 照末行的样子克隆,值清空。
*
* 克隆的是**渲染出来的那一行**,不是模版:模版在服务端,浏览器这边看不见。
* 一张空表(一行都没有)加不了行 —— 没有可照的样子;那种情形本来也不该由人开头
* (行由活动写下,人改的是它写错的地方)。
*/
function addRow(doc: Document, address: string): boolean {
const host = bodyOf(doc, address);
const last = host?.querySelector<HTMLElement>('tr:last-of-type');
if (host === null || last == null) return false;
const fresh = last.cloneNode(true) as HTMLElement;
fresh.classList.remove('jc-row-gone');
fresh.classList.add('jc-row-new');
for (const cell of Array.from(fresh.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
cell.value = '';
// 序号不再有意义(见 `collect` 的注释),清掉免得两行同号
delete cell.dataset['row'];
}
// 只读那一份也清空 —— 新行还没有值,摆着上一行的字就是在撒谎
for (const view of Array.from(fresh.querySelectorAll<HTMLElement>('[data-slot], .jc-vcell, .jc-tbadge'))) {
if (view.tagName !== 'INPUT') view.textContent = '';
}
host.appendChild(fresh);
return true;
}
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits }: ViewContainerProps) {
const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(FALLBACK_HEIGHT);
/*
* **`srcDoc` 只用来开第一帧,之后就地换内容**(2026-08-02 用户报的「保存时闪黑」)。
*
* `srcDoc` 一变浏览器就**重载整个文档**:那一瞬 iframe 里什么都没有,
* 高度也从量好的五千多掉回兜底的 420 再涨回来 —— 底下的深色画布露出来一下,
* 看起来就是「闪黑」。而人刚按下保存,件在他眼皮底下抖一下,像是出错了。
*
* 容器是同源的,换内容不必换文档:直接写 `body.innerHTML`。
* 文档不重载,于是高度不跳、滚动位置不丢、挂在文档上的监听也还在。
*/
const [seed] = useState(html);
const shown = useRef(html);
const measure = useCallback(() => {
const doc = ref.current?.contentDocument;
@@ -30,8 +140,146 @@ export function ViewContainer({ html, title = '活动视图' }: ViewContainerPro
// 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异
const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
if (next > 0) setHeight(next);
bindRef.current();
}, []);
/*
* 回调放 ref —— 外层多半是行内箭头函数,每次渲染都是新的一个。
* 让它进 effect 的依赖,等于每渲染一次就拆装一次监听;而真正要重来的时机只有一个:
* **文档换了**(srcdoc 一变就是一份新文档,挂在旧文档上的东西随之作废)。
*/
const sink = useRef(onEdits);
sink.current = onEdits;
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
const snapshot = useRef(new Map<string, { values: string[]; rows: number }>());
/*
* 挂事件 + 开关闸 —— 都在**父文档这一侧**做,iframe 里没有脚本。
*
* 点击处理器是**稳定引用**,于是「重复挂同一个」是幂等的(DOM 规范如此)——
* 这一条正是这里的关键:`srcDoc` 换文档的时机抓不准,
* 与其判断「挂过没有」,不如**每次有机会就挂一遍**,哪一次落在真文档上都算数。
*
* 从前用一个 `bound` ref 记「挂过哪一份文档」去重,实测点「编辑」毫无反应:
* 挂载那一刻 `contentDocument` 还是 srcdoc 之前的空白文档,监听挂在了它身上;
* 而 `onLoad` 已经错过、effect 的依赖又没变,于是**再也没有第二次机会**。
*/
const onClick = useRef((event: Event) => {
const doc = ref.current?.contentDocument;
if (doc == null) return;
/*
* **不能用 `instanceof Element`** —— 这是跨 realm 的经典陷阱。
*
* `Element` 是**父文档**的那个构造器,而 `event.target` 来自 iframe 的 realm:
* 它的原型链上挂的是 iframe 那份 `Element`,两者不是同一个对象,
* `instanceof` 恒为 false —— handler 挂上了、事件也收到了,却每次都在第一行 return。
* 实测症状:点「编辑」毫无反应,而根节点的 class、CSS、选择器逐个查都是对的。
* 认能力不认血统:有 `closest` 就当它是元素。
*/
const target = event.target as { closest?: (s: string) => HTMLElement | null } | null;
const hit =
typeof target?.closest === 'function'
? target.closest('[data-edit-open],[data-edit-cancel],[data-edit-save],[data-edit-add],[data-edit-del]')
: null;
if (hit === null || hit === undefined) return;
event.preventDefault();
/** 那张表与它的闸一起开合 */
const setOpen = (address: string, on: boolean): void => {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
doc
.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`)
?.closest('table')
?.classList.toggle('on', on);
};
const open = hit.dataset['editOpen'];
const cancel = hit.dataset['editCancel'];
const save = hit.dataset['editSave'];
const add = hit.dataset['editAdd'];
const del = hit.dataset['editDel'];
if (open !== undefined) {
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
snapshot.current.set(open, {
values: cellsOf(doc, open).map((c) => c.value),
rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
});
setOpen(open, true);
return;
}
if (cancel !== undefined) {
const was = snapshot.current.get(cancel);
if (was !== undefined) {
// 加出来的那几行整个撤掉,删掉的标记摘掉,再把值放回去 —— 三样一起才叫还原
const host = bodyOf(doc, cancel);
if (host !== null) {
while (host.children.length > was.rows) host.lastElementChild?.remove();
for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone');
}
cellsOf(doc, cancel).forEach((c, i) => (c.value = was.values[i] ?? c.value));
}
setOpen(cancel, false);
return;
}
/*
* **增删只在编辑态里发生,保存那一下才落库** —— 与改格子同一条路子:
* 删是给那一行挂个记号(`jc-row-gone`),不是当场从屏上抹掉 ——
* 人得看得见自己删了哪一行,也才有得后悔(「取消」把记号摘了就回来了)。
*/
if (del !== undefined) {
target?.closest?.('tr')?.classList.toggle('jc-row-gone');
return;
}
if (add !== undefined) {
addRow(doc, add);
measureRef.current();
return;
}
if (save !== undefined) {
/*
* **保存 = 把这张表现在的样子交出去**:留下的行 + 人删掉的行,两份分开说。
* 删要单独说、不靠「没回传就是删了」—— 那种口径会把一次不完整的回传变成一次删表。
*/
sink.current?.({ tables: { [save]: collect(doc, save) }, drops: { [save]: collectGone(doc, save) } });
snapshot.current.delete(save);
setOpen(save, false);
}
});
/*
* `measure` 由 `onLoad` 与 ResizeObserver 反复触发,而它们一定发生在**真文档**上 ——
* 所以顺手在那里也挂一次监听:这是抓不准 srcdoc 时机的可靠兜底。
* 走 ref 是为了不让 `measure` 依赖 `bind`(那会让 ResizeObserver 每次 editable 变就重挂)。
*/
const bindRef = useRef<() => void>(() => undefined);
/** 加一行之后件长高了 —— 重量一次,不然新行被容器裁在外面(走 ref,同 `bindRef` 的道理) */
const measureRef = useRef<() => void>(() => undefined);
measureRef.current = measure;
const bind = useCallback(() => {
const doc = ref.current?.contentDocument;
if (doc?.documentElement == null) return;
// `editable` 只让闸现身;开不开由人按 —— 件默认是锁着的
doc.documentElement.classList.toggle('editable', editable);
// 同一个 (type, listener) 重复 add 无副作用 —— 所以不去重,只求哪一次挂中了真文档
doc.addEventListener('click', onClick.current);
}, [editable]);
bindRef.current = bind;
/*
* 内容变了 —— **就地换,不重载**(见 `seed` 那段注释)。
* 换完立刻重量一次高度:新件长短不同,不重量会留一截空白或被截断。
*/
useEffect(() => {
const doc = ref.current?.contentDocument;
if (doc?.body != null && shown.current !== html) {
shown.current = html;
doc.body.innerHTML = html;
measure();
}
bind();
}, [bind, html, measure]);
useEffect(() => {
const frame = ref.current;
const doc = frame?.contentDocument;
@@ -42,21 +290,25 @@ export function ViewContainer({ html, title = '活动视图' }: ViewContainerPro
return () => observer.disconnect();
}, [measure, html]);
const onLoad = useCallback(() => {
measure();
bind();
}, [measure, bind]);
return (
<iframe
ref={ref}
className="jc-view-frame"
title={title}
/*
* `allow-same-origin` **只为让外层量到内容高度**(否则 iframe 是不透明源,
* contentDocument 读不到,高度无从得知)。
* `allow-same-origin` 让外层量得到高度、挂得上事件、收得回值。
* 关键在于**始终不给 `allow-scripts`** —— 里面一行脚本也跑不了,
* 「无脚本执行权」这条不变。两者同时给才是破沙箱,这里永远不会。
*/
sandbox="allow-same-origin"
srcDoc={html}
srcDoc={seed}
style={{ height }}
onLoad={measure}
onLoad={onLoad}
scrolling="no"
/>
);
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* @ui —— 无业务语义展示组件(卡片原语 / 时间线 / **净化视图容器**)。
*/
export type { ViewContainerProps } from './ViewContainer.tsx';
export type { ViewContainerProps, ViewEdits, ViewSave } from './ViewContainer.tsx';
export { ViewContainer } from './ViewContainer.tsx';
export { ViewBlocks } from './ViewBlocks.tsx';
export { Ico, hasIco } from './Ico.tsx';