1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
<template>
<div class="process-flow">
<canvas ref="canvasRef" :width="canvasWidth" :height="height"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch, nextTick, onUnmounted } from 'vue'
interface Node {
text: string
}
const props = defineProps<{
nodes: Node[]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvasWidth = computed(() => window.innerWidth - 40)
const NODES_PER_ROW = 3
const NODE_HEIGHT = 100
const NODE_WIDTH = computed(() => (canvasWidth.value - 60) / NODES_PER_ROW)
const HORIZONTAL_PADDING = 30
const NODE_RADIUS = 8
const LINE_GAP = 16
const height = computed(() => {
const rows = Math.ceil(props.nodes.length / NODES_PER_ROW)
return rows * NODE_HEIGHT + 40
})
const drawFlow = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvasWidth.value, height.value)
const nodeCount = props.nodes.length
if (nodeCount <= 1) return
// 先绘制连接线
for (let i = 0; i < nodeCount - 1; i++) {
drawConnection(ctx, i)
}
// 再绘制节点和文字
for (let i = 0; i < nodeCount; i++) {
const pos = getNodePosition(i)
drawNode(ctx, pos, props.nodes[i])
}
}
const drawNode = (
ctx: CanvasRenderingContext2D,
pos: { x: number; y: number },
node: Node
) => {
const x = pos.x + NODE_WIDTH.value / 2
const y = pos.y + 16
// 绘制圆点
ctx.beginPath()
ctx.arc(x, y, NODE_RADIUS, 0, Math.PI * 2)
ctx.fillStyle = '#1989fa'
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.stroke()
// 绘制文字
ctx.font = '14px Arial'
ctx.fillStyle = '#1989fa'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillText(node.text, x, y + 24)
}
const drawConnection = (ctx: CanvasRenderingContext2D, index: number) => {
const currentRow = Math.floor(index / NODES_PER_ROW)
const nextRow = Math.floor((index + 1) / NODES_PER_ROW)
const isRightToLeft = currentRow % 2 !== 0
const startPos = getNodePosition(index)
const endPos = getNodePosition(index + 1)
const startX = startPos.x + NODE_WIDTH.value / 2
const startY = startPos.y + 16
const endX = endPos.x + NODE_WIDTH.value / 2
const endY = endPos.y + 16
// 设置线条样式
ctx.beginPath()
ctx.strokeStyle = '#1989fa'
ctx.lineWidth = 2
if (currentRow !== nextRow) {
// 垂直连接
const midY = startY + (NODE_HEIGHT / 2)
ctx.moveTo(startX, startY + LINE_GAP)
ctx.lineTo(startX, midY)
ctx.lineTo(endX, midY)
ctx.lineTo(endX, endY - LINE_GAP)
} else {
// 水平连接
const startOffsetX = isRightToLeft ? -LINE_GAP : LINE_GAP
const endOffsetX = isRightToLeft ? LINE_GAP : -LINE_GAP
ctx.moveTo(startX + startOffsetX, startY)
ctx.lineTo(endX + endOffsetX, endY)
}
ctx.stroke()
// 绘制箭头
const angle = currentRow !== nextRow ? Math.PI / 2 : (isRightToLeft ? Math.PI : 0)
const arrowX = currentRow !== nextRow ? endX : (endX + (isRightToLeft ? LINE_GAP : -LINE_GAP))
const arrowY = currentRow !== nextRow ? endY - LINE_GAP : endY
drawArrow(ctx, arrowX, arrowY, angle)
}
const drawArrow = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
angle: number
) => {
const arrowSize = 6
ctx.save()
ctx.translate(x, y)
ctx.rotate(angle)
ctx.beginPath()
ctx.moveTo(-arrowSize, -arrowSize/2)
ctx.lineTo(0, 0)
ctx.lineTo(-arrowSize, arrowSize/2)
ctx.closePath()
ctx.fillStyle = '#1989fa'
ctx.fill()
ctx.restore()
}
const getNodePosition = (index: number) => {
const row = Math.floor(index / NODES_PER_ROW)
const isRightToLeft = row % 2 !== 0
let col = index % NODES_PER_ROW
if (isRightToLeft) {
col = NODES_PER_ROW - 1 - col
}
return {
x: col * NODE_WIDTH.value + HORIZONTAL_PADDING,
y: row * NODE_HEIGHT + 40
}
}
// 监听窗口大小变化
const handleResize = () => {
if (canvasRef.value) {
canvasRef.value.width = canvasWidth.value
nextTick(drawFlow)
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
drawFlow()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
watch(
() => props.nodes.length,
() => {
nextTick(drawFlow)
}
)
</script>
<style scoped lang="scss">
.process-flow {
position: relative;
margin: 20px;
background: #fff;
border-radius: 12px;
width: auto;
box-sizing: border-box;
min-height: 300px;
}
</style>