ProcessDiagram.tsx 11.9 KB
// 场景流程图(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>
  )
}