ProcessDiagram.tsx
11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
// 场景流程图(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<string, { fill: string; stroke: string }> = {
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<string, string> = {} // 元素 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<HTMLDivElement | null>(null)
const viewerRef = useRef<any>(null)
const xmlRef = useRef<string>('')
const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading') // loading | ok | error
const [err, setErr] = useState('')
const [active, setActive] = useState(0) // 当前显示的场景下标
const [selected, setSelected] = useState<any>(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<string, string> = { startEvent: '开始事件', endEvent: '结束事件', task: '任务', userTask: '任务', exclusiveGateway: '排他网关(分支判断)', parallelGateway: '并行网关', intermediateCatchEvent: '中间事件', sequenceFlow: '连线 / 分支' }
const dRow = (k: string, v: any, mono?: boolean) => (
<div style={{ marginBottom: 9 }}>
<div style={{ fontSize: 11, color: '#a0a0a6', marginBottom: 2 }}>{k}</div>
<div className={mono ? 'om-mono' : ''} style={{ fontSize: 12.5, color: '#27272a', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{(v ?? '') !== '' ? v : '—'}</div>
</div>
)
const dList = (k: string, arr?: string[]) => (arr && arr.length > 0 ? (
<div style={{ marginBottom: 9 }}>
<div style={{ fontSize: 11, color: '#a0a0a6', marginBottom: 3 }}>{k}</div>
{arr.map((x, i) => <div key={i} className="om-mono" style={{ fontSize: 11.5, color: '#3f3f46', background: '#f7f7f9', borderRadius: 6, padding: '5px 8px', marginBottom: 4, whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{x}</div>)}
</div>
) : 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 ? <div style={{ fontSize: 12, color: '#a0a0a6' }}>这是一条无条件连线。</div> : null}
</>)
}
if (sel.kind === 'exclusiveGateway' || sel.kind === 'parallelGateway') {
return (
<div>
<div style={{ fontSize: 11, color: '#a0a0a6', marginBottom: 4 }}>分支({(sel.branches || []).length})</div>
{(sel.branches || []).map((b: any, i: number) => (
<div key={i} style={{ border: '1px solid #eee', borderRadius: 8, padding: '7px 9px', marginBottom: 6 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#27272a' }}>{b.label || '(无标签)'} <span style={{ color: '#a0a0a6', fontWeight: 400 }}>→ {b.to}</span></div>
{b.condition ? <div className="om-mono" style={{ fontSize: 11.5, color: '#52525b', marginTop: 3, whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{b.condition}</div> : null}
</div>
))}
</div>
)
}
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 <div style={{ fontSize: 12, color: '#a0a0a6' }}>{sel.kind === 'startEvent' ? '流程开始。' : sel.kind === 'endEvent' ? '流程结束。' : '(无更多详情)'}</div>
}
if (empty) {
return (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#a0a0a6', fontSize: 14 }}>暂无可视化的场景流程</div>
)
}
return (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<div ref={boxRef} style={{ position: 'absolute', inset: 0 }} />
{scenes.length > 1 && (
<div style={{ position: 'absolute', top: 12, left: 14, display: 'flex', flexWrap: 'wrap', gap: 6, maxWidth: 'calc(100% - 80px)', zIndex: 5 }}>
{scenes.map((pm, i) => (
<div key={pm.id} onClick={() => 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}
</div>
))}
</div>
)}
{status === 'loading' && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#a0a0a6', fontSize: 14 }}>正在生成 BPMN 流程图…</div>
)}
{status === 'error' && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#c0392b', fontSize: 13, padding: 24, textAlign: 'center' }}>流程图渲染失败:{err}</div>
)}
<div style={{ position: 'absolute', top: 12, right: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={btn} onClick={() => zoom(1.2)}>+</div>
<div style={btn} onClick={() => zoom(1 / 1.2)}>-</div>
<div style={{ ...btn, fontSize: 13 }} onClick={fit} title="适应画布">⤢</div>
<div style={{ ...btn, fontSize: 15 }} onClick={download} title="导出 .bpmn 文件">⬇</div>
</div>
{selected && (
<div style={{ position: 'absolute', top: 12, right: 56, width: 300, maxHeight: 'calc(100% - 24px)', overflowY: 'auto', background: '#fff', border: '1px solid #e6e6e6', borderRadius: 12, boxShadow: '0 8px 28px rgba(0,0,0,.14)', zIndex: 8, padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 10 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#18181b' }}>{selected.name || selected.kindLabel || KIND_LABEL[selected.kind] || selected.kind}</div>
<div style={{ fontSize: 11.5, color: '#a0a0a6', marginTop: 2 }}>{(selected.kindLabel || KIND_LABEL[selected.kind] || selected.kind)}{selected.lane ? ' · ' + selected.lane : ''}</div>
</div>
<div onClick={() => setSelected(null)} title="关闭" style={{ cursor: 'pointer', color: '#a0a0a6', fontSize: 18, lineHeight: 1, flex: 'none' }}>✕</div>
</div>
{renderDetailBody(selected)}
</div>
)}
<div style={{ position: 'absolute', bottom: 14, left: 14, fontSize: 11, color: '#c4c4cc', pointerEvents: 'none' }}>滚轮缩放 · 拖拽平移 · 点击节点/连线看详情 · 由 bpmn-js 渲染</div>
</div>
)
}