mta-mcp 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1007 @@
1
+ # LogicFlow 流程图开发规范
2
+
3
+ > 基于 LogicFlow 2.1+ 的流程图开发标准规范
4
+ > 适用于 Vue 3 + TypeScript 项目集成
5
+
6
+ ---
7
+
8
+ ## 🎯 核心架构原则
9
+
10
+ LogicFlow 基于 **Model-View-Component 三层架构**:
11
+
12
+ ```typescript
13
+ // 节点注册标准模式
14
+ import { register } from '@logicflow/vue-node-registry'
15
+
16
+ register({
17
+ type: 'customNode', // 节点类型标识
18
+ model: CustomNodeModel, // 节点逻辑模型(锚点、规则、属性)
19
+ view: CustomNodeView, // 节点视图(锚点渲染)
20
+ component: CustomNodeComponent // 节点 UI 组件(样式、内容)
21
+ }, lfInstance)
22
+ ```
23
+
24
+ ### 核心概念
25
+
26
+ 1. **Model(模型层)**:
27
+ - 定义节点的数据结构和行为逻辑
28
+ - 管理锚点位置和连接规则
29
+ - 控制节点的属性和状态
30
+
31
+ 2. **View(视图层)**:
32
+ - 渲染节点的 SVG 结构
33
+ - 渲染锚点的视觉表现
34
+ - 处理节点的交互事件
35
+
36
+ 3. **Component(组件层)**:
37
+ - 使用 Vue 组件定义节点的 UI
38
+ - 响应节点属性变化
39
+ - 提供丰富的交互体验
40
+
41
+ ---
42
+
43
+ ## 📐 节点模式分类
44
+
45
+ 根据业务需求选择合适的节点模式:
46
+
47
+ | 模式 | 锚点配置 | 连线规则 | 典型应用 |
48
+ |-----|---------|---------|---------|
49
+ | **单向流节点** | 左入右出 / 上入下出 | 单入单出 | 数据转换、审批步骤 |
50
+ | **分支节点** | 1入多出(动态锚点) | 单入多出,每个出口限1条 | 条件分支、并行处理 |
51
+ | **汇聚节点** | 多入1出(动态锚点) | 多入单出 | 分支合并、数据汇总 |
52
+ | **起始节点** | 只有出口 | 只能连出,限1条 | 流程开始、数据源 |
53
+ | **终止节点** | 只有入口 | 只能连入 | 流程结束、数据输出 |
54
+ | **双向节点** | 左右 / 上下对称 | 双向连接 | 数据交互、状态切换 |
55
+
56
+ ---
57
+
58
+ ## 🔧 节点模型开发
59
+
60
+ ### 基础模板
61
+
62
+ ```typescript
63
+ import { RectNodeModel } from '@logicflow/core'
64
+
65
+ // 自定义节点模型基础模板
66
+ export class CustomNodeModel extends RectNodeModel {
67
+ // 是否实时更新(属性变化时重新渲染)
68
+ shouldUpdate() {
69
+ return true // 需要响应式更新时返回 true
70
+ }
71
+
72
+ // 设置节点尺寸和样式
73
+ setAttributes() {
74
+ this.width = 200 // 根据内容调整
75
+ this.height = 80
76
+
77
+ // 可选:根据动态属性调整尺寸
78
+ if (this.properties?.branches) {
79
+ this.height = 60 + this.properties.branches.length * 20
80
+ }
81
+ }
82
+
83
+ // 连出规则(作为源节点)
84
+ getConnectedSourceRules() {
85
+ const rules = super.getConnectedSourceRules()
86
+
87
+ // 示例规则 1: 限制出口连线数量
88
+ const limitOutgoing = {
89
+ message: '该节点最多只能连出 X 条线',
90
+ validate: () => {
91
+ const { edges } = this.outgoing
92
+ return !(edges && edges.length >= X) // X 根据业务需求设置
93
+ },
94
+ }
95
+
96
+ // 示例规则 2: 限制连接的目标节点类型
97
+ const allowedTargets = {
98
+ message: '只能连接到特定类型的节点',
99
+ validate: (sourceNode, targetNode) => {
100
+ const allowedTypes = ['typeA', 'typeB']
101
+ return allowedTypes.includes(targetNode.type)
102
+ },
103
+ }
104
+
105
+ // 示例规则 3: 防止自环
106
+ const noSelfLoop = {
107
+ message: '节点不能连接自己',
108
+ validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) =>
109
+ sourceNode.id !== targetNode.id
110
+ }
111
+
112
+ // 根据业务需求选择规则
113
+ rules.push(limitOutgoing, allowedTargets, noSelfLoop)
114
+ return rules
115
+ }
116
+
117
+ // 连入规则(作为目标节点)
118
+ getConnectedTargetRules() {
119
+ const rules = super.getConnectedTargetRules()
120
+
121
+ // 示例:限制入口连线数量
122
+ const limitIncoming = {
123
+ message: '该节点最多只能有 Y 条输入',
124
+ validate: () => {
125
+ const { edges } = this.incoming
126
+ return !(edges && edges.length >= Y)
127
+ },
128
+ }
129
+
130
+ rules.push(limitIncoming)
131
+ return rules
132
+ }
133
+
134
+ // 定义锚点位置
135
+ getDefaultAnchor(): { x: number; y: number; id: string }[] {
136
+ const { id, x, y, width, height } = this
137
+
138
+ // 常见模式 1: 上下锚点(垂直流)
139
+ return [
140
+ { x, y: y - height / 2, id: `${id}_top` }, // 上锚点
141
+ { x, y: y + height / 2, id: `${id}_bottom` } // 下锚点
142
+ ]
143
+
144
+ // 常见模式 2: 左右锚点(水平流)
145
+ // return [
146
+ // { x: x - width / 2, y, id: `${id}_left` },
147
+ // { x: x + width / 2, y, id: `${id}_right` }
148
+ // ]
149
+
150
+ // 常见模式 3: 四向锚点
151
+ // return [
152
+ // { x, y: y - height / 2, id: `${id}_top` },
153
+ // { x, y: y + height / 2, id: `${id}_bottom` },
154
+ // { x: x - width / 2, y, id: `${id}_left` },
155
+ // { x: x + width / 2, y, id: `${id}_right` }
156
+ // ]
157
+ }
158
+ }
159
+ ```
160
+
161
+ ### 动态锚点节点(分支/并行)
162
+
163
+ ```typescript
164
+ export class BranchNodeModel extends RectNodeModel {
165
+ constructor(data: any, graphModel: any) {
166
+ // 自定义文本位置
167
+ data.text = {
168
+ value: typeof data.text === 'string' ? data.text : data?.text?.value,
169
+ x: data?.x + 50,
170
+ y: data?.y,
171
+ }
172
+ super(data, graphModel)
173
+ }
174
+
175
+ setAttributes() {
176
+ this.width = 120
177
+ this.height = 60
178
+ }
179
+
180
+ // 动态锚点生成
181
+ getDefaultAnchor(): { x: number; y: number; id: string }[] {
182
+ const { id, x, y, width, height } = this
183
+ const { branches = [] } = this.properties as { branches?: { anchorId: string; index: number }[] }
184
+
185
+ const anchors: { x: number; y: number; id: string }[] = [
186
+ { x, y: y - height / 2, id: `${id}_top` } // 固定上锚点
187
+ ]
188
+
189
+ // 动态生成多个下锚点
190
+ const branchCount = branches.length || 2 // 默认 2 个分支
191
+ const spacing = width / (branchCount + 1)
192
+
193
+ branches.forEach((branch, index) => {
194
+ anchors.push({
195
+ x: x - width / 2 + spacing * (index + 1),
196
+ y: y + height / 2,
197
+ id: `${id}${branch.anchorId}`
198
+ })
199
+ })
200
+
201
+ return anchors
202
+ }
203
+
204
+ // 单个锚点只能连出一条线
205
+ getConnectedSourceRules() {
206
+ const rules = super.getConnectedSourceRules()
207
+
208
+ const onlyUniqueSource = {
209
+ message: '分支锚点只能与一个节点相连',
210
+ validate: (sourceNode: any, targetNode: any, sourceAnchor: any) => {
211
+ const { edges } = this.outgoing
212
+ const isHaveBranchEdge = edges && edges.some(edge =>
213
+ edge.sourceAnchorId === sourceAnchor.id
214
+ )
215
+ return !isHaveBranchEdge
216
+ },
217
+ }
218
+
219
+ rules.push(onlyUniqueSource)
220
+ return rules
221
+ }
222
+ }
223
+ ```
224
+
225
+ ---
226
+
227
+ ## 🎨 节点组件开发
228
+
229
+ ### 通用节点组件模板
230
+
231
+ ```vue
232
+ <template>
233
+ <section class="viewport">
234
+ <!-- 普通节点:显示类型和名称 -->
235
+ <div v-if="!isSpecialType"
236
+ :class="['custom-node', `custom-node-${data.type}`, { 'error-node': data.properties?.isError }]">
237
+ <div class="node-header">
238
+ <span class="node-type">{{ getNodeTypeLabel(data.type) }}</span>
239
+ </div>
240
+ <div class="node-content">
241
+ <span class="node-name">{{ data.properties.name || 'Unnamed' }}</span>
242
+ <span v-if="data.properties.description" class="node-desc">
243
+ {{ data.properties.description }}
244
+ </span>
245
+ </div>
246
+ <!-- 错误状态指示 -->
247
+ <div v-if="data.properties.hasError" class="error-indicator">⚠️</div>
248
+ </div>
249
+
250
+ <!-- 特殊节点:如分支、合并等 -->
251
+ <div v-else :class="`${data.type} custom-node-${data.type}`">
252
+ <div :class="`center-line center-line-${data.type}`"></div>
253
+ <div v-if="needsParallelIndicator(data.type)" class="parallel-indicator"></div>
254
+ </div>
255
+ </section>
256
+ </template>
257
+
258
+ <script setup lang="ts">
259
+ import { ref, computed, onMounted } from 'vue'
260
+ import { EventType } from '@logicflow/core'
261
+
262
+ interface Props {
263
+ node: any
264
+ graph: any
265
+ }
266
+
267
+ const props = defineProps<Props>()
268
+ const data = ref({ ...props.graph.getNodeModelById(props.node.id) })
269
+
270
+ // 定义特殊节点类型(需要特殊渲染的)
271
+ const specialTypes = ['branch', 'branchEnd', 'parallel', 'parallelEnd', 'gateway']
272
+ const isSpecialType = computed(() => specialTypes.includes(data.value.type))
273
+
274
+ // 获取节点类型的显示文本(可从配置或i18n获取)
275
+ const getNodeTypeLabel = (type: string) => {
276
+ const labelMap = {
277
+ start: '开始',
278
+ end: '结束',
279
+ task: '任务',
280
+ decision: '决策'
281
+ // 根据项目需求扩展...
282
+ }
283
+ return labelMap[type] || type
284
+ }
285
+
286
+ // 判断是否需要并行指示器
287
+ const needsParallelIndicator = (type: string) => {
288
+ return type.includes('parallel')
289
+ }
290
+
291
+ // 监听节点属性变化
292
+ onMounted(() => {
293
+ const eventHandler = (eventData: any) => {
294
+ if (eventData.id === props.node.id) {
295
+ data.value.properties = eventData?.properties
296
+ }
297
+ }
298
+
299
+ props.graph.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, eventHandler)
300
+
301
+ return () => {
302
+ props.graph.eventCenter.off(EventType.NODE_PROPERTIES_CHANGE, eventHandler)
303
+ }
304
+ })
305
+ </script>
306
+
307
+ <style scoped>
308
+ .custom-node {
309
+ display: flex;
310
+ flex-direction: column;
311
+ background: #fff;
312
+ border: 2px solid #ddd;
313
+ border-radius: 4px;
314
+ padding: 8px;
315
+ transition: border-color 0.3s;
316
+ }
317
+
318
+ .node-header {
319
+ font-size: 12px;
320
+ color: #666;
321
+ margin-bottom: 4px;
322
+ }
323
+
324
+ .node-content {
325
+ font-size: 14px;
326
+ color: #333;
327
+ }
328
+
329
+ /* 错误状态:红色边框 + 脉冲动画 */
330
+ .error-node {
331
+ border-color: #f56c6c !important;
332
+ animation: error-pulse 1s ease-in-out infinite;
333
+ }
334
+
335
+ @keyframes error-pulse {
336
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.4); }
337
+ 50% { box-shadow: 0 0 0 6px rgba(245, 108, 108, 0); }
338
+ }
339
+
340
+ .error-indicator {
341
+ position: absolute;
342
+ top: -8px;
343
+ right: -8px;
344
+ color: #f56c6c;
345
+ }
346
+ </style>
347
+ ```
348
+
349
+ ---
350
+
351
+ ## 🔧 流程图主组件集成
352
+
353
+ ### 初始化流程
354
+
355
+ ```typescript
356
+ import LogicFlow from '@logicflow/core'
357
+ import { Control, Menu, DndPanel, SelectionSelect } from '@logicflow/extension'
358
+ import { Dagre } from '@logicflow/layout'
359
+ import { register, getTeleport } from '@logicflow/vue-node-registry'
360
+
361
+ // 注册插件
362
+ LogicFlow.use(Control) // 控制面板(放大/缩小/还原)
363
+ LogicFlow.use(Menu) // 右键菜单
364
+ LogicFlow.use(DndPanel) // 拖拽面板
365
+ LogicFlow.use(SelectionSelect) // 框选
366
+ LogicFlow.use(Dagre) // 自动布局
367
+
368
+ let lf: any = null
369
+
370
+ const renderLf = async () => {
371
+ lf = new LogicFlow({
372
+ container: document.querySelector('.vita-flow') as HTMLElement,
373
+ grid: true, // 显示网格
374
+ plugins: [Dagre],
375
+ keyboard: { enabled: true }, // 启用键盘快捷键
376
+ isSilentMode: props.read, // 只读模式
377
+ })
378
+
379
+ // 注册自定义节点
380
+ initFlow(props.render, lf, $t, props.read, menuCallback.value, menuItem.value, addItem.value)
381
+ }
382
+ ```
383
+
384
+ ### 事件监听
385
+
386
+ ```typescript
387
+ // 定义事件处理器配置
388
+ const setupEventListeners = (lf: any, options: {
389
+ onNodeClick?: (node: any) => void
390
+ onNodeDoubleClick?: (node: any) => void
391
+ onEdgeChange?: () => void
392
+ onConnectionNotAllowed?: (data: any) => void
393
+ shouldAutoValidate?: boolean
394
+ }) => {
395
+ let clickTimer: NodeJS.Timeout | null = null
396
+
397
+ // 连线规则校验失败
398
+ lf.on('connection:not-allowed', (data: any) => {
399
+ if (options.onConnectionNotAllowed) {
400
+ options.onConnectionNotAllowed(data)
401
+ } else {
402
+ console.warn('连接不被允许:', data?.msg)
403
+ }
404
+ })
405
+
406
+ // 单击节点
407
+ lf.on('node:click', (e: any) => {
408
+ if (clickTimer) clearTimeout(clickTimer)
409
+ clickTimer = setTimeout(() => {
410
+ clickTimer = null
411
+ console.log('单击节点', e?.data)
412
+ options.onNodeClick?.(e.data)
413
+ }, 200)
414
+ })
415
+
416
+ // 双击节点(打开配置对话框)
417
+ lf.on('node:dbclick', (e: any) => {
418
+ if (clickTimer) {
419
+ clearTimeout(clickTimer)
420
+ clickTimer = null
421
+ }
422
+
423
+ console.log('双击节点', e?.data)
424
+ options.onNodeDoubleClick?.(e.data)
425
+ })
426
+
427
+ // 连线变化 - 可选的自动校验
428
+ if (options.shouldAutoValidate) {
429
+ lf.on('edge:add', () => {
430
+ setTimeout(() => options.onEdgeChange?.(), 100)
431
+ })
432
+
433
+ lf.on('edge:delete', () => {
434
+ setTimeout(() => options.onEdgeChange?.(), 100)
435
+ })
436
+ }
437
+ }
438
+
439
+ // 使用示例
440
+ setupEventListeners(lf, {
441
+ onNodeClick: (node) => {
442
+ // 处理节点点击
443
+ selectNode(node.id)
444
+ },
445
+ onNodeDoubleClick: (node) => {
446
+ // 打开编辑器
447
+ openNodeEditor(node)
448
+ },
449
+ onEdgeChange: () => {
450
+ // 重新校验流程图
451
+ validateGraph()
452
+ },
453
+ onConnectionNotAllowed: (data) => {
454
+ showMessage({ type: 'warning', message: data?.msg })
455
+ },
456
+ shouldAutoValidate: true
457
+ })
458
+ ```
459
+
460
+ ### 节点拖拽监听
461
+
462
+ ```typescript
463
+ lf.on('node:dnd-add', (data: any) => {
464
+ const rawData = lf.getGraphRawData()
465
+ const node = lf.graphModel.getNodeModelById(data?.data?.id)
466
+
467
+ // 示例 1: 限制特定节点的数量
468
+ if (needUnique(data.data.type)) {
469
+ const existingNodes = rawData.nodes.filter(n => n.type === data.data.type)
470
+ if (existingNodes.length > 1) {
471
+ lf.deleteNode(data.data.id)
472
+ showMessage({ type: 'warning', message: `只能有一个${data.data.type}节点` })
473
+ return
474
+ }
475
+ }
476
+
477
+ // 示例 2: 自动命名新节点
478
+ if (needAutoName(data.data.type)) {
479
+ const sameTypeNodes = rawData.nodes.filter(n => n.type === data.data.type)
480
+ const index = sameTypeNodes.length
481
+ node.setProperty('name', `${data.data.type}_${String(index).padStart(3, '0')}`)
482
+ }
483
+
484
+ // 示例 3: 初始化节点属性
485
+ if (needInitProperties(data.data.type)) {
486
+ node.setProperty('status', 'pending')
487
+ node.setProperty('createdAt', Date.now())
488
+ }
489
+ })
490
+ ```
491
+
492
+ ---
493
+
494
+ ## ✅ 流程图校验系统
495
+
496
+ > **核心特性**:校验失败时,不符合规则的节点和连线会自动变红高亮,直观显示错误位置
497
+
498
+ ### 校验触发时机
499
+
500
+ 1. 点击"校验"按钮 → 执行校验 → 高亮错误
501
+ 2. 点击"提交"按钮 → 先校验 → 失败则高亮错误并阻止提交
502
+ 3. 添加/删除连线时 → 自动重新校验 → 更新高亮状态
503
+
504
+ ### 前端结构校验
505
+
506
+ ```typescript
507
+ export interface ValidationResult {
508
+ valid: boolean
509
+ errors: ValidationError[]
510
+ errorEdges: string[] // 错误的边 ID
511
+ errorNodes: string[] // 错误的节点 ID
512
+ }
513
+
514
+ export interface ValidationError {
515
+ type: 'isolated_node' | 'isolated_path' | 'circular_path' | 'unconnected_anchor' | 'no_start' | 'no_end' | 'unreachable'
516
+ message: string
517
+ nodeIds?: string[]
518
+ edgeIds?: string[]
519
+ }
520
+
521
+ export class FlowValidator {
522
+ private nodes: any[]
523
+ private edges: any[]
524
+ private adjacencyList: Map<string, string[]> // 邻接表
525
+ private reverseAdjacencyList: Map<string, string[]> // 反向邻接表
526
+ private startNode: any | null = null
527
+ private endNode: any | null = null
528
+
529
+ constructor(graphData: GraphData) {
530
+ this.nodes = graphData.nodes || []
531
+ this.edges = graphData.edges || []
532
+ this.buildAdjacencyLists()
533
+ this.findStartEndNodes()
534
+ }
535
+
536
+ public validate(): ValidationResult {
537
+ const errors: ValidationError[] = []
538
+ const errorEdges: string[] = []
539
+ const errorNodes: string[] = []
540
+
541
+ // 1. 检查是否存在 start 和 end 节点
542
+ if (!this.startNode) {
543
+ errors.push({ type: 'no_start', message: '缺少开始节点' })
544
+ }
545
+ if (!this.endNode) {
546
+ errors.push({ type: 'no_end', message: '缺少结束节点' })
547
+ }
548
+
549
+ if (!this.startNode || !this.endNode) {
550
+ return { valid: false, errors, errorEdges, errorNodes }
551
+ }
552
+
553
+ // 2. 检查从 start 可达的节点(BFS)
554
+ const reachableFromStart = this.getReachableNodes(this.startNode.id, this.adjacencyList)
555
+
556
+ // 3. 检查可以到达 end 的节点(反向 BFS)
557
+ const canReachEnd = this.getReachableNodes(this.endNode.id, this.reverseAdjacencyList)
558
+
559
+ // 4. 计算主路径节点(既能从 start 到达,又能到达 end)
560
+ const mainPathNodes = new Set(
561
+ [...reachableFromStart].filter(nodeId => canReachEnd.has(nodeId))
562
+ )
563
+
564
+ // 5. 检查孤立节点(不在主路径上)
565
+ const isolatedNodes = this.nodes.filter(node => {
566
+ if (node.type === 'start' || node.type === 'end') return false
567
+ return !mainPathNodes.has(node.id)
568
+ })
569
+
570
+ if (isolatedNodes.length > 0) {
571
+ const isolatedNodeIds = isolatedNodes.map(n => n.id)
572
+ errors.push({
573
+ type: 'isolated_node',
574
+ message: `发现 ${isolatedNodes.length} 个孤立节点(不在 start 到 end 的有效路径上)`,
575
+ nodeIds: isolatedNodeIds,
576
+ })
577
+ errorNodes.push(...isolatedNodeIds)
578
+
579
+ // 找到连接到孤立节点的边
580
+ const isolatedEdges = this.edges.filter(edge =>
581
+ isolatedNodeIds.includes(edge.sourceNodeId) ||
582
+ isolatedNodeIds.includes(edge.targetNodeId)
583
+ )
584
+ errorEdges.push(...isolatedEdges.map(e => e.id))
585
+ }
586
+
587
+ // 6. 检查回路(DFS)
588
+ const cycles = this.detectCycles(mainPathNodes)
589
+ if (cycles.length > 0) {
590
+ cycles.forEach(cycle => {
591
+ errors.push({
592
+ type: 'circular_path',
593
+ message: `检测到回路:${cycle.join(' → ')}`,
594
+ nodeIds: cycle,
595
+ })
596
+ errorNodes.push(...cycle)
597
+ })
598
+ }
599
+
600
+ // 7. 检查 end 节点是否可达
601
+ if (!reachableFromStart.has(this.endNode.id)) {
602
+ errors.push({
603
+ type: 'unreachable',
604
+ message: '无法从开始节点到达结束节点',
605
+ nodeIds: [this.endNode.id],
606
+ })
607
+ errorNodes.push(this.endNode.id)
608
+ }
609
+
610
+ return {
611
+ valid: errors.length === 0,
612
+ errors,
613
+ errorEdges: [...new Set(errorEdges)],
614
+ errorNodes: [...new Set(errorNodes)],
615
+ }
616
+ }
617
+
618
+ // BFS 获取可达节点
619
+ private getReachableNodes(startNodeId: string, adjacency: Map<string, string[]>): Set<string> {
620
+ const visited = new Set<string>()
621
+ const queue: string[] = [startNodeId]
622
+ visited.add(startNodeId)
623
+
624
+ while (queue.length > 0) {
625
+ const current = queue.shift()!
626
+ const neighbors = adjacency.get(current) || []
627
+
628
+ neighbors.forEach(neighbor => {
629
+ if (!visited.has(neighbor)) {
630
+ visited.add(neighbor)
631
+ queue.push(neighbor)
632
+ }
633
+ })
634
+ }
635
+
636
+ return visited
637
+ }
638
+
639
+ // DFS 检测回路
640
+ private detectCycles(validNodes: Set<string>): string[][] {
641
+ const visited = new Set<string>()
642
+ const recursionStack = new Set<string>()
643
+ const cycles: string[][] = []
644
+
645
+ const dfs = (nodeId: string): boolean => {
646
+ visited.add(nodeId)
647
+ recursionStack.add(nodeId)
648
+
649
+ const neighbors = (this.adjacencyList.get(nodeId) || [])
650
+ .filter(n => validNodes.has(n))
651
+
652
+ for (const neighbor of neighbors) {
653
+ if (!visited.has(neighbor)) {
654
+ if (dfs(neighbor)) return true
655
+ } else if (recursionStack.has(neighbor)) {
656
+ // 发现回路
657
+ const cycle: string[] = []
658
+ let current = nodeId
659
+ cycle.push(neighbor)
660
+ while (current !== neighbor) {
661
+ cycle.push(current)
662
+ current = this.getParent(current, neighbor)
663
+ }
664
+ cycles.push(cycle.reverse())
665
+ return true
666
+ }
667
+ }
668
+
669
+ recursionStack.delete(nodeId)
670
+ return false
671
+ }
672
+
673
+ validNodes.forEach(nodeId => {
674
+ if (!visited.has(nodeId)) {
675
+ dfs(nodeId)
676
+ }
677
+ })
678
+
679
+ return cycles
680
+ }
681
+ }
682
+ ```
683
+
684
+ ### 调用校验与错误高亮
685
+
686
+ ```typescript
687
+ const validateGraph = async () => {
688
+ try {
689
+ if (!lf) return false
690
+
691
+ const rawData = lf.getGraphRawData()
692
+ const { nodes, edges } = rawData
693
+
694
+ // 1. 前端结构校验
695
+ const frontendValidation = validateFlow(rawData)
696
+
697
+ // 核心:高亮错误的边和节点
698
+ highlightErrors(frontendValidation)
699
+
700
+ if (!frontendValidation.valid) {
701
+ const errorMessages = frontendValidation.errors.map(err => err.message).join('\n')
702
+ showMessage({
703
+ type: 'error',
704
+ message: `流程图校验失败:\n${errorMessages}`,
705
+ duration: 5000
706
+ })
707
+ return false
708
+ }
709
+
710
+ // 2. (可选) 调用后端业务校验
711
+ const backendResult = await callBackendValidation(nodes, edges)
712
+ if (!backendResult.success) {
713
+ showMessage({
714
+ type: 'error',
715
+ message: backendResult.message || '后端校验失败'
716
+ })
717
+ return false
718
+ }
719
+
720
+ // 3. 校验通过,清除高亮
721
+ clearErrorHighlight()
722
+ showMessage({ type: 'success', message: '校验通过' })
723
+ return true
724
+ } catch (err) {
725
+ console.error('Validation error:', err)
726
+ showMessage({ type: 'error', message: '校验失败' })
727
+ return false
728
+ }
729
+ }
730
+
731
+ // 核心功能:高亮错误节点和连线
732
+ const highlightErrors = (validation: ValidationResult) => {
733
+ if (!lf) return
734
+
735
+ // 清除之前的高亮
736
+ clearErrorHighlight()
737
+
738
+ // 高亮错误的边(变红色,加粗)
739
+ validation.errorEdges.forEach(edgeId => {
740
+ const edgeModel = lf.graphModel.getEdgeModelById(edgeId)
741
+ if (edgeModel) {
742
+ edgeModel.setProperties({ ...edgeModel.properties, isError: true })
743
+ edgeModel.setAttributes({
744
+ stroke: '#f56c6c', // 红色
745
+ strokeWidth: 2 // 加粗至 2px
746
+ })
747
+ }
748
+ })
749
+
750
+ // 高亮错误的节点(红色边框)
751
+ validation.errorNodes.forEach(nodeId => {
752
+ const nodeModel = lf.graphModel.getNodeModelById(nodeId)
753
+ if (nodeModel) {
754
+ nodeModel.setProperties({ ...nodeModel.properties, isError: true })
755
+ // 注意:节点红色边框通过 CSS 样式实现
756
+ }
757
+ })
758
+ }
759
+
760
+ // 清除错误高亮
761
+ const clearErrorHighlight = () => {
762
+ if (!lf) return
763
+
764
+ const rawData = lf.getGraphRawData()
765
+
766
+ // 清除所有边的错误状态
767
+ rawData.edges.forEach((edge: any) => {
768
+ const edgeModel = lf.graphModel.getEdgeModelById(edge.id)
769
+ if (edgeModel && edgeModel.properties?.isError) {
770
+ edgeModel.setProperties({ ...edgeModel.properties, isError: false })
771
+ edgeModel.setAttributes({
772
+ stroke: '#000000', // 恢复默认颜色
773
+ strokeWidth: 1 // 恢复默认宽度
774
+ })
775
+ }
776
+ })
777
+
778
+ // 清除所有节点的错误状态
779
+ rawData.nodes.forEach((node: any) => {
780
+ const nodeModel = lf.graphModel.getNodeModelById(node.id)
781
+ if (nodeModel && nodeModel.properties?.isError) {
782
+ nodeModel.setProperties({ ...nodeModel.properties, isError: false })
783
+ }
784
+ })
785
+ }
786
+ ```
787
+
788
+ ---
789
+
790
+ ## 🎨 右键菜单与工具栏
791
+
792
+ ### 右键菜单配置
793
+
794
+ ```typescript
795
+ const setMenu = (lf: any, $t: any, menuCallback: any) => {
796
+ const hidePublic = ['start', 'end']
797
+ const publicMenu = [
798
+ { text: $t('删除'), callback: (node: any) => lf.deleteNode(node.id) },
799
+ { text: $t('复制'), callback: (node: any) => lf.graphModel.cloneNode(node.id) }
800
+ ]
801
+
802
+ const menus = [
803
+ {
804
+ type: 'start',
805
+ menu: [
806
+ { text: $t('编辑文本'), callback: (node: any) => lf.graphModel.setElementStateById(node.id, 2) }
807
+ ]
808
+ },
809
+ {
810
+ type: 'branch',
811
+ menu: [
812
+ { text: $t('添加分支锚点'), callback: (node: any) => menuCallback.addBranch(node) },
813
+ { text: $t('减少分支锚点'), callback: (node: any) => menuCallback.removeBranch(node) }
814
+ ]
815
+ },
816
+ ]
817
+
818
+ menus.forEach(item => {
819
+ if (!hidePublic.includes(item.type)) {
820
+ publicMenu.forEach(i => item.menu.push(i))
821
+ }
822
+ lf.setMenuByType(item)
823
+ })
824
+ }
825
+ ```
826
+
827
+ ### 动态锚点管理
828
+
829
+ ```typescript
830
+ // logicFunc.ts
831
+ export default {
832
+ // 添加分支锚点
833
+ addBranch: (node: any, lf: any) => {
834
+ const newBranch = {
835
+ anchorId: `_bottom_${(node.properties.branches || []).length + 1}`,
836
+ index: (node.properties.branches || []).length,
837
+ }
838
+ const newBranches = (node.properties.branches || []).concat(newBranch)
839
+ const nodeModel = lf.graphModel.getNodeModelById(node.id)
840
+ const edges = lf.graphModel.getNodeEdges(node.id)
841
+
842
+ nodeModel.setProperty('branches', newBranches)
843
+
844
+ // 更新连线起点位置
845
+ setTimeout(() => {
846
+ edges.forEach(edge => {
847
+ nodeModel?.anchors?.forEach(fil => {
848
+ if (fil.id === edge.sourceAnchorId) {
849
+ edge.updateStartPoint({ x: fil?.x, y: fil?.y })
850
+ }
851
+ })
852
+ })
853
+ }, 10)
854
+ },
855
+
856
+ // 移除分支锚点
857
+ removeBranch: (node: any, lf: any, $t: any) => {
858
+ const newBranches = (node.properties.branches || [])
859
+
860
+ if (newBranches.length && newBranches.length > 2) {
861
+ const popEdge = newBranches.pop()
862
+ const nodeModel = lf.graphModel.getNodeModelById(node.id)
863
+
864
+ nodeModel.setProperty('branches', newBranches)
865
+
866
+ const edges = lf.graphModel.getNodeEdges(node.id)
867
+ setTimeout(() => {
868
+ edges.forEach(edge => {
869
+ nodeModel?.anchors?.forEach(fil => {
870
+ if (fil.id === edge.sourceAnchorId) {
871
+ edge.updateStartPoint({ x: fil?.x, y: fil?.y })
872
+ }
873
+ if (`${node.id}${popEdge.anchorId}` === edge.sourceAnchorId) {
874
+ lf.graphModel.deleteEdgeById(edge.id)
875
+ }
876
+ })
877
+ })
878
+ }, 10)
879
+ } else {
880
+ ElMessage.warning($t('没有可以删除的分支锚点'))
881
+ }
882
+ },
883
+ }
884
+ ```
885
+
886
+ ---
887
+
888
+ ## 📐 自动布局
889
+
890
+ ```typescript
891
+ const autoSort = (type = 'LR') => {
892
+ if (!lf) return
893
+
894
+ const newRender = { ...lf.getGraphRawData() }
895
+
896
+ // 先对边排序
897
+ lf.render({
898
+ nodes: newRender?.nodes,
899
+ edges: logicFunc.sortPolylinesByAnchorSuffix(newRender?.edges)
900
+ })
901
+
902
+ // 使用 Dagre 自动布局
903
+ setTimeout(() => {
904
+ lf.extension.dagre.layout({
905
+ rankdir: type, // 'TB' 垂直,'LR' 水平
906
+ ranker: 'longest-path', // 布局算法
907
+ align: undefined, // 对齐方式
908
+ nodesep: 60, // 节点间距
909
+ ranksep: 70, // 层级间距
910
+ isDefaultAnchor: false, // 不使用默认锚点
911
+ })
912
+ }, 100)
913
+ }
914
+ ```
915
+
916
+ ---
917
+
918
+ ## 💾 数据提交
919
+
920
+ ```typescript
921
+ const getData = async () => {
922
+ if (!lf) return
923
+
924
+ // 先执行校验
925
+ const isValid = await checkData()
926
+ if (!isValid) {
927
+ ElMessage.warning($t('请先修复流程图中的错误'))
928
+ return
929
+ }
930
+
931
+ // 获取画布数据
932
+ const rawData = lf.getGraphRawData()
933
+ emit('flowSubmit', { rawData: rawData })
934
+ }
935
+ ```
936
+
937
+ ---
938
+
939
+ ## ⚠️ 常见问题与解决方案
940
+
941
+ ### 1. 节点属性不更新
942
+
943
+ ```typescript
944
+ // ❌ 错误:直接修改 properties
945
+ node.properties.name = 'new name'
946
+
947
+ // ✅ 正确:使用 setProperty
948
+ const nodeModel = lf.graphModel.getNodeModelById(nodeId)
949
+ nodeModel.setProperty('name', 'new name')
950
+ ```
951
+
952
+ ### 2. 锚点位置错乱
953
+
954
+ ```typescript
955
+ // ✅ 添加/删除锚点后,必须更新连线起点
956
+ setTimeout(() => {
957
+ edges.forEach(edge => {
958
+ nodeModel?.anchors?.forEach(anchor => {
959
+ if (anchor.id === edge.sourceAnchorId) {
960
+ edge.updateStartPoint({ x: anchor.x, y: anchor.y })
961
+ }
962
+ })
963
+ })
964
+ }, 10)
965
+ ```
966
+
967
+ ### 3. 只读模式下右键菜单仍显示
968
+
969
+ ```typescript
970
+ // ✅ 初始化时设置只读模式
971
+ lf = new LogicFlow({
972
+ container: document.querySelector('.flow-container') as HTMLElement,
973
+ grid: true,
974
+ isSilentMode: isReadOnly, // 只读模式
975
+ })
976
+
977
+ // ✅ 只读模式下不设置菜单和拖拽面板
978
+ if (!isReadOnly) {
979
+ setupContextMenu(lf, nodeTypes, callbacks)
980
+ lf.extension.dndPanel.setPatternItems(patternItems)
981
+ }
982
+ ```
983
+
984
+ ---
985
+
986
+ ## 📚 参考资源
987
+
988
+ - **LogicFlow 官方文档**: http://logic-flow.cn/
989
+ - **LogicFlow GitHub**: https://github.com/didi/LogicFlow
990
+ - **LogicFlow API 参考**: http://logic-flow.cn/api/
991
+ - **Vue Node Registry**: https://github.com/Logic-Flow/logicflow-node-registry-vue3
992
+ - **Dagre 布局算法**: https://github.com/dagrejs/dagre
993
+
994
+ ---
995
+
996
+ **核心原则**:
997
+ 1. 📐 **架构清晰**: Model-View-Component 分离
998
+ 2. 🔗 **规则完备**: 连接规则明确,校验逻辑严密
999
+ 3. 🎨 **视觉反馈**: 错误状态红色高亮,成功状态清晰提示
1000
+ 4. 🔧 **可扩展性**: 易于添加新节点类型和校验规则
1001
+ 5. 📝 **类型安全**: 完整的 TypeScript 类型定义
1002
+
1003
+ ---
1004
+
1005
+ **维护者**: MTA工作室
1006
+ **创建日期**: 2025-12-16
1007
+ **最后更新**: 2025-12-16