仿真测试台(界面):装配一次试跑 → 起单 → 全链 TRACE 看穿(frontend-design 专项)
测试台从「只能造一封邮件」升级成仿真试跑台,两种试法同屏切换: **装配态**:选生效版流程包 → 按 callInterface.input 现生成参数表单(必填标出)→ 节点指派表(每个活动点/审批点选账号,留空即轮值)→ 起单试跑; 起不来把后端判词原样上屏(如「must have required property 'rfqRef'」)。 **观测态**:WF 工作流图签 · DSK 任务落到了谁的工作台(明说测试台不代答)· **TRC 全链 TRACE(签名元素)** · NOD 节点实例。 TRACE 做成一张走查记录纸:左半 span 树(细线方角枝线),右半同轴甘特(方格纸底纹)。 三处判断值得留: - 轴的右端**只认已知时刻,不认 now** —— 按 now 收口会被不收尾的工作流 span 拉到 十几秒,真干活的两三毫秒压成发丝;而根 span 常常一辈子不收尾,拉到「此刻」 等于替它编了个结束时间 - 未收尾的段用剖面线 + 箭头(图纸的「未完待续」画法) - 包络(流/节点自己不干活)画尺寸线而非实心条 —— 实心会盖过真正干活的那几笔 颜色只花在动作三色(工具石青 / 模型靛 / 人黄铜),结构走灰阶。 顺带修:区头 desc 是纯文本渲染,里面的 markdown `**` 会原样露星号 —— 六处全清;测试台页签名改「联调试跑」(它现在不止入站邮件)。 实测:真起单三次,指派两处都落地(TRACE 的 attributes.assigneeId 与节点实例 受理人一致);浏览器核对装配态与观测态。五闸全过。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -662,3 +662,220 @@
|
||||
.g-maprow > .g-chip { grid-column: 3 / 6; justify-self: start; }
|
||||
.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 树),右边是时间(同一条刻度轴上的条),一行读两件事。 */
|
||||
|
||||
.g-tb {
|
||||
--sh-ink: #161b22; --sh-steel: #46586a; --sh-muted: #6a7686;
|
||||
--sh-line: #d8dee6; --sh-brass: #8a5f1c; --sh-brass-wash: #f7f0e0;
|
||||
--sh-grid: #edf1f5;
|
||||
--sh-mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace;
|
||||
display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
||||
}
|
||||
|
||||
/* 模式选择:不是第二条页签栏 —— 前置 mono 墨签点明这是个**控件**,方角、黄铜底线定当前 */
|
||||
.g-tbmode { display: flex; align-items: center; gap: 8px; }
|
||||
.g-tbmode > em { font: 600 9.5px/1 var(--sh-mono, var(--mono)); font-style: normal; letter-spacing: .14em; text-transform: uppercase; color: #6a7686; }
|
||||
.g-tbmode-b { display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 12px; border: 1px solid #d8dee6; border-bottom-width: 2px; background: #f7f9fb; color: #6a7686; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); }
|
||||
.g-tbmode-b:hover { color: #46586a; background: #fff; }
|
||||
.g-tbmode-b.on { background: #fff; color: #161b22; font-weight: 700; border-color: #c6cfd9; border-bottom-color: #8a5f1c; }
|
||||
.g-tbmode-b:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
|
||||
|
||||
/* ── 图纸卡:方角白纸 + mono 三字码题头(与流程区的 VER — / FIG — 同一idiom)── */
|
||||
.g-sheet { display: flex; flex-direction: column; min-width: 0; background: #fff; 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); }
|
||||
.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 { border: 0; border-bottom: 1px solid var(--sh-line); border-radius: 0; background-color: transparent; padding: 5px 2px; }
|
||||
.g-tb .g-input:focus, .g-tb .g-select:focus, .g-tb .g-ta:focus { outline: 0; border-color: #4e7daf; box-shadow: 0 1px 0 0 #4e7daf; }
|
||||
.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.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; }
|
||||
|
||||
/* ── 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: #fff; 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 #eef1f5; }
|
||||
.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: #3a4452; 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: #fff; }
|
||||
.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-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: #fff; color: var(--sh-brass); }
|
||||
.g-tbdesk-h .g-btn:hover { border-color: var(--sh-brass); background: #fff; }
|
||||
.g-tbdesk-r { display: flex; flex-wrap: wrap; align-items: baseline; gap: 7px; font-size: 12px; color: #6f4b14; }
|
||||
.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; }
|
||||
|
||||
/* ══ 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-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.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; }
|
||||
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); }
|
||||
/* 时刻:相对起单的偏移,等宽数字对齐成一列读数 */
|
||||
.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: #fff; 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-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.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.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.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 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: #fff; 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; }
|
||||
|
||||
/* ── 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: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 > .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.wait { color: var(--sh-brass); }
|
||||
.g-nins-r > .st.done { color: #2e7d4f; }
|
||||
.g-nins-r > .st.bad { color: #b0413e; }
|
||||
.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-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 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: 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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 调用参数表单 —— 按流程包 `callInterface.input`(JSON Schema 2020-12 子集)现生成。
|
||||
*
|
||||
* 表单不认业务字段,只认声明:必填由 `required` 决定,控件由 `type` / `enum` 决定。
|
||||
* 前端这一层只做**填得顺手**,不做裁决 —— 合不合契约由后端 ajv 判,判词原样回显。
|
||||
* 少填一个必填就把按钮按灰,是为了不让人白跑一趟接口,不是替后端把关。
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { Field } from '@web-shared/gov/primitives.tsx';
|
||||
|
||||
/** 控件五型:字符串 / 数值 / 真假 / 枚举 / 复合(对象与数组落 JSON 文本框) */
|
||||
export type CallFieldKind = 'text' | 'number' | 'bool' | 'enum' | 'json';
|
||||
|
||||
export type CallField = {
|
||||
readonly key: string;
|
||||
readonly required: boolean;
|
||||
readonly kind: CallFieldKind;
|
||||
readonly options: readonly string[];
|
||||
/** 声明里的人读名与说明(有就用,没有就退回字段名) */
|
||||
readonly title: string | undefined;
|
||||
readonly hint: string | undefined;
|
||||
};
|
||||
|
||||
export type CallValues = Readonly<Record<string, string>>;
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | undefined {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asStrings(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
}
|
||||
|
||||
function kindOf(prop: Record<string, unknown>): CallFieldKind {
|
||||
if (asStrings(prop['enum']).length > 0) return 'enum';
|
||||
const t = prop['type'];
|
||||
if (t === 'number' || t === 'integer') return 'number';
|
||||
if (t === 'boolean') return 'bool';
|
||||
if (t === 'object' || t === 'array') return 'json';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明 → 字段清单。**必填排前面**:properties 的键序来自库里的 jsonb,
|
||||
* 是存储顺序不是语义顺序,照抄等于把「哪几个非填不可」藏进随机次序里。
|
||||
*/
|
||||
export function callFieldsOf(schema: Readonly<Record<string, unknown>> | undefined): CallField[] {
|
||||
const props = asRecord(schema?.['properties']);
|
||||
if (props === undefined) return [];
|
||||
const required = new Set(asStrings(schema?.['required']));
|
||||
const fields = Object.entries(props).map(([key, raw]) => {
|
||||
const prop = asRecord(raw) ?? {};
|
||||
const title = typeof prop['title'] === 'string' ? prop['title'] : undefined;
|
||||
const desc = typeof prop['description'] === 'string' ? prop['description'] : undefined;
|
||||
const fmt = typeof prop['format'] === 'string' ? prop['format'] : undefined;
|
||||
const t = typeof prop['type'] === 'string' ? prop['type'] : 'any';
|
||||
return {
|
||||
key,
|
||||
required: required.has(key),
|
||||
kind: kindOf(prop),
|
||||
options: asStrings(prop['enum']),
|
||||
title,
|
||||
hint: desc ?? (fmt === undefined ? t : `${t} · ${fmt}`),
|
||||
};
|
||||
});
|
||||
return fields.sort((a, b) =>
|
||||
a.required === b.required ? a.key.localeCompare(b.key) : a.required ? -1 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** 起单载荷 —— 空的选填字段不发出去,免得把 `""` 当成「填过了」 */
|
||||
export function buildCallData(
|
||||
fields: readonly CallField[],
|
||||
values: CallValues,
|
||||
): { data: Record<string, unknown>; errors: string[] } {
|
||||
const data: Record<string, unknown> = {};
|
||||
const errors: string[] = [];
|
||||
for (const f of fields) {
|
||||
const raw = (values[f.key] ?? '').trim();
|
||||
if (raw === '') {
|
||||
if (f.required) errors.push(`${f.key} 必填`);
|
||||
continue;
|
||||
}
|
||||
if (f.kind === 'number') {
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) errors.push(`${f.key} 要是数字`);
|
||||
else data[f.key] = n;
|
||||
continue;
|
||||
}
|
||||
if (f.kind === 'bool') {
|
||||
data[f.key] = raw === 'true';
|
||||
continue;
|
||||
}
|
||||
if (f.kind === 'json') {
|
||||
try {
|
||||
data[f.key] = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
errors.push(`${f.key} 不是合法 JSON`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
data[f.key] = raw;
|
||||
}
|
||||
return { data, errors };
|
||||
}
|
||||
|
||||
/** 占位值:按类型各填一份能过契约的样子,让人一键就能跑通一趟再回头改 */
|
||||
export function placeholderValues(fields: readonly CallField[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
out[f.key] =
|
||||
f.kind === 'number'
|
||||
? '0'
|
||||
: f.kind === 'bool'
|
||||
? 'false'
|
||||
: f.kind === 'enum'
|
||||
? (f.options[0] ?? '')
|
||||
: f.kind === 'json'
|
||||
? '{}'
|
||||
: `${f.key}-占位`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格一字段。必填不靠星号靠**颜色**:整条注记转黄铜(黄铜在这套图纸语言里恒表
|
||||
* 「生效 / 裁定 / 人」这类要紧事),扫一眼就知道哪几格非填不可。
|
||||
*/
|
||||
export function CallForm({
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
fields: readonly CallField[];
|
||||
values: CallValues;
|
||||
onChange: (key: string, v: string) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{fields.map((f) => {
|
||||
const v = values[f.key] ?? '';
|
||||
const hint = `${f.required ? '必填' : '选填'}${f.hint === undefined ? '' : ` · ${f.hint}`}`;
|
||||
return (
|
||||
<div key={f.key} className={'g-tb-f' + (f.required ? ' req' : '')}>
|
||||
<Field k={f.title ?? f.key} hint={hint}>
|
||||
{f.kind === 'enum' ? (
|
||||
<select className="g-select" value={v} onChange={(e) => onChange(f.key, e.target.value)}>
|
||||
<option value="">不填</option>
|
||||
{f.options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.kind === 'bool' ? (
|
||||
<select className="g-select" value={v} onChange={(e) => onChange(f.key, e.target.value)}>
|
||||
<option value="">不填</option>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
) : f.kind === 'json' ? (
|
||||
<textarea
|
||||
className="g-ta mono"
|
||||
rows={3}
|
||||
value={v}
|
||||
placeholder="{ }"
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={'g-input' + (f.kind === 'number' ? '' : ' mono')}
|
||||
type={f.kind === 'number' ? 'number' : 'text'}
|
||||
value={v}
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 联调测试台 · 入站邮件演练 —— 从**外界**推一封信进来,看谁接。
|
||||
*
|
||||
* 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路
|
||||
* (`POST /api/ingest/channel/:id/mail` → 归属解析 → 统一事件流 → 三级消费)。
|
||||
* 邮件通道是外界,模拟通道是允许的(README:mock 只许出现在外界);
|
||||
* 系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。
|
||||
*
|
||||
* 这一屏要回答的是三个问题,所以结果就摆这三样:
|
||||
* · 归属判到谁了(哪一级、认领岗位)
|
||||
* · 谁接了(任务监听 / 触发器,各命中几个)
|
||||
* · 没人接的话落哪儿(消息兜底)
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { IngestMailOutput, InboundMail } from '@contracts';
|
||||
import { ingestMailRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { useGovChannels, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Acts, Card, Field, Note, PageHead, Rows, Sec, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
|
||||
/*
|
||||
* 出参形状**直接用契约类型**,不手搓。
|
||||
* 我手搓过一次(`level` / `kind` / `position` / `rationale`),四个字段名全是编的 ——
|
||||
* 页面上就摆出两个永远显示「—」的格子。归属解析真实是按 `outcome` 判别的联合:
|
||||
* owned 给 `rule` + `taskId`,unowned 给 `reason` + 可选的 `fallbackPosition`。
|
||||
*/
|
||||
type IngestResult = IngestMailOutput;
|
||||
|
||||
const RULE_LABEL: Readonly<Record<string, string>> = {
|
||||
thread: '①线程精确 —— 回信落在原线程上',
|
||||
'listener-key': '②关联键命中在途任务的监听',
|
||||
'address-window': '③地址 + 时间窗对上了',
|
||||
};
|
||||
|
||||
const REASON_LABEL: Readonly<Record<string, string>> = {
|
||||
'no-match': '三级都没对上',
|
||||
ambiguous: '对上多条,有歧义 —— 宁交人不错投',
|
||||
'extract-failed': '归属键抽不出来',
|
||||
};
|
||||
|
||||
type Attach = { name: string; contentType: string; size: number; ref: string };
|
||||
|
||||
/**
|
||||
* 样例邮件 —— 一键就能起单的那一封。
|
||||
*
|
||||
* 三件事都得对,少一件就不起单(这也正是这一屏要让人看见的):
|
||||
* ① 投给「公司询价邮箱」这个通道(触发器规则写死了 `channelId = "ch-rfq-mail"`)
|
||||
* ② 主题含「询价」或 RFQ(触发器规则的第二半)
|
||||
* ③ 正文或主题里有客户码 `C\d{3}` 与单号 `RFQ-[A-Z0-9]+` —— 通道的归属键提取规则按正则抽,
|
||||
* 抽不出来就没有关联键,起单装配拿不到 customerCode / rfqRef
|
||||
*/
|
||||
const SAMPLE = {
|
||||
from: 'buyer@sz-precision.com',
|
||||
to: 'rfq@we.com',
|
||||
subject: '【询价】RFQ-2607-501 精密车削件 6 项',
|
||||
body: [
|
||||
'你好,',
|
||||
'',
|
||||
'我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。',
|
||||
'月需约 2000 套,请两周内回价。',
|
||||
'',
|
||||
'深圳精密件采购部',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const SAMPLE_ATTACH: Attach = {
|
||||
name: 'BOM-RFQ-2607-501.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: 18_432,
|
||||
ref: 'sim://attach/BOM-RFQ-2607-501.xlsx',
|
||||
};
|
||||
|
||||
export function InboundDrill({ nav }: { nav: Nav }) {
|
||||
const channels = useGovChannels();
|
||||
const rows = channels.data?.channels ?? [];
|
||||
// 岗位在出参里是 id;摆一串 UUID 等于没说 —— 换成人读的名字
|
||||
const staffQ = useGovStaff();
|
||||
const positionName = (id: string) =>
|
||||
(staffQ.data?.positions ?? []).find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [from, setFrom] = useState(SAMPLE.from);
|
||||
const [to, setTo] = useState(SAMPLE.to);
|
||||
const [subject, setSubject] = useState(SAMPLE.subject);
|
||||
const [body, setBody] = useState(SAMPLE.body);
|
||||
const [attachments, setAttachments] = useState<readonly Attach[]>([SAMPLE_ATTACH]);
|
||||
const [result, setResult] = useState<IngestResult | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 没选通道时默认落到第一个 —— 让「一键投递」真的一键
|
||||
const effectiveChannel = channelId !== '' ? channelId : (rows[0]?.id ?? '');
|
||||
const channel = rows.find((c) => c.id === effectiveChannel);
|
||||
|
||||
/**
|
||||
* 起单前置自检 —— 把「为什么没起单」提前说出来,而不是让人投完了猜。
|
||||
* 判据与通道/触发器的真实配置同源:通道 id、主题关键词、两个归属键正则。
|
||||
*/
|
||||
const preflight = useMemo(() => {
|
||||
const text = `${subject}\n${body}`;
|
||||
return [
|
||||
{ ok: effectiveChannel === 'ch-rfq-mail', k: '投给公司询价邮箱', v: 'ch-rfq-mail' },
|
||||
{ ok: /询价|RFQ/.test(subject), k: '主题含「询价」或 RFQ', v: '触发器规则' },
|
||||
{ ok: /\bC\d{3}\b/.test(text), k: '含客户码', v: 'C000 形' },
|
||||
{ ok: /RFQ-[A-Z0-9]+/.test(text), k: '含询价单号', v: 'RFQ-XXXX 形' },
|
||||
];
|
||||
}, [effectiveChannel, subject, body]);
|
||||
const allGreen = preflight.every((p) => p.ok);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (mail: InboundMail) =>
|
||||
call<IngestResult>(ingestMailRoute, { params: { channelId: effectiveChannel as never }, body: mail }),
|
||||
onSuccess: (r) => {
|
||||
setResult(r);
|
||||
setError(undefined);
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setResult(undefined);
|
||||
setError((e as { message?: string }).message ?? '投递失败');
|
||||
},
|
||||
});
|
||||
|
||||
const fire = () => {
|
||||
// messageId 每次不同 —— 同一封信重投在归属解析里会被当同一线程,那是另一回事
|
||||
const stamp = Date.now();
|
||||
send.mutate({
|
||||
messageId: `sim-${stamp}`,
|
||||
from,
|
||||
to: [to],
|
||||
cc: [],
|
||||
subject,
|
||||
body,
|
||||
receivedAt: new Date().toISOString(),
|
||||
attachments: [...attachments],
|
||||
} as InboundMail);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="flask-conical"
|
||||
tone="brand"
|
||||
kicker="联调测试台 · 事件触发"
|
||||
title="入站邮件演练"
|
||||
desc="试跑测的是「调用接口起单」那条路,这一屏测的是另一条:外界来了封信,触发器接不接、起不起单。"
|
||||
chips={
|
||||
<>
|
||||
<St tone={allGreen ? 'ok' : 'warn'} ico={allGreen ? 'circle-check' : 'alert-circle'}>
|
||||
{allGreen ? '起单条件齐' : `差 ${preflight.filter((p) => !p.ok).length} 条`}
|
||||
</St>
|
||||
<St tone="mute" ico="paperclip">
|
||||
附件 {attachments.length}
|
||||
</St>
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{
|
||||
label: send.isPending ? '投递中…' : '投递这封信',
|
||||
ico: 'send',
|
||||
primary: true,
|
||||
disabled: send.isPending || effectiveChannel === '',
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Note>
|
||||
走的是与真通道同一条入站链路:通道归一 → 归属解析 → 统一事件流 → 三级消费(任务监听 /
|
||||
触发器 / 消息兜底)。邮件通道是外界,模拟通道是允许的;命中判定与起单都是真的。
|
||||
</Note>
|
||||
|
||||
<Sec ico="mail" title="造一封信" extra="预填的是一键能起单的样例 —— 改坏了下面的自检会告诉你差哪条">
|
||||
<Card ico="inbox" title="信头">
|
||||
{/* `.g-card` 自己不带内距 —— 卡里放自带框的块(Rows / Note)刚好贴边,
|
||||
放裸表单件就得垫一层卡体;console.css 早备好了 `.g-card-b` */}
|
||||
<div className="g-card-b">
|
||||
<Field k="投给哪个通道" hint="触发器的规则里写死了通道 id,投错通道不会命中">
|
||||
<select className="g-select" value={effectiveChannel} onChange={(e) => setChannelId(e.target.value)}>
|
||||
{rows.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} · {c.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field k="发件人">
|
||||
<input className="g-input" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="收件地址" {...(channel === undefined ? {} : { hint: `通道地址:${channel.address}` })}>
|
||||
<input className="g-input" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="主题">
|
||||
<input className="g-input" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card ico="file-text" title="正文">
|
||||
<div className="g-card-b">
|
||||
<textarea className="g-ta" rows={7} value={body} onChange={(e) => setBody(e.target.value)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
ico="paperclip"
|
||||
title="附件"
|
||||
extra={
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{
|
||||
label: '加一个附件',
|
||||
ico: 'plus',
|
||||
onClick: () =>
|
||||
setAttachments((a) => [
|
||||
...a,
|
||||
{
|
||||
name: `附件-${a.length + 1}.pdf`,
|
||||
contentType: 'application/pdf',
|
||||
size: 1024,
|
||||
ref: `sim://attach/${a.length + 1}`,
|
||||
},
|
||||
]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{attachments.length === 0 ? (
|
||||
<Note>没有附件 —— 这封信也照样能起单(触发器判的是通道与主题,不判附件)。</Note>
|
||||
) : (
|
||||
<Rows
|
||||
items={attachments.map((a) => ({
|
||||
k: a.name,
|
||||
v: `${a.contentType.split('.').pop() ?? a.contentType} · ${a.size} B · ${a.ref}`,
|
||||
mono: true,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<Note>
|
||||
附件目前只走<b>登记</b>:名字、类型、大小与存证指针进事件信封,内容不解析 ——
|
||||
解析 Excel / PDF 是活动的事,活动还没实现。
|
||||
</Note>
|
||||
</Card>
|
||||
|
||||
<Card ico="list-checks" title="起单前置自检" extra="投之前就知道会不会命中,不用投完了猜">
|
||||
<Rows
|
||||
items={preflight.map((p) => ({
|
||||
k: p.k,
|
||||
v: (
|
||||
<St tone={p.ok ? 'ok' : 'warn'} ico={p.ok ? 'circle-check' : 'alert-circle'}>
|
||||
{p.ok ? '齐' : `缺:${p.v}`}
|
||||
</St>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Sec>
|
||||
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>投递失败:</b>
|
||||
{error}
|
||||
</Note>
|
||||
)}
|
||||
|
||||
{result !== undefined && (
|
||||
<Sec ico="git-merge" title="这封信的下场" extra="归属判到谁 · 谁接了 · 没人接落哪儿">
|
||||
<Card ico="fingerprint" title="归属解析" extra="匹配次序确定、逐级短路">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '事件 id', v: result.eventId, mono: true },
|
||||
...(result.resolution.outcome === 'owned'
|
||||
? [
|
||||
{ k: '判定', v: '有主' },
|
||||
{ k: '命中哪一级', v: RULE_LABEL[result.resolution.rule] ?? result.resolution.rule },
|
||||
{ k: '归到任务', v: result.resolution.taskId, mono: true },
|
||||
]
|
||||
: [
|
||||
{ k: '判定', v: '无主' },
|
||||
{ k: '为什么', v: REASON_LABEL[result.resolution.reason] ?? result.resolution.reason },
|
||||
...(result.resolution.fallbackPosition === undefined
|
||||
? []
|
||||
: [{ k: '默认认领岗位', v: positionName(result.resolution.fallbackPosition) }]),
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
{result.resolution.outcome === 'unowned' && (
|
||||
<Note>
|
||||
无主不是错:新来的询价本来就没有在途任务可归。<b>无主入站不写台账</b>,只入事件流,
|
||||
由触发器决定要不要起单 —— 下面那格就是它的裁决。
|
||||
</Note>
|
||||
)}
|
||||
</Card>
|
||||
<Card ico="split" title="三级消费">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '命中任务监听', v: String(result.listenersHit) },
|
||||
{ k: '命中触发器', v: String(result.triggersHit) },
|
||||
{ k: '落消息兜底', v: result.fellBackToMessage ? '是' : '否' },
|
||||
]}
|
||||
/>
|
||||
{result.triggersHit > 0 ? (
|
||||
<Note tone="ok">
|
||||
触发器命中 {result.triggersHit} 个 —— 已按调起规则起单。去运营中心 · 任务看在途,
|
||||
或去工作台看落到谁工位上。
|
||||
</Note>
|
||||
) : result.listenersHit > 0 ? (
|
||||
<Note tone="ok">这封信被在途任务的监听接走了 —— 那一单的完成条件因此满足。</Note>
|
||||
) : (
|
||||
<Note tone="warn">
|
||||
没有任何监听或触发器接它,已落消息兜底 —— 认领岗位的人会在「消息」里看到它。
|
||||
想起单就回上面看自检差哪条。
|
||||
</Note>
|
||||
)}
|
||||
</Card>
|
||||
<Acts
|
||||
items={[
|
||||
{ label: '去运营中心看在途', ico: 'activity', onClick: () => nav.jump('tasks', 'list') },
|
||||
{ label: '再投一封', ico: 'rotate-ccw', primary: true, onClick: fire },
|
||||
]}
|
||||
/>
|
||||
</Sec>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +1,50 @@
|
||||
/**
|
||||
* 联调测试台 · 入站邮件 —— 把定义**试着跑一遍**。
|
||||
* 联调测试台 —— 把定义**试着跑一遍**。
|
||||
*
|
||||
* 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路
|
||||
* (`POST /api/ingest/channel/:id/mail` → 归属解析 → 统一事件流 → 三级消费)。
|
||||
* 邮件通道是外界,模拟通道是允许的(README:mock 只许出现在外界);
|
||||
* 系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。
|
||||
* 测试台是**后端检测活动与流程**的地方,不是代人干活的地方:在这儿装配并起单,
|
||||
* 真实的任务照旧落到被指派人的工作台上。所以它只管两件事:
|
||||
* **把流跑起来** + **看穿它怎么跑的**。
|
||||
*
|
||||
* 这一屏要回答的是三个问题,所以结果就摆这三样:
|
||||
* · 归属判到谁了(哪一级、认领岗位)
|
||||
* · 谁接了(任务监听 / 触发器,各命中几个)
|
||||
* · 没人接的话落哪儿(消息兜底)
|
||||
* 一条流有两种起法,对应两个模式 —— 两条路都得能试:
|
||||
* · 仿真试跑:直接调流程包的**调用接口**起单(触发器起单走的也是这个口),带节点指派;
|
||||
* · 入站邮件演练:从**外界**推一封信进来,看归属解析怎么判、哪个触发器接、有没有起单。
|
||||
*
|
||||
* 模式不做成区页签(区页签由壳层的 `TABS` 定,测试台只占一格),做成本屏的一个控件 ——
|
||||
* 它选的是「用哪种刺激」,不是「看哪个维度」。
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { IngestMailOutput, InboundMail } from '@contracts';
|
||||
import { ingestMailRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { useGovChannels, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Acts, Card, Field, Note, PageHead, Rows, Sec, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { InboundDrill } from './inbound.tsx';
|
||||
import { SimulateRun } from './simulate.tsx';
|
||||
|
||||
/*
|
||||
* 出参形状**直接用契约类型**,不手搓。
|
||||
* 我手搓过一次(`level` / `kind` / `position` / `rationale`),四个字段名全是编的 ——
|
||||
* 页面上就摆出两个永远显示「—」的格子。归属解析真实是按 `outcome` 判别的联合:
|
||||
* owned 给 `rule` + `taskId`,unowned 给 `reason` + 可选的 `fallbackPosition`。
|
||||
*/
|
||||
type IngestResult = IngestMailOutput;
|
||||
type Mode = 'run' | 'mail';
|
||||
|
||||
const RULE_LABEL: Readonly<Record<string, string>> = {
|
||||
thread: '①线程精确 —— 回信落在原线程上',
|
||||
'listener-key': '②关联键命中在途任务的监听',
|
||||
'address-window': '③地址 + 时间窗对上了',
|
||||
};
|
||||
|
||||
const REASON_LABEL: Readonly<Record<string, string>> = {
|
||||
'no-match': '三级都没对上',
|
||||
ambiguous: '对上多条,有歧义 —— 宁交人不错投',
|
||||
'extract-failed': '归属键抽不出来',
|
||||
};
|
||||
|
||||
type Attach = { name: string; contentType: string; size: number; ref: string };
|
||||
|
||||
/**
|
||||
* 样例邮件 —— 一键就能起单的那一封。
|
||||
*
|
||||
* 三件事都得对,少一件就不起单(这也正是这一屏要让人看见的):
|
||||
* ① 投给「公司询价邮箱」这个通道(触发器规则写死了 `channelId = "ch-rfq-mail"`)
|
||||
* ② 主题含「询价」或 RFQ(触发器规则的第二半)
|
||||
* ③ 正文或主题里有客户码 `C\d{3}` 与单号 `RFQ-[A-Z0-9]+` —— 通道的归属键提取规则按正则抽,
|
||||
* 抽不出来就没有关联键,起单装配拿不到 customerCode / rfqRef
|
||||
*/
|
||||
const SAMPLE = {
|
||||
from: 'buyer@sz-precision.com',
|
||||
to: 'rfq@we.com',
|
||||
subject: '【询价】RFQ-2607-501 精密车削件 6 项',
|
||||
body: [
|
||||
'你好,',
|
||||
'',
|
||||
'我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。',
|
||||
'月需约 2000 套,请两周内回价。',
|
||||
'',
|
||||
'深圳精密件采购部',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const SAMPLE_ATTACH: Attach = {
|
||||
name: 'BOM-RFQ-2607-501.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: 18_432,
|
||||
ref: 'sim://attach/BOM-RFQ-2607-501.xlsx',
|
||||
};
|
||||
|
||||
export function TestbedHome({ nav }: { tab: string; nav: Nav }) {
|
||||
const channels = useGovChannels();
|
||||
const rows = channels.data?.channels ?? [];
|
||||
// 岗位在出参里是 id;摆一串 UUID 等于没说 —— 换成人读的名字
|
||||
const staffQ = useGovStaff();
|
||||
const positionName = (id: string) =>
|
||||
(staffQ.data?.positions ?? []).find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [from, setFrom] = useState(SAMPLE.from);
|
||||
const [to, setTo] = useState(SAMPLE.to);
|
||||
const [subject, setSubject] = useState(SAMPLE.subject);
|
||||
const [body, setBody] = useState(SAMPLE.body);
|
||||
const [attachments, setAttachments] = useState<readonly Attach[]>([SAMPLE_ATTACH]);
|
||||
const [result, setResult] = useState<IngestResult | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 没选通道时默认落到第一个 —— 让「一键投递」真的一键
|
||||
const effectiveChannel = channelId !== '' ? channelId : (rows[0]?.id ?? '');
|
||||
const channel = rows.find((c) => c.id === effectiveChannel);
|
||||
|
||||
/**
|
||||
* 起单前置自检 —— 把「为什么没起单」提前说出来,而不是让人投完了猜。
|
||||
* 判据与通道/触发器的真实配置同源:通道 id、主题关键词、两个归属键正则。
|
||||
*/
|
||||
const preflight = useMemo(() => {
|
||||
const text = `${subject}\n${body}`;
|
||||
return [
|
||||
{ ok: effectiveChannel === 'ch-rfq-mail', k: '投给公司询价邮箱', v: 'ch-rfq-mail' },
|
||||
{ ok: /询价|RFQ/.test(subject), k: '主题含「询价」或 RFQ', v: '触发器规则' },
|
||||
{ ok: /\bC\d{3}\b/.test(text), k: '含客户码', v: 'C000 形' },
|
||||
{ ok: /RFQ-[A-Z0-9]+/.test(text), k: '含询价单号', v: 'RFQ-XXXX 形' },
|
||||
const MODES: ReadonlyArray<{ k: Mode; label: string; ico: string }> = [
|
||||
{ k: 'run', label: '仿真试跑', ico: 'flask-conical' },
|
||||
{ k: 'mail', label: '入站邮件演练', ico: 'mail' },
|
||||
];
|
||||
}, [effectiveChannel, subject, body]);
|
||||
const allGreen = preflight.every((p) => p.ok);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (mail: InboundMail) =>
|
||||
call<IngestResult>(ingestMailRoute, { params: { channelId: effectiveChannel as never }, body: mail }),
|
||||
onSuccess: (r) => {
|
||||
setResult(r);
|
||||
setError(undefined);
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setResult(undefined);
|
||||
setError((e as { message?: string }).message ?? '投递失败');
|
||||
},
|
||||
});
|
||||
|
||||
const fire = () => {
|
||||
// messageId 每次不同 —— 同一封信重投在归属解析里会被当同一线程,那是另一回事
|
||||
const stamp = Date.now();
|
||||
send.mutate({
|
||||
messageId: `sim-${stamp}`,
|
||||
from,
|
||||
to: [to],
|
||||
cc: [],
|
||||
subject,
|
||||
body,
|
||||
receivedAt: new Date().toISOString(),
|
||||
attachments: [...attachments],
|
||||
} as InboundMail);
|
||||
};
|
||||
|
||||
export function TestbedHome({ nav }: { tab: string; nav: Nav }): ReactNode {
|
||||
const [mode, setMode] = useState<Mode>('run');
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="flask-conical"
|
||||
tone="brand"
|
||||
kicker="联调测试台"
|
||||
title="入站邮件"
|
||||
chips={
|
||||
<>
|
||||
<St tone={allGreen ? 'ok' : 'warn'} ico={allGreen ? 'circle-check' : 'alert-circle'}>
|
||||
{allGreen ? '起单条件齐' : `差 ${preflight.filter((p) => !p.ok).length} 条`}
|
||||
</St>
|
||||
<St tone="mute" ico="paperclip">
|
||||
附件 {attachments.length}
|
||||
</St>
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{
|
||||
label: send.isPending ? '投递中…' : '投递这封信',
|
||||
ico: 'send',
|
||||
primary: true,
|
||||
disabled: send.isPending || effectiveChannel === '',
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Note>
|
||||
走的是与真通道同一条入站链路:通道归一 → 归属解析 → 统一事件流 → 三级消费(任务监听 /
|
||||
触发器 / 消息兜底)。邮件通道是外界,模拟通道是允许的;命中判定与起单都是真的。
|
||||
</Note>
|
||||
|
||||
<Sec ico="mail" title="造一封信" extra="预填的是一键能起单的样例 —— 改坏了下面的自检会告诉你差哪条">
|
||||
<Card ico="inbox" title="信头">
|
||||
<Field k="投给哪个通道" hint="触发器的规则里写死了通道 id,投错通道不会命中">
|
||||
<select value={effectiveChannel} onChange={(e) => setChannelId(e.target.value)}>
|
||||
{rows.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} · {c.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field k="发件人">
|
||||
<input value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="收件地址" {...(channel === undefined ? {} : { hint: `通道地址:${channel.address}` })}>
|
||||
<input value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="主题">
|
||||
<input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card ico="file-text" title="正文">
|
||||
<textarea rows={7} value={body} onChange={(e) => setBody(e.target.value)} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
ico="paperclip"
|
||||
title="附件"
|
||||
extra={
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{
|
||||
label: '加一个附件',
|
||||
ico: 'plus',
|
||||
onClick: () =>
|
||||
setAttachments((a) => [
|
||||
...a,
|
||||
{
|
||||
name: `附件-${a.length + 1}.pdf`,
|
||||
contentType: 'application/pdf',
|
||||
size: 1024,
|
||||
ref: `sim://attach/${a.length + 1}`,
|
||||
},
|
||||
]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
<div className="g-tbmode">
|
||||
<em>试法</em>
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.k}
|
||||
className={'g-tbmode-b' + (mode === m.k ? ' on' : '')}
|
||||
aria-pressed={mode === m.k}
|
||||
onClick={() => setMode(m.k)}
|
||||
>
|
||||
{attachments.length === 0 ? (
|
||||
<Note>没有附件 —— 这封信也照样能起单(触发器判的是通道与主题,不判附件)。</Note>
|
||||
) : (
|
||||
<Rows
|
||||
items={attachments.map((a) => ({
|
||||
k: a.name,
|
||||
v: `${a.contentType.split('.').pop() ?? a.contentType} · ${a.size} B · ${a.ref}`,
|
||||
mono: true,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<Note>
|
||||
附件目前只走<b>登记</b>:名字、类型、大小与存证指针进事件信封,内容不解析 ——
|
||||
解析 Excel / PDF 是活动的事,活动还没实现。
|
||||
</Note>
|
||||
</Card>
|
||||
|
||||
<Card ico="list-checks" title="起单前置自检" extra="投之前就知道会不会命中,不用投完了猜">
|
||||
<Rows
|
||||
items={preflight.map((p) => ({
|
||||
k: p.k,
|
||||
v: (
|
||||
<St tone={p.ok ? 'ok' : 'warn'} ico={p.ok ? 'circle-check' : 'alert-circle'}>
|
||||
{p.ok ? '齐' : `缺:${p.v}`}
|
||||
</St>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Sec>
|
||||
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>投递失败:</b>
|
||||
{error}
|
||||
</Note>
|
||||
)}
|
||||
|
||||
{result !== undefined && (
|
||||
<Sec ico="git-merge" title="这封信的下场" extra="归属判到谁 · 谁接了 · 没人接落哪儿">
|
||||
<Card ico="fingerprint" title="归属解析" extra="匹配次序确定、逐级短路">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '事件 id', v: result.eventId, mono: true },
|
||||
...(result.resolution.outcome === 'owned'
|
||||
? [
|
||||
{ k: '判定', v: '有主' },
|
||||
{ k: '命中哪一级', v: RULE_LABEL[result.resolution.rule] ?? result.resolution.rule },
|
||||
{ k: '归到任务', v: result.resolution.taskId, mono: true },
|
||||
]
|
||||
: [
|
||||
{ k: '判定', v: '无主' },
|
||||
{ k: '为什么', v: REASON_LABEL[result.resolution.reason] ?? result.resolution.reason },
|
||||
...(result.resolution.fallbackPosition === undefined
|
||||
? []
|
||||
: [{ k: '默认认领岗位', v: positionName(result.resolution.fallbackPosition) }]),
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
{result.resolution.outcome === 'unowned' && (
|
||||
<Note>
|
||||
无主不是错:新来的询价本来就没有在途任务可归。<b>无主入站不写台账</b>,只入事件流,
|
||||
由触发器决定要不要起单 —— 下面那格就是它的裁决。
|
||||
</Note>
|
||||
)}
|
||||
</Card>
|
||||
<Card ico="split" title="三级消费">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '命中任务监听', v: String(result.listenersHit) },
|
||||
{ k: '命中触发器', v: String(result.triggersHit) },
|
||||
{ k: '落消息兜底', v: result.fellBackToMessage ? '是' : '否' },
|
||||
]}
|
||||
/>
|
||||
{result.triggersHit > 0 ? (
|
||||
<Note tone="ok">
|
||||
触发器命中 {result.triggersHit} 个 —— 已按调起规则起单。去运营中心 · 任务看在途,
|
||||
或去工作台看落到谁工位上。
|
||||
</Note>
|
||||
) : result.listenersHit > 0 ? (
|
||||
<Note tone="ok">这封信被在途任务的监听接走了 —— 那一单的完成条件因此满足。</Note>
|
||||
) : (
|
||||
<Note tone="warn">
|
||||
没有任何监听或触发器接它,已落消息兜底 —— 认领岗位的人会在「消息」里看到它。
|
||||
想起单就回上面看自检差哪条。
|
||||
</Note>
|
||||
)}
|
||||
</Card>
|
||||
<Acts
|
||||
items={[
|
||||
{ label: '去运营中心看在途', ico: 'activity', onClick: () => nav.jump('tasks', 'list') },
|
||||
{ label: '再投一封', ico: 'rotate-ccw', primary: true, onClick: fire },
|
||||
]}
|
||||
/>
|
||||
</Sec>
|
||||
)}
|
||||
<Ico name={m.ico} s={12} />
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === 'run' ? <SimulateRun nav={nav} /> : <InboundDrill nav={nav} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 试跑观测取数 —— 与 `shared/gov/gov-api.ts` 分开只为一件事:**这两把查询要轮询**。
|
||||
* 治理台其余画面是快照,取一次就够;观测态盯的是一条**正在跑**的流,不轮询就看不见它跑。
|
||||
* 流一到终态就停轮询 —— 已经不动的东西没必要每两秒问一遍。
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import type { GovWorkflowOutput } from '@contracts';
|
||||
import { govWorkflowRoute, traceOfWorkflowRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
|
||||
/**
|
||||
* span 的形状**不手搓**。
|
||||
*
|
||||
* 契约在 `governance.ts` 里给十几个出参起了名(`Gov*Output`),trace 不在其列;
|
||||
* 而这一屏从前正是栽在手搓出参上(编出四个不存在的字段名,页面上摆出永远显示「—」的格子)。
|
||||
* 所以这里不照抄形状,改从 schema 自己的 `parse` 返回值上把类型接出来:
|
||||
* 契约一改,这里编译期就跟着变 —— 与 `z.infer` 等价,又不必让前端 import zod。
|
||||
*/
|
||||
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
|
||||
|
||||
export type TraceOutput = Parsed<typeof traceOfWorkflowRoute.output>;
|
||||
export type TraceSpan = TraceOutput['spans'][number];
|
||||
export type SpanKind = TraceSpan['kind'];
|
||||
|
||||
/** 轮询间隔:人眼看得出「在动」,又不至于把接口打穿 */
|
||||
const LIVE_MS = 2000;
|
||||
|
||||
/** 一条流的全链 span(树由 `parentId` 拼,呈现层自己拼) */
|
||||
export const useRunTrace = (id: string | undefined, live: boolean): UseQueryResult<TraceOutput> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'testbed', 'trace', id],
|
||||
queryFn: () => call<TraceOutput>(traceOfWorkflowRoute, { params: { id: id! } }),
|
||||
enabled: id !== undefined,
|
||||
refetchInterval: live ? LIVE_MS : false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 工作流态 + 流程包定义 + 节点实例 —— 判「谁在跑」以它为准,不以 span 为准。
|
||||
* 停不停轮询由**它自己的出参**说了算(轮询判据读 `query.state.data`),
|
||||
* 不需要调用方先拿到状态再回头传进来 —— 那是个绕不开的环。
|
||||
*/
|
||||
export const useRunWorkflow = (id: string | undefined): UseQueryResult<GovWorkflowOutput> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'testbed', 'workflow', id],
|
||||
queryFn: () => call<GovWorkflowOutput>(govWorkflowRoute, { params: { id: id! } }),
|
||||
enabled: id !== undefined,
|
||||
refetchInterval: (q) => (isLive(q.state.data?.workflow.state) ? LIVE_MS : false),
|
||||
});
|
||||
|
||||
/** 还在动的两态:running(有任务在跑)/ waiting(卡在人身上) */
|
||||
export const isLive = (state: string | undefined): boolean =>
|
||||
state === undefined || state === 'running' || state === 'waiting';
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。
|
||||
*
|
||||
* 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口
|
||||
* (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**,
|
||||
* 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事:
|
||||
* ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态)
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { ProcessNode, ProcessPackage } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testRunRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Acts, Field, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { CallForm, buildCallData, callFieldsOf, placeholderValues } from './call-form.tsx';
|
||||
import type { CallValues } from './call-form.tsx';
|
||||
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
|
||||
import { TraceSheet } from './trace-sheet.tsx';
|
||||
|
||||
type RunOutput = { workflowId: string; state: string };
|
||||
|
||||
/** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */
|
||||
const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval';
|
||||
|
||||
const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
|
||||
activity: '活动',
|
||||
approval: '审批',
|
||||
start: '起点',
|
||||
end: '终点',
|
||||
};
|
||||
|
||||
function Sheet({
|
||||
code,
|
||||
title,
|
||||
ico,
|
||||
extra,
|
||||
tight,
|
||||
children,
|
||||
}: {
|
||||
code: string;
|
||||
title: string;
|
||||
ico: string;
|
||||
extra?: ReactNode;
|
||||
tight?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name={ico} s={12} />
|
||||
{code} — {title}
|
||||
{extra !== undefined && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
<div className={'g-sheet-b' + (tight === true ? ' tight' : '')}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
const pkgQ = useGovPackages();
|
||||
const staffQ = useGovStaff();
|
||||
|
||||
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
||||
const livePkgs = useMemo<ProcessPackage[]>(
|
||||
() => (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active'),
|
||||
[pkgQ.data],
|
||||
);
|
||||
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 [pickedId, setPicked] = useState('');
|
||||
const [subject, setSubject] = useState('测试台试跑');
|
||||
const [values, setValues] = useState<CallValues>({});
|
||||
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
|
||||
const [workflowId, setWorkflowId] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
||||
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
|
||||
const fields = useMemo(() => callFieldsOf(pkg?.callInterface.input), [pkg]);
|
||||
const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
|
||||
const built = useMemo(() => buildCallData(fields, values), [fields, values]);
|
||||
const ready = pkg !== undefined && subject.trim() !== '' && built.errors.length === 0;
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (body: { packageId: string; subject: string; data: Record<string, unknown>; assignments: Record<string, string> }) =>
|
||||
call<RunOutput>(testRunRoute, { body }),
|
||||
onSuccess: (r) => {
|
||||
setWorkflowId(r.workflowId);
|
||||
setError(undefined);
|
||||
},
|
||||
// 后端的判词照抄:「输入不合调用接口契约」「流程包无生效版本」都是要人改配置的话,不该被我改写
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
||||
});
|
||||
|
||||
const fire = (): void => {
|
||||
if (pkg === undefined) return;
|
||||
run.mutate({
|
||||
packageId: pkg.id,
|
||||
subject: subject.trim(),
|
||||
data: built.data,
|
||||
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
||||
});
|
||||
};
|
||||
|
||||
if (workflowId !== undefined) {
|
||||
return (
|
||||
<Observe
|
||||
id={workflowId}
|
||||
staffName={staffName}
|
||||
nav={nav}
|
||||
onBack={() => {
|
||||
setWorkflowId(undefined);
|
||||
run.reset();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pinned = Object.values(assignments).filter((v) => v !== '').length;
|
||||
|
||||
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">
|
||||
<QueryGate query={pkgQ}>
|
||||
{livePkgs.length === 0 ? (
|
||||
<div className="g-sheet">
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>没有可起单的流程包</b>
|
||||
<p>
|
||||
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 一张装配单读下来就是一次试跑的次序:选包 → 填参 → 派人 → 起单 */}
|
||||
<div className="g-tb-rig">
|
||||
<Sheet code="PKG" title="流程包" ico="workflow" extra={`${livePkgs.length} 个生效`}>
|
||||
<div className="g-tbpkgs">
|
||||
{livePkgs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={'g-tbpkg' + (p.id === pkg?.id ? ' on' : '')}
|
||||
onClick={() => {
|
||||
setPicked(p.id);
|
||||
setValues({});
|
||||
setAssignments({});
|
||||
}}
|
||||
>
|
||||
<b>{p.name}</b>
|
||||
<span className="id">
|
||||
{p.id}@v{p.version}
|
||||
</span>
|
||||
<span className="cnt">
|
||||
<span>节点 {p.nodes.length}</span>
|
||||
<span>边 {p.edges.length}</span>
|
||||
<span>入参 {callFieldsOf(p.callInterface.input).length}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Note>只列有生效版的包。在途的流按起单时那一版跑完,改包不影响它们。</Note>
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
code="ARG"
|
||||
title="调用参数"
|
||||
ico="sliders-horizontal"
|
||||
extra={pkg === undefined ? '' : `按 ${pkg.id} 的调用接口`}
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<Note>这个包的调用接口没声明入参 —— 直接起单即可。</Note>
|
||||
) : (
|
||||
<>
|
||||
<div className="g-tbargs">
|
||||
<CallForm
|
||||
fields={fields}
|
||||
values={values}
|
||||
onChange={(k, v) => setValues((prev) => ({ ...prev, [k]: v }))}
|
||||
/>
|
||||
</div>
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '填占位值', ico: 'sparkles', onClick: () => setValues(placeholderValues(fields)) },
|
||||
{ label: '清空', ico: 'trash-2', onClick: () => setValues({}) },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Sheet code="ASN" title="节点指派" ico="user-check" extra={`${nodes.length} 个可指派 · 已定 ${pinned}`}>
|
||||
{/* 分两栏排:一条流七八个节点,单栏会把这张卡拉得比整块装配单还长 */}
|
||||
<div className="g-asn-2">
|
||||
{[nodes.slice(0, Math.ceil(nodes.length / 2)), nodes.slice(Math.ceil(nodes.length / 2))]
|
||||
.filter((half) => half.length > 0)
|
||||
.map((half, i) => (
|
||||
<div key={i} className="g-asn">
|
||||
<div className="g-asn-h">
|
||||
<span>节点</span>
|
||||
<span>类型</span>
|
||||
<span>名称</span>
|
||||
<span>派给谁</span>
|
||||
</div>
|
||||
{half.map((n) => (
|
||||
<div key={n.id} className={'g-asn-r' + ((assignments[n.id] ?? '') === '' ? '' : ' pinned')}>
|
||||
<span className="nid">{n.id}</span>
|
||||
<span className="kd">{NODE_KIND_LABEL[n.kind] ?? n.kind}</span>
|
||||
<span className="lb">{n.label}</span>
|
||||
<select
|
||||
className="g-select"
|
||||
value={assignments[n.id] ?? ''}
|
||||
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
|
||||
>
|
||||
<option value="">照旧轮值</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.position.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Note>
|
||||
留空即<b>照旧轮值</b>(有员工组按组轮,没有按全员最闲者先)。指派只在那个人<b>接得了这个节点</b>
|
||||
时生效,接不了会退回轮值 —— 实际受理人以 TRACE 里那一行为准。
|
||||
</Note>
|
||||
</Sheet>
|
||||
|
||||
<Sheet code="RUN" title="起单" ico="play" extra={ready ? '装配齐了' : '还差料'}>
|
||||
<div className="g-tbrun">
|
||||
<div className="g-tb-f">
|
||||
<Field k="试跑标题" hint="落在工作流上的题,观测态与运营中心都按它认这一单">
|
||||
<input className="g-input" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Acts
|
||||
items={[
|
||||
{
|
||||
label: run.isPending ? '起单中…' : '起单试跑',
|
||||
ico: 'play',
|
||||
primary: true,
|
||||
disabled: !ready || run.isPending,
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{built.errors.length > 0 && (
|
||||
<Note tone="warn">
|
||||
<b>还差 {built.errors.length} 项才起得来:</b>
|
||||
{built.errors.join(' · ')}
|
||||
</Note>
|
||||
)}
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>没起成:</b>
|
||||
{error}
|
||||
</Note>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 观测态 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function Observe({
|
||||
id,
|
||||
staffName,
|
||||
nav,
|
||||
onBack,
|
||||
}: {
|
||||
id: string;
|
||||
staffName: (sid: string) => string;
|
||||
nav: Nav;
|
||||
onBack: () => void;
|
||||
}): ReactNode {
|
||||
const wf = useRunWorkflow(id);
|
||||
const state = wf.data?.workflow.state;
|
||||
// 到终态就停轮询:已经不动的东西没必要每两秒问一遍
|
||||
const live = isLive(state);
|
||||
const trQ = useRunTrace(id, live);
|
||||
|
||||
const spans = trQ.data?.spans ?? [];
|
||||
const w = wf.data;
|
||||
const nodeLabel = (nid: string): string => w?.pkg.nodes.find((n) => n.id === nid)?.label ?? nid;
|
||||
/** 节点实例出参里没有受理人,受理人只在 span 的 attributes 上 —— 从 TRACE 反查 */
|
||||
const assigneeOfNode = (nid: string): string | undefined => {
|
||||
const s = spans.find((x) => x.kind === 'node' && x.nodeId === nid);
|
||||
const a = s?.attributes?.['assigneeId'];
|
||||
return typeof a === 'string' ? staffName(a) : undefined;
|
||||
};
|
||||
|
||||
const started = w === undefined ? undefined : new Date(w.workflow.startedAt);
|
||||
const refresh = (): void => {
|
||||
void wf.refetch();
|
||||
void trQ.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{ label: '刷新', ico: 'refresh-cw', onClick: refresh },
|
||||
{ label: '再装一单', ico: 'rotate-ccw', primary: true, onClick: onBack },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="g-tb">
|
||||
<QueryGate query={wf}>
|
||||
{w !== undefined && (
|
||||
<>
|
||||
{/* 图签栏:工程图右下角那张表 —— 一条流的身份,一格一件事 */}
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="file-text" s={12} />
|
||||
WF — 工作流
|
||||
<span className="x">{w.workflow.source.kind} · {w.workflow.source.ref}</span>
|
||||
</div>
|
||||
<div className="g-tblock">
|
||||
<div>
|
||||
<em>工作流号</em>
|
||||
<b>{id.slice(0, 8)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>状态</em>
|
||||
<b className={state === 'running' || state === 'waiting' ? 'run' : 'brass'}>
|
||||
{WORKFLOW_STATE_LABEL[w.workflow.state] ?? w.workflow.state}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>流程包</em>
|
||||
<b>{w.workflow.packageId}@v{w.workflow.packageVersion}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单时刻</em>
|
||||
<b>{started?.toLocaleTimeString('zh-CN', { hour12: false }) ?? '—'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单人</em>
|
||||
<b>{w.workflow.source.by === undefined ? '—' : staffName(w.workflow.source.by)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>节点 / 边</em>
|
||||
<b>
|
||||
{w.pkg.nodes.length} / {w.pkg.edges.length}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 派工带:测试台不代答,这句必须说满 */}
|
||||
<div className="g-tbdesk">
|
||||
<div className="g-tbdesk-h">
|
||||
<Ico name="briefcase" s={12} />
|
||||
DSK — 任务落到了谁的工作台
|
||||
<button className="g-btn xs" onClick={() => nav.jump('tasks', 'list')}>
|
||||
<Ico name="activity" s={11} />
|
||||
去运营中心 · 任务
|
||||
</button>
|
||||
</div>
|
||||
{w.tasks.length === 0 ? (
|
||||
<p>还没有任务落到任何工位 —— 流刚起,或者卡在审批点上(审批单不是任务,见下面的节点实例)。</p>
|
||||
) : (
|
||||
w.tasks.map((t) => (
|
||||
<div key={t.id} className="g-tbdesk-r">
|
||||
<span className="nid">{t.nodeId}</span>
|
||||
{t.title}
|
||||
<span>→</span>
|
||||
<b>{t.ownerName} 的工作台</b>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<p>
|
||||
测试台<b>不代答</b>:这些是真任务,要那个人在自己的工作台上答,这条流才会往下走。
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="list-checks" s={12} />
|
||||
NOD — 节点实例
|
||||
<span className="x">{w.instances.length} 个已进入</span>
|
||||
</div>
|
||||
{w.instances.length === 0 ? (
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>还没有节点进入</b>
|
||||
<p>起点已过,第一个节点正在派工。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="g-nins">
|
||||
<div className="g-nins-h">
|
||||
<span>节点</span>
|
||||
<span>名称</span>
|
||||
<span>状态</span>
|
||||
<span>终态</span>
|
||||
<span>受理人</span>
|
||||
</div>
|
||||
{w.instances.map((it) => {
|
||||
const bad = it.terminal !== undefined && it.terminal !== 'released';
|
||||
const cls = bad ? 'bad' : it.running ? 'run' : it.waiting ? 'wait' : it.terminal === undefined ? '' : 'done';
|
||||
const who = assigneeOfNode(it.nodeId);
|
||||
return (
|
||||
<div key={`${it.nodeId}#${it.instance}`} className="g-nins-r">
|
||||
<span className="nid">{it.nodeId}</span>
|
||||
<span className="lb">{nodeLabel(it.nodeId)}</span>
|
||||
<span className={`st ${cls}`}>
|
||||
<i />
|
||||
{bad ? '异常' : it.running ? '在跑' : it.waiting ? '等人' : it.terminal === undefined ? '已进入' : '已放行'}
|
||||
</span>
|
||||
<span className="lb">
|
||||
{it.terminal === undefined ? '—' : (TERMINAL_STATE_LABEL[it.terminal] ?? it.terminal)}
|
||||
</span>
|
||||
<span className="who">
|
||||
{who === undefined ? '—' : <b>{who}</b>}
|
||||
{it.instance > 1 && <span className="ins"> 第 {it.instance} 次</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 全链 TRACE —— 这一屏的戏眼:一张**走查记录纸**。
|
||||
*
|
||||
* 一行读两件事:
|
||||
* · 左半是**结构** —— span 树按 `parentId` 拼出来,枝线是细线方角(与图框同一画法);
|
||||
* · 右半是**时间** —— 所有条画在同一条刻度轴上,底纹等距细格,谁套着谁跑、谁挨着谁跑,一眼看出来。
|
||||
* 未收尾的段按**剖面线**画(图纸里未定型的剖切面就是这么标的),右端出箭头 —— 不必写「还在跑」也知道。
|
||||
*
|
||||
* span 的语义分两层:workflow / node / step 是骨架(走灰阶),
|
||||
* tool / model / human 是**谁动的手**(各有其色)—— 全屏只有这三色是彩的。
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import type { SpanKind, TraceSpan } from './run-api.ts';
|
||||
|
||||
const KIND: Readonly<Record<SpanKind, { code: string; ico: string; label: string }>> = {
|
||||
workflow: { code: 'FLOW', ico: 'workflow', label: '一条流' },
|
||||
node: { code: 'NODE', ico: 'circle-dot', label: '节点' },
|
||||
step: { code: 'STEP', ico: 'chevron-right', label: '步骤' },
|
||||
tool: { code: 'TOOL', ico: 'wrench', label: '工具' },
|
||||
model: { code: 'MODEL', ico: 'cpu', label: '模型' },
|
||||
human: { code: 'HUMAN', ico: 'user-round', label: '人卡' },
|
||||
};
|
||||
|
||||
const KIND_ORDER: readonly SpanKind[] = ['workflow', 'node', 'step', 'tool', 'model', 'human'];
|
||||
|
||||
/** 刻度阶梯:轴的读数要是人能一眼换算的整数,不是 `1.37s` 这种算出来的余数 */
|
||||
const STEPS = [
|
||||
10, 25, 50, 100, 250, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000,
|
||||
600_000, 1_800_000, 3_600_000, 7_200_000, 21_600_000, 86_400_000,
|
||||
] as const;
|
||||
|
||||
const ms = (iso: string): number => Date.parse(iso);
|
||||
|
||||
function fmtDur(v: number): string {
|
||||
if (v < 1_000) return `${Math.round(v)}ms`;
|
||||
if (v < 60_000) return `${(v / 1_000).toFixed(v < 10_000 ? 2 : 1)}s`;
|
||||
const m = Math.floor(v / 60_000);
|
||||
return `${m}m${String(Math.floor((v % 60_000) / 1_000)).padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
function fmtOffset(v: number): string {
|
||||
if (v === 0) return 'T0';
|
||||
if (v < 60_000) return `+${(v / 1_000).toFixed(2)}s`;
|
||||
const m = Math.floor(v / 60_000);
|
||||
return `+${m}m${String(Math.floor((v % 60_000) / 1_000)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
readonly span: TraceSpan;
|
||||
/** 每一层祖先在这一行下面还有没有兄弟 —— 决定竖枝线画到哪 */
|
||||
readonly guides: readonly boolean[];
|
||||
readonly last: boolean;
|
||||
/**
|
||||
* 已知活跃到几时(毫秒)—— 未收尾的段画到这儿为止,再往后是**续画线**。
|
||||
* 收尾了就是 `endedAt`;没收尾就取子孙里最晚的一笔留痕:
|
||||
* 那是这一段**能证明自己还在动**的最后时刻,再往后没人知道。
|
||||
*/
|
||||
readonly known: number;
|
||||
/**
|
||||
* 有没有子段。有子段的是**包络**(流、节点)—— 它自己不干活,时长是子段的外框,
|
||||
* 所以按尺寸线画(细线 + 两端界限);没子段的才是真干了活的一笔,按实心条画。
|
||||
*/
|
||||
readonly envelope: boolean;
|
||||
};
|
||||
|
||||
/** 拍平成行:父在前、子按起始时刻排 —— 出参是按 startedAt 的平表,树得自己拼 */
|
||||
function layout(spans: readonly TraceSpan[]): Row[] {
|
||||
const known = new Set(spans.map((s) => s.id));
|
||||
const kids = new Map<string, TraceSpan[]>();
|
||||
const roots: TraceSpan[] = [];
|
||||
for (const s of spans) {
|
||||
const p = s.parentId;
|
||||
if (p === undefined || !known.has(p)) roots.push(s);
|
||||
else kids.set(p, [...(kids.get(p) ?? []), s]);
|
||||
}
|
||||
const byTime = (a: TraceSpan, b: TraceSpan): number => ms(a.startedAt) - ms(b.startedAt) || a.id.localeCompare(b.id);
|
||||
const knownOf = (s: TraceSpan): number => {
|
||||
if (s.endedAt !== undefined) return ms(s.endedAt);
|
||||
const sub = (kids.get(s.id) ?? []).map(knownOf);
|
||||
return Math.max(ms(s.startedAt), ...sub);
|
||||
};
|
||||
const out: Row[] = [];
|
||||
const walk = (list: readonly TraceSpan[], guides: readonly boolean[]): void => {
|
||||
const sorted = [...list].sort(byTime);
|
||||
sorted.forEach((s, i) => {
|
||||
const last = i === sorted.length - 1;
|
||||
out.push({ span: s, guides, last, known: knownOf(s), envelope: (kids.get(s.id) ?? []).length > 0 });
|
||||
walk(kids.get(s.id) ?? [], [...guides, !last]);
|
||||
});
|
||||
};
|
||||
walk(roots, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function payloadOf(s: TraceSpan): ReadonlyArray<{ cap: string; body: string; bad?: true }> {
|
||||
const parts: Array<{ cap: string; body: string; bad?: true }> = [];
|
||||
if (s.input !== undefined && Object.keys(s.input).length > 0)
|
||||
parts.push({ cap: '入参 IN', body: JSON.stringify(s.input, null, 2) });
|
||||
if (s.output !== undefined && Object.keys(s.output).length > 0)
|
||||
parts.push({ cap: '出参 OUT', body: JSON.stringify(s.output, null, 2) });
|
||||
if (s.attributes !== undefined && Object.keys(s.attributes).length > 0)
|
||||
parts.push({ cap: '属性 ATTR', body: JSON.stringify(s.attributes, null, 2) });
|
||||
if (s.error !== undefined) parts.push({ cap: '错误 ERR', body: s.error.message, bad: true });
|
||||
return parts;
|
||||
}
|
||||
|
||||
function assigneeOf(s: TraceSpan): string | undefined {
|
||||
const a = s.attributes?.['assigneeId'];
|
||||
return typeof a === 'string' ? a : undefined;
|
||||
}
|
||||
|
||||
export function TraceSheet({
|
||||
spans,
|
||||
staffName,
|
||||
extra,
|
||||
}: {
|
||||
spans: readonly TraceSpan[];
|
||||
/** 员工 id → 人名:span 的 `attributes.assigneeId` 是 UUID,摆 UUID 等于没说 */
|
||||
staffName: (id: string) => string;
|
||||
extra?: ReactNode;
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
||||
const rows = useMemo(() => layout(spans), [spans]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="git-branch" s={12} />
|
||||
TRC — 全链 TRACE
|
||||
{extra !== undefined && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>还没有 span</b>
|
||||
<p>流刚起,第一段留痕还没写下来。等两秒,或者按上面的刷新。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* 轴的右端只认**已知的时刻**,不认 `now`。
|
||||
*
|
||||
* 未收尾的段拉到「此刻」等于替它编了个结束时间 —— 而工作流根 span 常常一辈子不收尾
|
||||
* (它的收尾只挂在审批裁决那条路上),照 `now` 画,轴会一秒一秒地长,
|
||||
* 底下几笔真活(工具两三毫秒)就被压成看不见的发丝。
|
||||
* 所以轴按最后一笔留痕收口,未收尾的段用**续画线**画出右边界之外 —— 图纸里就是这么标「未完待续」的。
|
||||
*/
|
||||
const t0 = Math.min(...rows.map((r) => ms(r.span.startedAt)));
|
||||
const tEnd = Math.max(...rows.map((r) => r.known));
|
||||
const total = Math.max(tEnd - t0, 1);
|
||||
const step = STEPS.find((s) => total / s <= 5) ?? STEPS[STEPS.length - 1]!;
|
||||
const axis = Math.max(Math.ceil(total / step) * step, step);
|
||||
const ticks = Array.from({ length: Math.round(axis / step) + 1 }, (_, i) => i * step);
|
||||
|
||||
const counts = new Map<SpanKind, number>();
|
||||
for (const r of rows) counts.set(r.span.kind, (counts.get(r.span.kind) ?? 0) + 1);
|
||||
|
||||
return (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="git-branch" s={12} />
|
||||
TRC — 全链 TRACE
|
||||
<span className="x">
|
||||
<span>{rows.length} SPAN · 跨时 {fmtDur(total)}</span>
|
||||
{extra}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 刻度只算一次,挂在容器上 —— 每一行的方格底纹都读它,对得上不是碰巧 */}
|
||||
<div className="g-trc" style={{ '--tick': `${(step / axis) * 100}%` } as CSSProperties}>
|
||||
<div className="g-trc-legend">
|
||||
{KIND_ORDER.filter((k) => counts.has(k)).map((k) => (
|
||||
<span key={k} className={`g-trc-lg k-${k}`}>
|
||||
<i />
|
||||
<em>{KIND[k].code}</em>
|
||||
{KIND[k].label}
|
||||
<b>×{counts.get(k)}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 刻度带:轴的读数在这儿,下面每一行的方格与它同一组百分比 */}
|
||||
<div className="g-trc-rule">
|
||||
<span className="t">时刻</span>
|
||||
<span>结构 · SPAN 树</span>
|
||||
<span className="d">耗时</span>
|
||||
<span className="g">
|
||||
{ticks.map((v, i) => (
|
||||
<span
|
||||
key={v}
|
||||
className={'tick' + (i === ticks.length - 1 ? ' last' : '')}
|
||||
style={{ left: `${(v / axis) * 100}%` }}
|
||||
>
|
||||
{v === 0 ? '0' : fmtDur(v)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rows.map(({ span, guides, last, known, envelope }) => {
|
||||
const start = ms(span.startedAt) - t0;
|
||||
const end = known - t0;
|
||||
const live = span.endedAt === undefined;
|
||||
const parts = payloadOf(span);
|
||||
const who = assigneeOf(span);
|
||||
const isOpen = open.has(span.id);
|
||||
const body = (
|
||||
<>
|
||||
<span className={'g-trc-t' + (start === 0 ? ' zero' : '')}>{fmtOffset(start)}</span>
|
||||
<span className="g-trc-n">
|
||||
<span className="g-trc-tree">
|
||||
{guides.map((on, i) => (
|
||||
<i key={i} className={on ? 'on' : ''} />
|
||||
))}
|
||||
{guides.length > 0 && <i className={'elb' + (last ? '' : ' mid')} />}
|
||||
</span>
|
||||
<span className="g-trc-k">
|
||||
<Ico name={KIND[span.kind].ico} s={10} />
|
||||
</span>
|
||||
<span className="g-trc-kn">{KIND[span.kind].code}</span>
|
||||
<span className="g-trc-nm">{span.name}</span>
|
||||
{/* 只写受理人:节点号已经在 span 名字里(`A1 客户RFQ受理`),子行的 nodeId 又是继承来的 */}
|
||||
{who !== undefined && (
|
||||
<span className="g-trc-me">
|
||||
<span className="who">→ {staffName(who)}</span>
|
||||
</span>
|
||||
)}
|
||||
{parts.length > 0 && (
|
||||
<span className="g-trc-x-i">
|
||||
<Ico name={isOpen ? 'chevron-down' : 'chevron-right'} s={13} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={'g-trc-d' + (live ? ' live' : '')}>{live ? '进行中' : fmtDur(end - start)}</span>
|
||||
<span className="g-trc-g">
|
||||
{/* 实条 = 真干了活的一笔;尺寸线 = 包络;续画线 = 还没收尾,右边界之外没人知道 */}
|
||||
<i
|
||||
className={'g-trc-bar' + (envelope ? ' env' : '') + (span.error === undefined ? '' : ' err')}
|
||||
style={{ left: `${(start / axis) * 100}%`, width: `${Math.max((end - start) / axis, 0.006) * 100}%` }}
|
||||
/>
|
||||
{live && (
|
||||
<i
|
||||
className={'g-trc-bar open' + (envelope ? ' env' : '')}
|
||||
style={{ left: `${(end / axis) * 100}%`, right: '0' }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div key={span.id} className={`g-trc-item k-${span.kind}`}>
|
||||
{parts.length === 0 ? (
|
||||
<div className="g-trc-row">{body}</div>
|
||||
) : (
|
||||
<button
|
||||
className={'g-trc-row' + (isOpen ? ' open-x' : '')}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() =>
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(span.id)) next.add(span.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div className="g-trc-x">
|
||||
{parts.map((p) => (
|
||||
<figure key={p.cap} className={p.bad === true ? 'bad' : ''}>
|
||||
<figcaption>{p.cap}</figcaption>
|
||||
<pre>{p.body}</pre>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 治理台的分级页面路由 —— 自原型 `Governance.tsx` 第 25–90 行平移。
|
||||
*
|
||||
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
|
||||
* 「不用抽屉、不用弹窗:每一层内容都有自己的页面。」
|
||||
* 区 → 对象 → 子维度 → 元素,顶部面包屑可回跳任意一级,Esc / 返回退一级。
|
||||
*/
|
||||
|
||||
@@ -126,7 +126,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
title: '联调测试台',
|
||||
icon: 'flask-conical',
|
||||
accent: 'linear-gradient(140deg,#e8833a,#c25f1a)',
|
||||
desc: '把定义**试着跑一遍**:造一封入站邮件(可带附件)投进通道,看归属解析怎么判、哪个触发器命中、有没有起单。邮件通道是外界,模拟通道是允许的;系统内部机制一律真跑 —— 这里投出去的信走的是与真通道同一条入站归一链路。',
|
||||
desc: '把定义试着跑一遍。两种试法:仿真试跑 —— 装一次调用、指派每个节点归谁、起单看穿全程(真实任务照旧落到被指派人的工作台,测试台不代答);入站邮件演练 —— 造一封信投进通道,看归属解析怎么判、哪个触发器命中。模拟的只有外界那一侧,系统内部机制一律真跑。',
|
||||
},
|
||||
system: {
|
||||
title: '系统管理',
|
||||
@@ -139,11 +139,11 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
/**
|
||||
* 区内页签:一页一个维度 —— 不把四种维度摞在同一屏。
|
||||
*
|
||||
* `ledgers` 的页签是**数据域**,随发布动态生成(壳层运行时铺开),故此表留空。
|
||||
* `ledgers` 的页签是数据域,随发布动态生成(壳层运行时铺开),故此表留空。
|
||||
*/
|
||||
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
|
||||
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
|
||||
testbed: [{ key: 'inbound', label: '入站邮件', ico: 'mail' }],
|
||||
testbed: [{ key: 'inbound', label: '联调试跑', ico: 'flask-conical' }],
|
||||
workflows: [
|
||||
{ key: 'overview', label: '总览', ico: 'gauge' },
|
||||
{ key: 'pkgs', label: '流程包', ico: 'boxes' },
|
||||
|
||||
Reference in New Issue
Block a user