forked from ChaiHao/BPOS-MVP
测试台改仪表台:启动输入换成一封信;卡片化触发器与大模型;清掉 ⓘ 说教条
**测试台**
· 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信,
界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
+11
-2
@@ -2117,8 +2117,17 @@ export declare const testRunRoute: {
|
|||||||
readonly path: "/api/gov/testbed/run";
|
readonly path: "/api/gov/testbed/run";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
packageId: z.ZodString;
|
packageId: z.ZodString;
|
||||||
subject: z.ZodString;
|
mail: z.ZodObject<{
|
||||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
subject: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
assignments: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
assignments: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>): unknown => {
|
||||||
|
try {
|
||||||
|
return evalSfeel(expression, scope);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function assemble(subject: string, body = '正文'): ReturnType<typeof assembleInput> {
|
||||||
|
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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
upsertTriggerRoute,
|
upsertTriggerRoute,
|
||||||
validateProcessRoute,
|
validateProcessRoute,
|
||||||
} from '@contracts';
|
} 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 { InstallRejectedError } from '@modules/activity-registry';
|
||||||
import type { InstallVerdict } from '@modules/activity-registry';
|
import type { InstallVerdict } from '@modules/activity-registry';
|
||||||
import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
|
import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
|
||||||
@@ -92,6 +95,72 @@ async function destroySimulations(container: Container, workflowId?: string): Pr
|
|||||||
return taskCount;
|
return taskCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一封信 → 调用入参 —— **借的全是真件**:
|
||||||
|
* 收件地址认通道(真通道的抽键正则),包上挂的触发器出装配映射(真触发器的 mapping)。
|
||||||
|
*
|
||||||
|
* 测试台自己不解析邮件。它要是自带一套解析,试通的就是测试台而不是这条流 ——
|
||||||
|
* 线上真来一封信走的是通道抽键 + 触发器装配这两步,这里必须是同两步。
|
||||||
|
*/
|
||||||
|
async function assembleFromMail(
|
||||||
|
container: Container,
|
||||||
|
packageId: string,
|
||||||
|
mail: { from: string; to: string; subject: string; body: string; attachments: readonly { name: string }[] },
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
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<AppEnv>, container: Container): void {
|
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
/**
|
/**
|
||||||
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
||||||
@@ -280,16 +349,18 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
const me = await container.org.findByAccount(c.get('account'));
|
const me = await container.org.findByAccount(c.get('account'));
|
||||||
// **一次只跑一条**:再起一单就先把上一单连根拔掉 —— 仿真台上不留过夜的东西
|
const { packageId, mail, assignments } = parsed.data;
|
||||||
await destroySimulations(container);
|
|
||||||
try {
|
try {
|
||||||
|
const data = await assembleFromMail(container, packageId, mail);
|
||||||
|
// **一次只跑一条**:装配成了才拔上一单 —— 装配失败不该把在跑的那条也搭进去
|
||||||
|
await destroySimulations(container);
|
||||||
const record = await container.process.start({
|
const record = await container.process.start({
|
||||||
packageId: parsed.data.packageId,
|
packageId,
|
||||||
subject: parsed.data.subject,
|
subject: mail.subject,
|
||||||
// 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者
|
// 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者
|
||||||
source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) },
|
source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) },
|
||||||
data: parsed.data.data,
|
data,
|
||||||
...(Object.keys(parsed.data.assignments).length === 0 ? {} : { assignments: parsed.data.assignments }),
|
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
|
||||||
});
|
});
|
||||||
return c.json({ workflowId: record.id, state: record.state });
|
return c.json({ workflowId: record.id, state: record.state });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -247,16 +247,17 @@
|
|||||||
|
|
||||||
/* ============ 货架:卡片墙 ============ */
|
/* ============ 货架:卡片墙 ============ */
|
||||||
.g-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(264px, 1fr)); gap: 12px; }
|
.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: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-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-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 { 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 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); }
|
.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-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-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 { 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 { 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); }
|
.g-tile-f .r { margin-left: auto; font-family: var(--mono); color: var(--ink-4); }
|
||||||
|
|
||||||
/* ============ 规则清单(触发器):整行可下钻 ============ */
|
/* ============ 规则清单(触发器):整行可下钻 ============ */
|
||||||
@@ -672,10 +673,13 @@
|
|||||||
全屏只有一处戏眼 —— TRACE 那张**「树 + 甘特」同排的走查记录纸**:
|
全屏只有一处戏眼 —— TRACE 那张**「树 + 甘特」同排的走查记录纸**:
|
||||||
左边是结构(枝线画出 span 树),右边是时间(同一条刻度轴上的条),一行读两件事。 */
|
左边是结构(枝线画出 span 树),右边是时间(同一条刻度轴上的条),一行读两件事。 */
|
||||||
|
|
||||||
.g-tb {
|
/* 暗色仪表台:纸变面板,钢青转冷蓝,黄铜留着当「人 / 生效 / 裁定」的色。
|
||||||
--sh-ink: #161b22; --sh-steel: #46586a; --sh-muted: #6a7686;
|
结构一行没动 —— 换的只是这组变量与几处底色,TRACE 那张树 + 甘特照旧。 */
|
||||||
--sh-line: #d8dee6; --sh-brass: #8a5f1c; --sh-brass-wash: #f7f0e0;
|
.g-tb, .g-rig {
|
||||||
--sh-grid: #edf1f5;
|
--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;
|
--sh-mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace;
|
||||||
display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -683,7 +687,7 @@
|
|||||||
/* 模式选择:不是第二条页签栏 —— 前置 mono 墨签点明这是个**控件**,方角、黄铜底线定当前 */
|
/* 模式选择:不是第二条页签栏 —— 前置 mono 墨签点明这是个**控件**,方角、黄铜底线定当前 */
|
||||||
|
|
||||||
/* ── 图纸卡:方角白纸 + mono 三字码题头(与流程区的 VER — / FIG — 同一idiom)── */
|
/* ── 图纸卡:方角白纸 + 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 { 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; }
|
.g-sheet-h svg { color: var(--sh-steel); opacity: .65; }
|
||||||
/* 题头右角写的是**数据**(包 id / 计数 / 来源),不是标签 —— 不跟着题头转大写 */
|
/* 题头右角写的是**数据**(包 id / 计数 / 来源),不是标签 —— 不跟着题头转大写 */
|
||||||
@@ -698,8 +702,11 @@
|
|||||||
.g-tb .g-field { gap: 3px; }
|
.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 { 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-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, .g-tb .g-select, .g-tb .g-ta,
|
||||||
.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-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-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, .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 { 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-tb-f { flex: 0 1 440px; }
|
||||||
.g-tbrun > .g-acts { flex: none; margin-left: auto; }
|
.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 — 流程包:一包一行,生效版行按黄铜左线(与流程区的「生效版」同一记号)── */
|
/* ── PKG — 流程包:一包一行,生效版行按黄铜左线(与流程区的「生效版」同一记号)── */
|
||||||
.g-tbpkgs { display: flex; flex-direction: column; gap: 7px; }
|
.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:hover { border-color: #aeb9c6; }
|
||||||
.g-tbpkg.on { border-color: #cbb98d; border-left-color: var(--sh-brass); background: var(--sh-brass-wash); }
|
.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: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 { 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, .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-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: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 > .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 > .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); }
|
.g-asn-r.pinned > .nid, .g-asn-r.pinned > .lb { color: var(--sh-brass); }
|
||||||
|
|
||||||
/* ── 图签栏:工程图右下角那张表 —— 一条流的身份卡,纯细线不着色,把颜色让给 TRACE ── */
|
/* ── 图签栏:工程图右下角那张表 —— 一条流的身份卡,纯细线不着色,把颜色让给 TRACE ── */
|
||||||
.g-tblock { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 1px; background: var(--sh-line); }
|
.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 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 { 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.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 { 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 { 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 { 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: #fff; }
|
.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 { 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 b { font-weight: 700; color: var(--sh-brass); }
|
||||||
.g-tbdesk-r .nid { font-family: var(--sh-mono); font-size: 10px; color: #8a7743; }
|
.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.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::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-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-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-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 { 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 { 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 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 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 figcaption { color: #b0413e; }
|
||||||
.g-trc-x figure.bad pre { border-color: #e3b9b7; background: #fdf7f6; color: #b0413e; }
|
.g-trc-x figure.bad pre { border-color: #e3b9b7; background: #fdf7f6; color: #b0413e; }
|
||||||
/* kind 字典:结构三色走灰阶(骨架),动作三色各有其人(工具 / 模型 / 人)—— 有色的才是「谁动的手」 */
|
/* kind 字典:结构三色走灰阶(骨架),动作三色各有其人(工具 / 模型 / 人)—— 有色的才是「谁动的手」 */
|
||||||
|
|||||||
@@ -269,13 +269,7 @@ function InstallCard() {
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Note>
|
</Card>
|
||||||
交<b>清单</b>(manifest.json)+ <b>产物</b>(index.js)两份文件,两审当场跑:
|
|
||||||
静态审读清单(工具契约在否、模型名绑没绑、声明与已物化台账冲不冲),
|
|
||||||
回放审重演历史成功单 —— <b>旧成不坏</b>是生效硬门,首装无历史即免。
|
|
||||||
恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
|
|
||||||
</Note>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ export function DataHome({ nav }: HomeProps) {
|
|||||||
return (
|
return (
|
||||||
<QueryGate query={q}>
|
<QueryGate query={q}>
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
数据按<b>经营对象</b>四分:<b>项目</b>是每一单的流转单据(受理记录 → 比价 → 草案 → 报价单),
|
|
||||||
<b>客户 / 情报 / 供应商</b>是跨单沉淀的资产。域是承载的划分,对象由活动声明物化 ——
|
|
||||||
最大化合并、只增不破坏。这里改结构;<b>行数据在运营中心 · 台账看</b>。
|
|
||||||
</Note>
|
|
||||||
{domains.map((d) => {
|
{domains.map((d) => {
|
||||||
const inDomain = objects.filter((o) => o.domain === d);
|
const inDomain = objects.filter((o) => o.domain === d);
|
||||||
return (
|
return (
|
||||||
@@ -87,10 +82,6 @@ export function LedgersHome({ tab, nav }: HomeProps) {
|
|||||||
return (
|
return (
|
||||||
<QueryGate query={q}>
|
<QueryGate query={q}>
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
<b>{domainShort(domain)}</b>域(<span className="g-mono">{domain}</span>)的行数据、主键与列结构。
|
|
||||||
结构要改去设计中心 · 数据域;这里做的是运营动作 —— 查行、看版本、追引用。
|
|
||||||
</Note>
|
|
||||||
{inDomain.length === 0 ? (
|
{inDomain.length === 0 ? (
|
||||||
<Clear>这个域还没有对象 —— 对象随活动首次写入而物化,建好就出现在这里。</Clear>
|
<Clear>这个域还没有对象 —— 对象随活动首次写入而物化,建好就出现在这里。</Clear>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -176,10 +176,6 @@ export function KnowledgeHome({ nav }: HomeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
活动记忆四层:<b>事实层 / 模式层 / 裁决层 / 例外层</b>。写入唯蒸馏,条目必挂证据链;
|
|
||||||
记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。
|
|
||||||
</Note>
|
|
||||||
<div className="g-picker">
|
<div className="g-picker">
|
||||||
{(activities.data?.activities ?? []).map((a) => (
|
{(activities.data?.activities ?? []).map((a) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -243,12 +243,7 @@ function OverviewHome() {
|
|||||||
{/* 网关已废除:分支 = 条件出边,不再是一种要素 */}
|
{/* 网关已废除:分支 = 条件出边,不再是一种要素 */}
|
||||||
<Kpi ico="boxes" label="编排要素" value="四种" sub="活动 · 审批 · 触发器 · 流程包" />
|
<Kpi ico="boxes" label="编排要素" value="四种" sub="活动 · 审批 · 触发器 · 流程包" />
|
||||||
</div>
|
</div>
|
||||||
<Note>
|
</QueryGate>
|
||||||
画图不检查,激活才查完整:随手画的都存得下(存出来不生效),异常清零后激活,
|
|
||||||
即刻对<b>新起的</b>工作流生效;在途的按启动时版本跑完。要回退,把生效切回旧版本 ——
|
|
||||||
一条流一次只有一个生效版本。
|
|
||||||
</Note>
|
|
||||||
</QueryGate>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ function StaffHome({ tab, nav }: HomeProps) {
|
|||||||
return (
|
return (
|
||||||
<QueryGate query={q}>
|
<QueryGate query={q}>
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
停用前置检查:在途任务与待答卡须先<b>改派完毕</b>——停用不得让在途工作无人认领。
|
|
||||||
</Note>
|
|
||||||
<NList>
|
<NList>
|
||||||
{staff.map((s) => {
|
{staff.map((s) => {
|
||||||
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
|
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
* 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 { 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 type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||||
import {
|
import {
|
||||||
@@ -30,10 +31,6 @@ export function SystemHome({ tab, nav }: HomeProps) {
|
|||||||
if (tab === 'chan') {
|
if (tab === 'chan') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
通道实例声明<b>归属键规则</b>与默认认领岗位。归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口;
|
|
||||||
歧义或失败一律判无主——<b>宁交人,不错投</b>。
|
|
||||||
</Note>
|
|
||||||
<NList>
|
<NList>
|
||||||
{(channels.data?.channels ?? []).map((ch) => (
|
{(channels.data?.channels ?? []).map((ch) => (
|
||||||
<NRow
|
<NRow
|
||||||
@@ -64,31 +61,37 @@ export function SystemHome({ tab, nav }: HomeProps) {
|
|||||||
return (
|
return (
|
||||||
<QueryGate query={llm}>
|
<QueryGate query={llm}>
|
||||||
<>
|
<>
|
||||||
<Note>
|
{/* 卡而不是行:一个实例要一眼看全「谁家的 · 什么型号 · 密钥绑没绑」 */}
|
||||||
LLM 实例是<b>名称间接层</b>:活动只见自定义模型名,Provider / URL / API-Key / Model 四元在系统侧绑定。
|
<div className="g-shelf">
|
||||||
密钥与 endpoint 值只存治理台——不入包、不入活动、不入留痕明文。
|
|
||||||
</Note>
|
|
||||||
<NList>
|
|
||||||
{(llm.data?.instances ?? []).map((i) => (
|
{(llm.data?.instances ?? []).map((i) => (
|
||||||
<NRow
|
<button
|
||||||
key={i.id}
|
key={i.id}
|
||||||
tone={i.enabled ? 'brand' : 'mute'}
|
className={'g-tile t-' + (i.enabled ? 'brand' : 'mute')}
|
||||||
ico="cpu"
|
onClick={() => nav.push({ k: 'llm', id: i.id })}
|
||||||
title={i.name}
|
>
|
||||||
sub={`${i.provider} · ${i.model}`}
|
<span className="g-tile-h">
|
||||||
cols={[
|
<span className="g-tile-i">
|
||||||
{ k: 'Base URL', v: <span className="g-mono">{i.baseUrl}</span> },
|
<Ico name="cpu" s={16} />
|
||||||
{ k: '系统用途', v: i.systemUse ? '是' : '—' },
|
</span>
|
||||||
]}
|
<span className="g-tile-t">
|
||||||
right={
|
<b>{i.name}</b>
|
||||||
|
<em>{i.model}</em>
|
||||||
|
</span>
|
||||||
|
{i.systemUse && <Chip tone="brand">系统用途</Chip>}
|
||||||
|
</span>
|
||||||
|
<span className="g-tile-d">{i.baseUrl}</span>
|
||||||
|
<span className="g-tile-m">
|
||||||
|
<Chip ico="server">{i.provider}</Chip>
|
||||||
|
</span>
|
||||||
|
<span className="g-tile-f">
|
||||||
<St tone={i.apiKeyBound ? 'ok' : 'warn'} ico={i.apiKeyBound ? 'shield-check' : 'alert-triangle'}>
|
<St tone={i.apiKeyBound ? 'ok' : 'warn'} ico={i.apiKeyBound ? 'shield-check' : 'alert-triangle'}>
|
||||||
{i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'}
|
{i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'}
|
||||||
</St>
|
</St>
|
||||||
}
|
<span className="r">{i.enabled ? '启用' : '停用'}</span>
|
||||||
onClick={() => nav.push({ k: 'llm', id: i.id })}
|
</span>
|
||||||
/>
|
</button>
|
||||||
))}
|
))}
|
||||||
</NList>
|
</div>
|
||||||
</>
|
</>
|
||||||
</QueryGate>
|
</QueryGate>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -162,9 +162,6 @@ function RuntimeHome({ tab, nav }: HomeProps) {
|
|||||||
if (tab === 'list') {
|
if (tab === 'list') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
任务 = 工作流上的节点,一个活动 = 一个任务;它派进员工工作台的<b>任务清单</b>,负责人由节点声明的岗位解析而来。
|
|
||||||
</Note>
|
|
||||||
<NList>
|
<NList>
|
||||||
{tasks.map((t) => {
|
{tasks.map((t) => {
|
||||||
const st = t.terminal !== undefined ? TERMINAL_ST[t.terminal] : RUNTIME_ST[t.runtime];
|
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 }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Sec ico="user-plus" title="改派">
|
<Sec ico="user-plus" title="改派">
|
||||||
<Note>改派 = 负责人变更;改派留痕并通知双方,任务记忆与草稿层不动。</Note>
|
|
||||||
<div className="g-field">
|
<div className="g-field">
|
||||||
<em>新负责人</em>
|
<em>新负责人</em>
|
||||||
<div className="g-seg">
|
<div className="g-seg">
|
||||||
|
|||||||
@@ -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<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,11 +13,9 @@ import type { ProcessNode, ProcessPackage } from '@contracts';
|
|||||||
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testCurrentRoute, testRunRoute, testStopRoute } from '@contracts';
|
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testCurrentRoute, testRunRoute, testStopRoute } from '@contracts';
|
||||||
import { Ico } from '@web-shared/Ico.tsx';
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import { call } from '@web-shared/typed-fetch.ts';
|
import { call } from '@web-shared/typed-fetch.ts';
|
||||||
import { useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
import { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||||
import { Acts, Field, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
import { Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
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 { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
|
||||||
import { TraceSheet } from './trace-sheet.tsx';
|
import { TraceSheet } from './trace-sheet.tsx';
|
||||||
|
|
||||||
@@ -33,36 +31,10 @@ const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
|
|||||||
end: '终点',
|
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 {
|
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||||
const pkgQ = useGovPackages();
|
const pkgQ = useGovPackages();
|
||||||
const staffQ = useGovStaff();
|
const staffQ = useGovStaff();
|
||||||
|
const chanQ = useGovChannels();
|
||||||
|
|
||||||
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
||||||
const livePkgs = useMemo<ProcessPackage[]>(
|
const livePkgs = useMemo<ProcessPackage[]>(
|
||||||
@@ -71,10 +43,10 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
|||||||
);
|
);
|
||||||
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.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 staffName = (id: string): string => staff.find((s) => s.id === id)?.name ?? id;
|
||||||
|
const inbox = (chanQ.data?.channels ?? []).find((ch) => ch.kind === 'mail' && ch.enabled);
|
||||||
|
|
||||||
const [pickedId, setPicked] = useState('');
|
const [pickedId, setPicked] = useState('');
|
||||||
const [subject, setSubject] = useState('测试台试跑');
|
const [mail, setMail] = useState<MailDraft>(SAMPLE_MAIL);
|
||||||
const [values, setValues] = useState<CallValues>({});
|
|
||||||
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
|
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [workflowId, setWorkflowId] = useState<string | undefined>(undefined);
|
const [workflowId, setWorkflowId] = useState<string | undefined>(undefined);
|
||||||
@@ -102,19 +74,17 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
|||||||
|
|
||||||
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
||||||
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
|
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 nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
|
||||||
const built = useMemo(() => buildCallData(fields, values), [fields, values]);
|
const to = mail.to === '' ? (inbox?.address ?? '') : mail.to;
|
||||||
const ready = pkg !== undefined && subject.trim() !== '' && built.errors.length === 0;
|
const ready = pkg !== undefined && mail.from.trim() !== '' && to !== '' && mail.subject.trim() !== '';
|
||||||
|
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: (body: { packageId: string; subject: string; data: Record<string, unknown>; assignments: Record<string, string> }) =>
|
mutationFn: (input: RunInput) => call<RunOutput>(testRunRoute, { body: input }),
|
||||||
call<RunOutput>(testRunRoute, { body }),
|
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
setWorkflowId(r.workflowId);
|
setWorkflowId(r.workflowId);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
},
|
},
|
||||||
// 后端的判词照抄:「输入不合调用接口契约」「流程包无生效版本」都是要人改配置的话,不该被我改写
|
// 后端的判词照抄:「抽不出客户码」「无生效版」都是要人改配置的话,不该被我改写
|
||||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,21 +92,14 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
|||||||
if (pkg === undefined) return;
|
if (pkg === undefined) return;
|
||||||
run.mutate({
|
run.mutate({
|
||||||
packageId: pkg.id,
|
packageId: pkg.id,
|
||||||
subject: subject.trim(),
|
mail: { ...mail, to },
|
||||||
data: built.data,
|
|
||||||
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (running !== undefined) {
|
if (running !== undefined) {
|
||||||
return (
|
return (
|
||||||
<Observe
|
<Observe id={running} staffName={staffName} nav={nav} stopping={stop.isPending} onBack={() => stop.mutate(running)} />
|
||||||
id={running}
|
|
||||||
staffName={staffName}
|
|
||||||
nav={nav}
|
|
||||||
stopping={stop.isPending}
|
|
||||||
onBack={() => stop.mutate(running)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +112,7 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
|||||||
tone="brand"
|
tone="brand"
|
||||||
kicker="联调测试台 · 装配态"
|
kicker="联调测试台 · 装配态"
|
||||||
title="仿真试跑"
|
title="仿真试跑"
|
||||||
desc="装一次调用、把流真起起来,然后看穿它怎么跑。任务照旧落到被指派人的工作台 —— 测试台不代答。"
|
desc="这条流真实的起法就是一封客户来信 —— 写这封信、指派每个节点归谁,起单看穿全程。任务照旧落到被指派人的工作台。"
|
||||||
chips={
|
chips={
|
||||||
<>
|
<>
|
||||||
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
|
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
|
||||||
@@ -171,160 +134,194 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="g-tb">
|
<QueryGate query={pkgQ}>
|
||||||
<QueryGate query={pkgQ}>
|
{livePkgs.length === 0 ? (
|
||||||
{livePkgs.length === 0 ? (
|
<div className="g-rig">
|
||||||
<div className="g-sheet">
|
<div className="g-tb-empty">
|
||||||
<div className="g-tb-empty">
|
<Ico name="scan" s={26} />
|
||||||
<Ico name="scan" s={26} />
|
<b>没有可起单的流程包</b>
|
||||||
<b>没有可起单的流程包</b>
|
<p>
|
||||||
<p>
|
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
||||||
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
{/* 一张装配单读下来就是一次试跑的次序:选包 → 填参 → 派人 → 起单 */}
|
/* 左选流,右配置 —— 一屏两大块,不再把装配单摞成一长条 */
|
||||||
<div className="g-tb-rig">
|
<div className="g-rig">
|
||||||
<Sheet code="PKG" title="流程包" ico="workflow" extra={`${livePkgs.length} 个生效`}>
|
<aside className="g-rig-l">
|
||||||
<div className="g-tbpkgs">
|
<div className="g-rig-h">
|
||||||
{livePkgs.map((p) => (
|
<Ico name="workflow" s={11} />
|
||||||
<button
|
流程包
|
||||||
key={p.id}
|
<span className="x">{livePkgs.length}</span>
|
||||||
className={'g-tbpkg' + (p.id === pkg?.id ? ' on' : '')}
|
</div>
|
||||||
onClick={() => {
|
<div className="g-rig-pk">
|
||||||
setPicked(p.id);
|
{livePkgs.map((p) => (
|
||||||
setValues({});
|
<button
|
||||||
setAssignments({});
|
key={p.id}
|
||||||
}}
|
className={'g-pk' + (p.id === pkg?.id ? ' on' : '')}
|
||||||
>
|
onClick={() => {
|
||||||
<b>{p.name}</b>
|
setPicked(p.id);
|
||||||
<span className="id">
|
setAssignments({});
|
||||||
{p.id}@v{p.version}
|
}}
|
||||||
</span>
|
>
|
||||||
<span className="cnt">
|
<b>{p.name}</b>
|
||||||
<span>节点 {p.nodes.length}</span>
|
<span className="id">
|
||||||
<span>边 {p.edges.length}</span>
|
{p.id}@v{p.version}
|
||||||
<span>入参 {callFieldsOf(p.callInterface.input).length}</span>
|
</span>
|
||||||
</span>
|
<span className="cnt">
|
||||||
</button>
|
<i>节点 {p.nodes.length}</i>
|
||||||
))}
|
<i>边 {p.edges.length}</i>
|
||||||
</div>
|
</span>
|
||||||
<Note>只列有生效版的包。在途的流按起单时那一版跑完,改包不影响它们。</Note>
|
</button>
|
||||||
</Sheet>
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<Sheet
|
<section className="g-rig-r">
|
||||||
code="ARG"
|
<div className="g-rig-h">
|
||||||
title="调用参数"
|
<span className="n">01</span>
|
||||||
ico="sliders-horizontal"
|
输入配置
|
||||||
extra={pkg === undefined ? '' : `按 ${pkg.id} 的调用接口`}
|
<span className="x">启动这条流的那封信</span>
|
||||||
>
|
</div>
|
||||||
{fields.length === 0 ? (
|
<MailForm draft={{ ...mail, to }} onChange={setMail} inbox={inbox?.address} />
|
||||||
<Note>这个包的调用接口没声明入参 —— 直接起单即可。</Note>
|
|
||||||
) : (
|
<div className="g-rig-h">
|
||||||
<>
|
<span className="n">02</span>
|
||||||
<div className="g-tbargs">
|
节点指派
|
||||||
<CallForm
|
<span className="x">
|
||||||
fields={fields}
|
{pinned}/{nodes.length} 已定 · 留空即照旧轮值
|
||||||
values={values}
|
</span>
|
||||||
onChange={(k, v) => setValues((prev) => ({ ...prev, [k]: v }))}
|
</div>
|
||||||
/>
|
<div className="g-asn">
|
||||||
</div>
|
{nodes.map((n) => (
|
||||||
<Acts
|
<div key={n.id} className={'g-asn-r' + ((assignments[n.id] ?? '') === '' ? '' : ' pinned')}>
|
||||||
align="end"
|
<span className="nid">{n.id}</span>
|
||||||
items={[
|
<span className="kd">{NODE_KIND_LABEL[n.kind] ?? n.kind}</span>
|
||||||
{ label: '填占位值', ico: 'sparkles', onClick: () => setValues(placeholderValues(fields)) },
|
<span className="lb">{n.label}</span>
|
||||||
{ label: '清空', ico: 'trash-2', onClick: () => setValues({}) },
|
<select
|
||||||
]}
|
className="g-select"
|
||||||
/>
|
value={assignments[n.id] ?? ''}
|
||||||
</>
|
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
|
||||||
)}
|
>
|
||||||
</Sheet>
|
<option value="">照旧轮值</option>
|
||||||
|
{staff.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} · {s.position.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sheet code="ASN" title="节点指派" ico="user-check" extra={`${nodes.length} 个可指派 · 已定 ${pinned}`}>
|
{error !== undefined && (
|
||||||
{/* 分两栏排:一条流七八个节点,单栏会把这张卡拉得比整块装配单还长 */}
|
<Note tone="warn">
|
||||||
<div className="g-asn-2">
|
<b>没起成:</b>
|
||||||
{[nodes.slice(0, Math.ceil(nodes.length / 2)), nodes.slice(Math.ceil(nodes.length / 2))]
|
{error}
|
||||||
.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>
|
</Note>
|
||||||
</Sheet>
|
)}
|
||||||
|
</section>
|
||||||
<Sheet code="RUN" title="起单" ico="play" extra={ready ? '装配齐了' : '还差料'}>
|
</div>
|
||||||
<div className="g-tbrun">
|
)}
|
||||||
<div className="g-tb-f">
|
</QueryGate>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 观测态 ─────────────────────────────────────────────────────────── */
|
/* ==================== 启动输入:一封信 ==================== */
|
||||||
|
|
||||||
|
type MailDraft = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunInput = { packageId: string; mail: MailDraft; assignments: Record<string, string> };
|
||||||
|
|
||||||
|
const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预填的是一封**抽得出键**的信 —— 主题里带客户码与询价单号,
|
||||||
|
* 通道的抽键正则就是照着这两样写的;改坏了起单会如实报「抽不出哪个」。
|
||||||
|
*/
|
||||||
|
const SAMPLE_MAIL: MailDraft = {
|
||||||
|
from: 'buyer@sz-machinery.com',
|
||||||
|
to: '',
|
||||||
|
subject: '【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项',
|
||||||
|
body: '你好,附件是本次询价的 BOM,共 12 项,请按标准交期报价。\n付款方式沿用上次合同。',
|
||||||
|
attachments: [{ name: 'BOM-RFQ-2607-001.xlsx', contentType: XLSX, size: 28_416 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function MailForm({
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
inbox,
|
||||||
|
}: {
|
||||||
|
draft: MailDraft;
|
||||||
|
onChange: (next: MailDraft) => void;
|
||||||
|
inbox: string | undefined;
|
||||||
|
}): ReactNode {
|
||||||
|
const set = <K extends keyof MailDraft>(k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v });
|
||||||
|
return (
|
||||||
|
<div className="g-ml">
|
||||||
|
<label className="g-ml-r">
|
||||||
|
<em>发件人</em>
|
||||||
|
<input className="g-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="g-ml-r">
|
||||||
|
<em>收件人</em>
|
||||||
|
<input
|
||||||
|
className="g-ml-i"
|
||||||
|
value={draft.to}
|
||||||
|
placeholder={inbox ?? '公司询价邮箱'}
|
||||||
|
onChange={(e) => set('to', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="g-ml-t">投给哪个信箱决定用哪套抽键规则</span>
|
||||||
|
</label>
|
||||||
|
<label className="g-ml-r">
|
||||||
|
<em>主题</em>
|
||||||
|
<input className="g-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
|
||||||
|
<span className="g-ml-t">客户码与询价单号从这里抽</span>
|
||||||
|
</label>
|
||||||
|
<label className="g-ml-r tall">
|
||||||
|
<em>正文</em>
|
||||||
|
<textarea className="g-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="g-ml-r tall">
|
||||||
|
<em>附件</em>
|
||||||
|
<div className="g-ml-at">
|
||||||
|
{draft.attachments.map((a, i) => (
|
||||||
|
<span key={a.name} className="g-att">
|
||||||
|
<Ico name="paperclip" s={11} />
|
||||||
|
{a.name}
|
||||||
|
<i>{(a.size / 1024).toFixed(0)} KB</i>
|
||||||
|
<button
|
||||||
|
aria-label={`移除 ${a.name}`}
|
||||||
|
onClick={() => set('attachments', draft.attachments.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
<Ico name="x" s={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="g-att-add"
|
||||||
|
onClick={() =>
|
||||||
|
set('attachments', [
|
||||||
|
...draft.attachments,
|
||||||
|
{ name: `BOM-${draft.attachments.length + 1}.xlsx`, contentType: XLSX, size: 20_480 },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Ico name="plus" s={11} />
|
||||||
|
加一个 BOM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Observe({
|
function Observe({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
St,
|
St,
|
||||||
} from '@web-shared/gov/primitives.tsx';
|
} from '@web-shared/gov/primitives.tsx';
|
||||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||||
import { useGovLlm, useGovTools } from '@web-shared/gov/gov-api.ts';
|
import { useGovTools } from '@web-shared/gov/gov-api.ts';
|
||||||
|
|
||||||
export type HomeProps = { tab: string; nav: Nav };
|
export type HomeProps = { tab: string; nav: Nav };
|
||||||
|
|
||||||
@@ -120,17 +120,11 @@ const LLM_TOOLS = [
|
|||||||
|
|
||||||
export function ToolsHome({ nav }: HomeProps) {
|
export function ToolsHome({ nav }: HomeProps) {
|
||||||
const tools = useGovTools();
|
const tools = useGovTools();
|
||||||
const llm = useGovLlm();
|
|
||||||
const rows = tools.data?.tools ?? [];
|
const rows = tools.data?.tools ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryGate query={tools}>
|
<QueryGate query={tools}>
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
一件事能交给谁办,全集在这里:交给<b>系统</b>(功能工具)、交给<b>模型</b>(LLM)、交给
|
|
||||||
<b>人</b>(人类工具)。工具承载权力,约束严于活动 —— 契约对活动、实现对系统、凭证只存治理台。
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
<Sec ico="wrench" title="功能工具" extra={`${rows.length} 件 · 读类自由调用,写类登记意向、放行后落地`}>
|
<Sec ico="wrench" title="功能工具" extra={`${rows.length} 件 · 读类自由调用,写类登记意向、放行后落地`}>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<Clear>目录里还没有工具 —— 从下面「接入」导入一件,或由第一方随系统装入。</Clear>
|
<Clear>目录里还没有工具 —— 从下面「接入」导入一件,或由第一方随系统装入。</Clear>
|
||||||
@@ -203,12 +197,7 @@ export function ToolsHome({ nav }: HomeProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Note>
|
</Sec>
|
||||||
已绑 {(llm.data?.instances ?? []).length} 个模型实例:
|
|
||||||
{(llm.data?.instances ?? []).map((m) => m.name).join(' · ') || '尚未绑定'}。
|
|
||||||
换厂商或型号去<button className="g-link" onClick={() => nav.jump('models', 'llm')}>大模型</button>改绑定。
|
|
||||||
</Note>
|
|
||||||
</Sec>
|
|
||||||
|
|
||||||
<Sec ico="hand" title="人类工具" extra="调人也是调工具 · 打扰上限在契约授权边界,由 Harness 强制">
|
<Sec ico="hand" title="人类工具" extra="调人也是调工具 · 打扰上限在契约授权边界,由 Harness 强制">
|
||||||
<div className="g-shelf">
|
<div className="g-shelf">
|
||||||
@@ -311,11 +300,7 @@ function ImportToolCard() {
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Note>
|
</Card>
|
||||||
交一份调用契约 + 一个能调通的端点,两审当场跑:静态审读契约,试运行审<b>真调那个端点</b>。
|
|
||||||
调不通就不入目录 —— 一件调不通的工具进了目录,活动用它时才失败,那才是真的坏。
|
|
||||||
</Note>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,11 +412,7 @@ function McpCard() {
|
|||||||
</St>
|
</St>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Note>
|
</Card>
|
||||||
MCP 服务器上的工具,接进来之后与自定义工具同等待遇:经<b>工具调用一缝</b>去调、过两审入目录、
|
|
||||||
按白名单授给活动。当前系统还没有 MCP 客户端 —— 这里如实标着未连接,不摆一个点不动的按钮。
|
|
||||||
</Note>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +447,6 @@ export function ToolPage({ id }: { id: string }) {
|
|||||||
<p className="g-desc">{t.description}</p>
|
<p className="g-desc">{t.description}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card ico="shield-check" title="连接配置">
|
<Card ico="shield-check" title="连接配置">
|
||||||
<Note>凭证隔离:治理台只回<b>配置键</b>,永不回值;密钥不入包、不入活动、不入留痕明文。</Note>
|
|
||||||
<div className="g-rows">
|
<div className="g-rows">
|
||||||
{t.connectionKeys.map((k) => (
|
{t.connectionKeys.map((k) => (
|
||||||
<div key={k}>
|
<div key={k}>
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
/**
|
|
||||||
* 触发器 · 入站邮件演练 —— 从**外界**推一封信进来,看哪条规则接住它。
|
|
||||||
*
|
|
||||||
* 摆在触发器区里,是因为它验的正是这一区定的东西:规则写下来了,来一封真信试试对不对。
|
|
||||||
*
|
|
||||||
* 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路
|
|
||||||
* (`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, Band, Card, Field, Note, 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 (
|
|
||||||
<>
|
|
||||||
<Band ico="flask-conical" extra="定完就地验:规则写下来了,来一封真信试试对不对">
|
|
||||||
投一封信试试
|
|
||||||
</Band>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<Acts
|
|
||||||
align="end"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: send.isPending ? '投递中…' : allGreen ? '投递这封信' : `还差 ${preflight.filter((p) => !p.ok).length} 条,仍要投`,
|
|
||||||
ico: 'send',
|
|
||||||
primary: true,
|
|
||||||
disabled: send.isPending || effectiveChannel === '',
|
|
||||||
onClick: fire,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -39,8 +39,8 @@ import {
|
|||||||
Seg,
|
Seg,
|
||||||
St,
|
St,
|
||||||
} from '@web-shared/gov/primitives.tsx';
|
} from '@web-shared/gov/primitives.tsx';
|
||||||
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||||
import { InboundDrill } from './inbound.tsx';
|
|
||||||
import { useGovObjects, useGovPackages, useGovTriggers } from '@web-shared/gov/gov-api.ts';
|
import { useGovObjects, useGovPackages, useGovTriggers } from '@web-shared/gov/gov-api.ts';
|
||||||
import { domainShort } from '@web-shared/gov/domains.ts';
|
import { domainShort } from '@web-shared/gov/domains.ts';
|
||||||
|
|
||||||
@@ -88,14 +88,10 @@ export function TriggerRulesHome({ nav }: { nav: Nav }) {
|
|||||||
return (
|
return (
|
||||||
<QueryGate query={q}>
|
<QueryGate query={q}>
|
||||||
<>
|
<>
|
||||||
<Note>
|
|
||||||
调起规则决定<b>什么时候起一单</b>:周期 / 事件 / 登记三类。触发器与流程包平级——不入包结构、
|
|
||||||
不随包版本,改规则不动任何版本。<b>启停不在这里</b>,那是运营中心的事。
|
|
||||||
</Note>
|
|
||||||
{triggers.length === 0 ? (
|
{triggers.length === 0 ? (
|
||||||
<Clear>还没有调起规则 —— 配一条,这条流就能被自动或人工调起来。</Clear>
|
<Clear>还没有调起规则 —— 配一条,这条流就能被自动或人工调起来。</Clear>
|
||||||
) : (
|
) : (
|
||||||
<NList>
|
<div className="g-shelf">
|
||||||
{triggers.map((t) =>
|
{triggers.map((t) =>
|
||||||
editing === t.id ? (
|
editing === t.id ? (
|
||||||
<TriggerForm
|
<TriggerForm
|
||||||
@@ -105,37 +101,57 @@ export function TriggerRulesHome({ nav }: { nav: Nav }) {
|
|||||||
onCancel={() => setEditing(undefined)}
|
onCancel={() => setEditing(undefined)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NRow
|
/*
|
||||||
|
* 卡而不是行:一条规则要一眼看全「何时起 · 起哪条流 · 开没开」,
|
||||||
|
* 行只放得下前两样。卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
|
||||||
|
*/
|
||||||
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
tone={t.enabled ? 'brand' : 'mute'}
|
className={'g-tile t-' + (t.enabled ? 'brand' : 'mute')}
|
||||||
ico="zap"
|
role="button"
|
||||||
title={t.name}
|
tabIndex={0}
|
||||||
sub={ruleText(t)}
|
onClick={() => nav.push({ k: 'trigger', id: t.id })}
|
||||||
cols={[
|
onKeyDown={(e) => {
|
||||||
{ k: '类别', v: <Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip> },
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
{ k: '调起', v: t.processPackageId },
|
e.preventDefault();
|
||||||
{ k: '装配', v: `${t.mapping.length} 条` },
|
nav.push({ k: 'trigger', id: t.id });
|
||||||
]}
|
}
|
||||||
right={
|
}}
|
||||||
|
>
|
||||||
|
<span className="g-tile-h">
|
||||||
|
<span className="g-tile-i">
|
||||||
|
<Ico name="zap" s={16} />
|
||||||
|
</span>
|
||||||
|
<span className="g-tile-t">
|
||||||
|
<b>{t.name}</b>
|
||||||
|
<em>{t.processPackageId}</em>
|
||||||
|
</span>
|
||||||
|
<Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip>
|
||||||
|
</span>
|
||||||
|
<span className="g-tile-d">{ruleText(t)}</span>
|
||||||
|
<span className="g-tile-m">
|
||||||
|
<Chip ico="arrow-left-right">装配 {t.mapping.length} 条</Chip>
|
||||||
|
</span>
|
||||||
|
<span className="g-tile-f">
|
||||||
|
<St tone={t.enabled ? 'ok' : 'mute'} ico={t.enabled ? 'circle-check' : 'power'}>
|
||||||
|
{t.enabled ? '起单中' : '已停用'}
|
||||||
|
</St>
|
||||||
<button
|
<button
|
||||||
className="g-btn"
|
className="g-btn r"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// 行本身也可点(进详情)—— 按钮要拦住冒泡,否则点「改规则」会跳走
|
// 卡本身也可点(进详情)—— 按钮要拦住冒泡,否则点「改规则」会跳走
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditing(t.id);
|
setEditing(t.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
改规则
|
改规则
|
||||||
</button>
|
</button>
|
||||||
}
|
</span>
|
||||||
onClick={() => nav.push({ k: 'trigger', id: t.id })}
|
</div>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</NList>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 定完就地验:来一封真信,看这些规则里哪条接住 */}
|
|
||||||
<InboundDrill nav={nav} />
|
|
||||||
</>
|
</>
|
||||||
</QueryGate>
|
</QueryGate>
|
||||||
);
|
);
|
||||||
@@ -240,9 +256,6 @@ function TriggerForm({
|
|||||||
<Field k="类别" hint="周期 / 事件 / 登记三类">
|
<Field k="类别" hint="周期 / 事件 / 登记三类">
|
||||||
<Seg value={draft.spec.kind} options={SPEC_KINDS.map((s) => ({ k: s.k, label: s.label }))} onPick={setSpecKind} />
|
<Seg value={draft.spec.kind} options={SPEC_KINDS.map((s) => ({ k: s.k, label: s.label }))} onPick={setSpecKind} />
|
||||||
</Field>
|
</Field>
|
||||||
{draft.spec.kind === 'registration' && (
|
|
||||||
<Note>登记型无参:人在工作台按调用接口的 schema 填表提交,即起一单。</Note>
|
|
||||||
)}
|
|
||||||
{draft.spec.kind === 'event' && (
|
{draft.spec.kind === 'event' && (
|
||||||
<>
|
<>
|
||||||
<Field k="事件类型">
|
<Field k="事件类型">
|
||||||
@@ -422,7 +435,6 @@ export function TriggerOpsTab({ nav }: { nav: Nav }) {
|
|||||||
))}
|
))}
|
||||||
</NList>
|
</NList>
|
||||||
)}
|
)}
|
||||||
<Note>逐条启停在触发器详情页做 —— 开关是治理动作,要走影响确认带,不做列表上的顺手一点。</Note>
|
|
||||||
{toggle.isError && <Note tone="warn">切换失败,请重试。</Note>}
|
{toggle.isError && <Note tone="warn">切换失败,请重试。</Note>}
|
||||||
</>
|
</>
|
||||||
</QueryGate>
|
</QueryGate>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
|||||||
title: '触发器',
|
title: '触发器',
|
||||||
icon: 'zap',
|
icon: 'zap',
|
||||||
accent: 'linear-gradient(140deg,#f0a020,#c47a0a)',
|
accent: 'linear-gradient(140deg,#f0a020,#c47a0a)',
|
||||||
desc: '谁调起流程:事件(通道来件命中规则)、登记(人在表单上起一单)、周期(到点自己起)。规则在这里定,定完就地投一封信验哪条接住;起单开关在运营中心。',
|
desc: '谁调起流程:事件(通道来件命中规则)、登记(人在表单上起一单)、周期(到点自己起)。规则在这里定,起单开关在运营中心。',
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
title: '知识库',
|
title: '知识库',
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ export function Dial({ v, tone = 'brand', size = 42 }: { v: number; tone?: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) {
|
/** 卡体可空 —— 只有头与动作的卡(收起态的「装入」「导入」「连接 MCP」)是正当形状 */
|
||||||
|
export function Card({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="g-card">
|
<div className="g-card">
|
||||||
<div className="g-card-h">
|
<div className="g-card-h">
|
||||||
|
|||||||
@@ -522,9 +522,20 @@ export const testRunRoute = {
|
|||||||
path: '/api/gov/testbed/run',
|
path: '/api/gov/testbed/run',
|
||||||
body: z.object({
|
body: z.object({
|
||||||
packageId: z.string(),
|
packageId: z.string(),
|
||||||
subject: z.string().min(1),
|
/**
|
||||||
/** 调用接口入参 —— 按流程包的 callInterface.input 填 */
|
* 启动输入**就是一封信** —— 这条流真实的起法即客户来信(RFQ 正文 + BOM 附件)。
|
||||||
data: z.record(z.string(), z.unknown()),
|
* 服务端拿**真通道的抽键规则**抽出关联键、拿**真触发器的装配映射**推出调用入参:
|
||||||
|
* 测试台不自带一套解析,否则试通的是测试台、不是这条流。
|
||||||
|
*/
|
||||||
|
mail: z.object({
|
||||||
|
from: z.string().min(1),
|
||||||
|
to: z.string().min(1),
|
||||||
|
subject: z.string(),
|
||||||
|
body: z.string(),
|
||||||
|
attachments: z
|
||||||
|
.array(z.object({ name: z.string(), contentType: z.string(), size: z.number().int().min(0) }))
|
||||||
|
.default([]),
|
||||||
|
}),
|
||||||
/** 节点指派 `{ 节点id → 员工id }`;不指的节点照旧轮值 */
|
/** 节点指派 `{ 节点id → 员工id }`;不指的节点照旧轮值 */
|
||||||
assignments: z.record(z.string(), z.string()).default({}),
|
assignments: z.record(z.string(), z.string()).default({}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user