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-process/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 f03f23f2ca 编排页两处收口:图框下沿对齐左列底栏;边不再是可编辑对象
**对齐**:图框下边原本停在纸面内距上方,与左列版本卡底栏的顶线差 16px,
两条本该连成一线的横线错开。改法不是拿魔数把图框往下推 ——
纸面改成 flex 列、图框 flex:1 吃满(被栅格拉高时跟着长,不会又浮回上面),
下方留出**恰好一条底栏的高度**:20 = 38(底栏净高) − 18(纸面下内距)。
底栏那侧加 min-height:38 锁住基线,两边注释互指。实测差 0。

**边不再是可编辑对象**:上一轮边已瘦成「顺序 + 条件」,再给它一张属性卡、
一条 E1…E14 拣选行、一套与节点互斥的选中态,就是为一个条件养一整套机制。
按 Mastra 的语义,分支本来就归**发起它的那一步**管
(`.branch([[条件, 下一步], …])` 挂在上一步上)—— 于是:
  · 删掉边属性卡、边拣选行、selEdge 选中态;
  · 节点属性卡新增「出边 · 往哪走」:列出本节点的每条出边(E号 → 去向)与条件,
    单出边显示「无条件直连」,留空即默认兜底;
  · 审批点的提示单写一版 —— 它的判据是**系统注入**的 verdict,不是自己的输出字段;
  · 点画布上的线不再打开边卡,而是选中发起它的那个节点。

顺带清掉三处失真文案:头注与「节点 ID」提示里的「边映射」、空态里的
「边填传递契约(条件 + 映射)」。

五闸绿。

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

