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 Opus 5 448285776c 删掉流程详情的「契约细节」段 —— 页面到节点属性为止
它的三块都已有更好的去处或本就冗余:边映射的问题由异常看板逐条点名(还能点击定位),
调用接口在画布「开始」节点的属性里,触发器有自己的页签。
连带清掉因此变成死代码的整页 EdgesPage(路由类型 / 壳分发 / 出口 / 死 import 一并删,
不留只进不出的入口)。

详情页现在就四件:版本卡 · 流程图 · 异常看板 · 节点属性。

typecheck ✓ lint ✓ depcruise ✓ 506 测试 ✓

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

989 lines
41 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 是流程图,
* 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。
*
* 版本是**运行时开关**,不是发布流程:
* · 保存 = 严格检查 + 存为新版 + 即刻生效(不闭合存不进来)
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/
import { useMemo, 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, useGovStaff } 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 { FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import type { SpPosition } from '@web-shared/gov/flow/support.tsx';
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') };
});
}
/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
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: 'e-start-end', 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({
mutationFn: () =>
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
body: { pkg: minimalFlow(nfId, nfName.trim()) },
}),
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,
gateway: shown.nodes.filter((n) => n.kind === 'gateway').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">
网关 {c.gateway}
</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 staffQ = useGovStaff();
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 ?? []}
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
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,
positions,
nextVersion,
closedOf,
onPick,
onSaved,
}: {
pkg: ProcessPackage;
versions: readonly ProcessPackage[];
activeVersion: number | undefined;
nav: Nav;
activities: readonly ActivityRow[];
positions: SpPosition[];
nextVersion: number;
/** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */
closedOf: (version: number) => boolean | undefined;
onPick: (version: number) => void;
onSaved: (version: number) => void;
}) {
const queryClient = useQueryClient();
/*
* 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 ——
* 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。
*/
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, [], paradigms));
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) =>
(semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId) || a.name === node.label,
);
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
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.flavor === 'case'
? '案例活动 · loop'
: '标准活动 · flow'
: n.kind === 'gateway'
? n.mode === 'and'
? '并行网关'
: '判断网关'
: n.kind === 'callout'
? '卡点'
: n.kind === 'start'
? '调用接口'
: '显式终点';
const kindTone = (n: FlowNode) =>
n.kind === 'activity'
? n.flavor === 'approval'
? 'warn'
: n.flavor === 'case'
? 'collab'
: 'auto'
: n.kind === 'callout'
? 'danger'
: '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 } }),
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{nextVersion}」。
</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,
owner: n.owner,
// 编排上手写的注记(给人读)
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, approver: n.approver, 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="编排工作区"
extra={`画的是 v${pkg.version} —— 保存存为 v${nextVersion}(不生效);画图不检查,激活才查完整`}
>
<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)}
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>
) : activeReport.violations.length === 0 ? (
<div className="g-exboard-empty">
没有异常 —— v{pkg.version} 的编排闭合了。
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
</div>
) : (
activeReport.violations.map((v, i) => {
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>
);
})
)}
</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${nextVersion}`}
</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)}
>
<b className="g-mono">{n.id}</b>
{n.label}
</button>
))}
</div>
{node === null ? (
<Note>点画布上的节点(或上面的节点条)选一个 —— 属性就在下面填,网关判据与卡点去向同样在此改。</Note>
) : (
<>
<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 ?? 'flow'}
options={[
{ k: 'flow', label: '标准 · flow' },
{ k: 'case', label: '案例 · loop' },
{ k: 'approval', label: '审批点' },
]}
onPick={(k) =>
patch((n) => ({ ...n, flavor: k === 'flow' ? undefined : (k as 'case' | 'approval') }))
}
/>
</Field>
)}
{node.kind === 'gateway' && (
<Field k="网关模式" hint="× 判断分流 / + 并行同走">
<Seg
value={node.mode ?? 'x'}
options={[
{ k: 'x', label: '判断 ×' },
{ k: 'and', label: '并行 +' },
]}
onPick={(k) => patch((n) => ({ ...n, mode: k as 'x' | 'and' }))}
/>
</Field>
)}
<Field
k={node.kind === 'gateway' ? '判定岗位' : node.flavor === 'approval' ? '审批岗位' : '责任岗位'}
hint={node.flavor === 'approval' ? '审批单派给它' : '任务的负责人由它解析而来'}
>
<select
className="g-select"
value={node.owner ?? ''}
onChange={(e) =>
patch((n) => ({ ...n, owner: e.target.value === '' ? undefined : e.target.value }))
}
>
<option value="">未指定 —— 保存前选一个岗位</option>
{positions.map((q2) => (
<option key={q2.id} value={q2.abbr}>
{q2.abbr} · {q2.name}
</option>
))}
</select>
</Field>
{node.kind === 'activity' && node.flavor === 'approval' && (
<Note>
<b>审批点是系统内置要素</b>,不是活动 —— 声明即用、零开发、不进活动库(一个流程包里需要开发的
只有活动)。功能属性四件:审批岗位(复用负责人四策略解析)· 输入装配映射 · 驳回走向
(声明式:回指定节点重开新实例 / 终止)· 催办阶梯。运行时它产出<b>审批单</b>而非任务。
</Note>
)}
{node.owner === undefined && (
<Note tone="warn">
这个节点还没指定岗位 —— 不指定,起单时就不知道
{node.flavor === 'approval' ? '审批单' : '任务'}该派给谁。
</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 === 'gateway' && (
<Prop ico="shield-check" title="判据" extra="条件用 S-FEEL 子集,静态可检">
<textarea
className="g-ta"
rows={3}
value={node.basis ?? ''}
placeholder="如:台账 90 天内有成交锚点则直接定价,否则先寻源"
onChange={(e) =>
patch((n) => ({ ...n, basis: e.target.value === '' ? undefined : e.target.value }))
}
/>
</Prop>
)}
{node.kind === 'callout' && (
<Prop ico="rotate-ccw" title="去向" extra="失败走向 / 驳回去向">
<Field k="回到节点">
<select
className="g-select"
value={node.to ?? ''}
onChange={(e) =>
patch((n) => ({ ...n, to: e.target.value === '' ? undefined : e.target.value }))
}
>
<option value="">未指定</option>
{nodes
.filter((n) => n.kind === 'activity')
.map((n) => (
<option key={n.id} value={n.id}>
{n.id} · {n.label}
</option>
))}
</select>
</Field>
</Prop>
)}
{node.kind === 'activity' && node.flavor !== 'approval' && (
<Prop ico="boxes" title="引用的活动" extra="活动住库,编排只引用">
{refAct !== undefined ? (
<>
<div className="g-prop-ref">
<Chip
tone={refAct.paradigm === 'loop' ? 'collab' : 'auto'}
ico={refAct.paradigm === 'loop' ? 'repeat' : '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>
</>
);
}