Vue3+AntV X6 打造流程设计器

13,114 阅读4分钟

流程设计是信息系统中常用的功能,下面就是如何用Vue3+AntV X6来打造一个流程设计器。先上效果图与代码,之后再对核心代码片段进行说明。

1631950921(1).png

流程设计器:究极死胖兽/sps-flow-design (gitee.com)

注:系统基于笔者自己写的模板 究极死胖兽/sps-vite-simple (gitee.com)开发。

整体布局

组件分为四个区域:

  1. 头部区域:标题与工具栏,可拓展更多的操作按钮。
  2. 主体左侧:节点库,可拓展更多类型的节点
  3. 主体中部:流程面板
  4. 主体右侧:流程/节点/操作配置,可拓展更多的配置项 设计器的实现的功能为,从节点库中将所需节点拖拽入流程面板中,在面板中可进行删除,移动,连线等操作,选中节点/操作后,可对其data中挂载的内容进行配置,未选中时对流程自身属性进行配置,并可将流程及其节点/操作以Json格式输出。
<a-layout class="sps-flow-design h-full w-full">
  {/* 标题与工具栏 */}
  <a-layout-header class="flex">
    <div class="flex-1">Sps-Flow-Design</div>
    <div>
      <a-button onClick={ modalShow }>Json</a-button>
    </div>
  </a-layout-header>
  {/* 设计器主体 */}
  <a-layout>
    <a-layout-content class="flex">
      {/* 节点库 */}
      <div ref={ stencilContanerRef } class="w-60 h-full relative border-r-2 border-gray-300"></div>
      {/* 流程面板 */}
      <div ref={ graphContanerRef } class="h-full flex-1" style={{ height: '100%' }}></div>
    </a-layout-content>
    {/* 流程/节点/操作 配置 */}
    <a-layout-sider width={ 300 }>
      {
        //@ts-ignore
        flowState.currentCell ? <CellConfig onSubmit={ updateCell } /> : <FlowConfig />
      }
    </a-layout-sider>
  </a-layout>
</a-layout>

流程面板初始化

graph = new Graph({
  container: graphContainer,
  grid: true, // 显示网格线
  connecting: {
    router: 'manhattan', // 连接线路由算法
    connector: {
      name: 'rounded', // 连接线圆角
      args: {
        radius: 20
      }
    },
    snap: {
      radius: 50 // 锚点吸附半径
    },
    allowBlank: false, // 禁用连线到空处
    allowMulti: false, // 禁用在同一对节点中多条连线
    allowLoop: false, // 禁用自循环连线
    connectionPoint: 'anchor' // 连接点为锚点
  },
  selecting: {
    enabled: true, // 节点/边 可选中
    showNodeSelectionBox: true, // 节点选中后边框
    showEdgeSelectionBox: true // 边选中后边框
  },
  snapline: true, // 启用对齐线
  keyboard: true, // 启用键盘事件
  clipboard: true, // 启用粘贴板
  history: true // 启用历史记录
})

节点库初始化

使用x6中的Stencil插件:

const stencil = new Addon.Stencil({
  title: '流程节点',
  target: graph, // 绑定流程面板
  stencilGraphWidth: 200, // 节点库宽度
  stencilGraphHeight: 600, // 节点库高度
  layoutOptions: {
    columns: 2, // 每行节点数
    rowhHeight: 40 // 行高
  }
})
stencilContainer.appendChild(stencil.container)

自定义节点

注册自定义节点基类

export const registerNode = () => {
  // 注册自定义圆形
  Graph.registerNode('sps-circle', {
    inherit: 'circle',
    width: 45,
    height: 45,
    attrs,
    ports: createPorts() // 锚点配置
  })

  // 注册自定义矩形
  Graph.registerNode('sps-rect', {
    inherit: 'rect',
    width: 66,
    height: 36,
    attrs,
    ports: createPorts()
  })
}

根据自定义节点基类创建节点:

const createNodes = (graph: Graph) => {
  const startNode = graph.createNode({
    shape: 'sps-circle',
    label: '开始'
  })

  const eventNode = graph.createNode({
    shape: 'sps-rect',
    label: '事件',
    attrs: {
      body: {
        rx: 20,
        ry: 20
      }
    }
  })

  const endNode = graph.createNode({
    shape: 'sps-circle',
    label: '结束'
  })

  return {
    startNode,
    eventNode,
    endNode
  }
}

将创建的自定义节点加入到节点库中:

const { startNode, eventNode, endNode } = createNodes(graph)
stencil.load([startNode, eventNode, endNode])

自定义事件

键盘事件

