// 场景流程图(BPMN)视图 —— 通用渲染引擎,数据全在前端合成,无后端依赖。 // ProcessModel(M4 flowSteps 投影) → toElkBpmn 出 ELK-BPMN JSON(含泳道语义) // → bpmn-elk-layout(elkjs) 自动横向布局 → 按类型着色 → bpmn-js NavigatedViewer 渲染。 // 点击任意节点/连线 → 用 detailMap(按 element.id 索引)弹出详情(完整条件、关联行为、网关分支等)。 // 从 onto-app 的 BpmnDiagram.jsx 移植而来(JSX→TSX);唯一区别:ELK JSON + 详情表由本地 // toElkBpmn / detailMap 合成,而非后端 api.getBpmn 拉取。 import React, { useEffect, useRef, useState } from 'react' // @ts-ignore bpmn-js 的 NavigatedViewer 附带自身类型声明;此处兜底防止解析差异。 import NavigatedViewer from 'bpmn-js/lib/NavigatedViewer' import { BpmnElkLayout } from 'bpmn-elk-layout' import 'bpmn-js/dist/assets/diagram-js.css' import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css' import type { Ontology } from '../ontology/types' import { toElkBpmn, detailMap } from '../ontology/bpmn' // 按 BPMN 元素类型上色(bpmn-js 认 bioc/color 两套 DI 着色扩展)。配色对齐本体网络图。 const NODE_COLOR: Record = { startEvent: { fill: '#e9f7ee', stroke: '#3f9d63' }, endEvent: { fill: '#fdecef', stroke: '#d6486a' }, intermediateCatchEvent: { fill: '#fbe3ef', stroke: '#c2528a' }, userTask: { fill: '#eef2fd', stroke: '#5b7fe0' }, task: { fill: '#eef2fd', stroke: '#5b7fe0' }, exclusiveGateway: { fill: '#fff4e3', stroke: '#e0944a' }, parallelGateway: { fill: '#fff4e3', stroke: '#e0944a' }, } function colorize(xml: string): string { const doc = new DOMParser().parseFromString(xml, 'application/xml') const typeOf: Record = {} // 元素 id -> BPMN 类型(从语义部分收集) for (const el of Array.from(doc.getElementsByTagName('*'))) { const id = el.getAttribute && el.getAttribute('id') if (id && NODE_COLOR[el.localName]) typeOf[id] = el.localName } for (const el of Array.from(doc.getElementsByTagName('*'))) { if (el.localName !== 'BPMNShape') continue const be = el.getAttribute('bpmnElement') const c = be ? NODE_COLOR[typeOf[be]] : undefined if (!c) continue // 泳道/池保持白底 el.setAttribute('bioc:fill', c.fill) el.setAttribute('bioc:stroke', c.stroke) el.setAttribute('color:background-color', c.fill) el.setAttribute('color:border-color', c.stroke) } const defs = doc.documentElement defs.setAttribute('xmlns:bioc', 'http://bpmn.io/schema/bpmn/biocolor/1.0') defs.setAttribute('xmlns:color', 'http://www.omg.org/spec/BPMN/non-normative/color/1.0') return new XMLSerializer().serializeToString(doc) } export default function ProcessDiagram({ ontology }: { ontology: Ontology }) { const boxRef = useRef(null) const viewerRef = useRef(null) const xmlRef = useRef('') const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading') // loading | ok | error const [err, setErr] = useState('') const [active, setActive] = useState(0) // 当前显示的场景下标 const [selected, setSelected] = useState(null) // 点中的图元详情(节点/连线) const scenes = ontology.processModels || [] const empty = scenes.length === 0 // 切本体时复位到第一张 useEffect(() => { setActive(0) }, [ontology]) useEffect(() => { if (empty || !boxRef.current) return let cancelled = false const viewer: any = new NavigatedViewer({ container: boxRef.current }) viewerRef.current = viewer setSelected(null) ;(async () => { try { setStatus('loading') const pm = scenes[active] if (!pm) return const elk = toElkBpmn(pm) // ProcessModel → ELK-BPMN JSON(前端合成,替代后端) const details = detailMap(pm, ontology) // bpmn 元素 id → 点击详情(前端合成) const laid = await new BpmnElkLayout().to_bpmn(elk) // elkjs 布局 → 带坐标+泳道的原生横向 BPMN XML const xml = colorize(laid) // 只按类型着色(标签由布局库摆放) if (cancelled) return xmlRef.current = xml await viewer.importXML(xml) if (cancelled) return viewer.get('canvas').zoom('fit-viewport', 'auto') // 'auto' = 缩放后在视口内居中(否则窄图会贴左) // 点击节点/连线 → 查详情;点空白/泳道(无 details)则关闭面板 viewer.get('eventBus').on('element.click', (ev: any) => { const d = details[ev.element?.id] setSelected(d ? { ...d, _id: ev.element.id } : null) }) setStatus('ok') } catch (e: any) { if (!cancelled) { setErr(String(e?.message || e)); setStatus('error') } } })() return () => { cancelled = true; viewer.destroy() } }, [ontology, active, empty]) const zoom = (f: number) => { const c = viewerRef.current?.get('canvas'); if (c) c.zoom(c.zoom() * f) } const fit = () => { const c = viewerRef.current?.get('canvas'); if (c) c.zoom('fit-viewport', 'auto') } const download = () => { if (!xmlRef.current) return const blob = new Blob([xmlRef.current], { type: 'application/xml' }) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = (scenes[active]?.name || 'process') + '.bpmn' a.click() URL.revokeObjectURL(a.href) } const btn: React.CSSProperties = { width: 30, height: 30, borderRadius: 8, border: '1px solid #e6e6e6', background: '#fff', color: '#52525b', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' } // ---- 详情面板 ---- const KIND_LABEL: Record = { startEvent: '开始事件', endEvent: '结束事件', task: '任务', userTask: '任务', exclusiveGateway: '排他网关(分支判断)', parallelGateway: '并行网关', intermediateCatchEvent: '中间事件', sequenceFlow: '连线 / 分支' } const dRow = (k: string, v: any, mono?: boolean) => (
{k}
{(v ?? '') !== '' ? v : '—'}
) const dList = (k: string, arr?: string[]) => (arr && arr.length > 0 ? (
{k}
{arr.map((x, i) =>
{x}
)}
) : null) const renderDetailBody = (sel: any) => { if (sel.kind === 'sequenceFlow') { return (<> {dRow('流向', `${sel.from} → ${sel.to}`)} {sel.name ? dRow('分支标签', sel.name) : null} {dRow('完整条件 (condition)', sel.condition, true)} {!sel.condition ?
这是一条无条件连线。
: null} ) } if (sel.kind === 'exclusiveGateway' || sel.kind === 'parallelGateway') { return (
分支({(sel.branches || []).length})
{(sel.branches || []).map((b: any, i: number) => (
{b.label || '(无标签)'} → {b.to}
{b.condition ?
{b.condition}
: null}
))}
) } const b = sel.behavior if (b) { return (<> {dRow('关联行为', `${b.name}(${b.id})`)} {dRow('行为类型', b.type === 'COMMAND' ? 'COMMAND(改状态)' : b.type)} {b.commandType ? dRow('命令类型 (commandType)', b.commandType) : null} {dRow('归属对象', b.owner)} {b.description ? dRow('说明', b.description) : null} {dList('前置条件', b.preconditions)} {dList('后置赋值', b.effects)} {dList('产出事件', b.produced_events)} {dList('适用规则', b.applied_rules)} ) } return
{sel.kind === 'startEvent' ? '流程开始。' : sel.kind === 'endEvent' ? '流程结束。' : '(无更多详情)'}
} if (empty) { return (
暂无可视化的场景流程
) } return (
{scenes.length > 1 && (
{scenes.map((pm, i) => (
setActive(i)} title={pm.name} style={{ padding: '6px 12px', fontSize: 12.5, borderRadius: 8, cursor: 'pointer', border: '1px solid ' + (i === active ? '#18181b' : '#e6e6e6'), background: i === active ? '#18181b' : '#fff', color: i === active ? '#fff' : '#52525b', fontWeight: i === active ? 600 : 500, whiteSpace: 'nowrap', boxShadow: '0 1px 3px rgba(0,0,0,.06)' }}> {pm.name}
))}
)} {status === 'loading' && (
正在生成 BPMN 流程图…
)} {status === 'error' && (
流程图渲染失败:{err}
)}
zoom(1.2)}>+
zoom(1 / 1.2)}>-
{selected && (
{selected.name || selected.kindLabel || KIND_LABEL[selected.kind] || selected.kind}
{(selected.kindLabel || KIND_LABEL[selected.kind] || selected.kind)}{selected.lane ? ' · ' + selected.lane : ''}
setSelected(null)} title="关闭" style={{ cursor: 'pointer', color: '#a0a0a6', fontSize: 18, lineHeight: 1, flex: 'none' }}>✕
{renderDetailBody(selected)}
)}
滚轮缩放 · 拖拽平移 · 点击节点/连线看详情 · 由 bpmn-js 渲染
) }