测试台重制:浅色桌面上的一台仪器 —— 抽键预检读数屏是戏眼

产品:
  · 抽键预检(新):通道的抽键正则**实时**跑在正打的字上 —— 键名/抽到的值/一盏灯,
    该抽的必填全亮,起单钮才「上膛」发光。从前的头号困惑是投出去才知道
    「抽不出客户码」,现在打字就知道。契约把 extract 规则原文回给治理台
    (正则不是密钥),前端镜像服务端 extractFields 的取料与取值;服务端仍是唯一裁判。
  · 收件箱改成通道下拉 —— 投给哪个信箱决定用哪套抽键规则,手打地址错一位,
    预检整屏熄灯还不知道为什么。
  · 「全派 AI」一键(测试一律用 AI 账号,把项目规矩做成一枚钮)+「全部轮值」清空。
  · 双层页头砍成一层:区壳的英雄头留着,屏内改成一根「仪器顶条」——
    一盏灯 + 状态词,装配态与观测态共用同一根骨架,起单前后面对的是同一台机器。
  · 观测态删掉两枚手动「刷新」(2s 自动轮询早就在),唯一动作是珊瑚红的停止仿真。

视觉:
  · 概念:页面是浅色桌面,测试台是桌上**一台有边有圆角的暗色仪器**——
    不是把纸刷黑(上一版的问题:半迁移的暗色,TRACE 里十几处浅色时代的硬编码
    在深底上要么刺眼要么隐形,如 k-node #161b22 深底深条完全不可见)。
  · 三色律:磷光绿 = 机器活着(灯/命中/上膛);琥珀 = 人(指派/审批/等人/人卡);
    珊瑚红 = 坏(异常/销毁)。其余全是冷灰蓝结构色,集中在一组变量上。
  · 机器说 mono,那封信说无衬线 —— 它是外界唯一伸进仪器的东西。
  · 指派轨:节点挂在一条竖轨上,活动是圆点、审批是琥珀菱形,插了人点亮琥珀。
  · 呼吸灯尊重 prefers-reduced-motion;焦点环全部磷光可见。

实测走查:改坏主题 → 灯灭/计数 0/2/钮退膛/状态行说因;重置样例 → 全派 AI →
起单 → 观测态(呼吸灯+TRACE 示波记录+派工带)→ 停止 → 库里零残留。

四闸绿;契约快照随本提交入库(listChannels 增 extract,加法)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 11:12:22 +08:00
co-authored by Claude Opus 5
parent 72f90f293b
commit 70b261d067
6 changed files with 657 additions and 482 deletions
+11
View File
@@ -2082,6 +2082,17 @@ export declare const listChannelsRoute: {
address: z.ZodString;
enabled: z.ZodBoolean;
extractCount: z.ZodNumber;
extract: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
regex: z.ZodString;
source: z.ZodEnum<{
from: "from";
to: "to";
subject: "subject";
body: "body";
both: "both";
}>;
}, z.core.$strip>>>;
mappingCount: z.ZodNumber;
fallbackPosition: z.ZodOptional<z.ZodString>;
addressWindowMs: z.ZodNumber;
@@ -801,6 +801,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
address: ch.address,
enabled: ch.enabled,
extractCount: ch.extract.length,
extract: ch.extract.map((r) => ({ key: r.key, regex: r.regex, source: r.source })),
mappingCount: ch.mapping.length,
...(ch.fallbackPosition === undefined ? {} : { fallbackPosition: ch.fallbackPosition }),
addressWindowMs: ch.addressWindowMs,
+211 -196
View File
@@ -665,270 +665,285 @@
.g-maprow > .g-btn { grid-column: 6; justify-self: stretch; padding: 4px 0; display: inline-flex; align-items: center; justify-content: center; }
.g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; text-align: center; }
/* ═══════════ 测试台 · 仿真试跑(2026-07-29)═══════════
测试台是**后端检测活动与流程**的地方:在这儿装配并起单,真实任务照旧落到被指派人的
工作台上 —— 这一屏只管两件事:**把流跑起来** + **看穿它怎么跑的**。
形制沿用图纸语言(与 .g-vercard / .g-nodecard 同一套 --sh-*):白纸方角、细线、
钢青题头、黄铜是「人 / 生效 / 裁定」的颜色。
全屏只有一处戏眼 —— TRACE 那张**「树 + 甘特」同排的走查记录纸**:
左边是结构(枝线画出 span 树),右边是时间(同一条刻度轴上的条),一行读两件事。 */
/* ═══════════ 测试台 · 仿真试跑(2026-07-30 重制)═══════════
概念:**浅色桌面上的一台仪器**。整台机器是页面里唯一的暗色区 —— 有边、有圆角、
有台面,不是把纸刷黑。机器说 mono(读数、灯、编号、TRACE),
那封信说无衬线 —— 它是外界唯一伸进仪器的东西。
色语三条:磷光绿 = 机器活着(灯、命中、上膛);琥珀 = 人(指派、审批、等人);
珊瑚红 = 坏(异常、销毁)。三色之外全是冷灰蓝的结构色。
戏眼:左栏「抽键预检」读数屏 —— 通道正则实时跑在正打的字上,灯全亮起单钮才上膛。 */
/* 暗色仪表台:纸变面板,钢青转冷蓝,黄铜留着当「人 / 生效 / 裁定」的色。
结构一行没动 —— 换的只是这组变量与几处底色,TRACE 那张树 + 甘特照旧。 */
.g-tb, .g-rig {
--sh-ink: #e6edf6; --sh-steel: #8aa4c2; --sh-muted: #64788f;
--sh-line: #21303f; --sh-brass: #f0a83c; --sh-brass-wash: #241a0c;
--sh-grid: #16222f;
--sh-panel: #0e1823; --sh-panel-2: #121f2c; --sh-live: #35d0c0;
.g-tb {
--sh-ink: #e9eff9; --sh-steel: #92a8c9; --sh-muted: #5d7090;
--sh-line: #223350; --sh-lineb: #33496e;
--sh-bed: #0a111e; --sh-panel: #101a2c; --sh-panel-2: #152238; --sh-well: #060c16;
--sh-live: #45dcb2; --sh-live-dim: rgba(69, 220, 178, .13);
--sh-brass: #f2b45c; --sh-brass-wash: rgba(242, 180, 92, .1);
--sh-alarm: #ff7d6e; --sh-run: #62a9e0;
--sh-grid: #16233a;
--sh-mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace;
display: flex; flex-direction: column; gap: 12px; min-width: 0;
display: flex; flex-direction: column; gap: 10px; min-width: 0;
padding: 12px; border: 1px solid #1b2a44; border-radius: 14px; background: var(--sh-bed);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04), 0 10px 26px -16px rgba(8, 15, 28, .55);
}
/* 模式选择:不是第二条页签栏 —— 前置 mono 墨签点明这是个**控件**,方角、黄铜底线定当前 */
/* ── 仪器顶条:一盏灯 + 状态词 —— 装配态与观测态共用同一根 ── */
.tb-strip { display: flex; align-items: center; gap: 10px; min-height: 26px; padding: 1px 4px 3px; }
.tb-lamp { flex: none; width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 9px currentColor; }
.tb-lamp.idle { color: var(--sh-muted); background: transparent; border: 1.5px solid currentColor; box-shadow: none; }
.tb-lamp.live { color: var(--sh-live); }
.tb-lamp.wait { color: var(--sh-brass); }
.tb-lamp.done { color: var(--sh-live); box-shadow: none; }
.tb-lamp.bad { color: var(--sh-alarm); }
.tb-lamp.breathe { animation: tbPulse 2.4s ease-in-out infinite; }
@keyframes tbPulse { 50% { opacity: .4; box-shadow: 0 0 2px currentColor; } }
.tb-strip .word { font: 700 11px/1 var(--sh-mono); letter-spacing: .22em; text-transform: uppercase; color: var(--sh-ink); }
.tb-strip .det { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--sh-steel); }
.tb-strip .det .auto { font: 500 9.5px/1 var(--sh-mono); font-style: normal; color: var(--sh-muted); }
.tb-strip .x { margin-left: auto; flex: none; display: flex; align-items: center; gap: 8px; font: 600 9.5px/1 var(--sh-mono); letter-spacing: .1em; color: var(--sh-muted); }
/* ── 图纸卡:方角白纸 + mono 三字码题头(与流程区的 VER — / FIG — 同一idiom)── */
.g-sheet { display: flex; flex-direction: column; min-width: 0; background: var(--sh-panel); border: 1px solid var(--sh-line); box-shadow: var(--cshadow); }
.g-sheet-h { flex: none; display: flex; align-items: center; gap: 8px; padding: 11px 14px 10px; border-bottom: 1px solid var(--sh-line); font-family: var(--sh-mono); font-size: 9.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--sh-steel); }
/* 停止 = 销毁,珊瑚红说清后果;唯一动作,住在顶条右端 */
.tb-stop { display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 12px; border: 1px solid rgba(255, 125, 110, .45); border-radius: 7px; background: rgba(255, 125, 110, .08); color: var(--sh-alarm); font: 600 11px/1 var(--sh-mono); letter-spacing: .08em; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.tb-stop:hover:not(:disabled) { background: rgba(255, 125, 110, .16); border-color: var(--sh-alarm); }
.tb-stop:disabled { opacity: .5; cursor: default; }
.tb-stop:focus-visible { outline: 2px solid var(--sh-alarm); outline-offset: 1px; }
/* ── 装配台:左被测流,右两节 ── */
.g-rig { display: grid; grid-template-columns: minmax(236px, .78fr) minmax(0, 2fr); gap: 10px; align-items: start; }
.g-rig-l, .g-rig-r { display: flex; flex-direction: column; min-width: 0; background: var(--sh-panel); border: 1px solid var(--sh-line); border-radius: 10px; overflow: clip; }
/* 节题:mono 小号 + 序号章 */
.g-rig-h { display: flex; align-items: center; gap: 8px; padding: 10px 13px 9px; border-bottom: 1px solid var(--sh-line); background: var(--sh-panel-2); font-family: var(--sh-mono); font-size: 9.5px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--sh-steel); }
.g-rig-l > * + .g-rig-h, .g-rig-r > * + .g-rig-h { border-top: 1px solid var(--sh-line); }
.g-rig-h svg { color: var(--sh-steel); opacity: .7; }
.g-rig-h .n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 15px; border: 1px solid var(--sh-live); border-radius: 3px; color: var(--sh-live); font-size: 9px; letter-spacing: .06em; }
.g-rig-h .x { margin-left: auto; display: flex; align-items: center; gap: 6px; letter-spacing: .04em; text-transform: none; color: var(--sh-muted); }
/* 节题里的幽灵小钮:重置样例 / 全派 AI / 全部轮值 */
.tb-ghost { display: inline-flex; align-items: center; gap: 5px; height: 21px; padding: 0 8px; border: 1px solid var(--sh-line); border-radius: 5px; background: transparent; color: var(--sh-steel); font: 500 10px/1 var(--sans); letter-spacing: .02em; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); }
.tb-ghost:hover { border-color: var(--sh-lineb); background: var(--sh-panel); color: var(--sh-ink); }
.tb-ghost:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 1px; }
/* 左栏:一包一条 —— 选中的那条亮磷光左线(机器活了) */
.tb-pks { display: flex; flex-direction: column; gap: 2px; padding: 7px; }
.g-pk { display: flex; flex-direction: column; gap: 4px; padding: 9px 11px; border: 1px solid transparent; border-left: 2px solid var(--sh-line); border-radius: 0 7px 7px 0; background: transparent; text-align: left; font: inherit; color: var(--sh-ink); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.g-pk:hover { background: var(--sh-panel-2); border-left-color: var(--sh-steel); }
.g-pk.on { background: var(--sh-live-dim); border-left-color: var(--sh-live); }
.g-pk:focus-visible { outline: 2px solid var(--sh-live); outline-offset: -2px; }
.g-pk b { font-size: 12.5px; font-weight: 700; }
.g-pk .id { font-family: var(--sh-mono); font-size: 10px; color: var(--sh-muted); overflow-wrap: anywhere; }
.g-pk .cnt { display: flex; gap: 10px; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-steel); }
.g-pk .cnt i { font-style: normal; }
/* ── 抽键预检读数屏(戏眼):黑色凹槽 + 一键一灯,值走磷光 ── */
.tb-well { margin: 8px 8px 6px; padding: 3px 0; background: var(--sh-well); border: 1px solid #182742; border-radius: 8px; box-shadow: inset 0 2px 8px rgba(0, 0, 0, .45); }
.tb-well-r { display: grid; grid-template-columns: 16px minmax(0, .9fr) minmax(0, 1.1fr); align-items: center; gap: 7px; padding: 6px 11px 6px 9px; font-family: var(--sh-mono); }
.tb-well-r + .tb-well-r { border-top: 1px dashed #14213a; }
.tb-well-r > i { width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--sh-muted); background: transparent; justify-self: center; transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); }
.tb-well-r.hit > i { border-color: var(--sh-live); background: var(--sh-live); box-shadow: 0 0 7px var(--sh-live); }
.tb-well-r.dead > i { border-color: var(--sh-alarm); }
.tb-well-r .k { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: var(--sh-steel); }
.tb-well-r .k em { flex: none; font: 600 8px/1 var(--sh-mono); font-style: normal; letter-spacing: .1em; padding: 2px 4px; border: 1px solid rgba(242, 180, 92, .5); border-radius: 3px; color: var(--sh-brass); }
.tb-well-r .v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-size: 11px; font-weight: 700; color: var(--sh-muted); }
.tb-well-r.hit .v { color: var(--sh-live); text-shadow: 0 0 8px rgba(69, 220, 178, .35); }
.tb-well-r.dead .v { color: var(--sh-alarm); font-weight: 600; }
.tb-well-none { margin: 0; padding: 10px 12px; font-size: 11px; line-height: 1.8; color: var(--sh-muted); }
.tb-well-t { margin: 0; padding: 0 12px 11px; font-size: 10px; line-height: 1.7; color: var(--sh-muted); }
/* ── ① 一封信:外界的东西 —— 全屏唯一的无衬线区,标签在左、底线在下 ── */
.tb-ml { display: flex; flex-direction: column; padding: 2px 14px 12px; }
.tb-ml-r { display: grid; grid-template-columns: 56px minmax(0, 1fr); align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--sh-line); }
.tb-ml-r.tall { align-items: start; }
.tb-ml-r:last-child { border-bottom: 0; }
.tb-ml-r > em { font: 600 9px/1.7 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; font-style: normal; color: var(--sh-muted); padding-top: 1px; }
.tb-ml-i { width: 100%; box-sizing: border-box; border: 0; background: transparent; padding: 3px 0; color: var(--sh-ink); font: 400 13px/1.7 var(--sans); resize: vertical; }
.tb-ml-i::placeholder { color: var(--sh-muted); }
.tb-ml-i:focus { outline: 0; }
.tb-ml-r:focus-within { border-bottom-color: var(--sh-live); }
.tb-ml-i.sel { appearance: none; padding-right: 20px; background: transparent url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="%2392a8c9" stroke-width="1.5"/></svg>') no-repeat right 4px center; cursor: pointer; }
.tb-ml-i.sel option { background: var(--sh-panel); color: var(--sh-ink); }
.tb-ml-at { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 2px 0; }
.g-att { display: inline-flex; align-items: center; gap: 5px; padding: 4px 6px 4px 8px; border: 1px solid var(--sh-line); border-radius: 6px; background: var(--sh-panel-2); font-size: 11px; color: var(--sh-ink); }
.g-att svg { color: var(--sh-steel); }
.g-att i { font-style: normal; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); }
.g-att button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--sh-muted); cursor: pointer; }
.g-att button:hover { color: var(--sh-alarm); }
.g-att-add { display: inline-flex; align-items: center; gap: 5px; padding: 5px 9px; border: 1px dashed var(--sh-lineb); border-radius: 6px; background: transparent; color: var(--sh-steel); font: inherit; font-size: 11px; cursor: pointer; }
.g-att-add:hover { border-color: var(--sh-steel); color: var(--sh-ink); }
.g-att-add:focus-visible, .g-att button:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 1px; }
/* ── ② 节点指派:一条竖轨挂着所有节点,右端是插座 —— 插了人点琥珀 ── */
.tb-asn { display: flex; flex-direction: column; padding: 6px 14px 10px; }
.tb-asn-r { display: grid; grid-template-columns: 20px 34px minmax(0, 1fr) 40px minmax(150px, 190px); align-items: center; gap: 9px; padding: 6px 0; }
.tb-asn-r .rail { position: relative; align-self: stretch; }
.tb-asn-r .rail::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid var(--sh-line); }
.tb-asn-r:first-child .rail::before { top: 50%; }
.tb-asn-r:last-child .rail::before { bottom: 50%; }
.tb-asn-r .rail i { position: absolute; left: 50%; top: 50%; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; border: 1.5px solid var(--sh-steel); background: var(--sh-panel); transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); }
.tb-asn-r.apr .rail i { border-radius: 2px; transform: rotate(45deg); border-color: var(--sh-brass); }
.tb-asn-r.pin .rail i { border-color: var(--sh-brass); background: var(--sh-brass); box-shadow: 0 0 7px rgba(242, 180, 92, .5); }
.tb-asn-r .nid { font-family: var(--sh-mono); font-size: 11px; font-weight: 700; color: var(--sh-ink); }
.tb-asn-r .lb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--sh-steel); }
.tb-asn-r.pin .lb { color: var(--sh-ink); }
.tb-asn-r .kd { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; color: var(--sh-muted); }
.tb-asn-r.apr .kd { color: var(--sh-brass); }
.tb-socket { width: 100%; box-sizing: border-box; height: 27px; padding: 0 22px 0 9px; border: 1px solid var(--sh-line); border-radius: 6px; background: var(--sh-panel-2) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="%235d7090" stroke-width="1.5"/></svg>') no-repeat right 7px center; appearance: none; color: var(--sh-muted); font: 500 11px/1 var(--sans); cursor: pointer; transition: border-color var(--t-fast), color var(--t-fast); }
.tb-asn-r.pin .tb-socket { border-color: rgba(242, 180, 92, .55); color: var(--sh-brass); }
.tb-socket:hover { border-color: var(--sh-lineb); }
.tb-socket:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 1px; }
.tb-socket option { background: var(--sh-panel); color: var(--sh-ink); }
/* ── 起单条:仪器的前面板边缘 —— 全屏唯一的动作钮,灯全亮才上膛 ── */
.tb-fire { position: sticky; bottom: -1px; z-index: 3; display: flex; align-items: center; gap: 14px; margin-top: auto; padding: 11px 14px; border-top: 1px solid var(--sh-line); border-radius: 0 0 10px 10px; background: linear-gradient(to bottom, rgba(16, 26, 44, .92), var(--sh-panel-2)); backdrop-filter: blur(4px); }
.tb-fire .say { min-width: 0; font-size: 11px; line-height: 1.6; color: var(--sh-muted); }
.tb-fire .say .bad { font-weight: 600; color: var(--sh-alarm); }
.tb-go { flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 22px; border: 1px solid var(--sh-lineb); border-radius: 8px; background: var(--sh-panel); color: var(--sh-steel); font: 700 12.5px/1 var(--sh-mono); letter-spacing: .12em; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast); }
.tb-go:hover:not(:disabled) { border-color: var(--sh-steel); color: var(--sh-ink); }
.tb-go.armed { border-color: var(--sh-live); background: var(--sh-live-dim); color: var(--sh-live); box-shadow: 0 0 0 1px rgba(69, 220, 178, .22), 0 0 20px rgba(69, 220, 178, .16); }
.tb-go.armed:hover:not(:disabled) { background: rgba(69, 220, 178, .2); color: #eafff7; }
.tb-go:disabled { opacity: .45; cursor: default; }
.tb-go:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 2px; }
/* 空台面:没有生效流 —— 一个虚线的空槽,指路去流程包 */
.tb-slot { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 44px 16px 46px; border: 1.5px dashed var(--sh-line); border-radius: 10px; text-align: center; }
.tb-slot svg { color: var(--sh-muted); opacity: .7; }
.tb-slot b { font-size: 12.5px; font-weight: 700; color: var(--sh-steel); }
.tb-slot p { margin: 0; max-width: 380px; font-size: 11px; line-height: 1.9; color: var(--sh-muted); }
.tb-slot .g-btn { margin-top: 4px; border-color: var(--sh-lineb); background: var(--sh-panel); color: var(--sh-steel); border-radius: 7px; }
.tb-slot .g-btn:hover { border-color: var(--sh-steel); background: var(--sh-panel-2); color: var(--sh-ink); }
/* ── 观测态的模块卡(WF / TRC / NOD):面板方角圆 10,题头 mono 三字码 ── */
.g-sheet { display: flex; flex-direction: column; min-width: 0; background: var(--sh-panel); border: 1px solid var(--sh-line); border-radius: 10px; overflow: clip; }
.g-sheet-h { flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 14px 9px; border-bottom: 1px solid var(--sh-line); background: var(--sh-panel-2); font-family: var(--sh-mono); font-size: 9.5px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--sh-steel); }
.g-sheet-h svg { color: var(--sh-steel); opacity: .65; }
/* 题头右角写的是**数据**(包 id / 计数 / 来源),不是标签 —— 不跟着题头转大写 */
.g-sheet-h .x { margin-left: auto; display: flex; align-items: center; gap: 10px; letter-spacing: .06em; text-transform: none; color: var(--sh-muted); text-align: right; }
.g-sheet-b { padding: 13px 14px 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.g-sheet-b.tight { padding: 0; }
/* 装配台:左选包、右填单 —— 窄了就叠 */
.g-tb-rig { display: grid; grid-template-columns: minmax(230px, .8fr) minmax(0, 2fr); gap: 12px; align-items: start; }
.g-tb-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
/* 图纸卡里的表单件:压回方角、纸上填线(输入只留底线,聚焦转钢蓝)—— 同 .g-nodecard 的处理 */
.g-tb .g-field { gap: 3px; }
.g-tb .g-field > em { font: 600 9px/1.6 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-steel); }
.g-tb .g-field > em i { font-family: var(--sans); font-size: 10px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--sh-muted); }
.g-tb .g-input, .g-tb .g-select, .g-tb .g-ta,
.g-rig .g-input, .g-rig .g-select, .g-rig .g-ta { border: 0; border-bottom: 1px solid var(--sh-line); border-radius: 0; background-color: transparent; color: var(--sh-ink); padding: 5px 2px; }
.g-rig .g-select option { background: var(--sh-panel); color: var(--sh-ink); }
.g-tb .g-input:focus, .g-tb .g-select:focus, .g-tb .g-ta:focus,
.g-rig .g-input:focus, .g-rig .g-select:focus, .g-rig .g-ta:focus { outline: 0; border-color: var(--sh-live); box-shadow: 0 1px 0 0 var(--sh-live); }
.g-tb .g-select { padding-right: 22px; background-position: right 8px center, right 3px center; }
.g-tb .g-btn, .g-tb .g-chip, .g-tb .g-note, .g-tb .g-seg, .g-tb .g-seg-b { border-radius: 0; }
.g-tb .g-btn { border-color: var(--sh-line); color: var(--sh-steel); }
.g-tb .g-btn:hover:not(:disabled) { border-color: #9fb0c0; background: #f4f6f9; }
.g-tb .g-btn.primary { border-color: var(--sh-brass); background: var(--sh-brass); color: #fff; }
.g-tb .g-btn.primary:hover:not(:disabled) { border-color: #6f4b14; background: #6f4b14; }
.g-tb .g-note { border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fafbfd; font-size: 11px; line-height: 1.8; color: var(--sh-muted); }
.g-tb .g-note svg { color: var(--sh-steel); }
.g-tb .g-note.warn { border-color: #ecd9ab; border-left-color: var(--sh-brass); background: var(--sh-brass-wash); color: var(--sh-brass); }
.g-tb .g-note.warn svg { color: var(--sh-brass); }
.g-tb .g-note.ok { border-color: #c6dcc9; border-left-color: #2e7d4f; background: #f4f9f5; color: #2e7d4f; }
.g-tb .g-note.ok svg { color: #2e7d4f; }
.g-tb .g-ta.mono { font-family: var(--sh-mono); font-size: 11.5px; }
/* 机器里的通用小件走暗色 */
.g-tb .g-btn { border-color: var(--sh-line); background: transparent; color: var(--sh-steel); border-radius: 6px; }
.g-tb .g-btn:hover:not(:disabled) { border-color: var(--sh-lineb); background: var(--sh-panel-2); color: var(--sh-ink); }
.g-tb .g-btn.xs { height: 22px; padding: 0 8px; font-size: 10.5px; letter-spacing: 0; text-transform: none; }
/* 调用参数两列排:字段是**声明里的键**,不是题头 —— 保 mono 但不转大写(大写把驼峰读没了) */
.g-tbargs { display: grid; grid-template-columns: repeat(auto-fit, minmax(208px, 1fr)); gap: 11px 18px; }
.g-tb-f { min-width: 0; }
.g-tb-f .g-field > em { text-transform: none; letter-spacing: .03em; font-size: 10.5px; color: var(--sh-ink); }
/* 必填不靠星号靠**颜色**:注记转黄铜(黄铜在这套语言里恒表「生效 / 裁定 / 人」这类要紧事) */
.g-tb-f .g-field > em i { font-family: var(--sh-mono); font-size: 9px; letter-spacing: .06em; color: var(--sh-muted); }
.g-tb-f.req .g-field > em i { color: var(--sh-brass); font-weight: 600; }
/* 起单条 = 装配单的落款:题在左,动作在右,一行收口 */
.g-tbrun { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.g-tbrun > .g-tb-f { flex: 0 1 440px; }
.g-tbrun > .g-acts { flex: none; margin-left: auto; }
/* ══ 装配台:左选流 · 右配置 ═══════════════════════════════════════════
一屏两大块 —— 左边一条流一张条,右边两节:①这封信 ②派给谁。
面板是暗的:这一屏看的是机器,不是纸。 */
.g-rig { display: grid; grid-template-columns: minmax(212px, .72fr) minmax(0, 2.15fr); gap: 12px; align-items: start; }
.g-rig-l, .g-rig-r { display: flex; flex-direction: column; min-width: 0; background: var(--sh-panel); border: 1px solid var(--sh-line); }
.g-rig-r { gap: 0; }
/* 节题:mono 小号 + 序号章 —— 右栏两节靠它分,不用第二条页签栏 */
.g-rig-h { display: flex; align-items: center; gap: 8px; padding: 10px 13px 9px; border-bottom: 1px solid var(--sh-line); background: var(--sh-panel-2); font-family: var(--sh-mono); font-size: 9.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--sh-steel); }
.g-rig-r > .g-rig-h + .g-rig-h, .g-rig-r > * + .g-rig-h { border-top: 1px solid var(--sh-line); }
.g-rig-h svg { color: var(--sh-steel); opacity: .7; }
.g-rig-h .n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 15px; border: 1px solid var(--sh-brass); color: var(--sh-brass); font-size: 9px; letter-spacing: .06em; }
.g-rig-h .x { margin-left: auto; letter-spacing: .04em; text-transform: none; color: var(--sh-muted); }
/* 左栏:一包一条 —— 选中的那条亮一道黄铜边线 */
.g-rig-pk { display: flex; flex-direction: column; gap: 1px; padding: 8px; }
.g-pk { display: flex; flex-direction: column; gap: 4px; padding: 9px 10px; border: 1px solid transparent; border-left: 2px solid var(--sh-line); background: transparent; text-align: left; font: inherit; color: var(--sh-ink); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.g-pk:hover { background: var(--sh-panel-2); border-left-color: var(--sh-steel); }
.g-pk.on { background: var(--sh-brass-wash); border-color: #3a2c14; border-left-color: var(--sh-brass); }
.g-pk:focus-visible { outline: 2px solid var(--sh-live); outline-offset: -2px; }
.g-pk b { font-size: 12.5px; font-weight: 700; }
.g-pk .id { font-family: var(--sh-mono); font-size: 10px; color: var(--sh-muted); }
.g-pk .cnt { display: flex; gap: 10px; font-size: 10px; color: var(--sh-steel); }
.g-pk .cnt i { font-style: normal; }
/* ── 01 输入配置:一封信 ────────────────────────────────────────────
写信就该长得像写信 —— 标签在左、线在下,附件是一排小签。 */
.g-ml { display: flex; flex-direction: column; padding: 4px 13px 12px; }
.g-ml-r { display: grid; grid-template-columns: 58px minmax(0, 1fr); align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--sh-line); }
.g-ml-r.tall { align-items: start; }
.g-ml-r:last-child { border-bottom: 0; }
.g-ml-r > em { font: 600 9px/1.6 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; font-style: normal; color: var(--sh-steel); padding-top: 2px; }
.g-ml-i { width: 100%; box-sizing: border-box; border: 0; background: transparent; padding: 3px 0; color: var(--sh-ink); font: inherit; font-size: 12.5px; resize: vertical; }
.g-ml-i::placeholder { color: var(--sh-muted); }
.g-ml-i:focus { outline: 0; }
.g-ml-r:focus-within { border-bottom-color: var(--sh-live); }
.g-ml-t { grid-column: 2; font-size: 10px; color: var(--sh-muted); }
.g-ml-at { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 2px 0; }
.g-att { display: inline-flex; align-items: center; gap: 5px; padding: 3px 5px 3px 7px; border: 1px solid var(--sh-line); background: var(--sh-panel-2); font-size: 11px; color: var(--sh-ink); }
.g-att svg { color: var(--sh-steel); }
.g-att i { font-style: normal; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); }
.g-att button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--sh-muted); cursor: pointer; }
.g-att button:hover { color: #ff8b84; }
.g-att-add { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 1px dashed var(--sh-line); background: transparent; color: var(--sh-steel); font: inherit; font-size: 11px; cursor: pointer; }
.g-att-add:hover { border-color: var(--sh-steel); color: var(--sh-ink); }
/* ── 02 节点指派:一节点一行,定了的那行亮左线 ── */
.g-rig-r > .g-asn { padding: 6px 13px 12px; }
.g-rig-r > .g-note { margin: 0 13px 12px; }
/* ── PKG — 流程包:一包一行,生效版行按黄铜左线(与流程区的「生效版」同一记号)── */
.g-tbpkgs { display: flex; flex-direction: column; gap: 7px; }
.g-tbpkg { display: flex; flex-direction: column; gap: 5px; padding: 9px 11px; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-line); background: var(--sh-panel); text-align: left; font: inherit; cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast); }
.g-tbpkg:hover { border-color: #aeb9c6; }
.g-tbpkg.on { border-color: #cbb98d; border-left-color: var(--sh-brass); background: var(--sh-brass-wash); }
.g-tbpkg:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
.g-tbpkg b { font-size: 12.5px; font-weight: 700; color: var(--sh-ink); }
.g-tbpkg .id { font-family: var(--sh-mono); font-size: 9.5px; letter-spacing: .06em; color: var(--sh-muted); overflow-wrap: anywhere; }
.g-tbpkg .cnt { display: flex; gap: 10px; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); }
.g-tbpkg.on .cnt, .g-tbpkg.on .id { color: #8a7743; }
/* ── ASN — 节点指派:活动点与审批点各一行,留空即照旧轮值 ── */
.g-asn-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 0 28px; }
.g-asn { display: flex; flex-direction: column; min-width: 0; }
.g-asn-h, .g-asn-r { display: grid; grid-template-columns: 38px 38px minmax(0, 1fr) minmax(130px, 175px); align-items: center; gap: 10px; }
.g-asn-h { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-muted); padding-bottom: 6px; border-bottom: 1px solid var(--sh-line); }
.g-asn-r { padding: 5px 0; border-bottom: 1px dashed var(--sh-line); }
.g-asn-r:last-child { border-bottom: 0; }
.g-asn-r > .nid { font-family: var(--sh-mono); font-size: 11px; font-weight: 700; color: var(--sh-ink); }
.g-asn-r > .kd { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--sh-muted); }
.g-asn-r > .lb { font-size: 11.5px; color: var(--sh-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.g-asn-r.pinned > .nid, .g-asn-r.pinned > .lb { color: var(--sh-brass); }
/* ── 图签栏:工程图右下角那张表 —— 一条流的身份卡,纯细线不着色,把颜色让给 TRACE ── */
/* ── 图签栏:一格一件事 ── */
.g-tblock { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 1px; background: var(--sh-line); }
.g-tblock > div { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 10px 12px 11px; background: var(--sh-panel); }
.g-tblock em { font: 600 8.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .14em; text-transform: uppercase; color: var(--sh-muted); }
.g-tblock b { font-family: var(--sh-mono); font-size: 12px; font-weight: 700; color: var(--sh-ink); overflow-wrap: anywhere; }
.g-tblock b.brass { color: var(--sh-brass); }
.g-tblock b.run { color: #2e6f8f; }
.g-tblock b.run { color: var(--sh-live); }
/* 派工带:测试台不代答 —— 任务真的落在别人工位上,这一条必须说满 */
.g-tbdesk { display: flex; flex-direction: column; gap: 6px; padding: 10px 13px 11px; border: 1px solid #cbb98d; border-left: 3px solid var(--sh-brass); background: var(--sh-brass-wash); }
.g-tbdesk-h { display: flex; align-items: center; gap: 7px; font: 600 9.5px/1 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--sh-brass); }
.g-tbdesk-h .g-btn { margin-left: auto; border-color: #cbb98d; background: var(--sh-panel); color: var(--sh-brass); }
.g-tbdesk-h .g-btn:hover { border-color: var(--sh-brass); background: var(--sh-panel); }
.g-tbdesk-r { display: flex; flex-wrap: wrap; align-items: baseline; gap: 7px; font-size: 12px; color: #6f4b14; }
/* 派工带:琥珀 = 人 —— 任务真的落在别人工位上,这一条必须说满 */
.g-tbdesk { display: flex; flex-direction: column; gap: 6px; padding: 10px 14px 11px; border: 1px solid rgba(242, 180, 92, .35); border-left: 3px solid var(--sh-brass); border-radius: 10px; background: var(--sh-brass-wash); }
.g-tbdesk-h { display: flex; align-items: center; gap: 7px; font: 600 9.5px/1 var(--sh-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--sh-brass); }
.g-tbdesk-h .g-btn { margin-left: auto; border-color: rgba(242, 180, 92, .4); background: transparent; color: var(--sh-brass); }
.g-tbdesk-h .g-btn:hover { border-color: var(--sh-brass); background: rgba(242, 180, 92, .12); color: var(--sh-brass); }
.g-tbdesk-r { display: flex; flex-wrap: wrap; align-items: baseline; gap: 7px; font-size: 12px; color: var(--sh-steel); }
.g-tbdesk-r b { font-weight: 700; color: var(--sh-brass); }
.g-tbdesk-r .nid { font-family: var(--sh-mono); font-size: 10px; color: #8a7743; }
.g-tbdesk p { margin: 0; font-size: 11px; line-height: 1.8; color: #8a7743; }
.g-tbdesk-r .nid { font-family: var(--sh-mono); font-size: 10px; color: var(--sh-muted); }
.g-tbdesk p { margin: 0; font-size: 11px; line-height: 1.8; color: var(--sh-muted); }
.g-tbdesk p b { color: var(--sh-brass); }
/* ══ TRC — 全链 TRACE:走查记录纸 ══
一行两读:左半是**结构**(枝线连出 span 树,方角接头,细线画法同图框),
右半是**时间**(同一条刻度轴上的条,底纹是等距细格 —— 方格纸)。
未收尾的段按**剖面线**画:图纸里未定型的剖切面就是这么标的,不用再解释「进行中」。 */
/* ══ TRC — 全链 TRACE:示波记录 ══
一行两读:左半是**结构**(枝线连出 span 树),右半是**时间**(同一条刻度轴上的条,
底纹等距细格)。未收尾的段按剖面线画、右端出箭头 —— 不必写「还在跑」也知道。 */
.g-trc { --trc-cols: 62px minmax(0, 1fr) 58px minmax(190px, 1.05fr); display: flex; flex-direction: column; }
/* 图例:只列这条流真出现过的 kind,顺带交代各出现几次 */
.g-trc-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 9px 14px 10px; border-bottom: 1px solid var(--sh-line); background: #fbfcfd; }
.g-trc-lg { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: #3a4452; }
.g-trc-lg i { width: 9px; height: 9px; flex: none; background: var(--tk, #6a7686); }
.g-trc-lg em { font: 600 8.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .12em; color: var(--tk, #6a7686); }
.g-trc-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 9px 14px 10px; border-bottom: 1px solid var(--sh-line); background: var(--sh-panel-2); }
.g-trc-lg { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--sh-steel); }
.g-trc-lg i { width: 9px; height: 9px; flex: none; border-radius: 2px; background: var(--tk, var(--sh-muted)); }
.g-trc-lg em { font: 600 8.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .12em; color: var(--tk, var(--sh-muted)); }
.g-trc-lg b { font-family: var(--sh-mono); font-size: 9.5px; font-weight: 700; color: var(--sh-muted); }
/* 刻度带:轴的读数写在这里,底下每一行的方格与它同一组百分比 —— 对得上不是碰巧 */
.g-trc-rule { display: grid; grid-template-columns: var(--trc-cols); align-items: end; border-bottom: 1px solid var(--sh-line); font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-muted); }
.g-trc-rule > span { padding: 8px 10px 6px; }
.g-trc-rule > .t { text-align: right; }
.g-trc-rule > .d { text-align: right; }
.g-trc-rule > .g { position: relative; height: 26px; padding: 0; margin-right: 14px; }
.g-trc-rule .tick { position: absolute; bottom: 5px; font-family: var(--sh-mono); font-size: 8.5px; letter-spacing: .06em; text-transform: none; color: var(--sh-muted); transform: translateX(-1px); white-space: nowrap; }
.g-trc-rule .tick::before { content: ""; position: absolute; left: 0; bottom: -5px; height: 4px; border-left: 1px solid #c6cfd9; }
.g-trc-rule .tick::before { content: ""; position: absolute; left: 0; bottom: -5px; height: 4px; border-left: 1px solid var(--sh-lineb); }
.g-trc-rule .tick.last { transform: translateX(-100%); }
.g-trc-rule .tick.last::before { left: auto; right: 0; }
.g-trc-item { min-width: 0; }
.g-trc-item:last-child .g-trc-row { border-bottom: 0; }
.g-trc-row { display: grid; grid-template-columns: var(--trc-cols); align-items: center; width: 100%; padding: 0; border: 0; border-bottom: 1px dashed #eef1f5; background: transparent; font: inherit; text-align: left; }
.g-trc-row { display: grid; grid-template-columns: var(--trc-cols); align-items: center; width: 100%; padding: 0; border: 0; border-bottom: 1px dashed #17253e; background: transparent; font: inherit; text-align: left; }
button.g-trc-row { cursor: pointer; transition: background var(--t-fast); }
button.g-trc-row:hover { background: #f7f9fc; }
button.g-trc-row:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
.g-trc-row.open-x { background: #f7f9fc; border-bottom-style: solid; border-bottom-color: var(--sh-line); }
/* 时刻:相对起单的偏移,等宽数字对齐成一列读数 */
button.g-trc-row:hover { background: var(--sh-panel-2); }
button.g-trc-row:focus-visible { outline: 2px solid var(--sh-live); outline-offset: -2px; }
.g-trc-row.open-x { background: var(--sh-panel-2); border-bottom-style: solid; border-bottom-color: var(--sh-line); }
.g-trc-t { padding: 0 10px 0 14px; text-align: right; font-family: var(--sh-mono); font-size: 9.5px; font-variant-numeric: tabular-nums; color: var(--sh-muted); }
.g-trc-t.zero { color: var(--sh-steel); font-weight: 700; }
/* 结构半:枝线 + 方形字号 + 名 */
.g-trc-n { display: flex; align-items: center; gap: 8px; min-width: 0; align-self: stretch; padding: 5px 12px 5px 0; }
.g-trc-tree { display: flex; flex: none; align-self: stretch; }
.g-trc-tree i { position: relative; flex: none; width: 14px; }
.g-trc-tree i.on::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; border-left: 1px solid #dfe5ec; }
.g-trc-tree i.elb::before { content: ""; position: absolute; left: 6px; top: 0; width: 7px; height: 50%; border-left: 1px solid #dfe5ec; border-bottom: 1px solid #dfe5ec; }
.g-trc-tree i.elb.mid::after { content: ""; position: absolute; left: 6px; top: 50%; bottom: 0; border-left: 1px solid #dfe5ec; }
.g-trc-k { flex: none; display: grid; place-items: center; width: 17px; height: 17px; border: 1px solid var(--tk); background: var(--sh-panel); color: var(--tk); }
.g-trc-tree i.on::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; border-left: 1px solid var(--sh-line); }
.g-trc-tree i.elb::before { content: ""; position: absolute; left: 6px; top: 0; width: 7px; height: 50%; border-left: 1px solid var(--sh-line); border-bottom: 1px solid var(--sh-line); }
.g-trc-tree i.elb.mid::after { content: ""; position: absolute; left: 6px; top: 50%; bottom: 0; border-left: 1px solid var(--sh-line); }
.g-trc-k { flex: none; display: grid; place-items: center; width: 17px; height: 17px; border: 1px solid var(--tk); border-radius: 4px; background: var(--sh-panel); color: var(--tk); }
.g-trc-kn { flex: none; width: 46px; font: 600 8.5px/1 var(--sh-mono); letter-spacing: .1em; color: var(--tk); }
.g-trc-nm { min-width: 0; font-size: 11.5px; color: var(--sh-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.g-trc-me { flex: none; display: inline-flex; align-items: center; gap: 5px; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); }
.g-trc-me .who { color: var(--sh-brass); }
.g-trc-x-i { flex: none; color: #b6bfc9; display: grid; place-items: center; }
/* 耗时 */
.g-trc-d { padding: 0 10px; text-align: right; font-family: var(--sh-mono); font-size: 9.5px; font-variant-numeric: tabular-nums; color: #3a4452; }
.g-trc-x-i { flex: none; color: var(--sh-muted); display: grid; place-items: center; }
.g-trc-d { padding: 0 10px; text-align: right; font-family: var(--sh-mono); font-size: 9.5px; font-variant-numeric: tabular-nums; color: var(--sh-steel); }
.g-trc-d.live { color: var(--sh-brass); }
/* 时间半:方格纸底 + 条 */
.g-trc-g { position: relative; align-self: stretch; margin-right: 14px; background-image: repeating-linear-gradient(to right, var(--sh-grid) 0 1px, transparent 1px var(--tick)); }
.g-trc-bar { position: absolute; top: 50%; height: 8px; margin-top: -4px; min-width: 2px; background: var(--tk); }
/* 包络 = 尺寸线:两端界限 + 中缝细线 + 极淡的底 —— 流与节点自己不干活,时长是子段的外框,
画成实心黑条会把真正干活的那几笔(两三毫秒)压成看不见的发丝 */
.g-trc-bar.env { height: 11px; margin-top: -5.5px; background: color-mix(in srgb, var(--tk) 7%, transparent); border-left: 1px solid var(--tk); border-right: 1px solid var(--tk); }
.g-trc-bar { position: absolute; top: 50%; height: 8px; margin-top: -4px; min-width: 2px; border-radius: 1.5px; background: var(--tk); }
/* 包络 = 尺寸线:流与节点自己不干活,时长是子段的外框 —— 画实心会把真活压成发丝 */
.g-trc-bar.env { height: 11px; margin-top: -5.5px; background: color-mix(in srgb, var(--tk) 12%, transparent); border-left: 1px solid var(--tk); border-right: 1px solid var(--tk); border-radius: 0; }
.g-trc-bar.env::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--tk); }
/* 续画线:细剖面线 + 右端箭头 —— 图纸里「未完待续」就是这么标的,不必再写一句「还在跑」 */
.g-trc-bar.open { background: repeating-linear-gradient(135deg, var(--tk) 0 1px, transparent 1px 5px); min-width: 0; }
/* 续画线:细剖面线 + 右端箭头 —— 「未完待续」不必再写一句「还在跑」 */
.g-trc-bar.open { background: repeating-linear-gradient(135deg, var(--tk) 0 1px, transparent 1px 5px); min-width: 0; border-radius: 0; }
.g-trc-bar.open.env { background: none; border-left: 0; border-right: 0; }
.g-trc-bar.open.env::before { border-top-style: dashed; }
.g-trc-bar.err { background: #b0413e; }
.g-trc-bar.err { background: var(--sh-alarm); }
.g-trc-bar.open::after { content: ""; position: absolute; right: -7px; top: 0; border: 4px solid transparent; border-left: 6px solid var(--tk); }
.g-trc-bar.open.env::after { top: 50%; margin-top: -4px; }
/* 展开:入参 / 出参 / 属性 / 错误 —— 摊在原地,不弹窗 */
.g-trc-x { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); align-items: start; gap: 10px; padding: 11px 14px 13px; border-bottom: 1px solid var(--sh-line); background: #f7f9fc; }
.g-trc-x { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); align-items: start; gap: 10px; padding: 11px 14px 13px; border-bottom: 1px solid var(--sh-line); background: var(--sh-panel-2); }
.g-trc-x figure { display: flex; flex-direction: column; gap: 5px; min-width: 0; margin: 0; }
.g-trc-x figcaption { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--sh-steel); }
.g-trc-x pre { margin: 0; max-height: 148px; overflow: auto; padding: 8px 10px; border: 1px solid var(--sh-line); background: var(--sh-panel); font-family: var(--sh-mono); font-size: 10.5px; line-height: 1.65; color: #3a4452; white-space: pre-wrap; overflow-wrap: anywhere; }
.g-trc-x figure.bad figcaption { color: #b0413e; }
.g-trc-x figure.bad pre { border-color: #e3b9b7; background: #fdf7f6; color: #b0413e; }
/* kind 字典:结构三色走灰阶(骨架),动作三色各有其人(工具 / 模型 / 人)—— 有色的才是「谁动的手」 */
.g-tb .k-workflow { --tk: #46586a; }
.g-tb .k-node { --tk: #161b22; }
.g-tb .k-step { --tk: #6a7686; }
.g-tb .k-tool { --tk: #2c6b76; }
.g-tb .k-model { --tk: #4b4a8c; }
.g-tb .k-human { --tk: #8a5f1c; }
.g-trc-x pre { margin: 0; max-height: 148px; overflow: auto; padding: 8px 10px; border: 1px solid var(--sh-line); border-radius: 6px; background: var(--sh-well); font-family: var(--sh-mono); font-size: 10.5px; line-height: 1.65; color: var(--sh-steel); white-space: pre-wrap; overflow-wrap: anywhere; }
.g-trc-x figure.bad figcaption { color: var(--sh-alarm); }
.g-trc-x figure.bad pre { border-color: rgba(255, 125, 110, .4); color: var(--sh-alarm); }
/* kind 字典:骨架走灰阶,动作三色各有其人(工具 / 模型 / 人)—— 有色的才是「谁动的手」 */
.g-tb .k-workflow { --tk: #7e93b4; }
.g-tb .k-node { --tk: #c6d4ea; }
.g-tb .k-step { --tk: #5d7090; }
.g-tb .k-tool { --tk: #45b8d0; }
.g-tb .k-model { --tk: #9d8cf0; }
.g-tb .k-human { --tk: #f2b45c; }
/* ── NOD — 节点实例:哪个在跑 / 等 / 已放行 ── */
.g-nins { display: flex; flex-direction: column; }
.g-nins-h, .g-nins-r { display: grid; grid-template-columns: 44px minmax(0, 1fr) 74px 96px minmax(0, .9fr); align-items: center; gap: 10px; padding: 0 14px; }
.g-nins-h { padding-top: 10px; padding-bottom: 7px; border-bottom: 1px solid var(--sh-line); font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-muted); }
.g-nins-r { padding-top: 7px; padding-bottom: 7px; border-bottom: 1px dashed #eef1f5; }
.g-nins-r { padding-top: 7px; padding-bottom: 7px; border-bottom: 1px dashed #17253e; }
.g-nins-r:last-child { border-bottom: 0; }
.g-nins-r > .nid { font-family: var(--sh-mono); font-size: 11px; font-weight: 700; color: var(--sh-ink); }
.g-nins-r > .lb { font-size: 11.5px; color: #3a4452; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.g-nins-r > .lb { font-size: 11.5px; color: var(--sh-steel); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.g-nins-r > .st { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; color: var(--sh-muted); }
.g-nins-r > .st i { flex: none; width: 8px; height: 8px; background: currentcolor; }
.g-nins-r > .st.run { color: #2e6f8f; }
.g-nins-r > .st i { flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentcolor; }
.g-nins-r > .st.run i { box-shadow: 0 0 6px currentcolor; }
.g-nins-r > .st.run { color: var(--sh-run); }
.g-nins-r > .st.wait { color: var(--sh-brass); }
.g-nins-r > .st.done { color: #2e7d4f; }
.g-nins-r > .st.bad { color: #b0413e; }
.g-nins-r > .st.done { color: var(--sh-live); }
.g-nins-r > .st.bad { color: var(--sh-alarm); }
.g-nins-r > .who { font-size: 11px; color: var(--sh-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.g-nins-r > .who b { font-weight: 600; color: #3a4452; }
.g-nins-r > .ins { font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); text-align: right; }
.g-nins-r > .who b { font-weight: 600; color: var(--sh-brass); }
.g-nins-r > .ins { font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); }
/* 空页 = 图纸空页(与编排区的 .g-sheetempty 同款,这里自带一份以免依赖那边的容器) */
/* 空读数(观测态里还没来的数据) */
.g-tb-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; max-width: 460px; margin: 0 auto; padding: 26px 0 30px; text-align: center; }
.g-tb-empty svg { color: var(--sh-steel); opacity: .5; }
.g-tb-empty svg { color: var(--sh-muted); opacity: .6; }
.g-tb-empty b { font-size: 12px; font-weight: 700; color: var(--sh-steel); }
.g-tb-empty p { margin: 0; font-size: 11px; line-height: 1.9; color: var(--sh-muted); }
@media (max-width: 1100px) {
.g-rig { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 1240px) {
.g-tb-rig { grid-template-columns: minmax(0, 1fr); }
.g-trc { --trc-cols: 56px minmax(0, 1fr) 52px minmax(150px, .8fr); }
.g-trc-kn { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tb-lamp.breathe { animation: none; }
}
@@ -1,36 +1,129 @@
/**
* 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。
* 仿真试跑台 —— 浅色桌面上的**一台仪器**。
*
* 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口
* (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**,
* 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事:
* ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态)
*
* 视觉语言分两个世界:**机器说 mono**(读数、灯、编号、TRACE),
* **那封信说无衬线** —— 它是外界唯一伸进这台仪器的东西。
* 戏眼是左栏的「抽键预检」读数屏:通道的正则**实时**跑在正打的字上
* (与服务端同一套规则,契约原文回传,不另编一套),
* 该抽的键都点了灯,起单钮才上膛 —— 从前要投出去才知道「抽不出客户码」,现在打字就知道。
*/
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ProcessNode, ProcessPackage } from '@contracts';
import type { GovChannelsOutput, ProcessNode, ProcessPackage } from '@contracts';
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testCurrentRoute, testRunRoute, testStopRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
import { QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
import { TraceSheet } from './trace-sheet.tsx';
type RunOutput = { workflowId: string; state: string };
type Channel = GovChannelsOutput['channels'][number];
type ExtractRule = Channel['extract'][number];
/** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */
const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval';
const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
activity: '活动',
approval: '审批',
start: '起点',
end: '终点',
/* ==================== 启动输入:一封信 ==================== */
type MailDraft = {
from: string;
to: string;
subject: string;
body: string;
attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>;
};
const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
/**
* 预填一封**抽得出键**的信 —— 主题里带客户码与询价单号,
* 通道的抽键正则就是照着这两样写的;改坏了,左边的预检灯当场熄给你看。
*/
const SAMPLE_MAIL: MailDraft = {
from: 'buyer@sz-machinery.com',
to: '',
subject: '【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项',
body: '你好,附件是本次询价的 BOM,共 12 项,请按标准交期报价。\n付款方式沿用上次合同。',
attachments: [{ name: 'BOM-RFQ-2607-001.xlsx', contentType: XLSX, size: 28_416 }],
};
/**
* 抽键预检 —— **镜像服务端 `extractFields` 的取料与取值**(首个捕获组,无组取全匹配)。
* 服务端仍是唯一裁判:这里只负责在打字时点灯,起单被退回的判词永远以服务端为准。
*/
function preflight(rules: readonly ExtractRule[], mail: MailDraft): ReadonlyMap<string, string> {
const out = new Map<string, string>();
for (const rule of rules) {
const haystack =
rule.source === 'subject'
? mail.subject
: rule.source === 'body'
? mail.body
: rule.source === 'from'
? mail.from
: rule.source === 'to'
? mail.to
: `${mail.subject}\n${mail.body}`;
try {
const match = new RegExp(rule.regex).exec(haystack);
const captured = match?.[1] ?? match?.[0];
if (captured !== undefined) out.set(rule.key, captured);
} catch {
/* 配置里的正则写坏了:按未命中点灯,不让预检把整屏拖死 */
}
}
return out;
}
/** 信本身就带的字段 —— 装配时从载荷取,不走抽键,预检不必为它们报「无规则」 */
const MAIL_BUILTIN = new Set(['subject', 'body', 'from', 'quotedAt', 'occurredAt']);
/** 调用接口声明的必填键(JSON-Schema 的 required) */
function requiredOf(pkg: ProcessPackage | undefined): readonly string[] {
const input = pkg?.callInterface.input as { required?: string[] } | undefined;
return input?.required ?? [];
}
/* ==================== 台面骨架 ==================== */
/**
* 仪器顶条 —— 装配态与观测态共用同一根:左边一盏灯 + 状态词,右边是这一态的读数与动作。
* 两态换的是灯色与右侧内容,骨架不换 —— 起单前后你面对的是同一台机器。
*/
function Strip({
lamp,
breathe,
word,
detail,
right,
}: {
lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad';
breathe: boolean;
word: string;
detail?: ReactNode;
right?: ReactNode;
}): ReactNode {
return (
<div className="tb-strip">
<i className={`tb-lamp ${lamp}${breathe ? ' breathe' : ''}`} />
<span className="word">{word}</span>
{detail !== undefined && <span className="det">{detail}</span>}
{right !== undefined && <span className="x">{right}</span>}
</div>
);
}
/* ==================== 装配态 ==================== */
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
const pkgQ = useGovPackages();
const staffQ = useGovStaff();
@@ -43,7 +136,7 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
);
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
const staffName = (id: string): string => staff.find((s) => s.id === id)?.name ?? id;
const inbox = (chanQ.data?.channels ?? []).find((ch) => ch.kind === 'mail' && ch.enabled);
const mailChannels = (chanQ.data?.channels ?? []).filter((ch) => ch.kind === 'mail' && ch.enabled);
const [pickedId, setPicked] = useState('');
const [mail, setMail] = useState<MailDraft>(SAMPLE_MAIL);
@@ -72,27 +165,42 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
});
const [error, setError] = useState<string | undefined>(undefined);
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
// 没选就落到第一个:常见情形只有一个生效包、一个邮件通道,不该逼人先各点一下
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
const to = mail.to === '' ? (inbox?.address ?? '') : mail.to;
const ready = pkg !== undefined && mail.from.trim() !== '' && to !== '' && mail.subject.trim() !== '';
const channel = mailChannels.find((ch) => ch.address === mail.to) ?? mailChannels[0];
/* 预检:通道正则 × 正打的字,实时点灯 */
const caught = useMemo(
() => preflight(channel?.extract ?? [], mail),
[channel, mail],
);
const required = requiredOf(pkg);
const ruleKeys = new Set((channel?.extract ?? []).map((r) => r.key));
/** 必填但既不是信自带、又没有任何抽键规则 —— 投出去必死,预检就得说 */
const unruled = required.filter((k) => !MAIL_BUILTIN.has(k) && !ruleKeys.has(k));
const mustCatch = required.filter((k) => ruleKeys.has(k));
const armed =
pkg !== undefined && channel !== undefined && unruled.length === 0 && mustCatch.every((k) => caught.has(k));
const fireable = pkg !== undefined && channel !== undefined && mail.from.trim() !== '' && mail.subject.trim() !== '';
const run = useMutation({
mutationFn: (input: RunInput) => call<RunOutput>(testRunRoute, { body: input }),
mutationFn: (input: { packageId: string; mail: MailDraft; assignments: Record<string, string> }) =>
call<RunOutput>(testRunRoute, { body: input }),
onSuccess: (r) => {
setWorkflowId(r.workflowId);
setError(undefined);
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed', 'current'] });
},
// 后端的判词照抄:「抽不出客户码」「无生效版」都是要人改配置的话,不该被我改写
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
});
const fire = (): void => {
if (pkg === undefined) return;
if (pkg === undefined || channel === undefined) return;
run.mutate({
packageId: pkg.id,
mail: { ...mail, to },
mail: { ...mail, to: channel.address },
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
});
};
@@ -104,57 +212,39 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
}
const pinned = Object.values(assignments).filter((v) => v !== '').length;
const ai = staff.find((s) => s.name === 'AI');
return (
<>
<PageHead
ico="flask-conical"
tone="brand"
kicker="联调测试台 · 装配态"
title="仿真试跑"
desc="这条流真实的起法就是一封客户来信 —— 写这封信、指派每个节点归谁,起单看穿全程。任务照旧落到被指派人的工作台。"
chips={
<>
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
{pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version}`}
</St>
<St tone="mute" ico="user-check">
指派 {pinned}/{nodes.length}
</St>
</>
}
acts={[
{
label: run.isPending ? '起单中…' : '起单试跑',
ico: 'play',
primary: true,
disabled: !ready || run.isPending,
onClick: fire,
},
]}
<div className="g-tb">
<Strip
lamp="idle"
breathe={false}
word="装配中"
detail={pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version} · 单例仿真`}
right={`抽键 ${mustCatch.filter((k) => caught.has(k)).length}/${mustCatch.length} · 指派 ${pinned}/${nodes.length}`}
/>
<QueryGate query={pkgQ}>
{livePkgs.length === 0 ? (
<div className="g-rig">
<div className="g-tb-empty">
<div className="tb-slot">
<Ico name="scan" s={26} />
<b>没有可起单的流程包</b>
<p>
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
</p>
</div>
<b>台面上还没有流</b>
<p>试跑要有一个生效版才起得来 —— 去流程包激活一版,回来它就出现在这里。</p>
<button className="g-btn" onClick={() => nav.jump('workflows', 'pkgs')}>
<Ico name="workflow" s={12} />
去流程包
</button>
</div>
) : (
/* 左选流,右配置 —— 一屏两大块,不再把装配单摞成一长条 */
<div className="g-rig">
{/* ── 左:被测流 + 抽键预检读数屏 ── */}
<aside className="g-rig-l">
<div className="g-rig-h">
<Ico name="workflow" s={11} />
流程包
<span className="x">{livePkgs.length}</span>
被测流
<span className="x">{livePkgs.length} 个生效</span>
</div>
<div className="g-rig-pk">
<div className="tb-pks">
{livePkgs.map((p) => (
<button
key={p.id}
@@ -171,35 +261,108 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
<span className="cnt">
<i>节点 {p.nodes.length}</i>
<i>边 {p.edges.length}</i>
<i>必填 {requiredOf(p).length}</i>
</span>
</button>
))}
</div>
<div className="g-rig-h">
<Ico name="scan" s={11} />
抽键预检
<span className="x">{channel === undefined ? '无邮件通道' : channel.name}</span>
</div>
{/*
* 读数屏:通道的每条抽键正则一行 —— 键名 / 抽到的值 / 一盏灯。
* 实时跑在正打的字上;必填的键标出来,没灯的必填就是投出去会被退回的那单。
*/}
<div className="tb-well">
{channel === undefined ? (
<p className="tb-well-none">没有可用的邮件通道 —— 去系统区配一个,信才有地方投。</p>
) : (
<>
{channel.extract.map((r) => {
const hit = caught.get(r.key);
const must = required.includes(r.key);
return (
<div key={r.key} className={'tb-well-r' + (hit === undefined ? '' : ' hit')}>
<i />
<span className="k">
{r.key}
{must && <em>必填</em>}
</span>
<span className="v">{hit ?? '—'}</span>
</div>
);
})}
{unruled.map((k) => (
<div key={k} className="tb-well-r dead">
<i />
<span className="k">
{k}
<em>必填</em>
</span>
<span className="v">无抽键规则</span>
</div>
))}
</>
)}
</div>
<p className="tb-well-t">与服务端同一套正则 —— 灯全亮,这封信才装配得出调用入参。</p>
</aside>
{/* ── 右:① 一封信 ② 派给谁 + 起单条 ── */}
<section className="g-rig-r">
<div className="g-rig-h">
<span className="n">01</span>
输入配置
<span className="x">启动这条流的那封信</span>
输入 · 一封客户来信
<span className="x">
<button className="tb-ghost" onClick={() => setMail(SAMPLE_MAIL)}>
<Ico name="rotate-ccw" s={10} />
重置样例
</button>
</span>
</div>
<MailForm draft={{ ...mail, to }} onChange={setMail} inbox={inbox?.address} />
<LetterForm draft={mail} onChange={setMail} channels={mailChannels} channel={channel} />
<div className="g-rig-h">
<span className="n">02</span>
节点指派
<span className="x">
{pinned}/{nodes.length} 已定 · 留空即照旧轮值
{ai !== undefined && (
<button
className="tb-ghost"
onClick={() => setAssignments(Object.fromEntries(nodes.map((n) => [n.id, ai.id])))}
>
<Ico name="bot" s={10} />
全派 AI
</button>
)}
<button className="tb-ghost" onClick={() => setAssignments({})}>
<Ico name="users" s={10} />
全部轮值
</button>
</span>
</div>
<div className="g-asn">
{nodes.map((n) => (
<div key={n.id} className={'g-asn-r' + ((assignments[n.id] ?? '') === '' ? '' : ' pinned')}>
{/*
* 链轨:节点按编排序挂在一条竖轨上,右端是「插座」——
* 空着即照旧轮值(有员工组按组轮,没有按全员最闲者先),插了人那一格点琥珀
* (琥珀在这套语言里恒表「人」)。指派只在那人接得了这个节点时生效,接不了退回轮值。
*/}
<div className="tb-asn">
{nodes.map((n) => {
const pin = (assignments[n.id] ?? '') !== '';
return (
<div key={n.id} className={'tb-asn-r' + (pin ? ' pin' : '') + (n.kind === 'approval' ? ' apr' : '')}>
<span className="rail">
<i />
</span>
<span className="nid">{n.id}</span>
<span className="kd">{NODE_KIND_LABEL[n.kind] ?? n.kind}</span>
<span className="lb">{n.label}</span>
<span className="kd">{n.kind === 'approval' ? '审批' : '活动'}</span>
<select
className="g-select"
className="tb-socket"
aria-label={`${n.id} ${n.label} 派给谁`}
value={assignments[n.id] ?? ''}
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
>
@@ -211,87 +374,78 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
))}
</select>
</div>
))}
);
})}
</div>
{error !== undefined && (
<Note tone="warn">
<b>没起成:</b>
{error}
</Note>
{/* 起单条:仪器的前面板边缘 —— 全屏唯一的一枚动作钮,灯全亮才上膛 */}
<div className="tb-fire">
<span className="say">
{error !== undefined ? (
<b className="bad">{error}</b>
) : armed ? (
'装配齐了 —— 起单会先销毁上一单,任务真实落到被指派人的工作台'
) : unruled.length > 0 ? (
`必填的 ${unruled.join('、')} 没有抽键规则 —— 投出去会被退回`
) : (
'预检还有灯没亮 —— 主题或正文里要写出能被抽中的键'
)}
</span>
<button className={'tb-go' + (armed ? ' armed' : '')} disabled={!fireable || run.isPending} onClick={fire}>
<Ico name="play" s={13} />
{run.isPending ? '起单中…' : '起单试跑'}
</button>
</div>
</section>
</div>
)}
</QueryGate>
</>
</div>
);
}
/* ==================== 启动输入:一封信 ==================== */
/* ==================== ① 一封信 ==================== */
type MailDraft = {
from: string;
to: string;
subject: string;
body: string;
attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>;
};
type RunInput = { packageId: string; mail: MailDraft; assignments: Record<string, string> };
const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
/**
* 预填的是一封**抽得出键**的信 —— 主题里带客户码与询价单号,
* 通道的抽键正则就是照着这两样写的;改坏了起单会如实报「抽不出哪个」。
*/
const SAMPLE_MAIL: MailDraft = {
from: 'buyer@sz-machinery.com',
to: '',
subject: '【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项',
body: '你好,附件是本次询价的 BOM,共 12 项,请按标准交期报价。\n付款方式沿用上次合同。',
attachments: [{ name: 'BOM-RFQ-2607-001.xlsx', contentType: XLSX, size: 28_416 }],
};
function MailForm({
function LetterForm({
draft,
onChange,
inbox,
channels,
channel,
}: {
draft: MailDraft;
onChange: (next: MailDraft) => void;
inbox: string | undefined;
channels: readonly Channel[];
channel: Channel | undefined;
}): ReactNode {
const set = <K extends keyof MailDraft>(k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v });
return (
<div className="g-ml">
<label className="g-ml-r">
<div className="tb-ml">
<label className="tb-ml-r">
<em>发件人</em>
<input className="g-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
<input className="tb-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
</label>
<label className="g-ml-r">
<em>收件人</em>
<input
className="g-ml-i"
value={draft.to}
placeholder={inbox ?? '公司询价邮箱'}
onChange={(e) => set('to', e.target.value)}
/>
<span className="g-ml-t">投给哪个信箱决定用哪套抽键规则</span>
<label className="tb-ml-r">
<em>收件箱</em>
{/* 投给哪个信箱决定用哪套抽键规则 —— 选,不手打:地址错一位,预检整屏熄灯还不知道为什么 */}
<select className="tb-ml-i sel" value={channel?.address ?? ''} onChange={(e) => set('to', e.target.value)}>
{channels.map((ch) => (
<option key={ch.id} value={ch.address}>
{ch.address} —— {ch.name}
</option>
))}
</select>
</label>
<label className="g-ml-r">
<label className="tb-ml-r">
<em>主题</em>
<input className="g-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
<span className="g-ml-t">客户码与询价单号从这里抽</span>
<input className="tb-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
</label>
<label className="g-ml-r tall">
<label className="tb-ml-r tall">
<em>正文</em>
<textarea className="g-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
<textarea className="tb-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
</label>
<div className="g-ml-r tall">
<div className="tb-ml-r tall">
<em>附件</em>
<div className="g-ml-at">
<div className="tb-ml-at">
{draft.attachments.map((a, i) => (
<span key={a.name} className="g-att">
<Ico name="paperclip" s={11} />
@@ -323,6 +477,15 @@ function MailForm({
);
}
/* ==================== 观测态 ==================== */
const LAMP_OF: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
running: 'live',
waiting: 'wait',
completed: 'done',
abnormal: 'bad',
};
function Observe({
id,
staffName,
@@ -353,44 +516,27 @@ function Observe({
};
const started = w === undefined ? undefined : new Date(w.workflow.startedAt);
const refresh = (): void => {
void wf.refetch();
void trQ.refetch();
};
return (
<div className="g-tb">
<Strip
lamp={state === undefined ? 'live' : (LAMP_OF[state] ?? 'live')}
breathe={live}
word={state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
detail={
<>
<PageHead
ico="activity"
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
kicker="联调测试台 · 观测态"
title={w?.workflow.subject ?? '试跑中'}
desc="流已经起了。下面这三样看穿它:全链 TRACE 看它怎么跑,节点实例看它跑到哪,派工带看它把活派给了谁。"
chips={
<>
<St
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
ico={state === 'completed' ? 'circle-check' : state === 'abnormal' ? 'alert-triangle' : 'radio'}
>
{state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
</St>
<St tone="mute" ico="hash">
{id.slice(0, 8)}
</St>
{live && (
<St tone="mute" ico="refresh-cw">
每 2 秒自动刷新
</St>
)}
{w?.workflow.subject ?? id.slice(0, 8)}
{live && <i className="auto"> · 自动刷新 2s</i>}
</>
}
acts={[
{ label: '刷新', ico: 'refresh-cw', onClick: refresh },
{ label: stopping ? '销毁中…' : '停止仿真', ico: 'square', primary: true, onClick: onBack },
]}
right={
<button className="tb-stop" disabled={stopping} onClick={onBack}>
<Ico name="square" s={11} />
{stopping ? '销毁中…' : '停止仿真'}
</button>
}
/>
<div className="g-tb">
<QueryGate query={wf}>
{w !== undefined && (
<>
@@ -399,7 +545,9 @@ function Observe({
<div className="g-sheet-h">
<Ico name="file-text" s={12} />
WF — 工作流
<span className="x">{w.workflow.source.kind} · {w.workflow.source.ref}</span>
<span className="x">
{w.workflow.source.kind} · {w.workflow.source.ref}
</span>
</div>
<div className="g-tblock">
<div>
@@ -414,7 +562,9 @@ function Observe({
</div>
<div>
<em>流程包</em>
<b>{w.workflow.packageId}@v{w.workflow.packageVersion}</b>
<b>
{w.workflow.packageId}@v{w.workflow.packageVersion}
</b>
</div>
<div>
<em>起单时刻</em>
@@ -460,16 +610,7 @@ function Observe({
</p>
</div>
<TraceSheet
spans={spans}
staffName={staffName}
extra={
<button className="g-btn xs" onClick={refresh} disabled={trQ.isFetching}>
<Ico name="refresh-cw" s={11} />
{trQ.isFetching ? '取数中' : '刷新'}
</button>
}
/>
<TraceSheet spans={spans} staffName={staffName} />
<div className="g-sheet">
<div className="g-sheet-h">
@@ -521,6 +662,5 @@ function Observe({
)}
</QueryGate>
</div>
</>
);
}
+7
View File
@@ -480,6 +480,13 @@ export const listChannelsRoute = {
address: z.string(),
enabled: z.boolean(),
extractCount: z.number().int(),
/**
* 抽键规则原文 —— 测试台的**抽键预检**要在人打字时就点灯,
* 跑的必须是与服务端同一套正则,不是前端另编的一套(正则不是密钥,回它无妨)。
*/
extract: z
.array(z.object({ key: z.string(), regex: z.string(), source: z.enum(['subject', 'body', 'from', 'to', 'both']) }))
.default([]),
mappingCount: z.number().int(),
fallbackPosition: z.string().optional(),
addressWindowMs: z.number().int(),
+1
View File
@@ -124,6 +124,7 @@ const GLYPHS: Record<string, React.ReactNode> = {
"redo-2": (<><path d="m15 14 5-5-5-5" /><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" /></>),
"maximize-2": (<><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" x2="14" y1="3" y2="10" /><line x1="3" x2="10" y1="21" y2="14" /></>),
x: (<><path d="M18 6 6 18" /><path d="m6 6 12 12" /></>),
square: (<rect width="18" height="18" x="3" y="3" rx="2" />),
library: (<><path d="m16 6 4 14" /><path d="M12 6v14" /><path d="M8 8v12" /><path d="M4 4v16" /></>),
"log-out": (<><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" /></>),
};