export default function registerKeyboardEvent (graph: Graph) {
  // #region 复制 剪切 粘贴
  graph.bindKey(_createCtrlKey('c'), () => {
    const cells = graph.getSelectedCells()
    if (cells.length) {
      graph.copy(cells)
    }
    return false
  })

  graph.bindKey(_createCtrlKey('x'), () => {
    const cells = graph.getSelectedCells()
    if (cells.length) {
      graph.cut(cells)
    }
    return false
  })

  graph.bindKey(_createCtrlKey('v'), () => {
    if (!graph.isClipboardEmpty()) {
      const cells = graph.paste({ offset: 32 })
      graph.cleanSelection()
      graph.select(cells)
    }
    return false
  })
  // #endregion

  // #region 撤销 重做
  graph.bindKey(_createCtrlKey('z'), () => {
    if (graph.history.canUndo()) {
      graph.history.undo()
    }
    return false
  })
  graph.bindKey(_createCtrlKey('z', true), () => {
    if (graph.history.canRedo()) {
      graph.history.redo()
    }
    return false
  })
  // #endregion

  // 删除
  graph.bindKey('backspace', () => {
    const cells = graph.getSelectedCells()
    if (cells.length) {
      graph.removeCells(cells)
    }
  })
}

节点/边事件

export default function registerNodeEvent (graph: Graph, container: HTMLElement, state: FlowState) {
  // #region 控制连接桩显示/隐藏
  const showPorts = (ports: any, show: boolean) => {
    for (let i = 0, len = ports.length; i < len; i = i + 1) {
      ports[i].style.visibility = show ? 'visible' : 'hidden'
    }
  }
  graph.on('node:mouseenter', () => {
    const ports = container.querySelectorAll(
      '.x6-port-body'
    )
    showPorts(ports, true)
  })
  graph.on('node:mouseleave', () => {
    const ports = container.querySelectorAll(
      '.x6-port-body'
    )
    showPorts(ports, false)
  })
  // #endregion

  // #region 节点/边 选中/取消选中
  graph.on('cell:selected', ({ cell }) => {
    state.currentCell = {
      id: cell.id,
      ...cell.data,
      isNode: cell.isNode()
    }
  })
  graph.on('cell:unselected', () => {
    state.currentCell = null
  })
  // #endregion
}

状态管理

本组件采用provide/inject的方式来进行状态管理

详见:Vue3+TS 优雅地使用状态管理 - 掘金 (juejin.cn)

export interface FlowState {
  currentCell: any
  flow: any
}

export const createFlowState = () => {
  const state: FlowState = reactive({
    currentCell: null,
    flow: {
      id: v4()
    }
  })

  return state
}

export const flowStateKey: InjectionKey<FlowState> = Symbol('FlowState')

export const useFlowState = () => {
  const state = inject(flowStateKey)!
  return {
    state
  }
}

配置

在节点配置组件与流程配置组件中通过useFlowState实现与根组件的交互。

节点配置

目前的配置项仅作为示例,在完善的系统中,审批人应当与用户角色模块相结合。后续还可将节点与表单进行绑定。

export default defineComponent({
  name: 'CellConfig',
  emits: ['submit'],
  setup (_, { emit }) {
    const { state } = useFlowState()
    const submit = () => {
      emit('submit', state.currentCell!)
    }
    /* render 函数 */
    return () => {
      const cell = state.currentCell!
      const text = cell.isNode ? '节点' : '操作'
      const nodeConfig = (
        <a-form-item label="审批人">
          <a-input v-model={[cell.audit, 'value']} />
        </a-form-item>
      )
      return (
        <a-form class="p-1" layout="vertical" model={ cell }>
          <a-form-item label={`${text}ID`}>{ cell.id }</a-form-item>
          <a-form-item label={`${text}名称`}>
            <a-input v-model={[cell.name, 'value']} />
          </a-form-item>
          { cell.isNode && nodeConfig }
          <a-form-item>
            <a-button type="primary" onClick={ submit }><i class='far fa-save'></i> 保存</a-button>
          </a-form-item>
        </a-form>
      )
    }
  }
})

流程配置

export default defineComponent({
  name: 'FlowConfig',
  setup () {
    const { state } = useFlowState()
    /* render 函数 */
    return () => {
      const flow = state.flow
      return (
        <a-form class="p-1" layout="vertical" model={ flow }>
          <a-form-item label="流程ID">{ flow.id }</a-form-item>
          <a-form-item label="流程名称">
            <a-input v-model={[flow.name, 'value']} />
          </a-form-item>
        </a-form>
      )
    }
  }
})

Json输出

将流程相关内容转化为JSON,在完善的系统中,可将其内容存到数据库中,后续就可使用graph.fromJSON(data)方法生成流程图。

const graphToJson = () => {
    return {
      ...state.flow,
      nodes: graph.getNodes().map(item => {
        // @ts-ignore
        const { id, shape, label, ports: { items }, data } = item
        const { x, y } = item.position()
        return {
          id,
          shape,
          label,
          x,
          y,
          ports: {
            items
          },
          data
        }
      }),
      edges: graph.getEdges().map(item => ({
        target: item.target,
        source: item.source,
        data: item.data
      }))
    }
  }