FlowEditor.vue重写,数据结构调整,重新实现数据持久化和画布缩放保存

This commit is contained in:
2025-07-11 17:15:25 +08:00
parent 2b740f5a75
commit 99231ce52c
3 changed files with 248 additions and 313 deletions

View File

@@ -10,11 +10,11 @@ import FlowEditor from './components/flow/FlowEditor.vue';
import ShikigamiSelect from './components/flow/nodes/yys/ShikigamiSelect.vue'; import ShikigamiSelect from './components/flow/nodes/yys/ShikigamiSelect.vue';
import YuhunSelect from './components/flow/nodes/yys/YuhunSelect.vue'; import YuhunSelect from './components/flow/nodes/yys/YuhunSelect.vue';
import PropertySelect from './components/flow/nodes/yys/PropertySelect.vue'; import PropertySelect from './components/flow/nodes/yys/PropertySelect.vue';
import { useVueFlow } from '@vue-flow/core'; // import { useVueFlow } from '@vue-flow/core';
import DialogManager from './components/DialogManager.vue'; import DialogManager from './components/DialogManager.vue';
const filesStore = useFilesStore(); const filesStore = useFilesStore();
const { updateNode,toObject,fromObject } = useVueFlow(); // const { updateNode,toObject,fromObject } = useVueFlow();
const width = ref('100%'); const width = ref('100%');
const height = ref('100vh'); const height = ref('100vh');
@@ -89,23 +89,37 @@ const handleAddNode = (nodeData) => {
} }
}; };
const handleSaveViewport = (viewport) => {
filesStore.updateFileViewport(filesStore.activeFile, viewport);
};
const handleRequestViewport = () => {
return filesStore.getFileViewport(filesStore.activeFile);
};
watch( watch(
() => filesStore.activeFile, () => filesStore.activeFile,
(newVal, oldVal) => { async (newVal, oldVal) => {
// 切换前保存旧 tab 的 viewport // 切换前保存旧 tab 的数据和视口
if (oldVal && flowEditorRef.value && flowEditorRef.value.getViewport) { if (oldVal && flowEditorRef.value) {
const viewport = flowEditorRef.value.getViewport(); if (flowEditorRef.value.getGraphRawData) {
filesStore.updateFileViewport(oldVal, viewport); const rawData = flowEditorRef.value.getGraphRawData();
filesStore.updateFileFlowData(oldVal, toObject()); filesStore.updateFileFlowData(oldVal, rawData);
}
if (flowEditorRef.value.getViewport) {
const viewport = flowEditorRef.value.getViewport();
console.log(`[Tab切换] 切换前保存 tab "${oldVal}" 的视口信息:`, viewport);
filesStore.updateFileViewport(oldVal, viewport);
}
} }
lastActiveFile.value = newVal; lastActiveFile.value = newVal;
// 切换后恢复新 tab 的数据和视口
if (newVal && flowEditorRef.value) {
if (flowEditorRef.value.renderRawData) {
const newRawData = filesStore.getFileFlowData(newVal);
if (newRawData) flowEditorRef.value.renderRawData(newRawData);
}
if (flowEditorRef.value.setViewport) {
const newViewport = filesStore.getFileViewport(newVal);
console.log(`[Tab切换] 切换后恢复 tab "${newVal}" 的视口信息:`, newViewport);
requestAnimationFrame(() => {
flowEditorRef.value.setViewport(newViewport);
});
}
}
} }
); );

View File

