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-TMP-REPO/apps/web/src/features/gov-process/internal/panel.tsx
T
ChaiHaoandClaude Fable 5 f145bde875 三面拆分⑲:审批点卸岗位 —— 负责人一律运营时分配,绑员工组,实例记 assignee
模型(用户定):审批点不绑岗位;节点负责人是运营时分配的,绑定到员工组。

- 契约:approval 节点删 approver(功能属性只剩驳回走向/催办/委托消息);
  CLOSURE_VIOLATION_KINDS 删 missing-owner-position(岗位不入编排,无岗可核);
  治理审批列表删 approverPosition 字段
- 运行时统一口径(活动点=审批点):工作流绑了员工组按组轮值(组员并集最闲者先),
  没绑组全员轮值;staffId 稳定排序保证确定性,组员全不在岗退回全员。
  ADR-011「绑定先于策略」随岗位退场 —— 工作组快照的成员集合成为轮值池
- 实例记 assignee(迁移 0014 node_instance.assignee_id):分派结果是流转事实 ——
  审批箱收件(i.assigneeId === me)、裁决权(同一判据)、员工面链条报实际承接人、
  治理台审批列表报人,全部按它认,不再有岗位比对
- closure 卸 positions 入参;desk/desk-approval/governance-ops 三路由改口;
  web 属性卡删「审批岗位」选择与警示、positions 管线拆除;种子 A5 卸 PM;
  导出依赖文件审批段只留编排语义

测试:closure 25、save-package 6、seed 8、graph-to-pkg 5、端到端八例真库全绿;
五闸退出码全过。库:迁移 + v4 就地同步(唯一生效版)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-28 21:24:41 +08:00

