This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/web/src/features/gov-trigger/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 f9b2c07731 三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节
**触发确认链路**(用户口述五步的第 4/5 步):
  接收邮件 → 分类识别 → 命中 RFQ 事件 → ④(可选,运营勾)系统消息到指定人
  工作台(带邮件内容)→ ⑤ 工作台点「确认起单」,流程才起。
  · 命中不等于必起:勾了「需人工确认」,装配好的入参存成待确认单
    (sys.trigger_pending,migration 0019),ConfirmDesk 端口把带来件内容的
    系统消息送指定人(eventRef 挂票号,消息列表就地贴确认状态与那枚钮)。
  · 先认领后起单:consumed_at 空才认得到 —— 两人同时点、一人点两次,都只起一条流;
    起单失败退回认领,不留死票。确认人记进 source.by,留痕可查。
  · 登记触发不走确认(登记那一下本身就是人在确认);勾了没指定人 = 配置错误入死信。
  · 4 条单测 + 全链实测:投信 → triggersHit=1 且零直起 → AI 工作台收到确认消息
    → 确认起单 → 二次确认幂等回同一流号 → source.by = AI。

**运营中心重排**(区名即运行时:工作流是流程包的运行时,任务是活动的运行时):
  · 新「工作流」区:在途看板(从任务区迁入)+ 运行配置 —— 每包的触发器
    起单开关 / 确认打勾 / 确认人,和节点负责人(sys.node_owner)。
  · 负责人三层:显式指派 > 运营配置 > 轮值 —— 合并点在 process.start,
    复用既有派发链一行不改。
  · 任务区瘦身成活动运行时(任务/审批/需处置);知识库区改名「数据」
    (与设计中心的知识库解开同名);系统区只剩通道实例。
  · 设计中心触发器详情页摘掉启停按钮 —— 设计只管设计,启停/确认全在运营。
  · 旧深链有改嫁:tasks/board → runs/board,tasks/trg → runs/conf。

reset-runs 补 sys.trigger_pending(待确认单是运行痕 —— 和上回漏 trace_span 同类洞)。
邮件分类 Agent(第 ② 步)现由抽键正则 + S-FEEL 代位,升级路径记进 debt.md。
产品设计.md 运营章、trigger/process 两份 CONTRACT.md 同步。

五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 13:09:16 +08:00

440 lines
17 KiB
TypeScript