@@ -1,283 +1,137 @@
<script setup lang="ts">
import { ref, onMounted, shallowRef, markRaw, onUnmounted, watch, nextTick } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import PropertyPanel from './PropertyPanel.vue';
import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue';
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
import ImageNode from './nodes/common/ImageNode.vue';
import TextNode from './nodes/common/TextNode.vue';
import useDragAndDrop from '@/ts/useDnD';
import { useFilesStore } from '@/ts/useStore';
import type { Node, Edge, ViewportTransform } from '@vue-flow/core';
const props = defineProps<{
height: string;
nodes: Node[];
edges: Edge[];
viewport: ViewportTransform;
}>();
const emit = defineEmits(['save-viewport', 'request-viewport']);
// 获取文件 store
const filesStore = useFilesStore();
// 设置节点类型
const nodeTypes = shallowRef({
shikigamiSelect: markRaw(ShikigamiSelectNode),
yuhunSelect: markRaw(YuhunSelectNode),
propertySelect: markRaw(PropertySelectNode),
imageNode: markRaw(ImageNode),
textNode: markRaw(TextNode)
});
// 使用VueFlow的API
const { nodes, edges, setNodes, setEdges, setTransform, getViewport, onNodesChange, onEdgesChange, onConnect, addNodes, updateNode } = useVueFlow({
nodes: props.nodes,
edges: props.edges,
nodeTypes: nodeTypes.value
});
// 监听 viewport 变化,重绘视图
watch(
() => props.viewport,
(newViewport) => {
setTransform(newViewport);
},
{ immediate: true }
);
onMounted(() => {
setTransform(props.viewport);
});
// 监听节点变化
const handleNodesChange = (changes) => {
// 更新 store 中的节点
changes.forEach(change => {
if (change.type === 'position' && change.position) {
filesStore.updateNodePosition(change.id, change.position);
} else if (change.type === 'remove') {
filesStore.removeNode(change.id);
}
});
onNodesChange(changes);
};
// 监听边变化
const handleEdgesChange = (changes) => {
// 更新 store 中的边
changes.forEach(change => {
if (change.type === 'remove') {
filesStore.removeEdge(change.id);
}
});
onEdgesChange(changes);
};
// 监听连接
const handleConnect = (connection) => {
// 添加新边到 store
filesStore.addEdge({
id: `e${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target
});
onConnect(connection);
};
// 使用拖拽功能
const { onDragOver, onDrop } = useDragAndDrop();
// 右键菜单相关
const contextMenu = ref({
show: false,
x: 0,
y: 0,
nodeId: null
});
// 处理节点右键点击
const handleNodeContextMenu = (event) => {
const { event: mouseEvent, node } = event;
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
contextMenu.value = {
show: true,
x: mouseEvent.clientX,
y: mouseEvent.clientY,
nodeId: node.id
};
};
// 处理画布右键点击
const handlePaneContextMenu = (event) => {
event.preventDefault();
event.stopPropagation();
contextMenu.value.show = false;
};
// 点击其他地方时关闭菜单
const handleClickOutside = (event) => {
if (!event.target.closest('.context-menu')) {
contextMenu.value.show = false;
}
};
// 处理图层顺序调整
const handleLayerOrder = (action) => {
if (!contextMenu.value.nodeId) return;
const nodeId = contextMenu.value.nodeId;
const currentNodes = filesStore.activeFileNodes;
const nodeIndex = currentNodes.findIndex(n => n.id === nodeId);
if (nodeIndex === -1) return;
const node = currentNodes[nodeIndex];
const newNodes = [...currentNodes];
switch (action) {
case 'bringToFront':
// 移至最前
newNodes.splice(nodeIndex, 1);
newNodes.push(node);
break;
case 'sendToBack':
// 移至最后
newNodes.splice(nodeIndex, 1);
newNodes.unshift(node);
break;
case 'bringForward':
// 上移一层
if (nodeIndex < newNodes.length - 1) {
[newNodes[nodeIndex], newNodes[nodeIndex + 1]] = [newNodes[nodeIndex + 1], newNodes[nodeIndex]];
}
break;
case 'sendBackward':
// 下移一层
if (nodeIndex > 0) {
[newNodes[nodeIndex], newNodes[nodeIndex - 1]] = [newNodes[nodeIndex - 1], newNodes[nodeIndex]];
}
break;
}
// 更新 store 中的节点顺序
filesStore.updateNodesOrder(newNodes);
contextMenu.value.show = false;
};
defineExpose({
handleAddNode: addNodes,
getViewport
});
onMounted(() => {
console.log('FlowEditor 组件已挂载');
});
onUnmounted(() => {
// 移除事件监听
// document.removeEventListener('click', handleClickOutside);
});
const lastActiveFile = ref(filesStore.activeFile);
const flowEditorRef = ref();
</script>
<template> <template>
<div class="flow-editor" :style="{ height }"> <div class="container" ref="containerRef" :style="{ height }"></div>
<div class="editor-layout">
<!-- 中间流程图区域 -->
<div class="flow-container">
<VueFlow
:nodes="props.nodes"
:edges="props.edges"
@nodes-change="handleNodesChange"
@edges-change="handleEdgesChange"
@connect="handleConnect"
fit-view-on-init
@drop="onDrop"
@dragover="onDragOver"
@node-context-menu="handleNodeContextMenu"
@pane-context-menu="handlePaneContextMenu"
>
<Background :pattern-color="'#aaa'" :gap="8" />
<Controls />
<Panel position="top-right" class="flow-panel">
<div>流程图编辑器 (模仿 draw.io)</div>
</Panel>
</VueFlow>
<!-- 右键菜单 -->
<Teleport to="body">
<div v-if="contextMenu.show"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop>
<div class="menu-item" @click="handleLayerOrder('bringToFront')">移至最前</div>
<div class="menu-item" @click="handleLayerOrder('sendToBack')">移至最后</div>
<div class="menu-item" @click="handleLayerOrder('bringForward')">上移一层</div>
<div class="menu-item" @click="handleLayerOrder('sendBackward')">下移一层</div>
</div>
</Teleport>
</div>
<!-- 右侧属性面板 -->
<PropertyPanel
:height="height"
/>
</div>
</div>
</template> </template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, defineExpose } from 'vue';
import type { PropType } from 'vue';
import LogicFlow from '@logicflow/core';
import '@logicflow/core/lib/style/index.css';
// 类型定义放在 import 之后,避免顶层 await 错误
type NodeData = {
id: string;
type: string;
x: number;
y: number;
text?: string;
properties?: Record<string, any>;
};
type EdgeData = {
id: string;
type: string;
sourceNodeId: string;
targetNodeId: string;
properties?: Record<string, any>;
};
type Viewport = {
x: number;
y: number;
zoom: number;
};
const props = defineProps<{
nodes: NodeData[];
edges: EdgeData[];
viewport?: Viewport;
height?: string;
}>();
const containerRef = ref<HTMLElement | null>(null);
let lf: LogicFlow | null = null;
// 初始化 LogicFlow
onMounted(() => {
lf = new LogicFlow({
container: containerRef.value as HTMLElement,
grid: true,
});
// lf.zoom(2);
// zoom(2);
renderFlow();
// if (props.viewport) setViewport(props.viewport);
});
// 销毁 LogicFlow
onBeforeUnmount(() => {
lf?.destroy();
lf = null;
});
// 响应式更新 nodes/edges
watch(
() => [props.nodes, props.edges],
() => {
renderFlow();
},
{ deep: true }
);
// 响应式更新 viewport
watch(
() => props.viewport,
(val) => {
if (val) setViewport(val);
}
);
function renderFlow() {
if (!lf) return;
lf.render({
nodes: props.nodes,
edges: props.edges,
});
}
function setViewport(viewport: Viewport) {
if (!lf || !viewport) return;
lf.zoom(viewport.zoom);
// lf.focusOn({ x: viewport.x, y: viewport.y });
}
function getViewport(): Viewport {
if (!lf) return { x: 0, y: 0, zoom: 1 };
const t = lf.getTransform();
return {
x: t.TRANSLATE_X,
y: t.TRANSLATE_Y,
zoom: t.SCALE_X
};
}
function handleAddNode(node: NodeData) {
if (!lf) return;
lf.addNode(node);
}
function getGraphRawData() {
if (!lf) return null;
return lf.getGraphRawData();
}
function renderRawData(data: any) {
if (!lf) return;
lf.renderRawData(data);
}
defineExpose({
getViewport,
setViewport, // 新增暴露
handleAddNode,
getGraphRawData,
renderRawData,
});
</script>
<style scoped> <style scoped>
.flow-editor { .container {
display: flex;
flex-direction: column;
width: 100%; width: 100%;
min-height: 300px;
background: #fff;
} }
</style>
.editor-layout {
display: flex;
height: 100%;
}
.flow-container {
flex: 1;
position: relative;
overflow: hidden;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 5px 0;
z-index: 9999;
min-width: 120px;
user-select: none;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.menu-item:hover {
background-color: #f5f7fa;
color: #409eff;
}
</style>

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import type { Edge, Node, ViewportTransform } from '@vue-flow/core'; // import type { Edge, Node, ViewportTransform } from '@vue-flow/core';
import { ElMessageBox } from "element-plus"; import { ElMessageBox } from "element-plus";
import { useGlobalMessage } from "./useGlobalMessage"; import { useGlobalMessage } from "./useGlobalMessage";
@@ -8,24 +8,88 @@ const { showMessage } = useGlobalMessage();
function getDefaultState() { function getDefaultState() {
return { return {
fileList: [{ fileList: [
"label": "File 1", {
"name": "1", label: "File 1",
"visible": true, name: "1",
"type": "FLOW", visible: true,
"groups": [ type: "FLOW",
{ groups: [
"shortDescription": "", {
"groupInfo": [{}, {}, {}, {}, {}], shortDescription: "File 1 Group",
"details": "" groupInfo: [{}, {}, {}, {}, {}],
details: "File 1 详情"
}
],
flowData: {
nodes: [
{
id: "node-1",
type: "rect",
x: 100,
y: 100,
text: "File1-矩形节点"
},
{
id: "node-2",
type: "ellipse",
x: 350,
y: 120,
text: "File1-圆形节点"
}
],
edges: [
{
id: "edge-1",
type: "polyline",
sourceNodeId: "node-1",
targetNodeId: "node-2"
}
],
viewport: { x: 0, y: 0, zoom: 1 }
}
},
{
label: "File 2",
name: "2",
visible: true,
type: "FLOW",
groups: [
{
shortDescription: "File 2 Group",
groupInfo: [{}, {}, {}, {}, {}],
details: "File 2 详情"
}
],
flowData: {
nodes: [
{
id: "node-1",
type: "rect",
x: 100,
y: 100,
text: "File2-矩形节点"
},
{
id: "node-2",
type: "ellipse",
x: 350,
y: 120,
text: "File2222-圆形节点"
}
],
edges: [
{
id: "edge-1",
type: "polyline",
sourceNodeId: "node-1",
targetNodeId: "node-2"
}
],
viewport: { x: 0, y: 0, zoom: 1 }
} }
],
"flowData": {
"nodes": [],
"edges": [],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
} }
}], ],
activeFile: "1", activeFile: "1",
}; };
} }
@@ -157,7 +221,7 @@ export const useFilesStore = defineStore('files', () => {
}; };
// 添加边 // 添加边
const addEdge = (edge: Edge) => { const addEdge = (edge) => {
const file = fileList.value.find(f => f.name === activeFile.value); const file = fileList.value.find(f => f.name === activeFile.value);
if (!file) return; if (!file) return;
if (!file.flowData) file.flowData = { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }; if (!file.flowData) file.flowData = { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
@@ -191,14 +255,17 @@ export const useFilesStore = defineStore('files', () => {
// 更新文件的 viewport // 更新文件的 viewport
const updateFileViewport = (fileName: string, viewport: { x: number; y: number; zoom: number }) => { const updateFileViewport = (fileName: string, viewport: { x: number; y: number; zoom: number }) => {
const file = fileList.value.find(f => f.name === fileName); const file = fileList.value.find(f => f.name === fileName);
if (file && file.flowData) file.flowData.viewport = viewport; if (file && file.flowData) {
console.log(`[updateFileViewport] 保存 tab "${fileName}" 的视口信息:`, viewport);
file.flowData.viewport = viewport;
}
}; };
const getFileViewport = (fileName: string): ViewportTransform => { const getFileViewport = (fileName: string) => {
const file = fileList.value.find(f => f.name === fileName); const file = fileList.value.find(f => f.name === fileName);
const v = file?.flowData?.viewport; const v = file?.flowData?.viewport;
if (v && typeof v.x === 'number' && typeof v.y === 'number' && typeof v.zoom === 'number') { if (v && typeof v.x === 'number' && typeof v.y === 'number' && typeof v.zoom === 'number') {
return v as ViewportTransform; return v ;
} }
return { x: 0, y: 0, zoom: 1 }; return { x: 0, y: 0, zoom: 1 };
}; };