1097 lines
47 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,
ListEdit,
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';
import { TriggerRulesHome } from '@features/gov-trigger';
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') };
});
}
/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */
/** 映射行的工作形状 —— 语义边 mapping 的元素;设计者只见三种来源,其余是系统隐性上下文 */
type MapRow = { target: string; source: Record<string, unknown> & { from: string } };
const SOURCE_KINDS = [
{ k: 'output-port', label: '上游输出口' },
{ k: 'initiation', label: '起单载荷' },
{ k: 'constant', label: '常量' },
] as const;
function EdgeProps({ edge, onPatch }: { edge: FlowEdge; onPatch: (f: (e: FlowEdge) => FlowEdge) => void }) {
const rows = (edge.mapping ?? []) as MapRow[];
const setRows = (next: MapRow[]) => onPatch((e) => ({ ...e, mapping: next }));
const patchRow = (i: number, f: (r: MapRow) => MapRow) => setRows(rows.map((r, j) => (j === i ? f(r) : r)));
return (
<div className="g-props g-eprops">
<Prop ico="git-branch" title={`${edge.code ?? '边'} · ${edge.from} → ${edge.to}`} extra="多出边异或:命中即走;条件留空 = 默认兜底">
<Field k="S-FEEL 条件" hint="如 needSourcing = true;可用 and / or 连接。留空即无条件(默认边,至多一条)">
<input
className="g-input mono"
value={edge.condition ?? ''}
placeholder="留空 = 默认兜底"
onChange={(e) => onPatch((x) => ({ ...x, condition: e.target.value }))}
/>
</Field>
<Field k="边标签" hint="画在线旁的注记,辅助阅读,不参与判断">
<input
className="g-input"
value={edge.label ?? ''}
placeholder="如:需要选型"
onChange={(e) =>
onPatch((x) => {
const { label: _drop, ...rest } = x;
return e.target.value === '' ? rest : { ...rest, label: e.target.value };
})
}
/>
</Field>
</Prop>
<Prop ico="git-merge" title="映射 · 载荷装配" extra="下游字段 ← 来源;必填映射齐了这条边才闭合">
{rows.map((r, i) => {
const known = SOURCE_KINDS.some((k) => k.k === r.source.from);
return (
<div className="g-maprow" key={i}>
<input
className="g-input mono"
value={r.target}
placeholder="下游字段"
onChange={(e) => patchRow(i, (x) => ({ ...x, target: e.target.value }))}
/>
<span className="g-maparr">←</span>
{known ? (
<select
className="g-select"
value={r.source.from}
onChange={(e) =>
patchRow(i, (x) => ({
...x,
source:
e.target.value === 'output-port'
? { from: 'output-port', port: '', path: '' }
: e.target.value === 'initiation'
? { from: 'initiation', path: '' }
: { from: 'constant', value: '' },
}))
}
>
{SOURCE_KINDS.map((k) => (
<option key={k.k} value={k.k}>
{k.label}
</option>
))}
</select>
) : (
// workflow-var / expression 等是系统侧来源 —— 保留展示,不给编辑入口
<Chip tone="mute">{String(r.source.from)}(系统)</Chip>
)}
{r.source.from === 'output-port' && (
<>
<input
className="g-input mono"
value={String(r.source['port'] ?? '')}
placeholder="口名"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, port: e.target.value } }))}
/>
<input
className="g-input mono"
value={String(r.source['path'] ?? '')}
placeholder="字段路径"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
/>
</>
)}
{r.source.from === 'initiation' && (
<input
className="g-input mono"
value={String(r.source['path'] ?? '')}
placeholder="起单字段路径"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
/>
)}
{r.source.from === 'constant' && (
<input
className="g-input mono"
value={String(r.source['value'] ?? '')}
placeholder="常量值"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, value: e.target.value } }))}
/>
)}
<button className="g-btn" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
<Ico name="x" s={12} />
</button>
</div>
);
})}
<Acts
items={[
{
label: '加一条映射',
ico: 'plus',
onClick: () => setRows([...rows, { target: '', source: { from: 'output-port', port: '', path: '' } }]),
},
]}
/>
</Prop>
</div>
);
}
/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
export function WorkflowsHome({ tab, nav }: HomeProps) {
// 触发器的**定义面**:谁调起这条流,与流程包一处维护(启停在运营中心)
if (tab === 'rules') return <TriggerRulesHome nav={nav} />;
if (tab === 'pkgs') return <FlowShelf nav={nav} />;
return <OverviewHome />;
}
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>
<Note>
画图不检查,激活才查完整:随手画的都存得下(存出来不生效),异常清零后激活,
即刻对<b>新起的</b>工作流生效;在途的按启动时版本跑完。要回退,把生效切回旧版本 ——
一条流一次只有一个生效版本。
</Note>
</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);
/** 边选中态:graph.edges 的下标(与节点选中互斥 —— 属性卡一次编辑一件事) */
const [selEdge, setSelEdge] = useState<number | 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),
);
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
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);
};
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,
// 契约不带负责人岗位 —— 分派归运营,依赖文件只描述干什么
// 编排上手写的注记(给人读)
declaredIn: n.in ?? [],
declaredOut: n.out ?? [],
/*
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
*/
contract: actContract.get(n.activityId) ?? null,
upstreamAssembly: pkg.edges
.filter((e) => e.to === n.id)
.map((e) => ({ from: e.from, mapping: e.mapping })),
})),
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); setSelEdge(null); }}
onEdgeClick={(edge) => {
const i = (graph?.edges ?? []).findIndex(
(x) => x.from === edge.from && x.to === edge.to && x.code === edge.code,
);
if (i >= 0) { setSelEdge(i); setSel(null); }
}}
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>
<div className="g-vercard-list">
{activeReport === undefined ? (
<div className="g-exboard-empty">校验中…</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)); setSelEdge(null); }
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && nid !== undefined) { setSel(String(nid)); setSelEdge(null); }
}}
>
{nid !== undefined && <b>{String(nid)}</b>}
<span className="msg">{v.message}</span>
</div>
);
};
return (
<>
<div className={'g-exfacet' + (drawing.length === 0 ? ' ok' : '')}>
<Ico name={drawing.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
图编排 {drawing.length === 0 ? '闭合' : `${drawing.length} 项待补`}
</div>
{drawing.map(row)}
<div className={'g-exfacet' + (loading.length === 0 ? ' ok' : '')}>
<Ico name={loading.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
活动装载 {loading.length === 0 ? '齐' : `${loading.length} 个节点未链接实现`}
</div>
{loading.map(row)}
{activeReport.violations.length === 0 && (
<div className="g-exboard-empty">
两面都齐 —— 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 ? '有改动待保存' : '点节点就地填写'}</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-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); setSelEdge(null); }}
>
<b className="g-mono">{n.id}</b>
{n.label}
</button>
))}
</div>
{/* 边拣选条:边号立牌(E1、E2…),条件住属性面不上按钮 —— 图只定顺序,契约在属性里填 */}
<div className="g-picker">
{(graph?.edges ?? []).map((e, i) => (
<button
key={`${e.from}→${e.to}-${i}`}
className={'g-pick t-mute' + (selEdge === i ? ' on' : '')}
onClick={() => { setSelEdge(selEdge === i ? null : i); setSel(null); }}
>
<b className="g-mono">{e.code ?? `边${i + 1}`}</b>
{e.label ?? ''}
</button>
))}
</div>
{node === null && selEdge === null ? (
<Note>
点画布或上面的拣选条选一件 —— 节点填岗位与链接实现,边填传递契约(条件 + 映射)。
审批裁决(verdict)与放行选口(outcome)由系统注入判据,不用你声明。
</Note>
) : node === null && selEdge !== null && graph !== null ? (
<EdgeProps
edge={graph.edges[selEdge]!}
onPatch={(f) => {
setGraph((g) =>
g === null ? g : { ...g, edges: g.edges.map((e, i) => (i === selEdge ? f(e) : e)) },
);
setDirty(true);
}}
/>
) : node === null ? null : (
<>
<div className="g-props">
<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>
<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' && (
<Prop ico="git-merge" title="输入" extra="上游边映射的落点">
<ListEdit
items={node.in ?? []}
placeholder="如:客户邮件"
onChange={(v) => patch((n) => ({ ...n, in: v.length > 0 ? [...v] : undefined }))}
/>
</Prop>
)}
{node.kind === 'activity' && (
<Prop ico="upload" title="输出口" extra="下游边映射的源">
<ListEdit
items={node.out ?? []}
placeholder="如:受理记录"
onChange={(v) => patch((n) => ({ ...n, out: v.length > 0 ? [...v] : undefined }))}
/>
</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>
)}
</div>
</>
)}
</div>
</div>
</div>
</Sec>
</>
);
}