useFlowDesigner.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973
  1. import { ref, onMounted, onUnmounted, nextTick, reactive, computed, type Ref } from 'vue'
  2. import { useRoute, useRouter } from 'vue-router'
  3. import LogicFlow, {
  4. RectNode,
  5. RectNodeModel,
  6. CircleNode,
  7. CircleNodeModel,
  8. PolygonNode,
  9. PolygonNodeModel,
  10. } from '@logicflow/core'
  11. import { Menu, Snapshot } from '@logicflow/extension'
  12. import '@logicflow/core/dist/style/index.css'
  13. import '@logicflow/extension/lib/style/index.css'
  14. import { ElMessage } from 'element-plus'
  15. import type { FormRules } from 'element-plus'
  16. import { listRole } from '@/api/system/role'
  17. import { addDefinition, updateDefinition, getDefinition } from '@/api/flow/definition'
  18. import type { Role } from '@/types/system'
  19. import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
  20. /* eslint-disable @typescript-eslint/no-explicit-any */
  21. export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
  22. const route = useRoute()
  23. const router = useRouter()
  24. let lf: LogicFlow | null = null
  25. const lfRef = ref<any>(null)
  26. const mode = computed(() => (route.query.mode as string) || '')
  27. const flowId = computed(() => (route.query.id as string) || '')
  28. const flowCode = computed(() => (route.query.code as string) || '')
  29. const flowName = computed(() => (route.query.name as string) || '')
  30. const flowCategory = computed(() => (route.query.category as string) || '')
  31. const flowDescription = computed(() => (route.query.description as string) || '')
  32. const flowInfoVisible = computed(() => mode.value === 'create' || mode.value === 'edit')
  33. const selectedNode = ref<any>(null)
  34. const roleList = ref<Role[]>([])
  35. const nodeProps = reactive<Record<string, any>>({
  36. assigneeType: 'ROLE',
  37. assigneeValue: '',
  38. nodeKey: '',
  39. approveMode: 'or',
  40. timeoutHours: undefined as number | undefined,
  41. timeoutAction: 'remind',
  42. approveTimeField: '',
  43. ccUsers: '',
  44. subNodesEnabled: false,
  45. subNodeMode: 'parallel',
  46. subNodes: [] as any[],
  47. })
  48. // 条件节点出连线配置
  49. const outgoingEdges = ref<any[]>([])
  50. function loadOutgoingEdges(nodeId?: string) {
  51. if (!lf) {
  52. outgoingEdges.value = []
  53. return
  54. }
  55. const id = nodeId || selectedNode.value?.id
  56. if (!id) {
  57. outgoingEdges.value = []
  58. return
  59. }
  60. const graphData = lf.getGraphData()
  61. const edges = (graphData.edges || []).filter((e: any) => e.sourceNodeId === id)
  62. outgoingEdges.value = edges.map((e: any) => ({
  63. ...e,
  64. properties: {
  65. condition: '',
  66. isDefault: false,
  67. branchName: '',
  68. ...(e.properties || {}),
  69. },
  70. }))
  71. }
  72. function getEdgeTargetName(edge: any): string {
  73. if (!lf || !edge?.targetNodeId) return '—'
  74. try {
  75. const node = lf.getNodeData(edge.targetNodeId)
  76. return node?.text?.value || node?.text || '未命名节点'
  77. } catch {
  78. return '—'
  79. }
  80. }
  81. function getEdgeDisplayText(edge: any): string {
  82. const props = edge.properties || {}
  83. if (props.branchName) return props.branchName
  84. if (props.isDefault) return '默认'
  85. if (props.condition) return props.condition
  86. return ''
  87. }
  88. function syncEdgeToLf(edge: any) {
  89. if (!lf || !edge?.id) return
  90. const props = edge.properties || {}
  91. const displayText = getEdgeDisplayText(edge)
  92. lf.setEdgeData(edge.id, {
  93. properties: { ...props },
  94. text: displayText ? { value: displayText } : undefined,
  95. })
  96. }
  97. function onDefaultChange(edge: any) {
  98. if (edge.properties.isDefault) {
  99. edge.properties.condition = ''
  100. // 同一条件节点下只能有一个默认分支
  101. const nodeId = edge.sourceNodeId
  102. outgoingEdges.value.forEach((e: any) => {
  103. if (e.id !== edge.id && e.sourceNodeId === nodeId) {
  104. e.properties.isDefault = false
  105. syncEdgeToLf(e)
  106. }
  107. })
  108. }
  109. syncEdgeToLf(edge)
  110. }
  111. function onBranchBlur(edge: any) {
  112. syncEdgeToLf(edge)
  113. }
  114. function generateSubNodeId(): string {
  115. return 'sub-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
  116. }
  117. function getDefaultProps(type: string) {
  118. if (type === 'approval-node')
  119. return {
  120. assigneeType: 'ROLE',
  121. assigneeValue: '',
  122. nodeKey: '',
  123. approveMode: 'or',
  124. timeoutHours: undefined,
  125. timeoutAction: 'remind',
  126. approveTimeField: '',
  127. subNodesEnabled: false,
  128. subNodeMode: 'parallel',
  129. subNodes: [],
  130. }
  131. if (type === 'cc-node') return { ccUsers: '' }
  132. return {}
  133. }
  134. function saveCurrentNodeProps() {
  135. if (selectedNode.value && lf) {
  136. const type = selectedNode.value.type
  137. const defaultProps = getDefaultProps(type)
  138. // 只保存当前节点类型相关的属性,避免不同类型节点属性互相污染
  139. const props: Record<string, any> = JSON.parse(JSON.stringify(defaultProps))
  140. if (type === 'approval-node') {
  141. props.assigneeType = nodeProps.assigneeType ?? 'ROLE'
  142. props.assigneeValue = nodeProps.assigneeValue ?? ''
  143. props.nodeKey = nodeProps.nodeKey ?? ''
  144. props.approveMode = nodeProps.approveMode ?? 'or'
  145. props.timeoutHours = nodeProps.timeoutHours
  146. props.timeoutAction = nodeProps.timeoutAction ?? 'remind'
  147. props.approveTimeField = nodeProps.approveTimeField ?? ''
  148. props.subNodesEnabled = nodeProps.subNodesEnabled ?? false
  149. props.subNodeMode = nodeProps.subNodeMode ?? 'parallel'
  150. props.subNodes = Array.isArray(nodeProps.subNodes) ? nodeProps.subNodes : []
  151. } else if (type === 'cc-node') {
  152. props.ccUsers = nodeProps.ccUsers ?? ''
  153. }
  154. lf.setProperties(selectedNode.value.id, props)
  155. // 同步子节点数量角标到节点文本
  156. if (type === 'approval-node') {
  157. syncNodeTextWithSubNodeBadge(selectedNode.value.id)
  158. }
  159. // 通知画布刷新子节点 overlay
  160. lf.emit('properties:changed', { nodeId: selectedNode.value.id })
  161. }
  162. }
  163. function getNodeRawText(nodeId: string): string {
  164. if (!lf) return ''
  165. try {
  166. const node = lf.getNodeData(nodeId)
  167. const text = node?.text?.value || node?.text || ''
  168. return String(text).replace(/ \(\+\d+\)$/, '')
  169. } catch {
  170. return ''
  171. }
  172. }
  173. function syncNodeTextWithSubNodeBadge(nodeId: string) {
  174. if (!lf) return
  175. const rawText = getNodeRawText(nodeId)
  176. if (!rawText) return
  177. // 不再把子节点数量拼进节点文本,改为画布浮层角标
  178. lf.updateText(nodeId, rawText)
  179. }
  180. function loadNodeProps(data: any) {
  181. const props = data.properties || getDefaultProps(data.type)
  182. const cloned = JSON.parse(JSON.stringify(props))
  183. // 先重置为默认值,避免上一个节点的属性残留
  184. Object.assign(nodeProps, getDefaultProps(data.type))
  185. // 兼容旧格式:approver + approveType -> 新格式
  186. if (data.type === 'approval-node') {
  187. const supportedTypes = ['ROLE', 'SELF', 'EXTERNAL']
  188. let assigneeType = cloned.assigneeType ?? 'ROLE'
  189. let assigneeValue = cloned.assigneeValue ?? ''
  190. const approveMode = cloned.approveMode ?? cloned.approveType ?? 'or'
  191. if (!cloned.assigneeType && !cloned.assigneeValue && cloned.approver) {
  192. assigneeType = 'ROLE'
  193. assigneeValue = cloned.approver
  194. }
  195. // 当前设计器支持 ROLE / SELF / EXTERNAL,其它旧类型降级为 ROLE 并清空值
  196. if (!supportedTypes.includes(assigneeType)) {
  197. assigneeType = 'ROLE'
  198. assigneeValue = ''
  199. }
  200. // SELF / EXTERNAL 不需要审批角色值
  201. if (assigneeType === 'SELF' || assigneeType === 'EXTERNAL') {
  202. assigneeValue = ''
  203. }
  204. nodeProps.assigneeType = assigneeType
  205. nodeProps.assigneeValue = assigneeValue
  206. nodeProps.nodeKey = cloned.nodeKey ?? ''
  207. nodeProps.approveMode = approveMode
  208. nodeProps.timeoutHours = cloned.timeoutHours ?? undefined
  209. nodeProps.timeoutAction = cloned.timeoutAction ?? 'remind'
  210. nodeProps.approveTimeField = cloned.approveTimeField ?? ''
  211. nodeProps.subNodesEnabled = cloned.subNodesEnabled === true
  212. nodeProps.subNodeMode = cloned.subNodeMode ?? 'parallel'
  213. nodeProps.subNodes = Array.isArray(cloned.subNodes)
  214. ? cloned.subNodes.map((s: any) => ({
  215. id: s.id || generateSubNodeId(),
  216. name: s.name || '',
  217. assigneeType: s.assigneeType ?? 'ROLE',
  218. assigneeValue: s.assigneeValue ?? '',
  219. approveMode: s.approveMode ?? 'or',
  220. timeoutHours: s.timeoutHours ?? undefined,
  221. timeoutAction: s.timeoutAction ?? 'remind',
  222. }))
  223. : []
  224. } else if (data.type === 'cc-node') {
  225. nodeProps.ccUsers = cloned.ccUsers ?? ''
  226. }
  227. }
  228. function registerNodes() {
  229. if (!lf) return
  230. class StartNodeModel extends CircleNodeModel {
  231. setAttributes() {
  232. this.r = 30
  233. if (!this.text.value) this.text.value = '开始'
  234. }
  235. getNodeStyle() {
  236. const style = super.getNodeStyle()
  237. style.fill = '#67C23A'
  238. style.stroke = '#67C23A'
  239. return style
  240. }
  241. getTextStyle() {
  242. const style = super.getTextStyle()
  243. style.color = '#fff'
  244. return style
  245. }
  246. }
  247. lf.register({ type: 'start-node', view: CircleNode, model: StartNodeModel })
  248. class ApprovalNodeModel extends RectNodeModel {
  249. setAttributes() {
  250. this.width = 160
  251. this.height = 60
  252. this.radius = 8
  253. if (!this.text.value) this.text.value = '审批节点'
  254. }
  255. getNodeStyle() {
  256. const style = super.getNodeStyle()
  257. style.fill = '#fff'
  258. style.stroke = '#409EFF'
  259. style.strokeWidth = 1
  260. return style
  261. }
  262. }
  263. lf.register({ type: 'approval-node', view: RectNode, model: ApprovalNodeModel })
  264. class CcNodeModel extends RectNodeModel {
  265. setAttributes() {
  266. this.width = 160
  267. this.height = 60
  268. this.radius = 8
  269. if (!this.text.value) this.text.value = '抄送节点'
  270. }
  271. getNodeStyle() {
  272. const style = super.getNodeStyle()
  273. style.fill = '#fff'
  274. style.stroke = '#E6A23C'
  275. style.strokeWidth = 1
  276. return style
  277. }
  278. }
  279. lf.register({ type: 'cc-node', view: RectNode, model: CcNodeModel })
  280. class ConditionNodeModel extends PolygonNodeModel {
  281. setAttributes() {
  282. this.points = [
  283. [60, 0],
  284. [120, 40],
  285. [60, 80],
  286. [0, 40],
  287. ]
  288. if (!this.text.value) this.text.value = '条件'
  289. }
  290. getNodeStyle() {
  291. const style = super.getNodeStyle()
  292. style.fill = '#fff'
  293. style.stroke = '#909399'
  294. return style
  295. }
  296. }
  297. lf.register({ type: 'condition-node', view: PolygonNode, model: ConditionNodeModel })
  298. class EndNodeModel extends CircleNodeModel {
  299. setAttributes() {
  300. this.r = 30
  301. if (!this.text.value) this.text.value = '结束'
  302. }
  303. getNodeStyle() {
  304. const style = super.getNodeStyle()
  305. style.fill = '#F56C6C'
  306. style.stroke = '#F56C6C'
  307. return style
  308. }
  309. getTextStyle() {
  310. const style = super.getTextStyle()
  311. style.color = '#fff'
  312. return style
  313. }
  314. }
  315. lf.register({ type: 'end-node', view: CircleNode, model: EndNodeModel })
  316. }
  317. function defaultNodes() {
  318. return [
  319. { id: 'start-1', type: 'start-node', x: 200, y: 100, text: '开始' },
  320. { id: 'end-1', type: 'end-node', x: 200, y: 500, text: '结束' },
  321. ]
  322. }
  323. function initLogicFlow() {
  324. if (!lfContainer.value) return
  325. lf = new LogicFlow({
  326. container: lfContainer.value,
  327. grid: true,
  328. plugins: [Menu, Snapshot],
  329. pluginsOptions: {
  330. menu: {
  331. nodeMenu: [
  332. {
  333. text: '删除',
  334. callback(node: any) {
  335. lf?.deleteNode(node.id)
  336. },
  337. },
  338. ],
  339. },
  340. } as any,
  341. })
  342. lfRef.value = lf
  343. registerNodes()
  344. lf.on('node:click', ({ data }: any) => {
  345. saveCurrentNodeProps()
  346. selectedNode.value = data
  347. loadNodeProps(data)
  348. loadOutgoingEdges(data.id)
  349. })
  350. lf.on('blank:click', () => {
  351. saveCurrentNodeProps()
  352. selectedNode.value = null
  353. outgoingEdges.value = []
  354. })
  355. // 当条件节点的出连线发生变化时,刷新右侧分支配置面板
  356. lf.on('edge:add', ({ data }: any) => {
  357. if (selectedNode.value?.id && data?.sourceNodeId === selectedNode.value.id) {
  358. loadOutgoingEdges(selectedNode.value.id)
  359. }
  360. })
  361. lf.on('edge:delete', ({ data }: any) => {
  362. if (selectedNode.value?.id && data?.sourceNodeId === selectedNode.value.id) {
  363. loadOutgoingEdges(selectedNode.value.id)
  364. }
  365. })
  366. // 编辑模式:加载已有流程图
  367. if (mode.value === 'edit' && flowId.value) {
  368. getDefinition(Number(flowId.value))
  369. .then((def: FlowDefinition) => {
  370. parseFormSchema(def.formSchema)
  371. if (def.flowJson) {
  372. try {
  373. const parsed = JSON.parse(def.flowJson)
  374. const graphData = convertToFrontendFormat(parsed)
  375. lf?.render(graphData)
  376. nextTick(() => syncAllSubNodeBadges())
  377. } catch {
  378. lf?.render({ nodes: defaultNodes(), edges: [] })
  379. }
  380. } else {
  381. lf?.render({ nodes: defaultNodes(), edges: [] })
  382. }
  383. })
  384. .catch(() => {
  385. lf?.render({ nodes: defaultNodes(), edges: [] })
  386. })
  387. } else {
  388. lf.render({ nodes: defaultNodes(), edges: [] })
  389. }
  390. nextTick(() => syncAllSubNodeBadges())
  391. }
  392. function syncAllSubNodeBadges() {
  393. if (!lf) return
  394. const graphData = lf.getGraphData()
  395. for (const node of graphData.nodes || []) {
  396. if (node.type !== 'approval-node') continue
  397. syncNodeTextWithSubNodeBadge(node.id)
  398. }
  399. }
  400. const typeToBackend: Record<string, string> = {
  401. 'start-node': 'start',
  402. 'approval-node': 'approval',
  403. 'cc-node': 'cc',
  404. 'condition-node': 'condition',
  405. 'end-node': 'end',
  406. }
  407. const typeToFrontend: Record<string, string> = {
  408. start: 'start-node',
  409. approval: 'approval-node',
  410. cc: 'cc-node',
  411. condition: 'condition-node',
  412. end: 'end-node',
  413. }
  414. // 将 LogicFlow 的 graphData 转换为后端格式
  415. function convertToBackendFormat(graphData: any) {
  416. const data = JSON.parse(JSON.stringify(graphData))
  417. if (data.nodes) {
  418. data.nodes = data.nodes.map((node: any) => {
  419. const n = { ...node }
  420. if (n.type && typeToBackend[n.type]) {
  421. n.type = typeToBackend[n.type]
  422. }
  423. // text -> name
  424. if (n.text !== undefined) {
  425. n.name = typeof n.text === 'object' ? n.text.value : n.text
  426. // 去掉子节点数量角标,避免保存到后端
  427. n.name = String(n.name).replace(/ \(\+\d+\)$/, '')
  428. delete n.text
  429. }
  430. return n
  431. })
  432. }
  433. // 将 LogicFlow edge.properties 转换为后端的 edge.condition 结构
  434. if (data.edges) {
  435. data.edges = data.edges.map((edge: any) => {
  436. const e = { ...edge }
  437. const props = e.properties || {}
  438. e.condition = {
  439. condition: props.condition || '',
  440. isDefault: props.isDefault ?? !props.condition,
  441. branchName: props.branchName || '',
  442. }
  443. // 清理 LogicFlow 运行时不需要的属性,避免后端序列化冗余
  444. if (e.text !== undefined && (!e.text || !e.text.value)) {
  445. delete e.text
  446. }
  447. delete e.properties
  448. return e
  449. })
  450. }
  451. return data
  452. }
  453. // 将后端的 graphData 转换为 LogicFlow 格式
  454. function convertToFrontendFormat(graphData: any) {
  455. const data = JSON.parse(JSON.stringify(graphData))
  456. // 兼容旧格式:条件表达式写在节点 properties.condition 上,需要迁移到连线
  457. if (data.nodes && data.edges) {
  458. data.nodes.forEach((node: any) => {
  459. if ((node.type === 'condition' || node.type === 'condition-node') && node.properties?.condition) {
  460. const nodeCondition = String(node.properties.condition)
  461. const outEdges = data.edges.filter((e: any) => e.sourceNodeId === node.id)
  462. if (outEdges.length > 0) {
  463. // 第一条连线作为条件分支,其余作为默认分支
  464. outEdges.forEach((e: any, idx: number) => {
  465. if (!e.condition) {
  466. e.condition = {}
  467. }
  468. if (idx === 0) {
  469. e.condition.condition = nodeCondition
  470. e.condition.isDefault = false
  471. } else {
  472. e.condition.condition = ''
  473. e.condition.isDefault = true
  474. }
  475. })
  476. }
  477. delete node.properties.condition
  478. }
  479. })
  480. }
  481. if (data.edges) {
  482. data.edges = data.edges.map((edge: any) => {
  483. const e = { ...edge }
  484. // 将后端的 edge.condition 转换为 LogicFlow 的 edge.properties
  485. const conditionMap = e.condition || {}
  486. e.properties = {
  487. condition: conditionMap.condition || '',
  488. isDefault: conditionMap.isDefault ?? !conditionMap.condition,
  489. branchName: conditionMap.branchName || '',
  490. }
  491. // 设置/清理连线文本
  492. const displayText = e.properties.branchName || (!e.properties.isDefault && e.properties.condition)
  493. if (displayText) {
  494. e.text = { value: displayText }
  495. } else {
  496. delete e.text
  497. }
  498. delete e.condition
  499. return e
  500. })
  501. }
  502. if (data.nodes) {
  503. data.nodes = data.nodes.map((node: any) => {
  504. const n = { ...node }
  505. if (n.type && typeToFrontend[n.type]) {
  506. n.type = typeToFrontend[n.type]
  507. }
  508. // name -> text
  509. if (n.name !== undefined) {
  510. n.text = String(n.name).replace(/ \(\+\d+\)$/, '')
  511. delete n.name
  512. }
  513. return n
  514. })
  515. }
  516. return data
  517. }
  518. function handleDrop(e: DragEvent) {
  519. e.preventDefault()
  520. const dataStr = e.dataTransfer?.getData('application/json')
  521. if (!dataStr || !lf || !lfContainer.value) return
  522. const nodeData = JSON.parse(dataStr)
  523. const rect = lfContainer.value.getBoundingClientRect()
  524. lf.addNode({
  525. type: nodeData.type,
  526. x: e.clientX - rect.left,
  527. y: e.clientY - rect.top,
  528. text: nodeData.label,
  529. })
  530. }
  531. function updateNodeName() {
  532. if (!lf || !selectedNode.value) return
  533. const text = selectedNode.value.text
  534. const value = typeof text === 'object' && text !== null ? text.value || '' : text || ''
  535. // 使用 LogicFlow 公开 API updateText 更新节点文本
  536. lf.updateText(selectedNode.value.id, value)
  537. }
  538. // 保存流程
  539. const saveDialogVisible = ref(false)
  540. const saveForm = reactive({
  541. code: '',
  542. name: '',
  543. category: '',
  544. description: '',
  545. })
  546. const saveFormRules: FormRules = {
  547. code: [{ required: true, message: '请输入流程编码', trigger: 'blur' }],
  548. name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }],
  549. }
  550. const formFields = ref<FormField[]>([])
  551. const fieldTypeOptions = [
  552. { label: '文本', value: 'text' },
  553. { label: '多行文本', value: 'textarea' },
  554. { label: '数字', value: 'number' },
  555. { label: '日期', value: 'date' },
  556. { label: '下拉选择', value: 'select' },
  557. ]
  558. function addFormField() {
  559. formFields.value.push({ name: '', label: '', type: 'text', required: false })
  560. }
  561. function removeFormField(index: number) {
  562. formFields.value.splice(index, 1)
  563. }
  564. function getFormSchema(): string {
  565. const validFields = formFields.value.filter((f) => f.name.trim() && f.label.trim())
  566. return validFields.length > 0 ? JSON.stringify(validFields) : ''
  567. }
  568. function parseFormSchema(schema?: string) {
  569. if (!schema) return
  570. try {
  571. const parsed = JSON.parse(schema)
  572. if (Array.isArray(parsed)) {
  573. formFields.value = parsed
  574. }
  575. } catch {
  576. formFields.value = []
  577. }
  578. }
  579. // 下拉选项编辑
  580. const optionDialogVisible = ref(false)
  581. const optionEditingIndex = ref<number | null>(null)
  582. const optionEditingField = ref<FormField | null>(null)
  583. const optionList = ref<FormFieldOption[]>([])
  584. function openOptionDialog(index: number) {
  585. optionEditingIndex.value = index
  586. optionEditingField.value = formFields.value[index]
  587. const rawOptions = optionEditingField.value.options || []
  588. optionList.value = rawOptions.map((opt: any) => {
  589. if (typeof opt === 'string') {
  590. return { label: opt, value: opt }
  591. }
  592. return { label: String(opt.label ?? opt.value), value: opt.value }
  593. })
  594. optionDialogVisible.value = true
  595. }
  596. function addOption() {
  597. optionList.value.push({ label: '', value: '' })
  598. }
  599. function removeOption(index: number) {
  600. optionList.value.splice(index, 1)
  601. }
  602. function saveOptions() {
  603. if (optionEditingIndex.value === null || !optionEditingField.value) return
  604. const validOptions = optionList.value.filter(
  605. (opt) => String(opt.label).trim() !== '' && String(opt.value).trim() !== ''
  606. )
  607. formFields.value[optionEditingIndex.value].options = validOptions
  608. formFields.value[optionEditingIndex.value].multiple = optionEditingField.value.multiple
  609. optionDialogVisible.value = false
  610. }
  611. function validateFlow(graphData: any): string | null {
  612. const nodes = graphData.nodes || []
  613. const edges = graphData.edges || []
  614. if (nodes.length === 0) return '流程图不能为空'
  615. const startNodes = nodes.filter((n: any) => n.type === 'start-node' || n.type === 'start')
  616. const endNodes = nodes.filter((n: any) => n.type === 'end-node' || n.type === 'end')
  617. if (startNodes.length === 0) return '流程图缺少开始节点'
  618. if (endNodes.length === 0) return '流程图缺少结束节点'
  619. if (startNodes.length > 1) return '流程图只能有一个开始节点'
  620. // 检查审批节点是否配置了审批对象
  621. const externalNodeKeys = new Set<string>()
  622. for (const node of nodes) {
  623. if (node.type === 'approval-node' || node.type === 'approval') {
  624. const props = node.properties || {}
  625. const assigneeType = props.assigneeType ?? 'ROLE'
  626. const assigneeValue = props.assigneeValue || props.approver
  627. if (assigneeType === 'ROLE' && !assigneeValue && props.subNodesEnabled !== true) {
  628. return `审批节点 ${node.text || node.name} 未选择审批角色`
  629. }
  630. if (assigneeType === 'EXTERNAL') {
  631. if (props.subNodesEnabled === true) {
  632. return `审批节点 "${node.text || node.name}" 使用外部指定审批人时不支持子节点`
  633. }
  634. const nodeKey = String(props.nodeKey || '').trim()
  635. if (!nodeKey) {
  636. return `审批节点 "${node.text || node.name}" 未填写节点编码(nodeKey)`
  637. }
  638. if (externalNodeKeys.has(nodeKey)) {
  639. return `节点编码 "${nodeKey}" 重复,请保证每个外部指定节点编码唯一`
  640. }
  641. externalNodeKeys.add(nodeKey)
  642. }
  643. if (props.subNodesEnabled === true) {
  644. const subNodes = props.subNodes || []
  645. if (subNodes.length === 0) {
  646. return `审批节点 "${node.text || node.name}" 已启用子节点,请至少配置一个子节点`
  647. }
  648. for (let i = 0; i < subNodes.length; i++) {
  649. const sub = subNodes[i]
  650. const subAssigneeType = sub.assigneeType ?? 'ROLE'
  651. const subAssigneeValue = sub.assigneeValue
  652. if (subAssigneeType === 'ROLE' && !subAssigneeValue) {
  653. return `审批节点 ${node.text || node.name} 的子节点 ${i + 1} 未选择审批角色`
  654. }
  655. }
  656. }
  657. }
  658. }
  659. // 检查孤立节点
  660. const connectedNodeIds = new Set<string>()
  661. for (const edge of edges) {
  662. connectedNodeIds.add(edge.sourceNodeId)
  663. connectedNodeIds.add(edge.targetNodeId)
  664. }
  665. for (const node of nodes) {
  666. if (
  667. !connectedNodeIds.has(node.id) &&
  668. node.type !== 'start-node' &&
  669. node.type !== 'start' &&
  670. node.type !== 'end-node' &&
  671. node.type !== 'end'
  672. ) {
  673. return `节点 "${node.text || node.name}" 是孤立节点,请删除或连接`
  674. }
  675. }
  676. // 检查条件分支:有且只能有一个默认分支,非默认分支必须填写条件
  677. const sourceMap: Record<string, any[]> = {}
  678. for (const edge of edges) {
  679. if (!sourceMap[edge.sourceNodeId]) sourceMap[edge.sourceNodeId] = []
  680. sourceMap[edge.sourceNodeId].push(edge)
  681. }
  682. for (const node of nodes) {
  683. if (node.type === 'condition-node' || node.type === 'condition') {
  684. const outEdges = sourceMap[node.id] || []
  685. if (outEdges.length > 1) {
  686. const defaultEdges = outEdges.filter((e: any) => {
  687. const props = e.properties || {}
  688. return props.isDefault || (!props.condition && !e.condition)
  689. })
  690. if (defaultEdges.length === 0) {
  691. return `条件节点 "${node.text || node.name}" 需要至少一条默认分支`
  692. }
  693. if (defaultEdges.length > 1) {
  694. return `条件节点 "${node.text || node.name}" 只能有一条默认分支`
  695. }
  696. const invalid = outEdges.some((e: any) => {
  697. const props = e.properties || {}
  698. return !props.isDefault && !props.condition && !e.condition
  699. })
  700. if (invalid) {
  701. return `条件节点 "${node.text || node.name}" 的非默认分支必须填写条件表达式`
  702. }
  703. }
  704. }
  705. }
  706. return null
  707. }
  708. async function handleSave() {
  709. if (!lf) return
  710. saveCurrentNodeProps()
  711. // 先刷新属性面板中可能未失焦的节点名称修改,再读取画布数据
  712. updateNodeName()
  713. const rawGraphData = lf.getGraphData()
  714. const error = validateFlow(rawGraphData)
  715. if (error) {
  716. ElMessage.warning(error)
  717. return
  718. }
  719. // 统一弹出保存弹窗,方便配置流程表单字段
  720. if (mode.value === 'create') {
  721. saveForm.code = flowCode.value
  722. saveForm.name = flowName.value
  723. saveForm.category = flowCategory.value
  724. saveForm.description = flowDescription.value
  725. formFields.value = []
  726. } else if (mode.value === 'edit' && flowId.value) {
  727. saveForm.code = flowCode.value
  728. saveForm.name = flowName.value
  729. saveForm.category = flowCategory.value
  730. saveForm.description = flowDescription.value
  731. // formFields 在编辑模式加载流程时已通过 parseFormSchema 填充
  732. } else {
  733. saveForm.code = ''
  734. saveForm.name = ''
  735. saveForm.category = ''
  736. saveForm.description = ''
  737. formFields.value = []
  738. }
  739. saveDialogVisible.value = true
  740. }
  741. async function submitSave() {
  742. if (!lf) return
  743. saveCurrentNodeProps()
  744. // 先刷新属性面板中可能未失焦的节点名称修改,再读取画布数据
  745. updateNodeName()
  746. const rawGraphData = lf.getGraphData()
  747. const error = validateFlow(rawGraphData)
  748. if (error) {
  749. ElMessage.warning(error)
  750. return
  751. }
  752. const graphData = convertToBackendFormat(rawGraphData)
  753. if (mode.value === 'create') {
  754. const data: Partial<FlowDefinition> = {
  755. code: saveForm.code,
  756. name: saveForm.name,
  757. category: saveForm.category,
  758. description: saveForm.description,
  759. formSchema: getFormSchema(),
  760. flowJson: JSON.stringify(graphData),
  761. status: 0,
  762. }
  763. await addDefinition(data)
  764. ElMessage.success('流程新增成功')
  765. saveDialogVisible.value = false
  766. router.push('/flow/definition')
  767. } else if (mode.value === 'edit' && flowId.value) {
  768. const data: Partial<FlowDefinition> = {
  769. id: Number(flowId.value),
  770. code: saveForm.code,
  771. name: saveForm.name,
  772. category: saveForm.category,
  773. description: saveForm.description,
  774. formSchema: getFormSchema(),
  775. flowJson: JSON.stringify(graphData),
  776. }
  777. const newId = await updateDefinition(data)
  778. if (newId !== Number(flowId.value)) {
  779. ElMessage.success('流程已创建为新版本,请重新发布')
  780. } else {
  781. ElMessage.success('流程修改成功')
  782. }
  783. saveDialogVisible.value = false
  784. router.push('/flow/definition')
  785. } else {
  786. const data: Partial<FlowDefinition> = {
  787. code: saveForm.code,
  788. name: saveForm.name,
  789. category: saveForm.category,
  790. description: saveForm.description,
  791. formSchema: getFormSchema(),
  792. flowJson: JSON.stringify(graphData),
  793. status: 0,
  794. }
  795. await addDefinition(data)
  796. ElMessage.success('流程保存成功')
  797. saveDialogVisible.value = false
  798. router.push('/flow/definition')
  799. }
  800. }
  801. function deleteSelectedNode() {
  802. if (!lf || !selectedNode.value) return
  803. lf.deleteNode(selectedNode.value.id)
  804. selectedNode.value = null
  805. outgoingEdges.value = []
  806. }
  807. function selectNodeById(nodeId: string) {
  808. if (!lf) return
  809. try {
  810. const nodeData = lf.getNodeData(nodeId)
  811. if (!nodeData) return
  812. lf.selectElementById(nodeId)
  813. saveCurrentNodeProps()
  814. selectedNode.value = nodeData
  815. loadNodeProps(nodeData)
  816. loadOutgoingEdges(nodeData.id)
  817. } catch {
  818. // ignore
  819. }
  820. }
  821. function handleClear() {
  822. if (!lf) return
  823. lf.clearData()
  824. selectedNode.value = null
  825. }
  826. function goBack() {
  827. router.push('/flow/definition')
  828. }
  829. async function loadRoles() {
  830. try {
  831. const res = await listRole({ pageNum: 1, pageSize: 100 })
  832. roleList.value = res.list
  833. } catch {
  834. /* ignore */
  835. }
  836. }
  837. onMounted(() => {
  838. // 防止快速进入/离开设计器时重复创建 LogicFlow 实例(v1.x 无 destroy 方法)
  839. if (lf) {
  840. if (typeof lf.destroy === 'function') {
  841. lf.destroy()
  842. }
  843. lf = null
  844. }
  845. nextTick(initLogicFlow)
  846. loadRoles()
  847. })
  848. onUnmounted(() => {
  849. if (lf) {
  850. if (typeof lf.destroy === 'function') {
  851. lf.destroy()
  852. }
  853. lf = null
  854. }
  855. selectedNode.value = null
  856. })
  857. return {
  858. // flow info
  859. mode,
  860. flowId,
  861. flowCode,
  862. flowName,
  863. flowCategory,
  864. flowDescription,
  865. flowInfoVisible,
  866. // logic flow / selection
  867. selectedNode,
  868. outgoingEdges,
  869. roleList,
  870. nodeProps,
  871. lfRef,
  872. // property panel helpers
  873. loadOutgoingEdges,
  874. getEdgeTargetName,
  875. onDefaultChange,
  876. onBranchBlur,
  877. saveCurrentNodeProps,
  878. loadNodeProps,
  879. updateNodeName,
  880. generateSubNodeId,
  881. deleteSelectedNode,
  882. selectNodeById,
  883. // save dialog
  884. saveDialogVisible,
  885. saveForm,
  886. saveFormRules,
  887. formFields,
  888. fieldTypeOptions,
  889. addFormField,
  890. removeFormField,
  891. getFormSchema,
  892. parseFormSchema,
  893. // option dialog
  894. optionDialogVisible,
  895. optionEditingIndex,
  896. optionEditingField,
  897. optionList,
  898. openOptionDialog,
  899. addOption,
  900. removeOption,
  901. saveOptions,
  902. // toolbar actions
  903. handleSave,
  904. submitSave,
  905. handleClear,
  906. goBack,
  907. // drag/drop
  908. handleDrop,
  909. }
  910. }