/**
* feature `gov-trigger` —— 触发器的两副面孔,各归其台:
*
* `TriggerRulesHome`(设计中心 · 流程包 - 调起规则):**定义面** —— 谁按什么规则调起这条流,
* 起单载荷怎么装配。与流程包一处维护:「谁调起它」本就是流程定义的一部分。
* `TriggerOpsTab`(运营中心 · 任务 - 起单开关):**处置面** —— 今天开不开。
*
* 同一个对象两种动作各归其位:定义是设计者的事,启停是当班主管的事。
* 触发器与流程包**平级** —— 不入包结构、不随包版本,增删启停改定义都不动任何版本。
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { TriggerConfig } from '@contracts';
import {
TRIGGER_KIND_LABEL,
processPackageIdSchema,
triggerKindOf,
upsertTriggerRoute,
} from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import {
Acts,
Card,
Chip,
Clear,
Field,
ListEdit,
Note,
PageHead,
Prop,
QueryGate,
Rows,
Seg,
St,
} from '@web-shared/gov/primitives.tsx';
import { Ico } from '@web-shared/Ico.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovObjects, useGovPackages, useGovTriggers } from '@web-shared/gov/gov-api.ts';
import { domainShort } from '@web-shared/gov/domains.ts';
export type HomeProps = { tab: string; nav: Nav };
/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */
function useGovAction<TInput>(mutate: (input: TInput) => Promise<unknown>, keys: readonly string[]) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: mutate,
onSuccess: () => {
for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] });
},
});
}
/** 规则原文:一行说清「什么时候起」 */
function ruleText(t: TriggerConfig): string {
switch (t.spec.kind) {
case 'periodic-calendar':
return `按日历 ${t.spec.calendar}`;
case 'periodic-data':
return `按 ${t.spec.domain}.${t.spec.object} 的 ${t.spec.dateField} ${t.spec.offset}${t.spec.filter === undefined ? '' : ` 且 ${t.spec.filter}`}`;
case 'event':
return `事件(${t.spec.eventType})命中:${t.spec.rule}`;
case 'registration':
return '人工登记即起单';
}
}
/* ==================== 设计侧:调起规则(定义面) ==================== */
const SPEC_KINDS = [
{ k: 'registration', label: '登记' },
{ k: 'event', label: '事件' },
{ k: 'periodic-calendar', label: '周期 · 日历' },
{ k: 'periodic-data', label: '周期 · 数据日历' },
] as const;
export function TriggerRulesHome({ nav }: { nav: Nav }) {
const q = useGovTriggers();
const [editing, setEditing] = useState<string | undefined>(undefined);
const triggers = q.data?.triggers ?? [];
return (
<QueryGate query={q}>
<>
{triggers.length === 0 ? (
<Clear>还没有调起规则 —— 配一条,这条流就能被自动或人工调起来。</Clear>
) : (
<div className="g-shelf">
{triggers.map((t) =>
editing === t.id ? (
<TriggerForm
key={t.id}
trigger={t}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
) : (
/*
* 卡而不是行:一条规则要一眼看全「何时起 · 起哪条流 · 开没开」,
* 行只放得下前两样。卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
*/
<div
key={t.id}
className={'g-tile t-' + (t.enabled ? 'brand' : 'mute')}
role="button"
tabIndex={0}
onClick={() => nav.push({ k: 'trigger', id: t.id })}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
nav.push({ k: 'trigger', id: t.id });
}
}}
>
<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
className="g-btn r"
onClick={(e) => {
// 卡本身也可点(进详情)—— 按钮要拦住冒泡,否则点「改规则」会跳走
e.stopPropagation();
setEditing(t.id);
}}
>
改规则
</button>
</span>
</div>
),
)}
</div>
)}
</>
</QueryGate>
);
}
/**
* 行内编辑表单 —— 改名 / 改目标包 / 改规则 / 改起单装配。
*
* 起单装配这里只编辑 `initiation` 一种来源(起单载荷 → 调用接口字段):
* 触发器只知道调用接口、不知流程内部,别的来源(上游输出口、工作流变量)是流程包边映射的事。
* 写成 `目标字段 = 载荷路径` 一行一条 —— 与边映射页的读法一致。
*/
function TriggerForm({
trigger,
onDone,
onCancel,
}: {
trigger: TriggerConfig;
onDone: () => void;
onCancel: () => void;
}) {
const packages = useGovPackages();
const objects = useGovObjects();
const [draft, setDraft] = useState<TriggerConfig>(trigger);
const save = useGovAction(
(next: TriggerConfig) => call(upsertTriggerRoute, { body: { trigger: next } }),
['triggers'],
);
const packageIds = [...new Set((packages.data?.packages ?? []).map((p) => p.id))];
const domains = [...new Set((objects.data?.objects ?? []).map((o) => o.domain))];
const objectsOfDomain = (domain: string) =>
(objects.data?.objects ?? []).filter((o) => o.domain === domain).map((o) => o.object);
/**
* 起单装配的行式写法:`目标字段 = 载荷路径`。
*
* 编辑期这些行是**本地状态**,不从 `draft.mapping` 派生 ——
* 派生的话,刚点「添加」的空行会被解析器当场滤掉,人永远打不出第一个字(实测)。
* 解析只在存盘那一刻做。非 initiation 来源不在此面编辑,原样带着走。
*/
const [lines, setLines] = useState<string[]>(() =>
trigger.mapping.flatMap((m) => (m.source.from === 'initiation' ? [`${m.target} = ${m.source.path}`] : [])),
);
const foreignMappings = trigger.mapping.filter((m) => m.source.from !== 'initiation');
/** 行 → 映射:写不成 `目标 = 路径` 的行直接丢掉,不塞半条进契约 */
const parseLines = (): TriggerConfig['mapping'] => [
...lines.flatMap((line) => {
const at = line.indexOf('=');
if (at <= 0) return [];
const target = line.slice(0, at).trim();
const path = line.slice(at + 1).trim();
if (target === '' || path === '') return [];
return [{ target, source: { from: 'initiation' as const, path } }];
}),
...foreignMappings,
];
const setSpecKind = (k: string) => {
if (k === draft.spec.kind) return;
const spec: TriggerConfig['spec'] =
k === 'event'
? { kind: 'event', rule: 'true', eventType: 'data' }
: k === 'periodic-calendar'
? { kind: 'periodic-calendar', calendar: '0 9 * * 1' }
: k === 'periodic-data'
? {
kind: 'periodic-data',
domain: domains[0] ?? 'shared',
object: objectsOfDomain(domains[0] ?? 'shared')[0] ?? 'record',
dateField: 'dueDate',
offset: '-P3D',
}
: { kind: 'registration' };
setDraft({ ...draft, spec });
};
return (
<Card ico="zap" title={`改规则:${trigger.name}`}>
<Prop ico="tag" title="身份与调起目标">
<Field k="名称" hint="人读的一句话,说明它什么时候起">
<input className="g-input" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
</Field>
<Field k="调起哪条流" hint="只认调用接口,不知流程内部">
<select
className="g-input"
value={draft.processPackageId}
onChange={(e) => setDraft({ ...draft, processPackageId: processPackageIdSchema.parse(e.target.value) })}
>
{packageIds.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</Field>
</Prop>
<Prop ico="settings" title="起单时机">
<Field k="类别" hint="周期 / 事件 / 登记三类">
<Seg value={draft.spec.kind} options={SPEC_KINDS.map((s) => ({ k: s.k, label: s.label }))} onPick={setSpecKind} />
</Field>
{draft.spec.kind === 'event' && (
<>
<Field k="事件类型">
<Seg
value={draft.spec.eventType}
options={[
{ k: 'data', label: '数据' },
{ k: 'time', label: '时间' },
{ k: 'human', label: '人' },
{ k: 'control', label: '控制' },
]}
onPick={(k) => {
if (draft.spec.kind !== 'event') return;
if (k !== 'data' && k !== 'time' && k !== 'human' && k !== 'control') return;
setDraft({ ...draft, spec: { ...draft.spec, eventType: k } });
}}
/>
</Field>
<Field k="命中规则" hint="S-FEEL,对事件信封与载荷字段求值">
<input
className="g-input"
value={draft.spec.rule}
onChange={(e) =>
draft.spec.kind === 'event' && setDraft({ ...draft, spec: { ...draft.spec, rule: e.target.value } })
}
/>
</Field>
</>
)}
{draft.spec.kind === 'periodic-calendar' && (
<Field k="日历表达式" hint="cron 风格,如 0 9 * * 1 = 每周一 09:00">
<input
className="g-input"
value={draft.spec.calendar}
onChange={(e) =>
draft.spec.kind === 'periodic-calendar' &&
setDraft({ ...draft, spec: { ...draft.spec, calendar: e.target.value } })
}
/>
</Field>
)}
{draft.spec.kind === 'periodic-data' && (
<>
<Field k="数据域 · 对象" hint="按这张表的日期字段逐行起单">
<select
className="g-input"
value={`${draft.spec.domain}.${draft.spec.object}`}
onChange={(e) => {
if (draft.spec.kind !== 'periodic-data') return;
const [domain = '', object = ''] = e.target.value.split('.');
setDraft({ ...draft, spec: { ...draft.spec, domain, object } });
}}
>
{(objects.data?.objects ?? []).map((o) => (
<option key={`${o.domain}.${o.object}`} value={`${o.domain}.${o.object}`}>
{/* 选表的人认中文名,键跟在后面供对映射 */}
{domainShort(o.domain)}·{o.label ?? o.object}({o.domain}.{o.object})
</option>
))}
</select>
</Field>
<Field k="日期字段">
<input
className="g-input"
value={draft.spec.dateField}
onChange={(e) =>
draft.spec.kind === 'periodic-data' &&
setDraft({ ...draft, spec: { ...draft.spec, dateField: e.target.value } })
}
/>
</Field>
<Field k="偏移" hint="ISO 时段,如 -P3D = 日期字段前 3 天">
<input
className="g-input"
value={draft.spec.offset}
onChange={(e) =>
draft.spec.kind === 'periodic-data' &&
setDraft({ ...draft, spec: { ...draft.spec, offset: e.target.value } })
}
/>
</Field>
<Field k="过滤条件" hint="S-FEEL,留空即全表">
<input
className="g-input"
value={draft.spec.filter ?? ''}
onChange={(e) => {
if (draft.spec.kind !== 'periodic-data') return;
const filter = e.target.value.trim();
const { filter: _drop, ...rest } = draft.spec;
setDraft({ ...draft, spec: filter === '' ? rest : { ...rest, filter } });
}}
/>
</Field>
</>
)}
</Prop>
<Prop ico="arrow-left-right" title="起单装配" extra={`${lines.length + foreignMappings.length} 条`}>
<Field k="起单载荷 → 调用接口字段" hint="一行一条,写作「目标字段 = 载荷路径」;缺必填即起单失败入死信">
<ListEdit items={lines} placeholder="customerCode = data.customerCode" onChange={setLines} />
</Field>
{foreignMappings.length > 0 && (
<Note>
另有 {foreignMappings.length} 条非起单来源的映射(上游输出口 / 工作流变量),
那是流程包边映射的事 —— 在流程包详情的边映射页维护,这里原样保留不动。
</Note>
)}
</Prop>
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: onCancel },
{
label: save.isPending ? '保存中…' : '保存规则',
ico: 'circle-check',
primary: true,
disabled: save.isPending || draft.name.trim() === '',
ask: {
title: `保存调起规则:${draft.name}`,
note: <Note>触发器与流程包平级 —— 改规则不动任何流程包版本,已起的单照常跑完。</Note>,
impact: {
who: '此后命中新规则才起单;在途工作流不受影响',
undo: '可再改;要临时不起单,去运营中心停用',
when: '确认后即时生效',
},
okLabel: '确认保存',
onOk: () => save.mutate({ ...draft, mapping: parseLines() }, { onSuccess: onDone }),
},
},
]}
/>
</Card>
);
}
/* ==================== 运营侧:起单开关(处置面) ==================== */
export function TriggerPage({ id }: { id: string }) {
const q = useGovTriggers();
const t = (q.data?.triggers ?? []).find((x) => x.id === id);
if (t === undefined) return <Note>触发器不存在。</Note>;
return (
<>
<PageHead
ico="zap"
tone={t.enabled ? 'brand' : 'mute'}
kicker={`触发器 · ${t.id}`}
title={t.name}
chips={
<>
<Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip>
<Chip ico="boxes">调起 {t.processPackageId}</Chip>
<Chip tone={t.enabled ? 'ok' : 'mute'} ico="power">
{t.enabled ? '起单中' : '已停用'}
</Chip>
{t.confirm.required && <Chip tone="warn">命中须人工确认</Chip>}
</>
}
desc="设计中心只负责设计:这里改的是规则与装配。启停开关、要不要人工确认、谁来确认,在运营中心 · 工作流的运行配置里。"
/>
<Card ico="settings" title="起单规则">
<Rows
items={[
{ k: '类别', v: TRIGGER_KIND_LABEL[triggerKindOf(t.spec)] },
{ k: '何时起', v: ruleText(t) },
{ k: '规格', v: JSON.stringify(t.spec), mono: true },
{ k: '流程包', v: t.processPackageId, mono: true },
]}
/>
</Card>
<Card ico="arrow-left-right" title="起单映射" extra={<span className="g-mono">{t.mapping.length}</span>}>
<div className="g-rows">
{t.mapping.map((m, i) => (
<div key={i}>
<em>{m.target}</em>
<b className="mono">{JSON.stringify(m.source)}</b>
</div>
))}
</div>
</Card>
</>
);
}