用户定的两件,一并落地。
**一、活动不再分 flow 和 loop —— 彻底退役 flow**
· `flow-engine` 模块整个删除(runner 379 行 + step-sequence 184 行 + 测试);
· `paradigm` 从清单身份、`activity_package`/`task` 两张表、任务卡与活动库一并退场;
`businessModel` 不再是判别联合 —— 只有案例模型(CMMN + 说明书 + DMN)一形;
· task 的引擎分派从「按 paradigm 取两个之一」收成**唯一的引擎**;
· `activities/` 四个 flow 活动(A1 受理 / A4 定价 / A6 议价 / A7 归档)各写了一份
案例模型:阶段照原来的步分,触达世界的项一律 manualActivationRule,
能算的交 DMN(A1 的三分类 P-03、A4 的 P-01/P-02)不交 Agent;
· 六个占位槽位的骨架从「机器步落台账 + 一张验收单」改成案例骨架,包版本 0.8.0 → 0.9.0。
**代价写在明处,不假装没有**:`turn-runner` 每一拍都要 `agent.decide` ——
环境里没有模型 key,案例活动一步也走不动。三套端到端(统一报价 / 新建流程包 / 测试台
Trace)整套跳过,文件头写明恢复条件:①有可用模型 ②走法改写成按 Enabled 集推进。
**不许把断言改松让它变绿** —— 绿得不诚实比红着更坏。debt.md 已记。
**二、审批活动可管**(新能力)
· `sys.approval_template`:审批点**可复用的那一半** —— 审批人看哪几项、按什么名字看、
怎么催、说什么话。谁来审不在里面(与活动点同口径,运营时轮值);驳回回哪个节点
也不在里面(那是每张图自己的事,留在节点上);
· 三条路由 + 引用反查:还被审批点引着就不许删(409),删了那些节点会**静默**退回旧行为;
· 运行时 `basisOf`:引了模板就按模板筛与改名,没引即旧行为(载荷全摊开)。
模板改了只影响新发出的审批单 —— 在途单的依据在发出那一刻已定形。
**三、活动库重做**:一区两格 —— 「活动(装进来的)」与「审批活动(配出来的)」,
都用流程卡那套两段式卡片(头 + 四格数据板),幽灵卡当入口(装入活动 / 新建审批活动);
「标准/案例」那一维随范式一起撤,业务分类成了唯一的一维,以行内小签呈现。
迁移 0020:两张表 drop paradigm、建 approval_template。已在开发库跑过并重播种。
实测(丁子乔账号):建「定价审批」模板 → 流程包 A5 属性里选得到 → 引用反查计数正确。
四闸绿(api:check 随本提交)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
1228 lines
53 KiB
TypeScript
1228 lines
53 KiB
TypeScript
/**
|
||
* feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。
|
||
*
|
||
* 一区一内容:三枚计数摆在货架顶端,底下就是包货架(触发器已独立成区)。
|
||
* 货架一流程一卡(不按版本平铺);进详情是**三栏**:左窄轨=版本(选中那版才出动作:
|
||
* 激活/停用 · 改名 · 删除)、中=流程图、右=节点属性 —— **平铺一列字段,不分类**:
|
||
* 节点只决定 ID/名称/说明 · 类型 · 链接哪个活动 · 出边条件;数据契约归活动自己,
|
||
* 这里不写声明,要看去活动库看(点图选节点,内容长了卡内滚,不拉长图)。
|
||
* 「激活判据」不再单占一卡 —— 没选节点时,属性栏就地列出还差哪几条,点一条定位过去。
|
||
*
|
||
* 版本是**运行时开关**,不是发布流程:
|
||
* · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」
|
||
* · 画图不检查(未生效版);写回生效版须闭合;在途钉版不可变(409)
|
||
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
|
||
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
|
||
*/
|
||
import { useRef, useState, useEffect } from 'react';
|
||
import type { KeyboardEvent } from 'react';
|
||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
|
||
import {
|
||
deleteProcessFlowRoute,
|
||
deleteProcessVersionRoute,
|
||
processPackageSchema,
|
||
editProcessVersionRoute,
|
||
saveProcessRoute,
|
||
setProcessStateRoute,
|
||
validateProcessRoute,
|
||
} from '@contracts';
|
||
import { call } from '@web-shared/typed-fetch.ts';
|
||
import { Ico } from '@web-shared/Ico.tsx';
|
||
import type { AskSpec } from '@web-shared/gov/primitives.tsx';
|
||
import {
|
||
Acts,
|
||
AskBand,
|
||
Card,
|
||
Chip,
|
||
Field,
|
||
Kpi,
|
||
Note,
|
||
PageHead,
|
||
QueryGate,
|
||
Sec,
|
||
St,
|
||
} from '@web-shared/gov/primitives.tsx';
|
||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||
import { useGovActivities, useGovApprovalTemplates, useGovPackages, useGovPkgReports } from '@web-shared/gov/gov-api.ts';
|
||
import type { GovActivities } from '@web-shared/gov/gov-api.ts';
|
||
import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
|
||
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
|
||
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
|
||
import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
|
||
|
||
export type HomeProps = { tab: string; nav: Nav };
|
||
|
||
/* ── 报告:画图即校验,列表与详情同一来源 ─────────────────────────────── */
|
||
|
||
/** 边闭合数:边总数 − 有违规的边(同一条边多处违规只记一次) */
|
||
function edgeCheckOf(pkg: ProcessPackage, report: ClosureReport | undefined) {
|
||
const total = pkg.edges.length;
|
||
if (report === undefined) return { closed: total, total, issue: undefined };
|
||
const badEdges = new Set(report.violations.filter((v) => v.edgeId !== undefined).map((v) => v.edgeId));
|
||
const issue = report.violations[0]?.message;
|
||
return { closed: total - badEdges.size, total, issue };
|
||
}
|
||
|
||
/** 一条流的版本按新→旧;生效版恰一个(或零个 —— 整条流下线) */
|
||
function flowsOf(packages: readonly ProcessPackage[]) {
|
||
const byId = new Map<string, ProcessPackage[]>();
|
||
for (const p of packages) byId.set(p.id, [...(byId.get(p.id) ?? []), p]);
|
||
return [...byId.entries()].map(([id, versions]) => {
|
||
const sorted = [...versions].sort((a, b) => b.version - a.version);
|
||
return { id, versions: sorted, active: sorted.find((v) => v.state === 'active') };
|
||
});
|
||
}
|
||
|
||
/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
|
||
|
||
/**
|
||
* 流程包一区一内容:总览的三枚计数并进货架顶端 —— 一个页签的导航条不必存在,
|
||
* 「先点总览再点流程包」也不是人想做的事。触发器已独立成区。
|
||
*/
|
||
export function WorkflowsHome({ nav }: HomeProps) {
|
||
return (
|
||
<>
|
||
<OverviewHome />
|
||
<FlowShelf nav={nav} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function OverviewHome() {
|
||
const q = useGovPackages();
|
||
const packages = q.data?.packages ?? [];
|
||
const reports = useGovPkgReports(packages);
|
||
const flows = flowsOf(packages);
|
||
const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0);
|
||
const closedEdges = packages.reduce(
|
||
(a, p) => a + edgeCheckOf(p, reports.data?.get(`${p.id}@${p.version}`)).closed,
|
||
0,
|
||
);
|
||
return (
|
||
<QueryGate query={q}>
|
||
<div className="g-kpis">
|
||
<Kpi ico="workflow" tone="ok" label="流程" value={flows.length} sub="一条流恰一个生效版本" />
|
||
<Kpi ico="history" label="版本" value={packages.length} sub="新建才长版本 · 在途按启动版本跑完" />
|
||
<Kpi
|
||
ico="git-merge"
|
||
label="边闭合率"
|
||
value={totalEdges === 0 ? 100 : Math.round((closedEdges / totalEdges) * 100)}
|
||
unit="%"
|
||
sub="不闭合不得激活"
|
||
/>
|
||
</div>
|
||
</QueryGate>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 货架:一条流一张卡 + 新建流程 ---------------- */
|
||
|
||
/**
|
||
* 新建的流程 = 一张**最小可生效**的图:开始 → 结束。
|
||
* 激活要过闭合校验,这张最小图天然闭合,建完即激活 —— 建出来就能起单
|
||
* (起了直接走完,没业务意义,但机制是通的);进详情画上活动再存,即成下一版。
|
||
*/
|
||
function minimalFlow(id: string, name: string): ProcessPackage {
|
||
return processPackageSchema.parse({
|
||
id,
|
||
name,
|
||
version: 1,
|
||
state: 'active',
|
||
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
|
||
nodes: [
|
||
{ id: 'start', kind: 'start', label: '开始', layout: { row: 0, col: 0, x: 30, y: 70 } },
|
||
{ id: 'end', kind: 'end', label: '结束', layout: { row: 0, col: 1, x: 150, y: 70 } },
|
||
],
|
||
edges: [{ id: 'E1', from: 'start', to: 'end', mapping: [], reopen: false }],
|
||
variables: [],
|
||
});
|
||
}
|
||
|
||
function FlowShelf({ nav }: { nav: Nav }) {
|
||
const q = useGovPackages();
|
||
const queryClient = useQueryClient();
|
||
const packages = q.data?.packages ?? [];
|
||
const reports = useGovPkgReports(packages);
|
||
const flows = flowsOf(packages);
|
||
|
||
const [creating, setCreating] = useState(false);
|
||
const [nfName, setNfName] = useState('');
|
||
const [nfId, setNfId] = useState('');
|
||
const [nfError, setNfError] = useState<string | undefined>(undefined);
|
||
/** 卡上的 ⋯ 菜单:开在哪张卡上;`drop` = 那张卡的菜单已翻到删除确认 */
|
||
const [menu, setMenu] = useState<{ id: string; drop: boolean } | null>(null);
|
||
/** 就地改名:改的是卡面显示的**那一版**(名字是版本的属性) */
|
||
const [renaming, setRenaming] = useState<string | null>(null);
|
||
const [cardErr, setCardErr] = useState<string | undefined>(undefined);
|
||
// 标识进 URL 和触发器规则里引用,所以收紧:小写字母开头,只许小写/数字/连字符
|
||
const idOk = /^[a-z][a-z0-9-]{1,40}$/.test(nfId);
|
||
const idTaken = flows.some((f) => f.id === nfId);
|
||
|
||
const create = useMutation({
|
||
/*
|
||
* 保存不生效(存出来一律 retired)—— 新建要兑现「建出来就能起单」,
|
||
* 得跟一记激活。最小图天然闭合,激活闸必过;真有 412 就让它抛,onError 如实报。
|
||
*/
|
||
mutationFn: async () => {
|
||
const out = await call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
|
||
body: { pkg: minimalFlow(nfId, nfName.trim()) },
|
||
});
|
||
await call<{ packages: ProcessPackage[] }>(setProcessStateRoute, {
|
||
params: { id: out.pkg.id, version: String(out.pkg.version) },
|
||
body: { state: 'active' },
|
||
});
|
||
return out;
|
||
},
|
||
onSuccess: (out) => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setCreating(false);
|
||
setNfName('');
|
||
setNfId('');
|
||
nav.push({ k: 'pkg', id: out.pkg.id });
|
||
},
|
||
onError: (e: unknown) => setNfError((e as { message?: string }).message ?? '建流程失败'),
|
||
});
|
||
|
||
/** 卡上改名 —— 落在卡面那一版上(整条流的名字就是它生效版的名字) */
|
||
const rename = useMutation({
|
||
mutationFn: (input: { id: string; version: number; name: string }) =>
|
||
call<{ packages: ProcessPackage[] }>(editProcessVersionRoute, {
|
||
params: { id: input.id, version: String(input.version) },
|
||
body: { name: input.name },
|
||
}),
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setRenaming(null);
|
||
},
|
||
onError: (e: unknown) => setCardErr((e as { message?: string }).message ?? '改名失败'),
|
||
});
|
||
|
||
/** 删整条流 —— 在途单钉着、还有调起规则指着,服务端都会挡回来(409),判词照抄 */
|
||
const dropFlow = useMutation({
|
||
mutationFn: (id: string) =>
|
||
call<{ packages: ProcessPackage[] }>(deleteProcessFlowRoute, { params: { id } }),
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setMenu(null);
|
||
},
|
||
onError: (e: unknown) => {
|
||
setCardErr((e as { message?: string }).message ?? '删不掉');
|
||
setMenu(null);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<QueryGate query={q}>
|
||
{creating && (
|
||
<Card ico="workflow" title="新建流程" extra="建出来就是 v1 并即刻生效 —— 一张「开始 → 结束」的最小图,进详情再画">
|
||
<div className="g-newflow">
|
||
<Field k="流程名称" hint="人读的名字,如「统一报价」">
|
||
<input className="g-input" autoFocus value={nfName} placeholder="如:统一报价" onChange={(e) => setNfName(e.target.value)} />
|
||
</Field>
|
||
<Field k="标识" hint="进 URL 与触发器规则里引用 —— 小写字母开头,只许小写 / 数字 / 连字符">
|
||
<input
|
||
className="g-input mono"
|
||
value={nfId}
|
||
placeholder="如:unified-quote"
|
||
onChange={(e) => setNfId(e.target.value.trim())}
|
||
/>
|
||
</Field>
|
||
{nfId !== '' && !idOk && <Note tone="warn">标识不合规:小写字母开头,只许小写、数字、连字符,2–41 位。</Note>}
|
||
{idTaken && <Note tone="warn">这个标识已有流程在用 —— 换一个,或直接进那条流改。</Note>}
|
||
{nfError !== undefined && <Note tone="warn">{nfError}</Note>}
|
||
<Acts
|
||
align="end"
|
||
items={[
|
||
{ label: '算了', ico: 'x', onClick: () => setCreating(false) },
|
||
{
|
||
label: create.isPending ? '建流程中…' : '建流程并进入',
|
||
ico: 'plus',
|
||
primary: true,
|
||
disabled: create.isPending || !idOk || idTaken || nfName.trim() === '',
|
||
onClick: () => create.mutate(),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{cardErr !== undefined && <Note tone="warn">{cardErr}</Note>}
|
||
|
||
<div className="g-shelf">
|
||
{flows.map((f) => {
|
||
/* 卡面统计取生效版;整条流下线时取最新版(看的还是定义,只是没在跑) */
|
||
const shown = f.active ?? f.versions[0]!;
|
||
const ec = edgeCheckOf(shown, reports.data?.get(`${shown.id}@${shown.version}`));
|
||
const c = {
|
||
activity: shown.nodes.filter((n) => n.kind === 'activity').length,
|
||
approval: shown.nodes.filter((n) => n.kind === 'approval').length,
|
||
};
|
||
const open = menu?.id === f.id;
|
||
return (
|
||
/*
|
||
* 卡是个 div,整面盖一枚透明的「进这条流」按钮 —— 卡里还要住 ⋯ 菜单
|
||
* 与改名输入框,按钮套按钮是非法 DOM。字面部分不吃点击,直接穿到底下那枚。
|
||
*/
|
||
<div key={f.id} className={'g-fcard' + (f.active === undefined ? ' off' : '')}>
|
||
<button className="hit" aria-label={`打开 ${shown.name}`} onClick={() => nav.push({ k: 'pkg', id: f.id })} />
|
||
<div className="hd">
|
||
<span className="ico">
|
||
<Ico name="workflow" s={15} />
|
||
</span>
|
||
<div className="ttl">
|
||
{renaming === f.id ? (
|
||
<input
|
||
className="g-input"
|
||
autoFocus
|
||
maxLength={40}
|
||
defaultValue={shown.name}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onBlur={() => setRenaming(null)}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
const val = (e.target as HTMLInputElement).value.trim();
|
||
if (e.key === 'Enter' && val !== '') rename.mutate({ id: f.id, version: shown.version, name: val });
|
||
if (e.key === 'Escape') setRenaming(null);
|
||
}}
|
||
/>
|
||
) : (
|
||
<b>{shown.name}</b>
|
||
)}
|
||
<em>{f.id}</em>
|
||
</div>
|
||
<span className={'stx' + (f.active === undefined ? '' : ' on')}>
|
||
{f.active === undefined ? '已下线' : `生效 v${f.active.version}`}
|
||
</span>
|
||
<button
|
||
className={'mbtn' + (open ? ' on' : '')}
|
||
aria-label="更多"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setCardErr(undefined);
|
||
setMenu(open ? null : { id: f.id, drop: false });
|
||
}}
|
||
>
|
||
<Ico name="more-horizontal" s={14} />
|
||
</button>
|
||
</div>
|
||
{/* 数据板:四格等宽、竖线分隔 —— 一行说完这条流有多大、闭合没有 */}
|
||
<div className="plate">
|
||
<span>
|
||
<b>{c.activity}</b>
|
||
<i>活动</i>
|
||
</span>
|
||
<span>
|
||
<b>{c.approval}</b>
|
||
<i>审批</i>
|
||
</span>
|
||
<span className={ec.closed === ec.total ? '' : 'bad'}>
|
||
<b>
|
||
{ec.closed}/{ec.total}
|
||
</b>
|
||
<i>边闭合</i>
|
||
</span>
|
||
<span>
|
||
<b>{f.versions.length}</b>
|
||
<i>版本</i>
|
||
</span>
|
||
</div>
|
||
{open && (
|
||
<div className="menu">
|
||
{menu.drop ? (
|
||
<>
|
||
<span className="q">删掉整条流?{f.versions.length} 个版本一起没</span>
|
||
<button onClick={() => setMenu({ id: f.id, drop: false })}>算了</button>
|
||
<button className="bad" disabled={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
|
||
删除
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
setRenaming(f.id);
|
||
setMenu(null);
|
||
}}
|
||
>
|
||
<Ico name="pen-line" s={12} /> 编辑
|
||
</button>
|
||
<button className="bad" onClick={() => setMenu({ id: f.id, drop: true })}>
|
||
<Ico name="trash-2" s={12} /> 删除
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* 幽灵卡:新建就住货架里 —— 与真卡同一尺寸,一眼看出「这儿还能再添一条」 */}
|
||
<button className="g-fcard ghost" onClick={() => setCreating(true)}>
|
||
<Ico name="plus" s={18} />
|
||
新建流程
|
||
</button>
|
||
</div>
|
||
</QueryGate>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 流程详情:版本列表 + 画布 + 节点属性 + 契约细节 ---------------- */
|
||
|
||
export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
|
||
// 旧深链是 `id@version`;详情现在是**流程级**的,版本在页内切,所以只认 id 段
|
||
const flowId = id.split('@')[0] ?? id;
|
||
const q = useGovPackages();
|
||
const acts = useGovActivities();
|
||
const approvalTpl = useGovApprovalTemplates();
|
||
|
||
const versions = (q.data?.packages ?? [])
|
||
.filter((p) => p.id === flowId)
|
||
.sort((a, b) => b.version - a.version);
|
||
/* 全部版本的闭合报告 —— 版本行的「激活」按钮据它灰显(不闭合点不了) */
|
||
const reports = useGovPkgReports(versions);
|
||
/* 选中哪一版看图:默认跟着生效版走;手动点过就钉住;保存后钉到**刚存的新版**(它未生效) */
|
||
const [pinned, setPinned] = useState<number | null>(null);
|
||
const active = versions.find((v) => v.state === 'active');
|
||
const shown = (pinned === null ? undefined : versions.find((v) => v.version === pinned)) ?? active ?? versions[0];
|
||
|
||
if (shown === undefined) {
|
||
return (
|
||
<QueryGate query={q}>
|
||
<Note>流程不存在。</Note>
|
||
</QueryGate>
|
||
);
|
||
}
|
||
return (
|
||
<FlowLab
|
||
key={`${shown.id}@${shown.version}`}
|
||
pkg={shown}
|
||
versions={versions}
|
||
activeVersion={active?.version}
|
||
nav={nav}
|
||
activities={acts.data?.activities ?? []}
|
||
approvalTemplates={approvalTpl.data?.templates ?? []}
|
||
nextVersion={Math.max(...versions.map((x) => x.version)) + 1}
|
||
closedOf={(v) => reports.data?.get(`${flowId}@${v}`)?.closed}
|
||
onPick={(v) => setPinned(v)}
|
||
onSaved={(v) => setPinned(v)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** 本页要用的活动字段面 —— 与 `GovActivitiesOutput` 的一致子集(含契约面,导出依赖文件要它) */
|
||
type ActivityRow = {
|
||
activityId: string;
|
||
name: string;
|
||
packageVersions: ReadonlyArray<{ version: string }>;
|
||
assetVersions: ReadonlyArray<{ version: number; effective: boolean }>;
|
||
contract: GovActivities['activities'][number]['contract'];
|
||
};
|
||
|
||
function FlowLab({
|
||
pkg,
|
||
versions,
|
||
activeVersion,
|
||
nav,
|
||
activities,
|
||
approvalTemplates,
|
||
nextVersion,
|
||
closedOf,
|
||
onPick,
|
||
onSaved,
|
||
}: {
|
||
pkg: ProcessPackage;
|
||
versions: readonly ProcessPackage[];
|
||
activeVersion: number | undefined;
|
||
nav: Nav;
|
||
activities: readonly ActivityRow[];
|
||
/** 审批模板一览 —— 审批点属性里选一张 */
|
||
approvalTemplates: readonly { id: string; name: string }[];
|
||
nextVersion: number;
|
||
/** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */
|
||
closedOf: (version: number) => boolean | undefined;
|
||
onPick: (version: number) => void;
|
||
onSaved: (version: number) => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
/*
|
||
* 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 ——
|
||
* 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。
|
||
*/
|
||
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
|
||
const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, []));
|
||
const [sel, setSel] = useState<string | null>(null);
|
||
const [dirty, setDirty] = useState(false);
|
||
const [report, setReport] = useState<ClosureReport | undefined>(undefined);
|
||
const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined);
|
||
/**
|
||
* **版本记录**的编辑态:开着,每行的号与名就是输入框、行尾钮换成删除;
|
||
* 顶栏两枚钮 取消 / 保存 —— 改动攒在草稿里,按保存才落库。
|
||
* 它只管版本记录这一件事,与画布的手绘编辑(那是流程图卡自己的)互不相干。
|
||
*/
|
||
const [editingVersions, setEditingVersions] = useState(false);
|
||
/** 版本记录草稿:原版本号 → 改后的号与名 */
|
||
const [edits, setEdits] = useState<Record<number, { version: string; name: string }>>({});
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
|
||
const nodes = graph?.nodes ?? [];
|
||
const node = nodes.find((n) => n.id === sel) ?? null;
|
||
const semanticNode: ProcessNode | undefined = pkg.nodes.find((n) => n.id === sel);
|
||
const refAct =
|
||
node === null
|
||
? undefined
|
||
: activities.find(
|
||
(a) =>
|
||
(node.act !== undefined && a.activityId === node.act) ||
|
||
(node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId),
|
||
);
|
||
|
||
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
|
||
const liveReport = useQuery({
|
||
queryKey: ['gov', 'process-report', pkg.id, pkg.version],
|
||
queryFn: () => call<ClosureReport>(validateProcessRoute, { body: { pkg } }),
|
||
});
|
||
const activeReport = report ?? liveReport.data;
|
||
const ec = edgeCheckOf(pkg, activeReport);
|
||
|
||
const patch = (f: (n: FlowNode) => FlowNode) => {
|
||
if (sel === null) return;
|
||
setGraph((g) => (g === null ? g : { ...g, nodes: g.nodes.map((n) => (n.id === sel ? f(n) : n)) }));
|
||
setDirty(true);
|
||
};
|
||
|
||
/*
|
||
* 出边**归发起它的节点**(Mastra:`.branch([[条件, 下一步], …])` 挂在上一步上)——
|
||
* 边不再是可编辑对象,分支条件在源节点的属性卡里填,所以这里按 `from` 取它的出边。
|
||
*/
|
||
const outEdges = (graph?.edges ?? [])
|
||
.map((edge, index) => ({ edge, index }))
|
||
.filter(({ edge }) => sel !== null && edge.from === sel);
|
||
const patchEdge = (index: number, f: (e: FlowEdge) => FlowEdge) => {
|
||
setGraph((g) => (g === null ? g : { ...g, edges: g.edges.map((e, i) => (i === index ? f(e) : e)) }));
|
||
setDirty(true);
|
||
};
|
||
|
||
|
||
// 画布上的选中态:diagram-js 给每个形状挂了 data-element-id,外挂一个类,不改画布件
|
||
useEffect(() => {
|
||
const root = canvasRef.current;
|
||
if (root === null) return;
|
||
root.querySelectorAll('.flow-node-sel').forEach((el) => el.classList.remove('flow-node-sel'));
|
||
if (sel !== null) root.querySelector(`[data-element-id="${sel}"]`)?.classList.add('flow-node-sel');
|
||
}, [sel, graph]);
|
||
|
||
const [saveErr, setSaveErr] = useState<string | undefined>(undefined);
|
||
const save = useMutation({
|
||
mutationFn: (input: { graph: FlowGraph; idMap: Record<string, string> }) =>
|
||
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
|
||
body: { pkg: mergeGraphIntoPackage(pkg, input.graph, input.idMap) },
|
||
}),
|
||
onSuccess: (out) => {
|
||
setReport(out.report);
|
||
setDirty(false);
|
||
setSaveErr(undefined);
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||
// 编辑即其身:写回的还是这一版,号不变;异常看板会列它还缺什么
|
||
onSaved(out.pkg.version);
|
||
},
|
||
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '保存失败'),
|
||
});
|
||
|
||
/** 版本启停:运行时开关,不改定义 —— 在途按启动版本跑完,零影响 */
|
||
const setState = useMutation({
|
||
mutationFn: (input: { version: number; state: 'active' | 'retired' }) =>
|
||
call<{ packages: ProcessPackage[] }>(setProcessStateRoute, {
|
||
params: { id: pkg.id, version: String(input.version) },
|
||
body: { state: input.state },
|
||
}),
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setAsk(undefined);
|
||
},
|
||
});
|
||
|
||
/** 删一版:生效版与在途钉版都会被服务端挡回来(409),判词照抄 */
|
||
const dropVersion = useMutation({
|
||
mutationFn: (version: number) =>
|
||
call<{ packages: ProcessPackage[] }>(deleteProcessVersionRoute, {
|
||
params: { id: pkg.id, version: String(version) },
|
||
}),
|
||
onSuccess: (out) => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setAsk(undefined);
|
||
// 删掉的正是在看的这一版 → 落到还剩的最新一版;一版不剩就回货架
|
||
const rest = out.packages;
|
||
if (rest.length === 0) nav.jump('workflows', 'pkgs');
|
||
else if (rest[0] !== undefined) onSaved(rest[0].version);
|
||
},
|
||
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '删不掉'),
|
||
});
|
||
|
||
/**
|
||
* 存版本记录:逐行把改过的号与名送上去 —— 撞号、跑过单改号,服务端 409,判词照抄。
|
||
* 一行不通,前面已落的不回滚:版本记录各自独立,没有「半条」可言。
|
||
*/
|
||
const saveVersions = useMutation({
|
||
mutationFn: async (drafts: readonly { from: number; version: number; name: string }[]) => {
|
||
let packages: ProcessPackage[] = [];
|
||
for (const d of drafts) {
|
||
const out = await call<{ packages: ProcessPackage[] }>(editProcessVersionRoute, {
|
||
params: { id: pkg.id, version: String(d.from) },
|
||
body: { version: d.version, name: d.name },
|
||
});
|
||
packages = out.packages;
|
||
}
|
||
return packages;
|
||
},
|
||
onSuccess: (_packages, drafts) => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||
setEditingVersions(false);
|
||
setEdits({});
|
||
// 正看的这一版换了号 → 跟着落到新号上,别停在一个已经不存在的号
|
||
const moved = drafts.find((d) => d.from === pkg.version);
|
||
if (moved !== undefined && moved.version !== pkg.version) onSaved(moved.version);
|
||
},
|
||
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '版本记录没存上'),
|
||
});
|
||
|
||
/**
|
||
* 新建版本 = **以当前看的版本为底克隆一版**(服务端定版号,**不生效**)。
|
||
* 与「激活」分工明确:激活只是把生效指针切过去,不产生新版本;
|
||
* 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。
|
||
*/
|
||
const cloneVersion = useMutation({
|
||
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg, asNew: true } }),
|
||
onSuccess: (out) => {
|
||
setReport(out.report);
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||
setAsk(undefined);
|
||
onSaved(out.pkg.version);
|
||
},
|
||
});
|
||
|
||
const askClone = () =>
|
||
setAsk({
|
||
spec: {
|
||
title: `新建版本:以 v${pkg.version} 为底 → v${nextVersion}`,
|
||
note: dirty ? (
|
||
<Note tone="warn">
|
||
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
|
||
想连改动一起走,先点「保存 v{pkg.version}」再来新建。
|
||
</Note>
|
||
) : (
|
||
<Note>
|
||
把 v{pkg.version} 的定义原样复制成 v{nextVersion}。<b>不生效</b> ——
|
||
画完、异常清零后再激活。
|
||
</Note>
|
||
),
|
||
impact: {
|
||
who: '只多一版,不动任何在跑的东西',
|
||
undo: '版本并存,不要就停着别激活',
|
||
when: '确认即建',
|
||
},
|
||
okLabel: `建 v${nextVersion}`,
|
||
onOk: () => cloneVersion.mutate(),
|
||
},
|
||
});
|
||
|
||
const askActivate = (v: ProcessPackage) =>
|
||
setAsk({
|
||
spec: {
|
||
title: `激活 v${v.version}:${pkg.name}`,
|
||
note: (
|
||
<Note>
|
||
激活即独占:此后<b>新起</b>的工作流用 v{v.version}
|
||
{activeVersion === undefined ? '' : `,v${activeVersion} 随之停用`}。 在途的按各自启动时的版本跑完,不受影响。
|
||
</Note>
|
||
),
|
||
impact: {
|
||
who: `新起单一律走 v${v.version}`,
|
||
undo: '可再切回任一版本',
|
||
when: '确认即切换',
|
||
},
|
||
okLabel: `激活 v${v.version}`,
|
||
onOk: () => setState.mutate({ version: v.version, state: 'active' }),
|
||
},
|
||
});
|
||
|
||
const askDrop = (v: ProcessPackage) =>
|
||
setAsk({
|
||
danger: true,
|
||
spec: {
|
||
title: `删除 v${v.version}:${pkg.name}`,
|
||
note: (
|
||
<Note tone="warn">
|
||
版本删了**不可撤销**。生效版与**在途钉版**删不掉 —— 后者那些单要按这一版跑完,
|
||
定义没了就再也推不动。
|
||
</Note>
|
||
),
|
||
impact: { who: '这一版的定义不再存在', undo: '不可撤销', when: '确认即删' },
|
||
okLabel: `删掉 v${v.version}`,
|
||
onOk: () => dropVersion.mutate(v.version),
|
||
},
|
||
});
|
||
|
||
const askRetire = (v: ProcessPackage) =>
|
||
setAsk({
|
||
danger: true,
|
||
spec: {
|
||
title: `停用 v${v.version}:${pkg.name}`,
|
||
note: (
|
||
<Note tone="warn">
|
||
停用后这条流<b>没有生效版本</b>:新起单会被拒,触发器命中了也起不了。
|
||
在途工作流照旧跑完,不受影响。
|
||
</Note>
|
||
),
|
||
impact: {
|
||
who: '整条流对新起单下线',
|
||
undo: '随时激活任一版本即恢复',
|
||
when: '确认即下线',
|
||
},
|
||
okLabel: '停用,整条流下线',
|
||
onOk: () => setState.mutate({ version: v.version, state: 'retired' }),
|
||
},
|
||
});
|
||
|
||
/**
|
||
* 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配,
|
||
* 一并成一份 JSON。**依赖文件即包清单骨架**,开发者对着声明在系统外开发活动。
|
||
*/
|
||
const exportDeps = () => {
|
||
const actContract = new Map(activities.map((a) => [a.activityId, a.contract]));
|
||
const doc = {
|
||
package: { id: pkg.id, name: pkg.name, version: pkg.version },
|
||
callInterface: pkg.callInterface,
|
||
activities: pkg.nodes
|
||
.filter((n) => n.kind === 'activity')
|
||
.map((n) => ({
|
||
nodeId: n.id,
|
||
label: n.label,
|
||
activityId: n.activityId,
|
||
// 契约不带负责人岗位 —— 分派归运营,依赖文件只描述干什么
|
||
/*
|
||
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
|
||
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
|
||
*/
|
||
contract: actContract.get(n.activityId) ?? null,
|
||
/*
|
||
* 数据按字段名流动(对齐 Mastra):依赖文件不再列边映射 ——
|
||
* 上游从哪来,看 contract.input 的字段名对上游 output 的字段名。
|
||
*/
|
||
upstream: pkg.edges.filter((e) => e.to === n.id).map((e) => e.from),
|
||
})),
|
||
approvals: pkg.nodes
|
||
.filter((n) => n.kind === 'approval')
|
||
// 审批人运营时分配,依赖文件只描述编排语义(驳回走向)
|
||
.map((n) => ({ nodeId: n.id, label: n.label, reject: n.reject })),
|
||
};
|
||
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${pkg.id}-v${pkg.version}-依赖文件.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
|
||
return (
|
||
<>
|
||
<PageHead
|
||
ico="workflow"
|
||
tone={activeVersion !== undefined ? 'ok' : 'warn'}
|
||
kicker={pkg.id}
|
||
title={pkg.name}
|
||
chips={
|
||
<>
|
||
{activeVersion !== undefined ? (
|
||
<St tone="ok" ico="circle-check">
|
||
生效 v{activeVersion}
|
||
</St>
|
||
) : (
|
||
<St tone="warn" ico="pause">
|
||
已下线 —— 没有生效版本
|
||
</St>
|
||
)}
|
||
<Chip tone="mute" ico="history">
|
||
{versions.length} 个版本
|
||
</Chip>
|
||
<St tone={ec.closed === ec.total ? 'ok' : 'danger'} ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'}>
|
||
边 {ec.closed}/{ec.total}
|
||
</St>
|
||
</>
|
||
}
|
||
acts={[
|
||
{
|
||
label: '导出依赖文件',
|
||
ico: 'upload',
|
||
ask: {
|
||
title: '导出依赖文件',
|
||
note: (
|
||
<Note>
|
||
编排定契约:导出各活动的接口声明、责任岗位、输入输出声明与上游装配 ——
|
||
依赖文件即包清单骨架,开发者对着声明在系统外开发。
|
||
</Note>
|
||
),
|
||
impact: { who: '活动开发者', undo: '可重复导出', when: '确认后生成' },
|
||
okLabel: '确认导出',
|
||
onOk: exportDeps,
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{ask !== undefined && <AskBand spec={ask.spec} danger={ask.danger === true} onCancel={() => setAsk(undefined)} />}
|
||
|
||
{/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
|
||
<Sec ico="route" title="编排工作区">
|
||
<div className="g-flowlab">
|
||
{/*
|
||
* 左栏:版本。**动作统一住顶部**(作用于正看的这一版),行里只放事实
|
||
* (v几 · 状态 · 点边计数)—— 完整显示,不在行内再展开一排小钮。
|
||
*/}
|
||
<aside className="g-vercard g-verslim">
|
||
<div className="g-vercard-h">
|
||
版本
|
||
{/*
|
||
* 编辑态两枚钮:取消 / 保存。改动攒在草稿里 —— 号与名一趟改完再落库,
|
||
* 与画布的「编辑 → 保存」同一套手势。
|
||
*/}
|
||
{editingVersions ? (
|
||
<>
|
||
<button
|
||
className="g-ver-act"
|
||
disabled={saveVersions.isPending}
|
||
onClick={() => {
|
||
setEditingVersions(false);
|
||
setEdits({});
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="g-ver-act"
|
||
disabled={saveVersions.isPending}
|
||
onClick={() => {
|
||
const drafts = versions.flatMap((v) => {
|
||
const d = edits[v.version];
|
||
if (d === undefined) return [];
|
||
const num = Number(d.version);
|
||
const name = d.name.trim();
|
||
if (!Number.isInteger(num) || num < 1 || name === '') return [];
|
||
if (num === v.version && name === v.name) return [];
|
||
return [{ from: v.version, version: num, name }];
|
||
});
|
||
if (drafts.length === 0) {
|
||
setEditingVersions(false);
|
||
setEdits({});
|
||
return;
|
||
}
|
||
saveVersions.mutate(drafts);
|
||
}}
|
||
>
|
||
<Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending}>
|
||
<Ico name="plus" s={11} /> 新建
|
||
</button>
|
||
{/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */}
|
||
<button
|
||
className="g-ver-act"
|
||
onClick={() => {
|
||
setEdits(Object.fromEntries(versions.map((v) => [v.version, { version: String(v.version), name: v.name }])));
|
||
setEditingVersions(true);
|
||
}}
|
||
>
|
||
<Ico name="pen-line" s={11} /> 编辑
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="g-vercard-list">
|
||
{versions.map((v) => {
|
||
const isActive = v.version === activeVersion;
|
||
const isShown = v.version === pkg.version;
|
||
const draft = edits[v.version];
|
||
/*
|
||
* 编辑态的行不再是「可点切版」的按钮 —— 它是一排字段:号、名、删。
|
||
* 展示态才是按钮(点行切到这一版看)。
|
||
*/
|
||
const pickable = editingVersions
|
||
? {}
|
||
: {
|
||
role: 'button',
|
||
tabIndex: 0,
|
||
onClick: () => onPick(v.version),
|
||
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
onPick(v.version);
|
||
}
|
||
},
|
||
};
|
||
return (
|
||
<div
|
||
key={v.version}
|
||
className={'g-verrow' + (isShown ? ' on' : '') + (isActive ? ' live' : '') + (editingVersions ? ' ed' : '')}
|
||
{...pickable}
|
||
>
|
||
<div className="ln">
|
||
{/* 号与名都归设计人员自己定:号自己编,名各版不同 —— 版本靠这两样互相区分 */}
|
||
{editingVersions && draft !== undefined ? (
|
||
<>
|
||
<b>v</b>
|
||
<input
|
||
className="g-input vnum"
|
||
inputMode="numeric"
|
||
value={draft.version}
|
||
onChange={(e) =>
|
||
setEdits({ ...edits, [v.version]: { ...draft, version: e.target.value.replace(/\D/g, '') } })
|
||
}
|
||
/>
|
||
<input
|
||
className="g-input nm"
|
||
maxLength={40}
|
||
value={draft.name}
|
||
onChange={(e) => setEdits({ ...edits, [v.version]: { ...draft, name: e.target.value } })}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<b>v{v.version}</b>
|
||
<span className="nm">{v.name}</span>
|
||
</>
|
||
)}
|
||
{editingVersions ? (
|
||
<button
|
||
className="g-ver-act t-bad"
|
||
disabled={isActive || dropVersion.isPending}
|
||
title={isActive ? '生效版先停用' : ''}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
askDrop(v);
|
||
}}
|
||
>
|
||
删除
|
||
</button>
|
||
) : isActive ? (
|
||
<button
|
||
className="g-ver-act"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
askRetire(v);
|
||
}}
|
||
>
|
||
停用
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="g-ver-act t-go"
|
||
/* 不闭合点不了:结构闭合 + 活动装载两面清零才亮 */
|
||
disabled={closedOf(v.version) !== true}
|
||
title={closedOf(v.version) === true ? '' : '还不闭合'}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
askActivate(v);
|
||
}}
|
||
>
|
||
激活
|
||
</button>
|
||
)}
|
||
</div>
|
||
{/* 第二行只放事实:状态 · 点边计数 */}
|
||
<div className="ln2">
|
||
<span className={'st' + (isActive ? ' live' : '')}>{isActive ? '生效' : '停用'}</span>
|
||
<span className="cnt">
|
||
{v.nodes.length} 节点 · {v.edges.length} 边
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="g-canvas" ref={canvasRef}>
|
||
<div className="process-desk">
|
||
<div className="process-doc">
|
||
<FlowFigureBlock
|
||
key={`${pkg.id}@${pkg.version}`}
|
||
title={`${pkg.name} v${pkg.version}`}
|
||
/* 记号的意思归图框下沿的图例说;图签这一格说这张图**是什么** */
|
||
oneLiner="顺序与条件 —— 数据按字段名流动"
|
||
graph={graph}
|
||
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
|
||
/*
|
||
* 画布只画**编排逻辑**:节点的名字、连线的顺序;边上的字是辅助阅读的注记。
|
||
* 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。
|
||
* 岗位、判据这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。
|
||
*/
|
||
canEdit
|
||
onNodeClick={(nid) => setSel(nid)}
|
||
/* 边不是可编辑对象 —— 点一条线,选中**发起它的那个节点**(分支归它管) */
|
||
onEdgeClick={(edge) => setSel(edge.from)}
|
||
onEditNodeText={async (nid, p2) => {
|
||
setGraph((g) =>
|
||
g === null
|
||
? g
|
||
: { ...g, nodes: g.nodes.map((n) => (n.id === nid ? { ...n, label: p2.label } : n)) },
|
||
);
|
||
setDirty(true);
|
||
}}
|
||
onSaveFlowGraph={async (next, idMap) => {
|
||
setGraph(next);
|
||
await save.mutateAsync({ graph: next, idMap });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/*
|
||
* 右栏:节点属性。外面这层包壳是**为了不撑高整行** —— 卡绝对定位撑满行高,
|
||
* 行高由中栏的图定;属性长了在卡内滚,不把图拉长(用户要的「卡片高度跟图一致」)。
|
||
*/}
|
||
<div className="g-nodewrap">
|
||
<div className="g-vercard g-nodecard">
|
||
<div className="g-vercard-h">
|
||
节点 · 属性
|
||
{dirty && <span className="n dirty">有改动待保存</span>}
|
||
{/* 与「新建 / 编辑」同款素钮 —— 可不可存由禁用态说,不靠换色抢戏 */}
|
||
<button
|
||
className="g-ver-act"
|
||
disabled={!dirty || save.isPending}
|
||
onClick={() => {
|
||
if (graph !== null) save.mutate({ graph, idMap: {} });
|
||
}}
|
||
>
|
||
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
|
||
</button>
|
||
</div>
|
||
<div className="g-nodecard-b">
|
||
{saveErr !== undefined && (
|
||
<Note tone="warn">
|
||
<b>保存失败:</b>
|
||
{saveErr}
|
||
</Note>
|
||
)}
|
||
{/* key 随选中对象换 —— 换一件看,明细区滚回顶,不带上一件的滚动位 */}
|
||
<div className="g-propsheet" key={node !== null ? `n:${node.id}` : 'blank'}>
|
||
{node === null ? (
|
||
/*
|
||
* 没选节点时,这块地方替原来的「激活判据」看板:还差哪几条就列在这儿,点一条定位过去。
|
||
* 激活闸只剩两面 —— **结构闭合**与**活动装载**;字段一概不查(只声明,不要求)。
|
||
*/
|
||
<div className="g-sheetempty">
|
||
{(activeReport?.violations.length ?? 0) === 0 ? (
|
||
<>
|
||
<Ico name="target" s={20} />
|
||
<b>在图上点一个节点</b>
|
||
<p>
|
||
节点填名称、链接实现、往哪走(出边条件)。边不是可编辑对象 —— 它只表达顺序;
|
||
点线会选中发起它的那个节点。负责人运营时分配,不在这儿。
|
||
</p>
|
||
{activeReport !== undefined && (
|
||
<span className="g-okstamp">
|
||
<Ico name="circle-check" s={13} />
|
||
结构闭合 · 活动装载齐备 —— v{pkg.version} 可运行
|
||
</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="g-lack">
|
||
<div className="g-lack-h">
|
||
<Ico name="triangle-alert" s={13} />
|
||
还差 {activeReport!.violations.length} 条才能激活
|
||
</div>
|
||
{activeReport!.violations.map((v, i) => {
|
||
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
|
||
return (
|
||
<button
|
||
key={`${v.kind}-${i}`}
|
||
className="g-lack-r"
|
||
disabled={nid === undefined}
|
||
onClick={() => {
|
||
if (nid !== undefined) setSel(String(nid));
|
||
}}
|
||
>
|
||
{nid !== undefined && <b>{String(nid)}</b>}
|
||
<span>{v.message}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/*
|
||
* 节点属性**平铺**(2026-07-30 用户定「不用分类了」):不再分「身份/类型/装载/出边」
|
||
* 几张小卡 —— 节点本来只决定这么几件,分类反而把四五个字段摊成半屏卡片。
|
||
* 也不再摆数据契约声明:数据契约**归活动自己**,要看去活动库看(下面那枚钮直达)。
|
||
*/
|
||
<div className="g-nodeform">
|
||
{/*
|
||
* 你点的就是它:选中的节点以**画布同款皮肤**摆在卡顶(活动蓝 / 审批琥珀,
|
||
* 左上角标同画布)—— 属性改的是哪一件,一眼对上,不用回图上找。
|
||
*/}
|
||
<div className={'g-nchip' + (node.flavor === 'approval' ? ' apr' : '')}>
|
||
<i>{node.id}</i>
|
||
<b>{node.label || '(未命名)'}</b>
|
||
<em>{node.flavor === 'approval' ? '审批点' : '活动点'}</em>
|
||
</div>
|
||
|
||
{/* 图签行:字段名住左格(mono 竖签),值住右格 —— 与图框的图签同一形制 */}
|
||
<div className="g-tbrow">
|
||
<em>ID</em>
|
||
<span className="v mono">{node.id}</span>
|
||
</div>
|
||
<div className="g-tbrow">
|
||
<em>名称</em>
|
||
<input
|
||
className="g-tbin"
|
||
value={node.label}
|
||
placeholder="节点名称"
|
||
onChange={(e) => patch((n) => ({ ...n, label: e.target.value }))}
|
||
/>
|
||
</div>
|
||
{node.kind === 'activity' && (
|
||
<div className="g-tbrow">
|
||
<em>类型</em>
|
||
{/* 切换器长成它代表的东西:两枚迷你节点皮肤(画布蓝 / 琥珀) */}
|
||
<div className="g-ntype">
|
||
<button
|
||
className={'act' + (node.flavor !== 'approval' ? ' on' : '')}
|
||
onClick={() => patch((n) => ({ ...n, flavor: undefined }))}
|
||
>
|
||
活动点
|
||
</button>
|
||
<button
|
||
className={'apr' + (node.flavor === 'approval' ? ' on' : '')}
|
||
onClick={() => patch((n) => ({ ...n, flavor: 'approval' as const }))}
|
||
>
|
||
审批点
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{node.kind === 'activity' && node.flavor === 'approval' && (
|
||
<div className="g-tbrow">
|
||
{/*
|
||
* 审批模板 = 从活动库链接一张**审批活动** —— 审批人看哪几项、怎么催、说什么。
|
||
* 不选也能跑(旧行为:审批卡把载荷全摊开);谁来审仍是运营时按组轮值。
|
||
*/}
|
||
<em>审批模板 · 链接自活动库</em>
|
||
<span className="v col">
|
||
<select
|
||
className="g-tbsel"
|
||
value={node.templateId ?? ''}
|
||
onChange={(e) =>
|
||
patch((n) => ({ ...n, templateId: e.target.value === '' ? undefined : e.target.value }))
|
||
}
|
||
>
|
||
<option value="">不引模板 —— 审批卡把载荷全摊开</option>
|
||
{approvalTemplates.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name} · {t.id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button className="g-tblink" onClick={() => nav.jump('library', 'shelf')}>
|
||
去活动库配审批活动 <Ico name="arrow-right" s={11} />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
)}
|
||
{node.kind === 'activity' && node.flavor !== 'approval' && (
|
||
<div className="g-tbrow">
|
||
{/* 实现 = **从活动库链接一个活动**:节点不含实现,链接才算装载(激活判据之二) */}
|
||
<em>实现 · 链接自活动库</em>
|
||
<span className="v col">
|
||
<select
|
||
className="g-tbsel"
|
||
value={refAct?.activityId ?? ''}
|
||
onChange={(e) => patch((n) => ({ ...n, act: e.target.value === '' ? undefined : e.target.value }))}
|
||
>
|
||
<option value="">从活动库链接一个活动 —— 未链接,激活会被拦</option>
|
||
{activities.map((a) => (
|
||
<option key={a.activityId} value={a.activityId}>
|
||
{a.name} · {a.activityId}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{refAct !== undefined ? (
|
||
/* 数据契约归活动自己 —— 要看它吃什么吐什么,去活动库看 */
|
||
<button
|
||
className="g-tblink"
|
||
onClick={() => nav.jump('library', 'shelf', { k: 'act', id: refAct.activityId })}
|
||
>
|
||
去活动库看契约 <Ico name="arrow-right" s={11} />
|
||
</button>
|
||
) : (
|
||
<small className="warn">图可以照画照存,激活前链上即可</small>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{outEdges.length > 0 && (
|
||
<>
|
||
{/*
|
||
* 出边 = 路由小表:**分支归发起它的这个节点管**(Mastra 的
|
||
* `.branch([[条件, 下一步], …])` 挂在上一步上)。三条重复的长提示收成一句;
|
||
* 条件留空即默认兜底(placeholder 说),边本身只是画布上的一条线。
|
||
*/}
|
||
<div className="g-tbband">
|
||
出边 · {outEdges.length === 1 ? '直连' : '命中即走'}
|
||
<span className="x">
|
||
{node.kind === 'activity' && node.flavor === 'approval'
|
||
? '条件读审批裁决 verdict'
|
||
: '条件读本节点的输出字段'}
|
||
</span>
|
||
</div>
|
||
{outEdges.map(({ edge, index }) => {
|
||
const target = nodes.find((n) => n.id === edge.to);
|
||
return (
|
||
<div key={edge.code ?? index} className="g-tbedge">
|
||
<span className="rt">
|
||
<b>{edge.code ?? `边${index + 1}`}</b>
|
||
<Ico name="arrow-right" s={11} />
|
||
<span className="to">{edge.to}</span>
|
||
{target !== undefined && target.label !== '' && <span className="nm">{target.label}</span>}
|
||
</span>
|
||
<input
|
||
className="g-tbin mono"
|
||
value={edge.condition ?? ''}
|
||
placeholder={
|
||
outEdges.length === 1
|
||
? '无条件直连'
|
||
: node.kind === 'activity' && node.flavor === 'approval'
|
||
? 'verdict = "pass" · 留空 = 默认兜底'
|
||
: 'needAlternative = true · 留空 = 默认兜底'
|
||
}
|
||
onChange={(e) => patchEdge(index, (x) => ({ ...x, condition: e.target.value }))}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Sec>
|
||
|
||
</>
|
||
);
|
||
}
|