FlowEditor.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. <template>
  2. <div class="flow-editor">
  3. <el-card>
  4. <div slot="header">
  5. <span>编排画布{{ flowId ? '(编辑)' : '(新建)' }}</span>
  6. <div style="float:right">
  7. <el-button size="small" @click="saveDraft">保存草稿</el-button>
  8. <el-button size="small" type="primary" @click="publishFlow">发布</el-button>
  9. <el-button size="small" @click="$router.push('/orchestration')">返回列表</el-button>
  10. </div>
  11. </div>
  12. <el-form :inline="true">
  13. <el-form-item label="流名称">
  14. <el-input v-model="flowName" placeholder="请输入编排流名称" style="width:250px"></el-input>
  15. </el-form-item>
  16. <el-form-item label="描述">
  17. <el-input v-model="flowDescription" placeholder="可选" style="width:300px"></el-input>
  18. </el-form-item>
  19. </el-form>
  20. <div class="editor-layout">
  21. <!-- 左侧:任务模板面板 -->
  22. <div class="node-palette">
  23. <div class="palette-title">任务模板(拖拽到画布)</div>
  24. <div
  25. v-for="tpl in templates"
  26. :key="tpl.id"
  27. class="palette-item"
  28. :draggable="true"
  29. @dragstart="onTemplateDragStart($event, tpl)"
  30. >
  31. {{ tpl.title || ('模板#' + tpl.id) }}
  32. </div>
  33. <div v-if="!templates.length" class="palette-empty">暂无模板</div>
  34. </div>
  35. <!-- 中央:jsPlumb 画布 -->
  36. <div class="canvas-wrap">
  37. <div ref="canvas" class="jsplumb-canvas" @dragover.prevent @drop="onCanvasDrop"></div>
  38. <div class="canvas-tip">拖拽左侧模板到画布创建节点,从节点圆点连线</div>
  39. </div>
  40. <!-- 右侧:属性面板 -->
  41. <div class="prop-panel">
  42. <div class="prop-title">节点/边属性</div>
  43. <template v-if="selectedNode">
  44. <el-form label-width="90px" size="mini">
  45. <el-form-item label="节点ID">
  46. <el-input v-model="selectedNode.id" :disabled="true"></el-input>
  47. </el-form-item>
  48. <el-form-item label="是否起点">
  49. <el-switch v-model="selectedNode.is_start_node"></el-switch>
  50. </el-form-item>
  51. <el-form-item label="执行人">
  52. <el-select v-model="selectedNode.executorType" style="width:100%">
  53. <el-option label="孩子" value="child"></el-option>
  54. <el-option label="成员" value="member"></el-option>
  55. </el-select>
  56. </el-form-item>
  57. <el-form-item label="超时(分钟)">
  58. <el-input-number v-model="selectedNode.timeout_minutes" :min="0"></el-input-number>
  59. </el-form-item>
  60. <el-form-item label="最大循环">
  61. <el-input-number v-model="selectedNode.max_loops" :min="0"></el-input-number>
  62. </el-form-item>
  63. <el-form-item label="删除节点">
  64. <el-button type="danger" size="mini" @click="deleteSelectedNode">删除</el-button>
  65. </el-form-item>
  66. </el-form>
  67. </template>
  68. <template v-else-if="selectedEdge">
  69. <el-form label-width="90px" size="mini">
  70. <el-form-item label="边类型">
  71. <el-select v-model="selectedEdge.type" style="width:100%">
  72. <el-option label="普通" value="normal"></el-option>
  73. <el-option label="失败兜底" value="failed"></el-option>
  74. <el-option label="超时兜底" value="timeout"></el-option>
  75. </el-select>
  76. </el-form-item>
  77. <el-form-item label="触发语义">
  78. <el-select v-model="selectedEdge.operator" style="width:100%">
  79. <el-option label="AND(全部完成)" value="AND"></el-option>
  80. <el-option label="OR(任一完成)" value="OR"></el-option>
  81. </el-select>
  82. </el-form-item>
  83. <el-form-item label="删除连线">
  84. <el-button type="danger" size="mini" @click="deleteSelectedEdge">删除</el-button>
  85. </el-form-item>
  86. </el-form>
  87. </template>
  88. <div v-else class="prop-empty">点击节点或连线编辑属性</div>
  89. </div>
  90. </div>
  91. </el-card>
  92. </div>
  93. </template>
  94. <script>
  95. import jsPlumb from 'jsplumb'
  96. import { saveFlow, publishFlow, getFlowDetail } from '@/api/orchestration'
  97. import { getTaskTemplateList } from '@/api/admin'
  98. let _nodeSeq = 0
  99. export default {
  100. name: 'FlowEditor',
  101. data() {
  102. return {
  103. flowId: this.$route.params.id ? Number(this.$route.params.id) : null,
  104. flowName: '',
  105. flowDescription: '',
  106. templates: [],
  107. nodes: [], // { id, task_template_ref, x, y, is_start_node, executorType, timeout_minutes, max_loops }
  108. edges: [], // { id, from, to, type, operator }
  109. selectedNode: null,
  110. selectedEdge: null,
  111. jsplumbInstance: null,
  112. nextNodeSeq: 1,
  113. canvasBounds: null
  114. }
  115. },
  116. created() {
  117. this.loadTemplates()
  118. if (this.flowId) {
  119. this.loadFlowDetail()
  120. }
  121. },
  122. mounted() {
  123. this.initJsPlumb()
  124. },
  125. beforeDestroy() {
  126. if (this.jsplumbInstance) {
  127. this.jsplumbInstance.reset()
  128. }
  129. },
  130. methods: {
  131. async loadTemplates() {
  132. try {
  133. const res = await getTaskTemplateList({ page: 1, size: 100 })
  134. this.templates = res.data.records || res.data || []
  135. } catch (e) {
  136. console.error('加载任务模板失败', e)
  137. this.templates = []
  138. }
  139. },
  140. async loadFlowDetail() {
  141. try {
  142. const res = await getFlowDetail(this.flowId)
  143. const flow = res.data.flow || {}
  144. this.flowName = flow.name || ''
  145. this.flowDescription = flow.description || ''
  146. const cfg = flow.configJson ? JSON.parse(flow.configJson) : { nodes: [], config: { edges: [] } }
  147. const nodeList = cfg.nodes || []
  148. this.nodes = nodeList.map(n => ({
  149. id: n.id,
  150. task_template_ref: n.task_template_ref,
  151. x: n.x || 100,
  152. y: n.y || 100,
  153. is_start_node: !!n.is_start_node,
  154. executorType: n.executorType || 'child',
  155. timeout_minutes: n.timeout_minutes || 0,
  156. max_loops: (n.loop_termination && n.loop_termination.max_loops) || n.max_loops || 0
  157. }))
  158. this.edges = (cfg.config && cfg.config.edges) || []
  159. // 等待 DOM 渲染后再画节点和连线
  160. this.$nextTick(() => {
  161. this.renderAllNodes()
  162. this.renderAllEdges()
  163. })
  164. } catch (e) {
  165. console.error('加载编排流失败', e)
  166. }
  167. },
  168. initJsPlumb() {
  169. const el = this.$refs.canvas
  170. this.jsplumbInstance = jsPlumb.jsPlumb.getInstance({
  171. Container: el,
  172. Connector: ['Bezier', { curviness: 50 }],
  173. Endpoint: ['Dot', { radius: 6 }],
  174. EndpointStyle: { fill: '#409EFF' },
  175. PaintStyle: { stroke: '#909399', strokeWidth: 2 },
  176. Anchor: ['Left', 'Right', 'Top', 'Bottom']
  177. })
  178. this.jsplumbInstance.bind('connection', (info) => {
  179. const from = info.sourceId
  180. const to = info.targetId
  181. if (this.edges.some(e => e.from === from && e.to === to)) {
  182. // 重复连线,移除
  183. setTimeout(() => this.jsplumbInstance.deleteConnection(info.connection), 0)
  184. return
  185. }
  186. const edge = { id: 'e_' + Date.now(), from, to, type: 'normal', operator: 'AND' }
  187. this.edges.push(edge)
  188. this.jsplumbInstance.setPaintStyle(info.connection, { stroke: '#909399', strokeWidth: 2 })
  189. })
  190. this.jsplumbInstance.bind('click', (conn) => {
  191. const edge = this.edges.find(e => e.from === conn.sourceId && e.to === conn.targetId)
  192. this.selectedEdge = edge
  193. this.selectedNode = null
  194. })
  195. this.jsplumbInstance.bind('dblclick', (conn) => {
  196. this.jsplumbInstance.deleteConnection(conn)
  197. const idx = this.edges.findIndex(e => e.from === conn.sourceId && e.to === conn.targetId)
  198. if (idx >= 0) this.edges.splice(idx, 1)
  199. this.selectedEdge = null
  200. })
  201. this.canvasBounds = el.getBoundingClientRect()
  202. },
  203. onTemplateDragStart(event, tpl) {
  204. event.dataTransfer.setData('text/plain', JSON.stringify(tpl))
  205. },
  206. onCanvasDrop(event) {
  207. const raw = event.dataTransfer.getData('text/plain')
  208. if (!raw) return
  209. try {
  210. const tpl = JSON.parse(raw)
  211. const rect = this.$refs.canvas.getBoundingClientRect()
  212. const x = event.clientX - rect.left - 60
  213. const y = event.clientY - rect.top - 20
  214. this.addNode(tpl, Math.max(10, x), Math.max(10, y))
  215. } catch (e) {
  216. console.error('drop 解析失败', e)
  217. }
  218. },
  219. addNode(tpl, x, y) {
  220. _nodeSeq += 1
  221. const nodeId = 'n' + _nodeSeq
  222. const node = {
  223. id: nodeId,
  224. task_template_ref: tpl.id,
  225. x,
  226. y,
  227. is_start_node: this.nodes.length === 0,
  228. executorType: 'child',
  229. timeout_minutes: 0,
  230. max_loops: 0
  231. }
  232. this.nodes.push(node)
  233. this.$nextTick(() => this.renderNode(node))
  234. },
  235. renderNode(node) {
  236. const el = document.createElement('div')
  237. el.id = node.id
  238. el.className = 'flow-node'
  239. el.style.left = node.x + 'px'
  240. el.style.top = node.y + 'px'
  241. const tpl = this.templates.find(t => t.id === Number(node.task_template_ref))
  242. el.innerHTML = (node.is_start_node ? '▶ ' : '') + (tpl ? tpl.title : ('模板#' + node.task_template_ref)) + '<span class="node-id">' + node.id + '</span>'
  243. el.addEventListener('click', (e) => {
  244. e.stopPropagation()
  245. const n = this.nodes.find(nd => nd.id === node.id)
  246. this.selectedNode = n
  247. this.selectedEdge = null
  248. })
  249. this.$refs.canvas.appendChild(el)
  250. this.jsplumbInstance.draggable(node.id, {
  251. stop: (state) => {
  252. const n = this.nodes.find(nd => nd.id === node.id)
  253. if (n) {
  254. n.x = state.pos[0]
  255. n.y = state.pos[1]
  256. }
  257. }
  258. })
  259. this.jsplumbInstance.addEndpoint(node.id, { isSource: true, maxConnections: -1 })
  260. this.jsplumbInstance.addEndpoint(node.id, { isTarget: true, maxConnections: -1 })
  261. },
  262. renderAllNodes() {
  263. this.nodes.forEach(n => this.renderNode(n))
  264. },
  265. renderAllEdges() {
  266. this.edges.forEach(e => {
  267. if (this.nodes.some(n => n.id === e.from) && this.nodes.some(n => n.id === e.to)) {
  268. this.jsplumbInstance.connect({
  269. source: e.from,
  270. target: e.to,
  271. parameters: { edgeId: e.id }
  272. })
  273. }
  274. })
  275. },
  276. deleteSelectedNode() {
  277. if (!this.selectedNode) return
  278. const id = this.selectedNode.id
  279. this.jsplumbInstance.remove(id)
  280. this.nodes = this.nodes.filter(n => n.id !== id)
  281. this.edges = this.edges.filter(e => e.from !== id && e.to !== id)
  282. this.selectedNode = null
  283. },
  284. deleteSelectedEdge() {
  285. if (!this.selectedEdge) return
  286. const edge = this.selectedEdge
  287. const conns = this.jsplumbInstance.getConnections({ source: edge.from, target: edge.to })
  288. conns.forEach(c => this.jsplumbInstance.deleteConnection(c))
  289. this.edges = this.edges.filter(e => !(e.from === edge.from && e.to === edge.to))
  290. this.selectedEdge = null
  291. },
  292. buildConfigJson() {
  293. const nodes = this.nodes.map(n => ({
  294. id: n.id,
  295. task_template_ref: n.task_template_ref,
  296. x: n.x,
  297. y: n.y,
  298. is_start_node: !!n.is_start_node,
  299. executorType: n.executorType || 'child',
  300. timeout_minutes: n.timeout_minutes || 0,
  301. loop_termination: n.max_loops > 0 ? { max_loops: n.max_loops } : null
  302. }))
  303. return JSON.stringify({ nodes, config: { edges: this.edges } })
  304. },
  305. validateBeforePublish() {
  306. const errs = []
  307. if (!this.flowName.trim()) errs.push('请填写流名称')
  308. if (!this.nodes.length) errs.push('画布为空')
  309. const startNodes = this.nodes.filter(n => n.is_start_node)
  310. if (startNodes.length !== 1) errs.push('必须有且仅有一个起点节点')
  311. if (this.nodes.some(n => !n.task_template_ref)) errs.push('存在未绑定任务模板的节点')
  312. return errs
  313. },
  314. async saveDraft() {
  315. const data = {
  316. id: this.flowId,
  317. name: this.flowName,
  318. description: this.flowDescription,
  319. configJson: this.buildConfigJson()
  320. }
  321. try {
  322. const res = await saveFlow(data)
  323. this.flowId = res.data.id || this.flowId
  324. this.$message.success('保存成功')
  325. } catch (e) {
  326. this.$message.error(e.message || '保存失败')
  327. }
  328. },
  329. async publishFlow() {
  330. const errs = this.validateBeforePublish()
  331. if (errs.length) {
  332. this.$message.warning(errs[0])
  333. return
  334. }
  335. try {
  336. const res = await saveFlow({
  337. id: this.flowId,
  338. name: this.flowName,
  339. description: this.flowDescription,
  340. configJson: this.buildConfigJson()
  341. })
  342. this.flowId = res.data.id || this.flowId
  343. await publishFlow(this.flowId)
  344. this.$message.success('发布成功')
  345. this.$router.push('/orchestration')
  346. } catch (e) {
  347. this.$message.error(e.message || '发布失败')
  348. }
  349. }
  350. }
  351. }
  352. </script>
  353. <style scoped>
  354. .editor-layout { display: flex; height: 600px; border: 1px solid #EBEEF5; border-radius: 4px; overflow: hidden; }
  355. .node-palette { width: 180px; border-right: 1px solid #EBEEF5; padding: 10px; overflow-y: auto; }
  356. .palette-title { font-weight: bold; margin-bottom: 10px; font-size: 13px; }
  357. .palette-item { background: #F4F4F5; border-radius: 4px; padding: 8px; margin-bottom: 8px; cursor: grab; font-size: 12px; border: 1px solid #DCDFE6; }
  358. .palette-item:hover { border-color: #409EFF; color: #409EFF; }
  359. .palette-empty { color: #909399; font-size: 12px; }
  360. .canvas-wrap { flex: 1; position: relative; }
  361. .jsplumb-canvas { position: relative; width: 100%; height: 100%; background: #FAFAFA; }
  362. .canvas-tip { position: absolute; bottom: 8px; left: 8px; color: #C0C4CC; font-size: 12px; pointer-events: none; }
  363. .prop-panel { width: 240px; border-left: 1px solid #EBEEF5; padding: 10px; overflow-y: auto; }
  364. .prop-title { font-weight: bold; margin-bottom: 10px; font-size: 13px; }
  365. .prop-empty { color: #909399; font-size: 12px; }
  366. .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; }
  367. .flow-node .node-id { display: block; color: #C0C4CC; font-size: 10px; margin-top: 2px; }
  368. </style>