学校网站定位群晖nas wordpress
4. VueFlow画布可视化
功能概述
基于VueFlow实现功能完整的画布可视化系统,支持自定义节点、交互操作、数据同步和截图功能。
技术难点
- 自定义节点类型设计
- 节点连接和交互逻辑
- 画布缩放和平移
- 实时数据同步
- 节点状态管理
实现思路
4.1 画布核心组件
// src/pages/chat/components/Canvas/index.tsx
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import '@vue-flow/minimap/dist/style.css'import { defineComponent, type PropType, watch, nextTick, ref } from 'vue'
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core'
import { MiniMap } from '@vue-flow/minimap'
import { Controls } from '@vue-flow/controls'
import { Background } from '@vue-flow/background'
import { nodeTypes } from './Node'
import cls from 'classnames'
import { mergeCss } from '@/theme'
import { useVueFlowScreenshot } from '@/composables/useVueFlowScreenshot'export interface FlowNode {id: stringtype: stringdata: {label: stringtype: 'start' | 'process' | 'end' | 'base'status?: 'onlyText' | 'clear' | 'missing' | 'pending'content?: string}position: { x: number; y: number }width: numberheight: number
}export interface FlowEdge {id: stringsource: stringtarget: stringtype: string
}export interface VueFlowData {nodes: FlowNode[]edges: FlowEdge[]
}export default defineComponent({name: 'VueFlowCanvas',props: {data: {type: Object as PropType<VueFlowData>,default: () => ({ nodes: [], edges: [] }),},forCapture: {type: Boolean,default: false,},captureIndex: {type: Number,default: -1,},},emits: ['screenshotComplete'],setup(props, { emit, expose }) {const {vueFlowRef,onConnect,addEdges,fitView,updateNodeInternals,onPaneReady,viewport,getNodes,getEdges,} = useVueFlow()const { capture } = useVueFlowScreenshot()// 连接节点时的回调onConnect(params => {addEdges([{...params,style: { stroke: '#6b7280', strokeWidth: 3 },animated: true,},])})const onNodeDoubleClick = (nodeMouseEvent: any) => {console.log('双击节点:', nodeMouseEvent.node)}const onEdgeDoubleClick = (edgeMouseEvent: any) => {console.log('双击边:', edgeMouseEvent.edge)}// 使用专门为VueFlow优化的截图功能const doScreenshot = async () => {if (!vueFlowRef.value) {console.warn('VueFlow element not found')return ''}try {// 先进行自适应缩放await fitView()// 等待缩放动画完成await new Promise(resolve => setTimeout(resolve, 600))// 使用优化的VueFlow截图方法const screenshotData = await capture(vueFlowRef.value, {shouldDownload: false,quality: 0.8,scale: 1,backgroundColor: '#222427',})return screenshotData} catch (error) {console.error('VueFlow截图失败:', error)return ''}}// 监听截图请求watch(() => props.captureIndex,async newIndex => {if (props.forCapture && newIndex >= 0) {try {await nextTick()const screenshotData = await doScreenshot()emit('screenshotComplete', screenshotData, newIndex)} catch (error) {emit('screenshotComplete', '', newIndex)}}})// 当画布准备就绪时onPaneReady(async instance => {console.log('VueFlow画布已准备就绪')if (props.forCapture) {await instance.fitView()} else {console.log('正常画布准备就绪,开始自动截图')await instance.fitView()setTimeout(async () => {try {const screenshotData = await doScreenshot()emit('screenshotComplete', screenshotData, -999)} catch (error) {console.error('正常画布自动截图失败:', error)}}, 100)}})// 暴露截图方法给父组件expose({takeScreenshot: doScreenshot,})return () => (<divclass={cls('w-full h-full rounded-2xl overflow-hidden',mergeCss(['backgroundCanvas', 'transition']))}><VueFlownodes={props.data.nodes}edges={props.data.edges}nodeTypes={nodeTypes as any}onNodeDoubleClick={onNodeDoubleClick}onEdgeDoubleClick={onEdgeDoubleClick}minZoom={0.1}maxZoom={4}snapGrid={[15, 15]}defaultViewport={{ x: 0, y: 0, zoom: 1 }}fitViewOnInit={true}><Background bgColor="#222427" gap={16} size={1} variant="dots" />{!props.forCapture && (<MiniMappannablezoomablestyle={{backgroundColor: '#1a1a1a',border: '1px solid #333',}}maskColor="rgba(0, 0, 0, 0.05)"position="bottom-right"class="!bg-white !shadow-lg !border !border-gray-200 !rounded-lg"/>)}</VueFlow></div>)},
})
4.2 自定义节点类型
// src/pages/chat/components/Canvas/Node/index.ts
import StartNode from './StartNode'
import ProcessNode from './ProcessNode'
import EndNode from './EndNode'
import BaseNode from './BaseNode'export const nodeTypes = {start: StartNode,process: ProcessNode,end: EndNode,base: BaseNode,
}
4.3 基础节点组件
// src/pages/chat/components/Canvas/Node/BaseNode/index.tsx
import { defineComponent, ref, computed, inject } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { NodeToolbar } from '@vue-flow/node-toolbar'
import { NButton, NIcon } from 'naive-ui'
import { ProcessIcon, EditIcon, DeleteIcon } from '../icons'
import cls from 'classnames'
import { common } from '@/theme'
import { useMarkdownRenderer } from '~/hooks'export default defineComponent({name: 'BaseNode',props: {id: { type: String, required: true },position: { type: Object, required: true },data: {type: Object,required: true,},selected: { type: Boolean, required: true },},setup(props, { emit }) {const { vnodes, renderMarkdown } = useMarkdownRenderer()const isPanning = inject('canvasIsPanning', ref(false)) as unknown as {value: boolean}// 统一使用外部传入的 labelconst localLabel = computed(() => (props?.data as any)?.label ?? '')const status = computed(() => (props?.data as any)?.status || 'clear')const isOnlyText = computed(() => status.value === 'onlyText')const toolbarVisible = ref((props?.data as any)?.toolbarVisible ?? props.selected)const toolbarPosition = ref((props?.data as any)?.toolbarPosition || Position.Top)const handleEdit = () => {}const handleDelete = () => emit('deleteNode', props.id)const handleProcess = () => {}const currentTime = computed(() => {const now = new Date()return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`})const statusConfig = computed(() => {const configs = {onlyText: {dotClass: 'bg-blue-400',textClass: 'text-blue-400',text: '仅文本',},clear: {dotClass: 'bg-green-400',textClass: 'text-green-400',text: '清晰',},missing: {dotClass: 'bg-red-400',textClass: 'text-red-400',text: '缺失',},pending: {dotClass: 'bg-yellow-400',textClass: 'text-yellow-400',text: '待处理',},}return configs[status.value] || configs.clear})return () => (<divclass={cls('relative group','min-w-[200px] max-w-[400px]','bg-white dark:bg-gray-800','border border-gray-200 dark:border-gray-700','rounded-lg shadow-sm','transition-all duration-200',{'ring-2 ring-blue-500': props.selected,'hover:shadow-md': !isPanning.value,})}>{/* 节点工具栏 */}{toolbarVisible.value && !isPanning.value && (<NodeToolbarposition={toolbarPosition.value}class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg"><div class="flex items-center gap-1 p-1"><NButtonsize="small"quaternaryonClick={handleEdit}class="!w-8 !h-8"><NIcon><EditIcon /></NIcon></NButton><NButtonsize="small"quaternaryonClick={handleProcess}class="!w-8 !h-8"><NIcon><ProcessIcon /></NIcon></NButton><NButtonsize="small"quaternaryonClick={handleDelete}class="!w-8 !h-8 text-red-500 hover:text-red-600"><NIcon><DeleteIcon /></NIcon></NButton></div></NodeToolbar>)}{/* 节点内容 */}<div class="p-3">{/* 节点头部 */}<div class="flex items-center justify-between mb-2"><div class="flex items-center gap-2"><divclass={cls('w-2 h-2 rounded-full',statusConfig.value.dotClass)}/><spanclass={cls('text-xs font-medium',statusConfig.value.textClass)}>{statusConfig.value.text}</span></div><span class="text-xs text-gray-500">{currentTime.value}</span></div>{/* 节点标签 */}<div class="mb-2"><h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">{localLabel.value}</h3></div>{/* 节点内容 */}{!isOnlyText.value && (<div class="text-xs text-gray-600 dark:text-gray-400 line-clamp-3">{renderMarkdown((props?.data as any)?.content || '')}</div>)}</div>{/* 连接点 */}<Handle type="target" position={Position.Top} /><Handle type="source" position={Position.Bottom} /></div>)},
})
4.4 流程节点组件
// src/pages/chat/components/Canvas/Node/ProcessNode/index.tsx
import { defineComponent, ref, type PropType } from 'vue'
import { Handle, Position, type NodeProps } from '@vue-flow/core'
import cls from 'classnames'
import { common } from '@/theme'export default defineComponent<NodeProps<{ label: string }>>({name: 'ProcessNode',emits: ['updateNode', 'deleteNode'],props: {data: { type: Object as PropType<NodeProps['data']>, required: true },id: { type: String as PropType<NodeProps['id']>, required: true },selected: {type: Boolean as PropType<NodeProps['selected']>,required: true,},connectable: {type: Boolean as PropType<NodeProps['connectable']>,required: true,},position: {type: Object as PropType<NodeProps['position']>,required: true,},},setup(props, { emit }) {const isEditing = ref(false)const localLabel = ref(props?.data?.label || '处理节点')const handleEdit = () => {if (isEditing.value) {emit('updateNode', {id: props.id,data: { ...props.data, label: localLabel.value },})}isEditing.value = !isEditing.value}return () => (<divclass={cls('w-40 min-h-10 rounded-xl bg-pink-500 text-white',common.transition)}id={props.id}><div class="text-center"><divclass={cls('font-medium cursor-pointer px-2 py-1 rounded hover:bg-white/10',common.transition)}onDblclick={handleEdit}>{localLabel.value}</div></div><Handle type="target" position={Position.Top} /><Handle type="source" position={Position.Bottom} /></div>)},
})
关键技术点
- 节点类型系统: 可扩展的自定义节点架构
- 交互逻辑: 双击编辑、拖拽、连接等交互
- 状态管理: 节点状态和画布状态同步
- 截图集成: 与截图功能的深度集成
- 性能优化: 大量节点的渲染优化