design.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. <template>
  2. <div class="workflow-designer">
  3. <!-- 顶部工具栏 -->
  4. <div class="toolbar">
  5. <div class="toolbar-left">
  6. <el-button icon="el-icon-back" size="small" @click="goBack">返回</el-button>
  7. <el-divider direction="vertical" />
  8. <el-input v-model="form.workflowName" placeholder="工作流名称" size="small" style="width: 200px" />
  9. <el-select v-model="form.workflowType" placeholder="类型" size="small" style="width: 120px; margin-left: 10px">
  10. <el-option
  11. v-for="dict in workflowTypeOptions"
  12. :key="dict.dictValue"
  13. :label="dict.dictLabel"
  14. :value="dict.dictValue"
  15. />
  16. </el-select>
  17. </div>
  18. <div class="toolbar-right">
  19. <el-input
  20. v-model="form.workflowDesc"
  21. placeholder="工作流描述"
  22. size="small"
  23. style="width: 200px; margin-right: 10px"
  24. />
  25. <el-divider direction="vertical" />
  26. <el-button icon="el-icon-zoom-in" size="small" @click="zoomIn">放大</el-button>
  27. <el-button icon="el-icon-zoom-out" size="small" @click="zoomOut">缩小</el-button>
  28. <el-button icon="el-icon-rank" size="small" @click="fitView">适应</el-button>
  29. <el-divider direction="vertical" />
  30. <el-button type="primary" icon="el-icon-check" size="small" @click="handleSave">保存</el-button>
  31. </div>
  32. </div>
  33. <div class="main-content">
  34. <!-- 左侧节点面板 -->
  35. <div class="node-panel">
  36. <div class="panel-title">节点类型</div>
  37. <div class="node-category" v-for="category in nodeCategories" :key="category.key">
  38. <div class="category-title">{{ category.name }}</div>
  39. <div class="node-list">
  40. <div
  41. class="node-item"
  42. v-for="nodeType in category.types"
  43. :key="nodeType.typeCode"
  44. draggable="true"
  45. @dragstart="onDragStart($event, nodeType)"
  46. :style="{ borderColor: nodeType.typeColor }"
  47. >
  48. <i :class="nodeType.typeIcon" :style="{ color: nodeType.typeColor }"></i>
  49. <span>{{ nodeType.typeName }}</span>
  50. </div>
  51. </div>
  52. </div>
  53. </div>
  54. <!-- 中间画布区域 -->
  55. <div
  56. class="canvas-container"
  57. ref="canvasContainer"
  58. tabindex="0"
  59. @drop="onDrop"
  60. @dragover.prevent
  61. @click="onCanvasClick"
  62. @mousedown="onCanvasMouseDown"
  63. @keydown.delete="handleDelete"
  64. @keydown.backspace="handleDelete"
  65. >
  66. <svg class="canvas-svg" ref="canvasSvg" :width="canvasSize.width" :height="canvasSize.height"
  67. :class="{ 'dragging-canvas': isDraggingCanvas }">
  68. <!-- 网格背景 -->
  69. <defs>
  70. <pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
  71. <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e0e0e0" stroke-width="0.5"/>
  72. </pattern>
  73. </defs>
  74. <rect :width="canvasSize.width" :height="canvasSize.height" fill="url(#grid)" />
  75. <!-- 连线 -->
  76. <g class="edges-layer" :transform="`translate(${canvasOffset.x}, ${canvasOffset.y}) scale(${scale})`">
  77. <g
  78. v-for="edge in edges"
  79. :key="edge.edgeKey"
  80. class="edge-group"
  81. @click.stop="selectEdge(edge)"
  82. >
  83. <path
  84. :d="getEdgePath(edge)"
  85. :stroke="edge.edgeColor || '#999'"
  86. stroke-width="2"
  87. fill="none"
  88. :class="{ selected: selectedEdge === edge }"
  89. />
  90. <text
  91. v-if="edge.edgeLabel"
  92. :x="getEdgeLabelPos(edge).x"
  93. :y="getEdgeLabelPos(edge).y"
  94. text-anchor="middle"
  95. font-size="12"
  96. fill="#666"
  97. >{{ edge.edgeLabel }}</text>
  98. </g>
  99. </g>
  100. <!-- 临时连线 -->
  101. <g :transform="`translate(${canvasOffset.x}, ${canvasOffset.y}) scale(${scale})`">
  102. <path
  103. v-if="tempEdge"
  104. :d="tempEdge.path"
  105. stroke="#1890ff"
  106. stroke-width="2"
  107. fill="none"
  108. stroke-dasharray="5,5"
  109. />
  110. </g>
  111. <!-- 节点 -->
  112. <g class="nodes-layer" :transform="`translate(${canvasOffset.x}, ${canvasOffset.y}) scale(${scale})`">
  113. <g
  114. v-for="node in nodes"
  115. :key="node.nodeKey"
  116. class="node-group"
  117. :transform="`translate(${node.posX}, ${node.posY})`"
  118. @mousedown="onNodeMouseDown($event, node)"
  119. @click.stop="selectNode(node)"
  120. >
  121. <rect
  122. :width="nodeWidth"
  123. :height="node.height || 36"
  124. rx="6"
  125. ry="6"
  126. :fill="getNodeBgColor(node)"
  127. :stroke="node.nodeColor || '#1890ff'"
  128. stroke-width="2"
  129. :class="{ selected: selectedNode === node }"
  130. />
  131. <foreignObject :width="nodeWidth" :height="node.height || 36">
  132. <div class="node-content" xmlns="http://www.w3.org/1999/xhtml" :title="node.nodeName">
  133. <i :class="node.nodeIcon" :style="{ color: node.nodeColor }"></i>
  134. <span class="node-name">{{ node.nodeName }}</span>
  135. </div>
  136. </foreignObject>
  137. <!-- 连接点 -->
  138. <circle :cx="nodeWidth / 2" cy="0" r="5" fill="#fff" stroke="#1890ff" stroke-width="2"
  139. class="anchor top" @mousedown.stop="startConnect($event, node, 'top')" />
  140. <circle :cx="nodeWidth / 2" :cy="node.height || 36" r="5" fill="#fff" stroke="#1890ff" stroke-width="2"
  141. class="anchor bottom" @mousedown.stop="startConnect($event, node, 'bottom')" />
  142. <circle cx="0" :cy="(node.height || 36) / 2" r="5" fill="#fff" stroke="#1890ff" stroke-width="2"
  143. class="anchor left" @mousedown.stop="startConnect($event, node, 'left')" />
  144. <circle :cx="nodeWidth" :cy="(node.height || 36) / 2" r="5" fill="#fff" stroke="#1890ff" stroke-width="2"
  145. class="anchor right" @mousedown.stop="startConnect($event, node, 'right')" />
  146. </g>
  147. </g>
  148. </svg>
  149. </div>
  150. <!-- 右侧属性面板 -->
  151. <div class="property-panel" v-if="selectedNode || selectedEdge">
  152. <div class="panel-title">
  153. {{ selectedNode ? '节点属性' : '连线属性' }}
  154. <i class="el-icon-close" @click="clearSelection"></i>
  155. </div>
  156. <!-- 节点属性 -->
  157. <el-form v-if="selectedNode" label-width="80px" size="small">
  158. <el-form-item label="节点内容">
  159. <el-input
  160. v-model="selectedNode.nodeName"
  161. type="textarea"
  162. :autosize="{ minRows: 1, maxRows: 6 }"
  163. />
  164. </el-form-item>
  165. <div v-if="selectedNode.nodeType == 'http'">
  166. <el-form-item label="URL地址">
  167. <el-input v-model="selectedNode.nodeConfig.url" />
  168. </el-form-item>
  169. </div>
  170. <el-form-item label="节点类型">
  171. <el-input :value="getNodeTypeName(selectedNode.nodeType)" disabled />
  172. </el-form-item>
  173. <el-form-item label="节点颜色">
  174. <el-color-picker v-model="selectedNode.nodeColor" />
  175. </el-form-item>
  176. <el-form-item label="X坐标">
  177. <el-input-number v-model="selectedNode.posX" :min="0" />
  178. </el-form-item>
  179. <el-form-item label="Y坐标">
  180. <el-input-number v-model="selectedNode.posY" :min="0" />
  181. </el-form-item>
  182. <el-form-item>
  183. <el-button type="danger" size="mini" @click="deleteNode">删除节点</el-button>
  184. </el-form-item>
  185. </el-form>
  186. <!-- 连线属性 -->
  187. <el-form v-if="selectedEdge" label-width="80px" size="small">
  188. <el-form-item label="连线标签">
  189. <el-input v-model="selectedEdge.edgeLabel" />
  190. </el-form-item>
  191. <el-form-item label="连线颜色">
  192. <el-color-picker v-model="selectedEdge.edgeColor" />
  193. </el-form-item>
  194. <el-form-item label="条件表达式">
  195. <el-input v-model="selectedEdge.conditionExpr" type="textarea" :rows="3" />
  196. </el-form-item>
  197. <el-form-item>
  198. <el-button type="danger" size="mini" @click="deleteEdge">删除连线</el-button>
  199. </el-form-item>
  200. </el-form>
  201. </div>
  202. </div>
  203. </div>
  204. </template>
  205. <script>
  206. import { getWorkflow, addWorkflow, updateWorkflow, getNodeTypes } from '@/api/his/aiWorkflow'
  207. export default {
  208. name: 'WorkflowDesign',
  209. data() {
  210. return {
  211. // 工作流ID
  212. workflowId: null,
  213. // 表单数据
  214. form: {
  215. workflowName: '新建工作流',
  216. workflowDesc: '',
  217. workflowType: '1',
  218. canvasData: ''
  219. },
  220. // 节点列表
  221. nodes: [],
  222. // 连线列表
  223. edges: [],
  224. // 节点类型列表
  225. nodeTypes: [],
  226. // 节点分类
  227. nodeCategories: [],
  228. // 选中的节点
  229. selectedNode: null,
  230. // 选中的连线
  231. selectedEdge: null,
  232. // 缩放比例
  233. scale: 1,
  234. // 画布偏移
  235. canvasOffset: { x: 0, y: 0 },
  236. // 拖拽中的节点
  237. draggingNode: null,
  238. // 拖拽偏移
  239. dragOffset: { x: 0, y: 0 },
  240. // 是否正在连线
  241. connecting: false,
  242. // 连线起点
  243. connectStart: null,
  244. // 临时连线
  245. tempEdge: null,
  246. // 画布尺寸
  247. canvasSize: { width: 2000, height: 2000 },
  248. // 节点固定宽度
  249. nodeWidth: 120,
  250. // 是否正在拖动画布
  251. isDraggingCanvas: false,
  252. // 画布拖动起始点
  253. canvasDragStart: { x: 0, y: 0 },
  254. // 工作流类型字典
  255. workflowTypeOptions: [
  256. { dictValue: '1', dictLabel: '对话流程' },
  257. { dictValue: '2', dictLabel: '任务流程' },
  258. { dictValue: '3', dictLabel: '审批流程' }
  259. ]
  260. }
  261. },
  262. created() {
  263. this.workflowId = this.$route.params.id
  264. this.loadNodeTypes()
  265. if (this.workflowId) {
  266. this.loadWorkflow()
  267. }
  268. },
  269. mounted() {
  270. // 确保容器可获取焦点
  271. this.$nextTick(() => {
  272. this.$refs.canvasContainer.focus() // 这里应该是 canvasContainer 不是 container
  273. })
  274. // 添加全局键盘事件监听
  275. // window.addEventListener('keydown', this.handleGlobalKeydown)
  276. document.addEventListener('mousemove', this.onMouseMove)
  277. document.addEventListener('mouseup', this.onMouseUp)
  278. },
  279. beforeDestroy() {
  280. // window.removeEventListener('keydown', this.handleGlobalKeydown)
  281. document.removeEventListener('mousemove', this.onMouseMove)
  282. document.removeEventListener('mouseup', this.onMouseUp)
  283. },
  284. methods: {
  285. // 聚焦画布容器
  286. focusCanvasContainer() {
  287. this.$refs.canvasContainer.focus()
  288. },
  289. // 点击画布时聚焦
  290. onCanvasClick() {
  291. this.clearSelection()
  292. this.focusCanvasContainer()
  293. },
  294. // 点击节点时聚焦
  295. selectNode(node) {
  296. this.selectedNode = node
  297. this.selectedNode.nodeConfig = JSON.parse(node.nodeConfig)
  298. this.selectedEdge = null
  299. this.focusCanvasContainer()
  300. },
  301. // 点击连线时聚焦
  302. selectEdge(edge) {
  303. this.selectedEdge = edge
  304. this.selectedNode = null
  305. this.focusCanvasContainer()
  306. },
  307. // 局部键盘事件
  308. handleDelete(event) {
  309. event.preventDefault() // 阻止默认行为(如浏览器后退)
  310. this.deleteSelected()
  311. },
  312. // 全局键盘事件
  313. // handleGlobalKeydown(event) {
  314. // // 检查是否按下了退格键或删除键
  315. // if ((event.key === 'Backspace' || event.key === 'Delete') && !this.isInputFocused()) {
  316. // event.preventDefault()
  317. // this.deleteSelected()
  318. // }
  319. // },
  320. // 删除选中项的逻辑
  321. deleteSelected() {
  322. if (this.selectedNode) {
  323. this.deleteSelectedNode()
  324. } else if (this.selectedEdge) {
  325. this.deleteSelectedEdge()
  326. }
  327. },
  328. // 删除选中节点
  329. deleteSelectedNode() {
  330. if (!this.selectedNode) return
  331. // 确认对话框
  332. this.$confirm('是否确认删除该节点?', '警告', {
  333. confirmButtonText: '确定',
  334. cancelButtonText: '取消',
  335. type: 'warning'
  336. }).then(() => {
  337. const key = this.selectedNode.nodeKey
  338. this.nodes = this.nodes.filter(n => n.nodeKey !== key)
  339. // 同时删除与该节点相关的连线
  340. this.edges = this.edges.filter(e =>
  341. e.sourceNodeKey !== key && e.targetNodeKey !== key)
  342. this.selectedNode = null
  343. }).catch(() => {})
  344. },
  345. // 删除选中连线
  346. deleteSelectedEdge() {
  347. if (!this.selectedEdge) return
  348. this.$confirm('是否确认删除该连线?', '警告', {
  349. confirmButtonText: '确定',
  350. cancelButtonText: '取消',
  351. type: 'warning'
  352. }).then(() => {
  353. this.edges = this.edges.filter(e => e.edgeKey !== this.selectedEdge.edgeKey)
  354. this.selectedEdge = null
  355. }).catch(() => {})
  356. },
  357. // 检查是否有输入框获得焦点
  358. isInputFocused() {
  359. const activeElement = document.activeElement
  360. // 注意:节点名称输入框也需要排除
  361. const isEditable = ['INPUT', 'TEXTAREA', 'SELECT'].includes(activeElement.tagName)
  362. // 额外检查是否是节点名称输入框
  363. const isNodeInput = activeElement.classList && activeElement.classList.contains('node-name-input')
  364. return isEditable || isNodeInput
  365. },
  366. /** 加载节点类型 */
  367. loadNodeTypes() {
  368. getNodeTypes().then(res => {
  369. this.nodeTypes = res.data || []
  370. this.groupNodeTypes()
  371. }).catch(() => {
  372. // 如果接口未实现,使用默认节点类型
  373. this.nodeTypes = [
  374. { typeCode: 'start', typeName: '开始', typeCategory: 'basic', typeIcon: 'el-icon-video-play', typeColor: '#52c41a' },
  375. { typeCode: 'end', typeName: '结束', typeCategory: 'basic', typeIcon: 'el-icon-video-pause', typeColor: '#ff4d4f' },
  376. { typeCode: 'condition', typeName: '条件判断', typeCategory: 'logic', typeIcon: 'el-icon-question', typeColor: '#faad14' },
  377. { typeCode: 'parallel', typeName: '并行网关', typeCategory: 'logic', typeIcon: 'el-icon-share', typeColor: '#722ed1' },
  378. { typeCode: 'ai_chat', typeName: 'AI对话', typeCategory: 'ai', typeIcon: 'el-icon-chat-dot-round', typeColor: '#1890ff' },
  379. { typeCode: 'ai_analysis', typeName: 'AI分析', typeCategory: 'ai', typeIcon: 'el-icon-data-analysis', typeColor: '#13c2c2' },
  380. { typeCode: 'http', typeName: 'HTTP请求', typeCategory: 'integration', typeIcon: 'el-icon-link', typeColor: '#eb2f96' },
  381. { typeCode: 'database', typeName: '数据库', typeCategory: 'integration', typeIcon: 'el-icon-coin', typeColor: '#fa8c16' }
  382. ]
  383. this.groupNodeTypes()
  384. })
  385. },
  386. /** 节点类型分组 */
  387. groupNodeTypes() {
  388. const categoryMap = {
  389. basic: { key: 'basic', name: '基础节点', types: [] },
  390. logic: { key: 'logic', name: '逻辑节点', types: [] },
  391. ai: { key: 'ai', name: 'AI节点', types: [] },
  392. ai: { key: 'ai-cell', name: '外呼几点', types: [] },
  393. integration: { key: 'integration', name: '集成节点', types: [] }
  394. }
  395. this.nodeTypes.forEach(t => {
  396. const cat = categoryMap[t.typeCategory] || categoryMap.basic
  397. cat.types.push(t)
  398. })
  399. this.nodeCategories = Object.values(categoryMap).filter(c => c.types.length > 0)
  400. },
  401. /** 加载工作流 */
  402. loadWorkflow() {
  403. getWorkflow(this.workflowId).then(res => {
  404. const data = res.data
  405. this.form = {
  406. workflowName: data.workflowName,
  407. workflowDesc: data.workflowDesc,
  408. workflowType: String(data.workflowType),
  409. canvasData: data.canvasData
  410. }
  411. this.nodes = data.nodes || []
  412. this.edges = data.edges || []
  413. })
  414. },
  415. /** 返回列表 */
  416. goBack() {
  417. this.$router.push('/his/aiWorkflow')
  418. },
  419. /** 放大 */
  420. zoomIn() {
  421. this.scale = Math.min(2, this.scale + 0.1)
  422. },
  423. /** 缩小 */
  424. zoomOut() {
  425. this.scale = Math.max(0.5, this.scale - 0.1)
  426. },
  427. /** 适应画布 */
  428. fitView() {
  429. this.scale = 1
  430. this.canvasOffset = { x: 0, y: 0 }
  431. this.focusCanvasContainer()
  432. },
  433. /** 生成唯一Key */
  434. generateKey() {
  435. return 'node_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
  436. },
  437. /** 拖拽开始 */
  438. onDragStart(e, nodeType) {
  439. e.dataTransfer.setData('nodeType', JSON.stringify(nodeType))
  440. },
  441. /** 放置节点 */
  442. onDrop(e) {
  443. const nodeTypeStr = e.dataTransfer.getData('nodeType')
  444. if (!nodeTypeStr) return
  445. const nodeType = JSON.parse(nodeTypeStr)
  446. const rect = this.$refs.canvasContainer.getBoundingClientRect()
  447. const x = (e.clientX - rect.left - this.canvasOffset.x) / this.scale
  448. const y = (e.clientY - rect.top - this.canvasOffset.y) / this.scale
  449. const newNode = {
  450. nodeKey: this.generateKey(),
  451. nodeName: nodeType.typeName,
  452. nodeType: nodeType.typeCode,
  453. nodeIcon: nodeType.typeIcon,
  454. nodeColor: nodeType.typeColor,
  455. posX: Math.round(x - 50),
  456. posY: Math.round(y - 20),
  457. height: 40,
  458. nodeConfig: nodeType.defaultConfig || '{}'
  459. }
  460. this.nodes.push(newNode)
  461. this.selectNode(newNode)
  462. this.focusCanvasContainer()
  463. // 检测并扩展画布
  464. this.checkAndExpandCanvas(newNode)
  465. },
  466. /** 画布鼠标按下 */
  467. onCanvasMouseDown(e) {
  468. // 只响应左键且点击在空白区域
  469. if (e.button !== 0) return
  470. if (e.target.tagName === 'rect' && e.target.getAttribute('fill') === 'url(#grid)') {
  471. this.isDraggingCanvas = true
  472. this.canvasDragStart = {
  473. x: e.clientX,
  474. y: e.clientY,
  475. scrollLeft: this.$refs.canvasContainer.scrollLeft,
  476. scrollTop: this.$refs.canvasContainer.scrollTop
  477. }
  478. e.preventDefault()
  479. }
  480. },
  481. /** 清除选中 */
  482. clearSelection() {
  483. this.selectedNode = null
  484. this.selectedEdge = null
  485. },
  486. /** 节点鼠标按下 */
  487. onNodeMouseDown(e, node) {
  488. if (e.target.classList.contains('anchor')) return
  489. this.draggingNode = node
  490. const rect = this.$refs.canvasContainer.getBoundingClientRect()
  491. const scrollLeft = this.$refs.canvasContainer.scrollLeft
  492. const scrollTop = this.$refs.canvasContainer.scrollTop
  493. this.dragOffset = {
  494. x: (e.clientX - rect.left + scrollLeft - this.canvasOffset.x) / this.scale - node.posX,
  495. y: (e.clientY - rect.top + scrollTop - this.canvasOffset.y) / this.scale - node.posY
  496. }
  497. this.selectNode(node)
  498. },
  499. /** 鼠标移动 */
  500. onMouseMove(e) {
  501. // 拖动画布
  502. if (this.isDraggingCanvas) {
  503. const dx = e.clientX - this.canvasDragStart.x
  504. const dy = e.clientY - this.canvasDragStart.y
  505. this.$refs.canvasContainer.scrollLeft = this.canvasDragStart.scrollLeft - dx
  506. this.$refs.canvasContainer.scrollTop = this.canvasDragStart.scrollTop - dy
  507. return
  508. }
  509. // 拖动节点
  510. if (this.draggingNode) {
  511. const rect = this.$refs.canvasContainer.getBoundingClientRect()
  512. const scrollLeft = this.$refs.canvasContainer.scrollLeft
  513. const scrollTop = this.$refs.canvasContainer.scrollTop
  514. const x = (e.clientX - rect.left + scrollLeft - this.canvasOffset.x) / this.scale - this.dragOffset.x
  515. const y = (e.clientY - rect.top + scrollTop - this.canvasOffset.y) / this.scale - this.dragOffset.y
  516. this.draggingNode.posX = Math.max(0, Math.round(x))
  517. this.draggingNode.posY = Math.max(0, Math.round(y))
  518. // 检测并扩展画布
  519. this.checkAndExpandCanvas(this.draggingNode)
  520. }
  521. // 连线
  522. if (this.connecting && this.connectStart) {
  523. const rect = this.$refs.canvasContainer.getBoundingClientRect()
  524. const scrollLeft = this.$refs.canvasContainer.scrollLeft
  525. const scrollTop = this.$refs.canvasContainer.scrollTop
  526. const endX = (e.clientX - rect.left + scrollLeft - this.canvasOffset.x) / this.scale
  527. const endY = (e.clientY - rect.top + scrollTop - this.canvasOffset.y) / this.scale
  528. this.tempEdge = {
  529. path: this.calcPath(this.connectStart.x, this.connectStart.y, endX, endY)
  530. }
  531. }
  532. },
  533. /** 检测并扩展画布 */
  534. checkAndExpandCanvas(node) {
  535. const nodeHeight = node.height || 36
  536. const padding = 200 // 边缘预留空间
  537. const expandStep = 500 // 每次扩展的大小
  538. const nodeRight = node.posX + this.nodeWidth
  539. const nodeBottom = node.posY + nodeHeight
  540. // 检测右边缘
  541. if (nodeRight + padding > this.canvasSize.width) {
  542. this.canvasSize.width += expandStep
  543. }
  544. // 检测下边缘
  545. if (nodeBottom + padding > this.canvasSize.height) {
  546. this.canvasSize.height += expandStep
  547. }
  548. },
  549. /** 鼠标松开 */
  550. onMouseUp(e) {
  551. // 停止拖动画布
  552. if (this.isDraggingCanvas) {
  553. this.isDraggingCanvas = false
  554. this.focusCanvasContainer()
  555. return
  556. }
  557. // 停止连线
  558. if (this.connecting) {
  559. const targetNode = this.findNodeAtPoint(e.clientX, e.clientY)
  560. if (targetNode && targetNode.nodeKey !== this.connectStart.nodeKey) {
  561. this.createEdge(this.connectStart.nodeKey, targetNode.nodeKey,
  562. this.connectStart.anchor, 'top')
  563. }
  564. this.connecting = false
  565. this.connectStart = null
  566. this.tempEdge = null
  567. }
  568. this.draggingNode = null
  569. },
  570. /** 开始连线 */
  571. startConnect(e, node, anchor) {
  572. this.connecting = true
  573. const anchorPos = this.getAnchorPos(node, anchor)
  574. this.connectStart = {
  575. nodeKey: node.nodeKey,
  576. anchor: anchor,
  577. x: anchorPos.x,
  578. y: anchorPos.y
  579. }
  580. this.selectNode(node)
  581. },
  582. /** 获取锚点位置 */
  583. getAnchorPos(node, anchor) {
  584. const w = this.nodeWidth
  585. const h = node.height || 36
  586. switch (anchor) {
  587. case 'top': return { x: node.posX + w / 2, y: node.posY }
  588. case 'bottom': return { x: node.posX + w / 2, y: node.posY + h }
  589. case 'left': return { x: node.posX, y: node.posY + h / 2 }
  590. case 'right': return { x: node.posX + w, y: node.posY + h / 2 }
  591. default: return { x: node.posX + w / 2, y: node.posY + h }
  592. }
  593. },
  594. /** 查找点击位置的节点 */
  595. findNodeAtPoint(clientX, clientY) {
  596. const rect = this.$refs.canvasContainer.getBoundingClientRect()
  597. const scrollLeft = this.$refs.canvasContainer.scrollLeft
  598. const scrollTop = this.$refs.canvasContainer.scrollTop
  599. const x = (clientX - rect.left + scrollLeft - this.canvasOffset.x) / this.scale
  600. const y = (clientY - rect.top + scrollTop - this.canvasOffset.y) / this.scale
  601. return this.nodes.find(n => {
  602. const w = this.nodeWidth
  603. const h = n.height || 36
  604. return x >= n.posX && x <= n.posX + w && y >= n.posY && y <= n.posY + h
  605. })
  606. },
  607. /** 创建连线 */
  608. createEdge(sourceKey, targetKey, sourceAnchor, targetAnchor) {
  609. const exists = this.edges.find(e =>
  610. e.sourceNodeKey === sourceKey && e.targetNodeKey === targetKey)
  611. if (exists) return
  612. this.edges.push({
  613. edgeKey: 'edge_' + Date.now(),
  614. sourceNodeKey: sourceKey,
  615. targetNodeKey: targetKey,
  616. sourceAnchor: sourceAnchor,
  617. targetAnchor: targetAnchor,
  618. edgeType: 'smoothstep',
  619. edgeColor: '#999999'
  620. })
  621. },
  622. /** 获取连线路径 */
  623. getEdgePath(edge) {
  624. const sourceNode = this.nodes.find(n => n.nodeKey === edge.sourceNodeKey)
  625. const targetNode = this.nodes.find(n => n.nodeKey === edge.targetNodeKey)
  626. if (!sourceNode || !targetNode) return ''
  627. const start = this.getAnchorPos(sourceNode, edge.sourceAnchor || 'bottom')
  628. const end = this.getAnchorPos(targetNode, edge.targetAnchor || 'top')
  629. return this.calcPath(start.x, start.y, end.x, end.y)
  630. },
  631. /** 计算贝塞尔曲线路径 */
  632. calcPath(x1, y1, x2, y2) {
  633. const midY = (y1 + y2) / 2
  634. return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`
  635. },
  636. /** 获取连线标签位置 */
  637. getEdgeLabelPos(edge) {
  638. const sourceNode = this.nodes.find(n => n.nodeKey === edge.sourceNodeKey)
  639. const targetNode = this.nodes.find(n => n.nodeKey === edge.targetNodeKey)
  640. if (!sourceNode || !targetNode) return { x: 0, y: 0 }
  641. const start = this.getAnchorPos(sourceNode, edge.sourceAnchor || 'bottom')
  642. const end = this.getAnchorPos(targetNode, edge.targetAnchor || 'top')
  643. return { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }
  644. },
  645. /** 获取节点背景色 */
  646. getNodeBgColor(node) {
  647. const color = node.nodeColor || '#1890ff'
  648. return color + '15'
  649. },
  650. /** 获取节点类型名称 */
  651. getNodeTypeName(typeCode) {
  652. const t = this.nodeTypes.find(n => n.typeCode === typeCode)
  653. return t ? t.typeName : typeCode
  654. },
  655. /** 删除节点(按钮触发) */
  656. deleteNode() {
  657. this.deleteSelectedNode()
  658. },
  659. /** 删除连线(按钮触发) */
  660. deleteEdge() {
  661. this.deleteSelectedEdge()
  662. },
  663. /** 保存工作流 */
  664. handleSave() {
  665. if (!this.form.workflowName) {
  666. this.msgWarning('请输入工作流名称')
  667. return
  668. }
  669. const nodes = JSON.parse(JSON.stringify(this.nodes))
  670. nodes.filter(node => typeof node.nodeConfig == 'object').forEach(node => {
  671. console.log(typeof node.nodeConfig)
  672. node.nodeConfig = JSON.stringify(node.nodeConfig);
  673. })
  674. const data = {
  675. workflowId: this.workflowId,
  676. workflowName: this.form.workflowName,
  677. workflowDesc: this.form.workflowDesc,
  678. workflowType: this.form.workflowType,
  679. canvasData: JSON.stringify({ scale: this.scale, offset: this.canvasOffset }),
  680. nodes: nodes,
  681. edges: this.edges
  682. }
  683. if (this.workflowId) {
  684. updateWorkflow(data).then(() => {
  685. this.msgSuccess('保存成功')
  686. })
  687. } else {
  688. addWorkflow(data).then(res => {
  689. this.msgSuccess('保存成功')
  690. this.workflowId = res.data
  691. this.$router.replace('/his/aiWorkflow/design/' + this.workflowId)
  692. })
  693. }
  694. }
  695. }
  696. }
  697. </script>
  698. <style lang="scss" scoped>
  699. @import './design.scss';
  700. </style>