<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>