From 72f90f293b28aff421baece6038421a3728ae310 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Wed, 29 Jul 2026 10:42:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=8F=B0=E6=94=B9=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E5=8F=B0:=E5=90=AF=E5=8A=A8=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=8D=A2=E6=88=90=E4=B8=80=E5=B0=81=E4=BF=A1;=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=8C=96=E8=A7=A6=E5=8F=91=E5=99=A8=E4=B8=8E=E5=A4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B;=E6=B8=85=E6=8E=89=20=E2=93=98=20=E8=AF=B4?= =?UTF-8?q?=E6=95=99=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **测试台** · 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信, 界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / BOM 附件)。 服务端拿**真通道的抽键正则** + **真触发器的装配映射**推出调用入参: 测试台自己不解析邮件,否则试通的是测试台而不是这条流。 抽不出必填就如实报「哪个字段没命中、通道的正则要什么」,不静默起空单。 · 一屏两大块:左选流,右两节 —— 01 输入配置、02 节点指派。 · 起单钮只剩一枚(原来页头一枚、RUN 卡里一枚)。 · 改暗色仪表台:调色板集中在 .g-tb/.g-rig 的一组变量上, TRACE 那张树 + 甘特的结构一行没动,只是纸变成了面板。 **触发器 / 大模型** 列表改卡片:一条规则、一个实例都要一眼看全 (何时起 · 起哪条流 · 开没开 / 谁家的 · 什么型号 · 密钥绑没绑)。 触发器卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。 **同类卡等高**:底栏钉在卡底、说明恒占两行、卡头一行截断。 实测同一货架内 13 张工具卡、6 张活动卡、3 张模型卡各自完全等高。 **删掉 19 条 ⓘ 说教横幅**(「只列有生效版的包…」那一类)。 留下的是三种:确认带里的影响说明(点了动作才出现,是决策依据)、 warn/ok 的真反馈、以及「X 不存在」这类空态。 删掉入站邮件演练整屏 —— 它验的那两步已经并进测试台的起单路径了。 新增一条接缝测试:通道抽出来的键叫 keys.customerCode,触发器映射也得照这名字取; 哪头改了名字单还是能起,只是入参悄悄变空、流走到 G1 才莫名其妙地停。 四闸绿;契约快照随本提交入库。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../contracts/src/api/governance.d.ts | 13 +- .../src/app/__tests__/mail-assembly.test.ts | 79 ++++ apps/api/src/app/routes/governance-ops.ts | 83 +++- apps/web/src/app/styles/console.css | 98 ++++- .../features/gov-activity/internal/panel.tsx | 8 +- .../src/features/gov-data/internal/panel.tsx | 9 - .../features/gov-memory/internal/panel.tsx | 4 - .../features/gov-process/internal/panel.tsx | 7 +- .../src/features/gov-staff/internal/panel.tsx | 3 - .../features/gov-system/internal/panel.tsx | 49 +-- .../src/features/gov-task/internal/panel.tsx | 4 - .../gov-testbed/internal/call-form.tsx | 183 --------- .../gov-testbed/internal/simulate.tsx | 385 +++++++++--------- .../src/features/gov-tool/internal/panel.tsx | 28 +- .../features/gov-trigger/internal/inbound.tsx | 319 --------------- .../features/gov-trigger/internal/panel.tsx | 70 ++-- apps/web/src/shared/gov/gov-route.ts | 2 +- apps/web/src/shared/gov/primitives.tsx | 3 +- packages/contracts/src/api/governance.ts | 17 +- 19 files changed, 526 insertions(+), 838 deletions(-) create mode 100644 apps/api/src/app/__tests__/mail-assembly.test.ts delete mode 100644 apps/web/src/features/gov-testbed/internal/call-form.tsx delete mode 100644 apps/web/src/features/gov-trigger/internal/inbound.tsx diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 7e8832c..8af075d 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -2117,8 +2117,17 @@ export declare const testRunRoute: { readonly path: "/api/gov/testbed/run"; readonly body: z.ZodObject<{ packageId: z.ZodString; - subject: z.ZodString; - data: z.ZodRecord; + mail: z.ZodObject<{ + from: z.ZodString; + to: z.ZodString; + subject: z.ZodString; + body: z.ZodString; + attachments: z.ZodDefault>>; + }, z.core.$strip>; assignments: z.ZodDefault>; }, z.core.$strip>; readonly output: z.ZodObject<{ diff --git a/apps/api/src/app/__tests__/mail-assembly.test.ts b/apps/api/src/app/__tests__/mail-assembly.test.ts new file mode 100644 index 0000000..5e16bfb --- /dev/null +++ b/apps/api/src/app/__tests__/mail-assembly.test.ts @@ -0,0 +1,79 @@ +/** + * 一封信 → 调用入参 —— 测试台起单前那两步的**接缝**。 + * + * 两步各自有测试(channel 的抽键、trigger 的装配),但**串起来**是新的: + * 通道抽出来的键叫 `keys.customerCode`,触发器的映射也得照这个名字取 —— + * 中间任何一头改了字段名,单还是能起,只是入参悄悄变空,流走到 G1 才莫名其妙地停。 + * 所以这条测试盯的就是这个名字对不对得上。 + */ +import { describe, expect, it } from 'vitest'; +import { evaluate as evalSfeel } from '@sfeel'; +import { extractFields } from '@modules/channel'; +import { assembleInput, scopeOf } from '@modules/trigger'; +import type { TriggerConfig } from '@modules/trigger'; + +/** 与种子里 `ch-rfq-mail` 同一套抽键规则 */ +const EXTRACT = [ + { key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' as const }, + { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*', source: 'both' as const }, +]; + +/** 与种子里「客户RFQ询价」同一套装配映射 */ +const TRIGGER = { + id: 'trg-rfq-mail', + name: '客户RFQ询价', + processPackageId: 'unified-quote', + enabled: true, + spec: { kind: 'event', eventType: 'data', rule: 'true' }, + mapping: [ + { source: { from: 'expression', sfeel: 'keys.customerCode' }, target: 'customerCode' }, + { source: { from: 'expression', sfeel: 'keys.rfqRef' }, target: 'rfqRef' }, + { source: { from: 'expression', sfeel: 'subject' }, target: 'subject' }, + { source: { from: 'expression', sfeel: 'body' }, target: 'body' }, + ], +} as TriggerConfig; + +const evaluator = (expression: string, scope: Readonly>): unknown => { + try { + return evalSfeel(expression, scope); + } catch { + return undefined; + } +}; + +function assemble(subject: string, body = '正文'): ReturnType { + const keys = extractFields({ subject, body, from: 'buyer@x.com', to: ['rfq@we.com'] }, EXTRACT); + const scope = scopeOf({ + type: 'data', + keys, + source: 'channel', + occurredAt: '2026-07-29T00:00:00.000Z', + payload: { subject, body, from: 'buyer@x.com', channelId: 'ch-rfq-mail' }, + }); + return assembleInput(TRIGGER, scope, ['customerCode', 'rfqRef'], evaluator); +} + +describe('一封信 → 调用入参', () => { + it('主题里带客户码与询价单号:两个必填都装得上', () => { + const out = assemble('【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项'); + expect(out.kind).toBe('ready'); + if (out.kind !== 'ready') return; + expect(out.data['customerCode']).toBe('C014'); + expect(out.data['rfqRef']).toBe('RFQ-2607-001'); + expect(out.data['subject']).toContain('工控主板'); + }); + + it('正文里带也算 —— 抽键两处都看(source: both)', () => { + const out = assemble('询价', '客户 C014,单号 RFQ-2607-002,请报价'); + expect(out.kind).toBe('ready'); + if (out.kind !== 'ready') return; + expect(out.data['rfqRef']).toBe('RFQ-2607-002'); + }); + + it('抽不出来就如实报缺哪个 —— 不静默起一张空单', () => { + const out = assemble('随便问问,有没有便宜的板子'); + expect(out.kind).toBe('missing'); + if (out.kind !== 'missing') return; + expect(out.fields).toEqual(expect.arrayContaining(['customerCode', 'rfqRef'])); + }); +}); diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index d7bcff1..c2caa45 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -39,6 +39,9 @@ import { upsertTriggerRoute, validateProcessRoute, } from '@contracts'; +import { evaluate as evalSfeel } from '@sfeel'; +import { extractFields } from '@modules/channel'; +import { assembleInput, scopeOf } from '@modules/trigger'; import { InstallRejectedError } from '@modules/activity-registry'; import type { InstallVerdict } from '@modules/activity-registry'; import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process'; @@ -92,6 +95,72 @@ async function destroySimulations(container: Container, workflowId?: string): Pr return taskCount; } +/** + * 一封信 → 调用入参 —— **借的全是真件**: + * 收件地址认通道(真通道的抽键正则),包上挂的触发器出装配映射(真触发器的 mapping)。 + * + * 测试台自己不解析邮件。它要是自带一套解析,试通的就是测试台而不是这条流 —— + * 线上真来一封信走的是通道抽键 + 触发器装配这两步,这里必须是同两步。 + */ +async function assembleFromMail( + container: Container, + packageId: string, + mail: { from: string; to: string; subject: string; body: string; attachments: readonly { name: string }[] }, +): Promise> { + const channels = await container.channel.list(); + const channel = + channels.find((ch) => ch.address === mail.to) ?? channels.find((ch) => ch.kind === 'mail' && ch.enabled); + if (channel === undefined) throw new Error(`没有可用的邮件通道 —— 收件地址 ${mail.to} 对不上任何通道`); + + const triggers = await container.trigger.list(); + const forPackage = triggers.filter((t) => t.processPackageId === packageId); + const trigger = forPackage.find((t) => t.spec.kind === 'event') ?? forPackage[0]; + if (trigger === undefined) { + throw new Error(`流程包 ${packageId} 还没有任何调起规则 —— 装配映射从触发器来,先去触发器区配一条`); + } + + const keys = extractFields( + { subject: mail.subject, body: mail.body, from: mail.from, to: [mail.to] }, + channel.extract, + ); + const scope = scopeOf({ + type: 'data', + keys, + source: 'channel', + occurredAt: new Date().toISOString(), + payload: { + subject: mail.subject, + body: mail.body, + from: mail.from, + channelId: channel.id, + // 附件只登记不解析 —— 解析 Excel 是活动的事 + attachments: mail.attachments.map((a) => a.name), + }, + }); + + // 与起单取版同口径:必填字段读**生效版本**的调用接口 + const active = (await container.process.listPackages()).find((x) => x.id === packageId && x.state === 'active'); + const declared = active?.callInterface.input as { required?: string[] } | undefined; + const required = declared?.required ?? []; + const assembled = assembleInput(trigger, scope, required, (expression, evalScope) => { + try { + return evalSfeel(expression, evalScope); + } catch { + return undefined; + } + }); + if (assembled.kind === 'missing') { + const wanted = channel.extract + .filter((r) => assembled.fields.includes(r.key)) + .map((r) => `${r.key} 要匹配 /${r.regex}/`); + throw new Error( + `这封信里抽不出 ${assembled.fields.join('、')} —— 通道「${channel.name}」的抽键规则没命中` + + (wanted.length > 0 ? `(${wanted.join(';')})` : ''), + ); + } + return assembled.data; +} + export function registerGovernanceOpsRoutes(app: Hono, container: Container): void { /** * 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。 @@ -280,16 +349,18 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json(body, 400); } const me = await container.org.findByAccount(c.get('account')); - // **一次只跑一条**:再起一单就先把上一单连根拔掉 —— 仿真台上不留过夜的东西 - await destroySimulations(container); + const { packageId, mail, assignments } = parsed.data; try { + const data = await assembleFromMail(container, packageId, mail); + // **一次只跑一条**:装配成了才拔上一单 —— 装配失败不该把在跑的那条也搭进去 + await destroySimulations(container); const record = await container.process.start({ - packageId: parsed.data.packageId, - subject: parsed.data.subject, + packageId, + subject: mail.subject, // 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者 source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) }, - data: parsed.data.data, - ...(Object.keys(parsed.data.assignments).length === 0 ? {} : { assignments: parsed.data.assignments }), + data, + ...(Object.keys(assignments).length === 0 ? {} : { assignments }), }); return c.json({ workflowId: record.id, state: record.state }); } catch (error) { diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index f84e912..bb942c3 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -247,16 +247,17 @@ /* ============ 货架:卡片墙 ============ */ .g-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(264px, 1fr)); gap: 12px; } -.g-tile { display: flex; flex-direction: column; gap: 10px; padding: 14px 15px; background: var(--card); border: 1px solid var(--cline); border-radius: 13px; box-shadow: var(--cshadow); cursor: pointer; text-align: left; font: inherit; transition: box-shadow var(--t-fast), border-color var(--t-fast), transform var(--t-fast); } +.g-tile { display: flex; flex-direction: column; gap: 10px; min-height: 158px; padding: 14px 15px; background: var(--card); border: 1px solid var(--cline); border-radius: 13px; box-shadow: var(--cshadow); cursor: pointer; text-align: left; font: inherit; transition: box-shadow var(--t-fast), border-color var(--t-fast), transform var(--t-fast); } .g-tile:hover { border-color: #c9d6e6; box-shadow: 0 5px 14px -5px rgba(19, 28, 38, .22); transform: translateY(-1px); } .g-tile-h { display: flex; align-items: center; gap: 9px; } .g-tile-i { flex: none; width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; color: var(--tone-ink, var(--brand-deep)); background: var(--tone-soft, var(--brand-wash)); } .g-tile-t { flex: 1; min-width: 0; line-height: 1.35; } -.g-tile-t b { display: block; font-size: 13.5px; font-weight: 700; color: var(--ink); } -.g-tile-t em { display: block; font-style: normal; font-size: 10.5px; font-family: var(--mono); color: var(--ink-4); } +.g-tile-t b { display: block; font-size: 13.5px; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-tile-t em { display: block; font-style: normal; font-size: 10.5px; font-family: var(--mono); color: var(--ink-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .g-tile-m { display: flex; flex-wrap: wrap; gap: 6px; } -.g-tile-d { font-size: 11.5px; line-height: 1.6; color: var(--ink-3); } -.g-tile-f { display: flex; align-items: center; gap: 8px; padding-top: 9px; border-top: 1px dashed var(--line-soft); font-size: 11px; color: var(--ink-3); } +.g-tile-d { min-height: 3.2em; font-size: 11.5px; line-height: 1.6; color: var(--ink-3); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.g-tile-f { margin-top: auto; min-height: 37px; box-sizing: border-box; display: flex; align-items: center; gap: 8px; padding-top: 9px; border-top: 1px dashed var(--line-soft); font-size: 11px; color: var(--ink-3); } +.g-tile-f > .g-btn { margin-left: auto; height: 24px; padding: 0 9px; font-size: 11px; } .g-tile-f .r { margin-left: auto; font-family: var(--mono); color: var(--ink-4); } /* ============ 规则清单(触发器):整行可下钻 ============ */ @@ -672,10 +673,13 @@ 全屏只有一处戏眼 —— 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; +/* 暗色仪表台:纸变面板,钢青转冷蓝,黄铜留着当「人 / 生效 / 裁定」的色。 + 结构一行没动 —— 换的只是这组变量与几处底色,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; --sh-mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace; display: flex; flex-direction: column; gap: 12px; min-width: 0; } @@ -683,7 +687,7 @@ /* 模式选择:不是第二条页签栏 —— 前置 mono 墨签点明这是个**控件**,方角、黄铜底线定当前 */ /* ── 图纸卡:方角白纸 + 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 { 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); } .g-sheet-h svg { color: var(--sh-steel); opacity: .65; } /* 题头右角写的是**数据**(包 id / 计数 / 来源),不是标签 —— 不跟着题头转大写 */ @@ -698,8 +702,11 @@ .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-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); } @@ -727,9 +734,60 @@ .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: #fff; text-align: left; font: inherit; cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast); } +.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; } @@ -743,16 +801,16 @@ .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 { 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: #3a4452; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.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: #fff; } +.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); } @@ -761,8 +819,8 @@ /* 派工带:测试台不代答 —— 任务真的落在别人工位上,这一条必须说满 */ .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-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-r b { font-weight: 700; color: var(--sh-brass); } .g-tbdesk-r .nid { font-family: var(--sh-mono); font-size: 10px; color: #8a7743; } @@ -807,7 +865,7 @@ button.g-trc-row:focus-visible { outline: 2px solid var(--brand); outline-offset .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-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-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); } @@ -834,7 +892,7 @@ button.g-trc-row:focus-visible { outline: 2px solid var(--brand); outline-offset .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 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 字典:结构三色走灰阶(骨架),动作三色各有其人(工具 / 模型 / 人)—— 有色的才是「谁动的手」 */ diff --git a/apps/web/src/features/gov-activity/internal/panel.tsx b/apps/web/src/features/gov-activity/internal/panel.tsx index 8783dce..e366f7f 100644 --- a/apps/web/src/features/gov-activity/internal/panel.tsx +++ b/apps/web/src/features/gov-activity/internal/panel.tsx @@ -269,13 +269,7 @@ function InstallCard() { } > - - 交清单(manifest.json)+ 产物(index.js)两份文件,两审当场跑: - 静态审读清单(工具契约在否、模型名绑没绑、声明与已物化台账冲不冲), - 回放审重演历史成功单 —— 旧成不坏是生效硬门,首装无历史即免。 - 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。 - - + ); } diff --git a/apps/web/src/features/gov-data/internal/panel.tsx b/apps/web/src/features/gov-data/internal/panel.tsx index fcc5738..226603e 100644 --- a/apps/web/src/features/gov-data/internal/panel.tsx +++ b/apps/web/src/features/gov-data/internal/panel.tsx @@ -32,11 +32,6 @@ export function DataHome({ nav }: HomeProps) { return ( <> - - 数据按经营对象四分:项目是每一单的流转单据(受理记录 → 比价 → 草案 → 报价单), - 客户 / 情报 / 供应商是跨单沉淀的资产。域是承载的划分,对象由活动声明物化 —— - 最大化合并、只增不破坏。这里改结构;行数据在运营中心 · 台账看。 - {domains.map((d) => { const inDomain = objects.filter((o) => o.domain === d); return ( @@ -87,10 +82,6 @@ export function LedgersHome({ tab, nav }: HomeProps) { return ( <> - - {domainShort(domain)}域({domain})的行数据、主键与列结构。 - 结构要改去设计中心 · 数据域;这里做的是运营动作 —— 查行、看版本、追引用。 - {inDomain.length === 0 ? ( 这个域还没有对象 —— 对象随活动首次写入而物化,建好就出现在这里。 ) : ( diff --git a/apps/web/src/features/gov-memory/internal/panel.tsx b/apps/web/src/features/gov-memory/internal/panel.tsx index b32fa9f..dcdcb88 100644 --- a/apps/web/src/features/gov-memory/internal/panel.tsx +++ b/apps/web/src/features/gov-memory/internal/panel.tsx @@ -176,10 +176,6 @@ export function KnowledgeHome({ nav }: HomeProps) { return ( <> - - 活动记忆四层:事实层 / 模式层 / 裁决层 / 例外层。写入唯蒸馏,条目必挂证据链; - 记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。 -
{(activities.data?.activities ?? []).map((a) => (
- - 画图不检查,激活才查完整:随手画的都存得下(存出来不生效),异常清零后激活, - 即刻对新起的工作流生效;在途的按启动时版本跑完。要回退,把生效切回旧版本 —— - 一条流一次只有一个生效版本。 - -
+
); } diff --git a/apps/web/src/features/gov-staff/internal/panel.tsx b/apps/web/src/features/gov-staff/internal/panel.tsx index c815799..8917c3b 100644 --- a/apps/web/src/features/gov-staff/internal/panel.tsx +++ b/apps/web/src/features/gov-staff/internal/panel.tsx @@ -68,9 +68,6 @@ function StaffHome({ tab, nav }: HomeProps) { return ( <> - - 停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。 - {staff.map((s) => { const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined); diff --git a/apps/web/src/features/gov-system/internal/panel.tsx b/apps/web/src/features/gov-system/internal/panel.tsx index ab23a71..0795114 100644 --- a/apps/web/src/features/gov-system/internal/panel.tsx +++ b/apps/web/src/features/gov-system/internal/panel.tsx @@ -5,6 +5,7 @@ * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ +import { Ico } from '@web-shared/Ico.tsx'; import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { @@ -30,10 +31,6 @@ export function SystemHome({ tab, nav }: HomeProps) { if (tab === 'chan') { return ( <> - - 通道实例声明归属键规则与默认认领岗位。归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口; - 歧义或失败一律判无主——宁交人,不错投。 - {(channels.data?.channels ?? []).map((ch) => ( <> - - LLM 实例是名称间接层:活动只见自定义模型名,Provider / URL / API-Key / Model 四元在系统侧绑定。 - 密钥与 endpoint 值只存治理台——不入包、不入活动、不入留痕明文。 - - + {/* 卡而不是行:一个实例要一眼看全「谁家的 · 什么型号 · 密钥绑没绑」 */} +
{(llm.data?.instances ?? []).map((i) => ( - {i.baseUrl} }, - { k: '系统用途', v: i.systemUse ? '是' : '—' }, - ]} - right={ + className={'g-tile t-' + (i.enabled ? 'brand' : 'mute')} + onClick={() => nav.push({ k: 'llm', id: i.id })} + > + + + + + + {i.name} + {i.model} + + {i.systemUse && 系统用途} + + {i.baseUrl} + + {i.provider} + + {i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'} - } - onClick={() => nav.push({ k: 'llm', id: i.id })} - /> + {i.enabled ? '启用' : '停用'} + + ))} - +
); diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index 5ee0ab1..4b4ff1b 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -162,9 +162,6 @@ function RuntimeHome({ tab, nav }: HomeProps) { if (tab === 'list') { return ( <> - - 任务 = 工作流上的节点,一个活动 = 一个任务;它派进员工工作台的任务清单,负责人由节点声明的岗位解析而来。 - {tasks.map((t) => { const st = t.terminal !== undefined ? TERMINAL_ST[t.terminal] : RUNTIME_ST[t.runtime]; @@ -445,7 +442,6 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) { - 改派 = 负责人变更;改派留痕并通知双方,任务记忆与草稿层不动。
新负责人
diff --git a/apps/web/src/features/gov-testbed/internal/call-form.tsx b/apps/web/src/features/gov-testbed/internal/call-form.tsx deleted file mode 100644 index bd4271c..0000000 --- a/apps/web/src/features/gov-testbed/internal/call-form.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/** - * 调用参数表单 —— 按流程包 `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>; - -function asRecord(v: unknown): Record | undefined { - return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : undefined; -} - -function asStrings(v: unknown): string[] { - return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; -} - -function kindOf(prop: Record): 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> | 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; errors: string[] } { - const data: Record = {}; - 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 { - const out: Record = {}; - 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 ( -
- - {f.kind === 'enum' ? ( - - ) : f.kind === 'bool' ? ( - - ) : f.kind === 'json' ? ( -