/** * 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(mutate: (input: TInput) => Promise, 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(undefined); const triggers = q.data?.triggers ?? []; return ( <> {triggers.length === 0 ? ( 还没有调起规则 —— 配一条,这条流就能被自动或人工调起来。 ) : (
{triggers.map((t) => editing === t.id ? ( setEditing(undefined)} onCancel={() => setEditing(undefined)} /> ) : ( /* * 卡而不是行:一条规则要一眼看全「何时起 · 起哪条流 · 开没开」, * 行只放得下前两样。卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。 */
nav.push({ k: 'trigger', id: t.id })} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); nav.push({ k: 'trigger', id: t.id }); } }} > {t.name} {t.processPackageId} {TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]} {ruleText(t)} 装配 {t.mapping.length} 条 {t.enabled ? '起单中' : '已停用'}
), )}
)}
); } /** * 行内编辑表单 —— 改名 / 改目标包 / 改规则 / 改起单装配。 * * 起单装配这里只编辑 `initiation` 一种来源(起单载荷 → 调用接口字段): * 触发器只知道调用接口、不知流程内部,别的来源(上游输出口、工作流变量)是流程包边映射的事。 * 写成 `目标字段 = 载荷路径` 一行一条 —— 与边映射页的读法一致。 */ function TriggerForm({ trigger, onDone, onCancel, }: { trigger: TriggerConfig; onDone: () => void; onCancel: () => void; }) { const packages = useGovPackages(); const objects = useGovObjects(); const [draft, setDraft] = useState(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(() => 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 ( setDraft({ ...draft, name: e.target.value })} /> ({ k: s.k, label: s.label }))} onPick={setSpecKind} /> {draft.spec.kind === 'event' && ( <> { if (draft.spec.kind !== 'event') return; if (k !== 'data' && k !== 'time' && k !== 'human' && k !== 'control') return; setDraft({ ...draft, spec: { ...draft.spec, eventType: k } }); }} /> draft.spec.kind === 'event' && setDraft({ ...draft, spec: { ...draft.spec, rule: e.target.value } }) } /> )} {draft.spec.kind === 'periodic-calendar' && ( draft.spec.kind === 'periodic-calendar' && setDraft({ ...draft, spec: { ...draft.spec, calendar: e.target.value } }) } /> )} {draft.spec.kind === 'periodic-data' && ( <> draft.spec.kind === 'periodic-data' && setDraft({ ...draft, spec: { ...draft.spec, dateField: e.target.value } }) } /> draft.spec.kind === 'periodic-data' && setDraft({ ...draft, spec: { ...draft.spec, offset: e.target.value } }) } /> { 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 } }); }} /> )} {foreignMappings.length > 0 && ( 另有 {foreignMappings.length} 条非起单来源的映射(上游输出口 / 工作流变量), 那是流程包边映射的事 —— 在流程包详情的边映射页维护,这里原样保留不动。 )} 触发器与流程包平级 —— 改规则不动任何流程包版本,已起的单照常跑完。, impact: { who: '此后命中新规则才起单;在途工作流不受影响', undo: '可再改;要临时不起单,去运营中心停用', when: '确认后即时生效', }, okLabel: '确认保存', onOk: () => save.mutate({ ...draft, mapping: parseLines() }, { onSuccess: onDone }), }, }, ]} /> ); } /* ==================== 运营侧:起单开关(处置面) ==================== */ export function TriggerPage({ id }: { id: string }) { const q = useGovTriggers(); const t = (q.data?.triggers ?? []).find((x) => x.id === id); if (t === undefined) return 触发器不存在。; return ( <> {TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]} 调起 {t.processPackageId} {t.enabled ? '起单中' : '已停用'} {t.confirm.required && 命中须人工确认} } desc="设计中心只负责设计:这里改的是规则与装配。启停开关、要不要人工确认、谁来确认,在运营中心 · 工作流的运行配置里。" /> {t.mapping.length}}>
{t.mapping.map((m, i) => (
{m.target} {JSON.stringify(m.source)}
))}
); }