1103 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。
*
* 一区一内容:三枚计数摆在货架顶端,底下就是包货架(触发器已独立成区)。
* 货架一流程一卡(不按版本平铺);进详情后左 1/3 是版本列表、右 2/3 是流程图,
* 图下是节点属性(身份 · 数据接口 · 装载 · 出边条件),再往下是调用接口与触发器。
*
* 版本是**运行时开关**,不是发布流程:
* · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」
* · 画图不检查(未生效版);写回生效版须闭合;在途钉版不可变(409)
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/
import { useRef, useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
import { processPackageSchema, 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,
Prop,
QueryGate,
Sec,
Seg,
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, 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="不闭合不得激活"
/>
{/* 网关已废除:分支 = 条件出边,不再是一种要素 */}
<Kpi ico="boxes" label="编排要素" value="四种" 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);
// 标识进 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 ?? '建流程失败'),
});
return (
<QueryGate query={q}>
{creating ? (
<Card ico="workflow" title="新建流程" extra="建出来就是 v1 并即刻生效 —— 一张「开始 → 结束」的最小图,进详情再画">
<div className="g-newflow">
<Field k="流程名称" hint="人读的名字,如「统一报价」">
<input className="g-input" 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>
) : (
<Acts items={[{ label: '新建流程', ico: 'plus', primary: true, onClick: () => setCreating(true) }]} />
)}
<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,
};
return (
<button
key={f.id}
className={'g-tile t-' + (f.active !== undefined ? 'ok' : 'mute')}
onClick={() => nav.push({ k: 'pkg', id: f.id })}
>
<span className="g-tile-h">
<span className="g-tile-i">
<Ico name="workflow" s={16} />
</span>
<span className="g-tile-t">
<b>{shown.name}</b>
<em>{f.id}</em>
</span>
{f.active !== undefined ? (
<St tone="ok" ico="circle-check">
生效 v{f.active.version}
</St>
) : (
<St tone="warn" ico="pause">
已下线
</St>
)}
</span>
<span className="g-tile-m">
<Chip tone="auto" ico="git-commit">
活动 {c.activity}
</Chip>
<Chip tone="warn" ico="shield-check">
审批 {c.approval}
</Chip>
<Chip tone="mute" ico="git-branch">
边 {shown.edges.length}
</Chip>
</span>
<span className="g-tile-f">
<St tone={ec.closed === ec.total ? 'ok' : 'danger'} ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'}>
边 {ec.closed}/{ec.total}
</St>
<span className="r">{f.versions.length} 个版本</span>
</span>
</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 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 ?? []}
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;
paradigm: string;
packageVersions: ReadonlyArray<{ version: string }>;
assetVersions: ReadonlyArray<{ version: number; effective: boolean }>;
contract: GovActivities['activities'][number]['contract'];
};
function FlowLab({
pkg,
versions,
activeVersion,
nav,
activities,
nextVersion,
closedOf,
onPick,
onSaved,
}: {
pkg: ProcessPackage;
versions: readonly ProcessPackage[];
activeVersion: number | undefined;
nav: Nav;
activities: readonly ActivityRow[];
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 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),
);
/*
* 数据接口的来源推导(对齐 Mastra 名流动):这个节点的每个输入字段,运行时会从哪来 ——
* ① 起单入参里有同名字段 → 起单带进;② 某个上游(沿边回溯)的输出口发同名字段 → 上游发;
* ③ 都没有 → 受理归一时派补全单找人收。设计期看得见,不必等跑起来猜。
*/
const fieldSources = (() => {
if (node === null) return new Map<string, string>();
const initFields = new Set(
Object.keys(((pkg.callInterface.input as { properties?: Record<string, unknown> }).properties ?? {})),
);
// 祖先集:沿边回溯(含回边,访问集防环)
const ancestors = new Set<string>();
const queue = [node.id];
while (queue.length > 0) {
const cur = queue.pop()!;
for (const e of graph?.edges ?? []) {
if (e.to !== cur || ancestors.has(e.from)) continue;
ancestors.add(e.from);
queue.push(e.from);
}
}
const actOf = (nid: string): string | undefined => {
const g = nodes.find((x) => x.id === nid);
if (g?.act !== undefined) return g.act;
const sem = pkg.nodes.find((x) => x.id === nid);
return sem?.kind === 'activity' ? sem.activityId : undefined;
};
const out = new Map<string, string>();
for (const f of refAct?.contract.inputFields ?? []) {
if (initFields.has(f.name)) {
out.set(f.name, '起单入参');
continue;
}
const from = [...ancestors].find((nid) =>
activities
.find((a) => a.activityId === actOf(nid))
?.contract.outputPorts.some((port) => port.fields.includes(f.name)),
);
out.set(f.name, from !== undefined ? `上游 ${from}` : '补全单');
}
return out;
})();
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
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);
};
const kindLabel = (n: FlowNode) =>
n.kind === 'activity' ? (n.flavor === 'approval' ? '审批点' : '活动点') : n.kind === 'start' ? '调用接口' : '显式终点';
const kindTone = (n: FlowNode) => (n.kind === 'activity' ? (n.flavor === 'approval' ? 'warn' : 'auto') : 'mute');
// 画布上的选中态: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);
},
});
/**
* 新建版本 = **以当前看的版本为底克隆一版**(服务端定版号,**不生效**)。
* 与「激活」分工明确:激活只是把生效指针切过去,不产生新版本;
* 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。
*/
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 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">
<aside className="g-vercard">
{/* 题头与右侧图题成对:「流程包 · 版本」对「流程图 · 顺序」;新建与编辑同款芯片 */}
<div className="g-vercard-h">
流程包 · 版本
<button className="g-ver-new" onClick={askClone} disabled={cloneVersion.isPending}>
<Ico name="plus" s={11} /> 新建
</button>
</div>
<div className="g-vercard-list">
{versions.map((v) => {
const isActive = v.version === activeVersion;
const isShown = v.version === pkg.version;
return (
<div
key={v.version}
className={'g-ver' + (isShown ? ' on' : '') + (isActive ? ' live' : '')}
role="button"
tabIndex={0}
onClick={() => onPick(v.version)}
onKeyDown={(e) => {
if (e.key === 'Enter') onPick(v.version);
}}
>
<b>v{v.version}</b>
<span className={'st' + (isActive ? ' live' : '')}>{isActive ? '生效中' : '停用'}</span>
<span className="m">
<span className="cnt">
{v.nodes.length}节点·{v.edges.length}边
</span>
{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 ? `激活 v${v.version}` : '有异常,先看异常看板'}
onClick={(e) => {
e.stopPropagation();
askActivate(v);
}}
>
激活
</button>
)}
</span>
</div>
);
})}
</div>
<div className="g-vercard-f">恰一个生效版本;在途工作流按启动时的版本跑完,启停与新建都不影响它们。</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>
{/*
* 左下:异常看板 —— 与版本卡同列同风(图纸式)。
* 画图不检查,这里**替**你盯着:哪个节点缺什么,点一条定位到属性去补;
* 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。
*/}
<aside className="g-vercard g-exboard">
<div className="g-vercard-h">激活 · 双判据</div>
{activeReport === undefined ? (
<div className="g-vercard-list g-exsheet">
<div className="g-exboard-empty">校验中…</div>
</div>
) : (
(() => {
/* 能不能激活看两面:①图编排闭合完善 ②活动装载。unknown-activity 归装载,其余归编排。 */
const loading = activeReport.violations.filter((v) => v.kind === 'unknown-activity');
const drawing = activeReport.violations.filter((v) => v.kind !== 'unknown-activity');
const row = (v: (typeof activeReport.violations)[number], i: number) => {
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
return (
<div
key={`${v.kind}-${i}`}
className="g-ver g-ex"
role="button"
tabIndex={0}
onClick={() => {
if (nid !== undefined) setSel(String(nid));
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && nid !== undefined) setSel(String(nid));
}}
>
{nid !== undefined && <b>{String(nid)}</b>}
<span className="msg">{v.message}</span>
</div>
);
};
/* 检验单式:两格判据面均分卡高,面体列违规行;全清的面盖检验章,两面全清出裁定带 */
return (
<div className="g-vercard-list g-exsheet">
<section className="g-exface">
<div className={'g-exfacet' + (drawing.length === 0 ? ' ok' : '')}>
<Ico name={drawing.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
判据一 · 图编排
<span className="st">{drawing.length === 0 ? '闭合' : `${drawing.length} 项待补`}</span>
</div>
<div className="g-exface-b">
{drawing.length === 0 ? (
<div className="g-exstamp">
编排闭合<small>CHK·1 — GRAPH CLOSED</small>
</div>
) : (
drawing.map(row)
)}
</div>
</section>
<section className="g-exface">
<div className={'g-exfacet' + (loading.length === 0 ? ' ok' : '')}>
<Ico name={loading.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
判据二 · 活动装载
<span className="st">{loading.length === 0 ? '齐' : `${loading.length} 个未链接实现`}</span>
</div>
<div className="g-exface-b">
{loading.length === 0 ? (
<div className="g-exstamp">
装载齐备<small>CHK·2 — IMPL LINKED</small>
</div>
) : (
loading.map(row)
)}
</div>
</section>
{activeReport.violations.length === 0 && (
<div className="g-exverdict">
两面都齐 —— v{pkg.version} 可运行。
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
</div>
)}
</div>
);
})()
)}
<div className="g-vercard-f">点一条定位到属性去补;两面清零,激活才亮。</div>
</aside>
{/* 右下:节点属性(图纸卡,与流程图同宽)—— 闭环(岗位/判据/映射)在这里决定 */}
<div className="g-vercard g-nodecard">
<div className="g-vercard-h">
节点 · 属性
<span className={'n' + (dirty ? ' dirty' : '')}>{dirty ? '有改动待保存' : '点节点就地填写'}</span>
<button
className="g-ver-new"
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>
)}
{/* 索引栏 = 图框分度带:节点行 + 边行,行首竖签;固定不滚,属性区在下面自己滚 */}
<div className="g-pickrail">
<div className="g-pickrow">
<span className="g-picklbl">
节点<b>{nodes.filter((n) => n.kind !== 'start' && n.kind !== 'end' && n.kind !== 'end-bad').length}</b>
</span>
<div className="g-picker">
{nodes
.filter((n) => n.kind !== 'start' && n.kind !== 'end' && n.kind !== 'end-bad')
.map((n) => (
<button
key={n.id}
className={`g-pick t-${kindTone(n)}` + (sel === n.id ? ' on' : '')}
onClick={() => setSel(sel === n.id ? null : n.id)}
>
<b className="g-mono">{n.id}</b>
{n.label}
</button>
))}
</div>
</div>
</div>
{/* key 随选中对象换 —— 换一件看,明细区滚回顶,不带上一件的滚动位 */}
<div className="g-propsheet" key={node !== null ? `n:${node.id}` : 'blank'}>
{node === null ? (
<div className="g-sheetempty">
<Ico name="target" s={20} />
<b>点画布或拣选条,选一个节点</b>
<p>
节点填名称、链接实现、往哪走(出边条件)。边不是可编辑对象 —— 它只表达顺序;
点画布上的线会选中发起它的那个节点。负责人运营时分配,不在这儿;
审批裁决(verdict)与放行选口(outcome)由系统注入判据,不用你声明。
</p>
</div>
) : (
<>
{/* 两列手排(不用自动砌体):左列 = 身份与数据口,右列 = 类型与装载 ——
卡数随节点种类变(审批点无装载卡),手排保证两列始终语义成组、高度可控 */}
<div className="g-props">
<div className="g-propcol">
<Prop ico="tag" title="身份与命名" extra={kindLabel(node)}>
<Field k="节点 ID" hint="编排内唯一,边按它连、判据按它寻址">
<input className="g-input mono" value={node.id} readOnly />
</Field>
<Field k="名称">
<input
className="g-input"
value={node.label}
placeholder="节点名称"
onChange={(e) => patch((n) => ({ ...n, label: e.target.value }))}
/>
</Field>
{node.kind === 'activity' && (
<Field k="说明" hint="画布上的第二行小字">
<input
className="g-input"
value={node.sub ?? ''}
placeholder="如:照谱执行 / 临场演绎 / 人裁节点"
onChange={(e) =>
patch((n) => ({ ...n, sub: e.target.value === '' ? undefined : e.target.value }))
}
/>
</Field>
)}
</Prop>
{node.kind === 'activity' && node.flavor !== 'approval' && (
/*
* 数据接口(对齐 Mastra):边不传数据,约束力全在输入/输出字段 ——
* 这里不填写、只**看清**:每个输入字段运行时从哪来(起单 / 上游同名 / 补全单),
* 每个输出口发什么名。要改字段,改的是活动契约,不是这张卡。
*/
<Prop
ico="git-merge"
title="数据接口 · 按字段名流动"
extra={refAct === undefined ? '先链接活动实现' : '契约即管道:名字对上,数据就到'}
>
{refAct === undefined ? (
<Note>链接活动实现后,这里列出它吃什么、吐什么、每个字段从哪来。</Note>
) : (
<>
<div className="g-dif">
<em>输入 · {refAct.contract.inputFields.length} 字段</em>
{refAct.contract.inputFields.map((f) => {
const src = fieldSources.get(f.name) ?? '补全单';
return (
<div key={f.name} className="g-dif-r">
<span className="k">
{f.name}
{f.required && <i>必填</i>}
</span>
<span className="t">{f.type}</span>
<span className={'s' + (src === '补全单' ? ' ask' : '')}>
{src === '补全单' ? '← 补全单(受理归一找人收)' : `← ${src}`}
</span>
</div>
);
})}
</div>
<div className="g-dif">
<em>输出 · {refAct.contract.outputPorts.length} 口</em>
{refAct.contract.outputPorts.map((port) => (
<div key={port.name} className="g-dif-r">
<span className="k">{port.name}</span>
<span className="f">{port.fields.length > 0 ? port.fields.join(' · ') : '(整包事实)'}</span>
</div>
))}
</div>
</>
)}
</Prop>
)}
</div>
<div className="g-propcol">
<Prop
ico="user-check"
title="类型与责任"
extra={
node.kind === 'activity'
? node.flavor === 'approval'
? '运行时产出审批单'
: '运行时产出任务'
: undefined
}
>
{node.kind === 'activity' && (
<Field k="节点类型" hint="节点只分两种 —— 实现方式是活动库的事,这里不选">
<Seg
value={node.flavor === 'approval' ? 'approval' : 'act'}
options={[
{ k: 'act', label: '活动点' },
{ k: 'approval', label: '审批点' },
]}
onPick={(k) =>
patch((n) => ({ ...n, flavor: k === 'approval' ? ('approval' as const) : undefined }))
}
/>
</Field>
)}
<Note>
编排<b>不带岗位</b> —— 负责人是运营时分配的:工作流绑了员工组按组轮值,
没绑组全员轮值(最闲者先)。审批点与活动点同一口径。
</Note>
{node.kind === 'activity' && node.flavor === 'approval' && (
<Note>
<b>审批点是系统内置要素</b>,不是活动 —— 声明即用、零开发、不进活动库(一个流程包里需要开发的
只有活动)。功能属性四件:审批岗位(复用负责人四策略解析)· 输入装配映射 · 驳回走向
(声明式:回指定节点重开新实例 / 终止)· 催办阶梯。运行时它产出<b>审批单</b>而非任务。
</Note>
)}
</Prop>
{node.kind === 'activity' && node.flavor !== 'approval' && (
<Prop ico="boxes" title="链接活动实现" extra="装载面:编排不含实现,链接决定可运行">
<Field k="实现" hint="活动住库;链好了这节点才算装载(激活判据之二)">
<select
className="g-select"
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>
</Field>
{refAct !== undefined ? (
<>
{/* 实现方式(范式/版本)是装载信息,只读展示 —— 编排面不决定它 */}
<div className="g-prop-ref">
<Chip tone={refAct.paradigm === 'loop' ? 'collab' : 'auto'} ico="git-commit">
{refAct.paradigm === 'loop' ? '实现:案例 loop' : '实现:标准 flow'}
</Chip>
<Chip tone="mute">
v{refAct.packageVersions[0]?.version ?? '—'} × a
{refAct.assetVersions.find((x) => x.effective)?.version ?? '—'}
</Chip>
</div>
<button
className="g-btn"
onClick={() => nav.jump('library', 'shelf', { k: 'act', id: refAct.activityId })}
>
<Ico name="arrow-right" s={13} />
去活动库看契约
</button>
</>
) : (
<Note tone="warn">还没链接活动库里已安装的实现 —— 图可以照画照存,激活前链上即可。</Note>
)}
</Prop>
)}
{outEdges.length > 0 && (
/*
* 出边 · 分支(2026-07-30):**分支归发起它的这个节点管**(Mastra 的
* `.branch([[条件, 下一步], …])` 就挂在上一步上)—— 所以条件填在这里,
* 边本身不再是可编辑对象:它只是画布上的一条线,表达顺序。
* 条件读的是这个节点的输出字段;留空即默认兜底(至多一条)。
*/
<Prop
ico="git-branch"
title="出边 · 往哪走"
extra={outEdges.length === 1 ? '单出边 · 直连' : `${outEdges.length} 条 · 命中即走`}
>
{outEdges.map(({ edge, index }) => (
<Field
key={edge.code ?? index}
k={`${edge.code ?? `边${index + 1}`} → ${edge.to}`}
hint={
outEdges.length === 1
? '只有一条出边,无条件直连'
: node.kind === 'activity' && node.flavor === 'approval'
? // 审批点的判据是**系统注入**的裁决,不是它自己的输出字段
'读审批裁决:verdict = "pass" / "reject";留空即默认兜底'
: '读本节点的输出字段,如 needAlternative = true;留空即默认兜底'
}
>
<input
className="g-input mono"
value={edge.condition ?? ''}
placeholder={outEdges.length === 1 ? '无条件' : '留空 = 默认兜底'}
onChange={(e) => patchEdge(index, (x) => ({ ...x, condition: e.target.value }))}
/>
</Field>
))}
</Prop>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
</Sec>
</>
);
}