| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- <template>
- <div class="flow-editor">
- <el-card>
- <div slot="header">
- <span>编排画布{{ flowId ? '(编辑)' : '(新建)' }}</span>
- <div style="float:right">
- <el-button size="small" @click="saveDraft">保存草稿</el-button>
- <el-button size="small" type="primary" @click="publishFlow">发布</el-button>
- <el-button size="small" @click="$router.push('/orchestration')">返回列表</el-button>
- </div>
- </div>
- <el-form :inline="true">
- <el-form-item label="流名称">
- <el-input v-model="flowName" placeholder="请输入编排流名称" style="width:250px"></el-input>
- </el-form-item>
- <el-form-item label="描述">
- <el-input v-model="flowDescription" placeholder="可选" style="width:300px"></el-input>
- </el-form-item>
- </el-form>
- <div class="editor-layout">
- <!-- 左侧:任务模板面板 -->
- <div class="node-palette">
- <div class="palette-title">任务模板(拖拽到画布)</div>
- <div
- v-for="tpl in templates"
- :key="tpl.id"
- class="palette-item"
- :draggable="true"
- @dragstart="onTemplateDragStart($event, tpl)"
- >
- {{ tpl.title || ('模板#' + tpl.id) }}
- </div>
- <div v-if="!templates.length" class="palette-empty">暂无模板</div>
- </div>
- <!-- 中央:jsPlumb 画布 -->
- <div class="canvas-wrap">
- <div ref="canvas" class="jsplumb-canvas" @dragover.prevent @drop="onCanvasDrop"></div>
- <div class="canvas-tip">拖拽左侧模板到画布创建节点,从节点圆点连线</div>
- </div>
- <!-- 右侧:属性面板 -->
- <div class="prop-panel">
- <div class="prop-title">节点/边属性</div>
- <template v-if="selectedNode">
- <el-form label-width="90px" size="mini">
- <el-form-item label="节点ID">
- <el-input v-model="selectedNode.id" :disabled="true"></el-input>
- </el-form-item>
- <el-form-item label="是否起点">
- <el-switch v-model="selectedNode.is_start_node"></el-switch>
- </el-form-item>
- <el-form-item label="执行人">
- <el-select v-model="selectedNode.executorType" style="width:100%">
- <el-option label="孩子" value="child"></el-option>
- <el-option label="成员" value="member"></el-option>
- </el-select>
- </el-form-item>
- <el-form-item label="超时(分钟)">
- <el-input-number v-model="selectedNode.timeout_minutes" :min="0"></el-input-number>
- </el-form-item>
- <el-form-item label="最大循环">
- <el-input-number v-model="selectedNode.max_loops" :min="0"></el-input-number>
- </el-form-item>
- <el-form-item label="删除节点">
- <el-button type="danger" size="mini" @click="deleteSelectedNode">删除</el-button>
- </el-form-item>
- </el-form>
- </template>
- <template v-else-if="selectedEdge">
- <el-form label-width="90px" size="mini">
- <el-form-item label="边类型">
- <el-select v-model="selectedEdge.type" style="width:100%">
- <el-option label="普通" value="normal"></el-option>
- <el-option label="失败兜底" value="failed"></el-option>
- <el-option label="超时兜底" value="timeout"></el-option>
- </el-select>
- </el-form-item>
- <el-form-item label="触发语义">
- <el-select v-model="selectedEdge.operator" style="width:100%">
- <el-option label="AND(全部完成)" value="AND"></el-option>
- <el-option label="OR(任一完成)" value="OR"></el-option>
- </el-select>
- </el-form-item>
- <el-form-item label="删除连线">
- <el-button type="danger" size="mini" @click="deleteSelectedEdge">删除</el-button>
- </el-form-item>
- </el-form>
- </template>
- <div v-else class="prop-empty">点击节点或连线编辑属性</div>
- </div>
- </div>
- </el-card>
- </div>
- </template>
- <script>
- import jsPlumb from 'jsplumb'
- import { saveFlow, publishFlow, getFlowDetail } from '@/api/orchestration'
- import { getTaskTemplateList } from '@/api/admin'
- let _nodeSeq = 0
- export default {
- name: 'FlowEditor',
- data() {
- return {
- flowId: this.$route.params.id ? Number(this.$route.params.id) : null,
- flowName: '',
- flowDescription: '',
- templates: [],
- nodes: [], // { id, task_template_ref, x, y, is_start_node, executorType, timeout_minutes, max_loops }
- edges: [], // { id, from, to, type, operator }
- selectedNode: null,
- selectedEdge: null,
- jsplumbInstance: null,
- nextNodeSeq: 1,
- canvasBounds: null
- }
- },
- created() {
- this.loadTemplates()
- if (this.flowId) {
- this.loadFlowDetail()
- }
- },
- mounted() {
- this.initJsPlumb()
- },
- beforeDestroy() {
- if (this.jsplumbInstance) {
- this.jsplumbInstance.reset()
- }
- },
- methods: {
- async loadTemplates() {
- try {
- const res = await getTaskTemplateList({ page: 1, size: 100 })
- this.templates = res.data.records || res.data || []
- } catch (e) {
- console.error('加载任务模板失败', e)
- this.templates = []
- }
- },
- async loadFlowDetail() {
- try {
- const res = await getFlowDetail(this.flowId)
- const flow = res.data.flow || {}
- this.flowName = flow.name || ''
- this.flowDescription = flow.description || ''
- const cfg = flow.configJson ? JSON.parse(flow.configJson) : { nodes: [], config: { edges: [] } }
- const nodeList = cfg.nodes || []
- this.nodes = nodeList.map(n => ({
- id: n.id,
- task_template_ref: n.task_template_ref,
- x: n.x || 100,
- y: n.y || 100,
- is_start_node: !!n.is_start_node,
- executorType: n.executorType || 'child',
- timeout_minutes: n.timeout_minutes || 0,
- max_loops: (n.loop_termination && n.loop_termination.max_loops) || n.max_loops || 0
- }))
- this.edges = (cfg.config && cfg.config.edges) || []
- // 等待 DOM 渲染后再画节点和连线
- this.$nextTick(() => {
- this.renderAllNodes()
- this.renderAllEdges()
- })
- } catch (e) {
- console.error('加载编排流失败', e)
- }
- },
- initJsPlumb() {
- const el = this.$refs.canvas
- this.jsplumbInstance = jsPlumb.jsPlumb.getInstance({
- Container: el,
- Connector: ['Bezier', { curviness: 50 }],
- Endpoint: ['Dot', { radius: 6 }],
- EndpointStyle: { fill: '#409EFF' },
- PaintStyle: { stroke: '#909399', strokeWidth: 2 },
- Anchor: ['Left', 'Right', 'Top', 'Bottom']
- })
- this.jsplumbInstance.bind('connection', (info) => {
- const from = info.sourceId
- const to = info.targetId
- if (this.edges.some(e => e.from === from && e.to === to)) {
- // 重复连线,移除
- setTimeout(() => this.jsplumbInstance.deleteConnection(info.connection), 0)
- return
- }
- const edge = { id: 'e_' + Date.now(), from, to, type: 'normal', operator: 'AND' }
- this.edges.push(edge)
- this.jsplumbInstance.setPaintStyle(info.connection, { stroke: '#909399', strokeWidth: 2 })
- })
- this.jsplumbInstance.bind('click', (conn) => {
- const edge = this.edges.find(e => e.from === conn.sourceId && e.to === conn.targetId)
- this.selectedEdge = edge
- this.selectedNode = null
- })
- this.jsplumbInstance.bind('dblclick', (conn) => {
- this.jsplumbInstance.deleteConnection(conn)
- const idx = this.edges.findIndex(e => e.from === conn.sourceId && e.to === conn.targetId)
- if (idx >= 0) this.edges.splice(idx, 1)
- this.selectedEdge = null
- })
- this.canvasBounds = el.getBoundingClientRect()
- },
- onTemplateDragStart(event, tpl) {
- event.dataTransfer.setData('text/plain', JSON.stringify(tpl))
- },
- onCanvasDrop(event) {
- const raw = event.dataTransfer.getData('text/plain')
- if (!raw) return
- try {
- const tpl = JSON.parse(raw)
- const rect = this.$refs.canvas.getBoundingClientRect()
- const x = event.clientX - rect.left - 60
- const y = event.clientY - rect.top - 20
- this.addNode(tpl, Math.max(10, x), Math.max(10, y))
- } catch (e) {
- console.error('drop 解析失败', e)
- }
- },
- addNode(tpl, x, y) {
- _nodeSeq += 1
- const nodeId = 'n' + _nodeSeq
- const node = {
- id: nodeId,
- task_template_ref: tpl.id,
- x,
- y,
- is_start_node: this.nodes.length === 0,
- executorType: 'child',
- timeout_minutes: 0,
- max_loops: 0
- }
- this.nodes.push(node)
- this.$nextTick(() => this.renderNode(node))
- },
- renderNode(node) {
- const el = document.createElement('div')
- el.id = node.id
- el.className = 'flow-node'
- el.style.left = node.x + 'px'
- el.style.top = node.y + 'px'
- const tpl = this.templates.find(t => t.id === Number(node.task_template_ref))
- el.innerHTML = (node.is_start_node ? '▶ ' : '') + (tpl ? tpl.title : ('模板#' + node.task_template_ref)) + '<span class="node-id">' + node.id + '</span>'
- el.addEventListener('click', (e) => {
- e.stopPropagation()
- const n = this.nodes.find(nd => nd.id === node.id)
- this.selectedNode = n
- this.selectedEdge = null
- })
- this.$refs.canvas.appendChild(el)
- this.jsplumbInstance.draggable(node.id, {
- stop: (state) => {
- const n = this.nodes.find(nd => nd.id === node.id)
- if (n) {
- n.x = state.pos[0]
- n.y = state.pos[1]
- }
- }
- })
- this.jsplumbInstance.addEndpoint(node.id, { isSource: true, maxConnections: -1 })
- this.jsplumbInstance.addEndpoint(node.id, { isTarget: true, maxConnections: -1 })
- },
- renderAllNodes() {
- this.nodes.forEach(n => this.renderNode(n))
- },
- renderAllEdges() {
- this.edges.forEach(e => {
- if (this.nodes.some(n => n.id === e.from) && this.nodes.some(n => n.id === e.to)) {
- this.jsplumbInstance.connect({
- source: e.from,
- target: e.to,
- parameters: { edgeId: e.id }
- })
- }
- })
- },
- deleteSelectedNode() {
- if (!this.selectedNode) return
- const id = this.selectedNode.id
- this.jsplumbInstance.remove(id)
- this.nodes = this.nodes.filter(n => n.id !== id)
- this.edges = this.edges.filter(e => e.from !== id && e.to !== id)
- this.selectedNode = null
- },
- deleteSelectedEdge() {
- if (!this.selectedEdge) return
- const edge = this.selectedEdge
- const conns = this.jsplumbInstance.getConnections({ source: edge.from, target: edge.to })
- conns.forEach(c => this.jsplumbInstance.deleteConnection(c))
- this.edges = this.edges.filter(e => !(e.from === edge.from && e.to === edge.to))
- this.selectedEdge = null
- },
- buildConfigJson() {
- const nodes = this.nodes.map(n => ({
- id: n.id,
- task_template_ref: n.task_template_ref,
- x: n.x,
- y: n.y,
- is_start_node: !!n.is_start_node,
- executorType: n.executorType || 'child',
- timeout_minutes: n.timeout_minutes || 0,
- loop_termination: n.max_loops > 0 ? { max_loops: n.max_loops } : null
- }))
- return JSON.stringify({ nodes, config: { edges: this.edges } })
- },
- validateBeforePublish() {
- const errs = []
- if (!this.flowName.trim()) errs.push('请填写流名称')
- if (!this.nodes.length) errs.push('画布为空')
- const startNodes = this.nodes.filter(n => n.is_start_node)
- if (startNodes.length !== 1) errs.push('必须有且仅有一个起点节点')
- if (this.nodes.some(n => !n.task_template_ref)) errs.push('存在未绑定任务模板的节点')
- return errs
- },
- async saveDraft() {
- const data = {
- id: this.flowId,
- name: this.flowName,
- description: this.flowDescription,
- configJson: this.buildConfigJson()
- }
- try {
- const res = await saveFlow(data)
- this.flowId = res.data.id || this.flowId
- this.$message.success('保存成功')
- } catch (e) {
- this.$message.error(e.message || '保存失败')
- }
- },
- async publishFlow() {
- const errs = this.validateBeforePublish()
- if (errs.length) {
- this.$message.warning(errs[0])
- return
- }
- try {
- const res = await saveFlow({
- id: this.flowId,
- name: this.flowName,
- description: this.flowDescription,
- configJson: this.buildConfigJson()
- })
- this.flowId = res.data.id || this.flowId
- await publishFlow(this.flowId)
- this.$message.success('发布成功')
- this.$router.push('/orchestration')
- } catch (e) {
- this.$message.error(e.message || '发布失败')
- }
- }
- }
- }
- </script>
- <style scoped>
- .editor-layout { display: flex; height: 600px; border: 1px solid #EBEEF5; border-radius: 4px; overflow: hidden; }
- .node-palette { width: 180px; border-right: 1px solid #EBEEF5; padding: 10px; overflow-y: auto; }
- .palette-title { font-weight: bold; margin-bottom: 10px; font-size: 13px; }
- .palette-item { background: #F4F4F5; border-radius: 4px; padding: 8px; margin-bottom: 8px; cursor: grab; font-size: 12px; border: 1px solid #DCDFE6; }
- .palette-item:hover { border-color: #409EFF; color: #409EFF; }
- .palette-empty { color: #909399; font-size: 12px; }
- .canvas-wrap { flex: 1; position: relative; }
- .jsplumb-canvas { position: relative; width: 100%; height: 100%; background: #FAFAFA; }
- .canvas-tip { position: absolute; bottom: 8px; left: 8px; color: #C0C4CC; font-size: 12px; pointer-events: none; }
- .prop-panel { width: 240px; border-left: 1px solid #EBEEF5; padding: 10px; overflow-y: auto; }
- .prop-title { font-weight: bold; margin-bottom: 10px; font-size: 13px; }
- .prop-empty { color: #909399; font-size: 12px; }
- .flow-node { position: absolute; width: 120px; padding: 8px; background: #fff; border: 2px solid #409EFF; border-radius: 6px; text-align: center; font-size: 12px; cursor: move; user-select: none; }
- .flow-node .node-id { display: block; color: #C0C4CC; font-size: 10px; margin-top: 2px; }
- </style>
|