refactor: rewrite editor with immutable state, async-safe Vue Flow, and loading guard

This commit is contained in:
2026-06-07 23:18:43 +08:00
parent 45461b4ed7
commit 4d48463164
3 changed files with 215 additions and 193 deletions

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { watch, onMounted, nextTick } from 'vue'
import { watch, nextTick } from 'vue'
import { VueFlow, useVueFlow } 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/controls/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import type { Node, Edge, Connection } from '@vue-flow/core'
import type { Connection } from '@vue-flow/core'
const props = defineProps<{
sceneNodes: { id: string; label: string }[]
@@ -17,82 +17,82 @@ const props = defineProps<{
const emit = defineEmits<{
selectNode: [id: string]
addNode: []
addEdge: [source: string, target: string]
}>()
const { nodes, edges, onNodeClick, onConnect, fitView } = useVueFlow()
function buildNodes() {
function makeNodes() {
return props.sceneNodes.map((n, i) => ({
id: n.id,
type: 'default',
position: { x: (i % 4) * 220 + 50, y: Math.floor(i / 4) * 120 + 50 },
data: { label: n.id === props.startScene ? ` ${n.label}` : n.label },
data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
style: n.id === props.startScene
? 'background:#1b5e20; color:#fff; border-color:#388e3c'
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
: n.id === props.selectedNodeId
? 'background:#1565c0; color:#fff; border-color:#1976d2'
: '',
? { background: '#1565c0', color: '#fff', borderColor: '#1976d2' }
: {},
sourcePosition: 'right' as const,
targetPosition: 'left' as const,
connectable: true,
}))
}
function buildEdges() {
function makeEdges() {
return props.sceneEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label || '',
label: e.label ?? '',
animated: true,
style: { stroke: '#4fc3f7' },
labelStyle: { fill: '#aaa', fontWeight: 400, fontSize: 11 },
labelBgStyle: { fill: 'rgba(0,0,0,0.7)' },
}))
}
let lastNodesLen = 0
let lastEdgesLen = 0
let prevNodeCount = -1
let prevEdgeCount = -1
let didFitView = false
let rebuildSeq = 0
async function structuralRebuild() {
const seq = ++rebuildSeq
edges.value = []
await nextTick()
nodes.value = buildNodes()
if (seq !== rebuildSeq) return
nodes.value = makeNodes()
await nextTick()
edges.value = buildEdges()
lastNodesLen = props.sceneNodes.length
lastEdgesLen = props.sceneEdges.length
if (seq !== rebuildSeq) return
edges.value = makeEdges()
prevNodeCount = props.sceneNodes.length
prevEdgeCount = props.sceneEdges.length
if (!didFitView && nodes.value.length > 0) {
didFitView = true
setTimeout(() => fitView({ padding: 0.2 }), 80)
}
}
function styleOnlyRebuild() {
nodes.value = buildNodes()
edges.value = buildEdges()
function inlineRebuild() {
nodes.value = makeNodes()
edges.value = makeEdges()
}
watch(() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId], () => {
const nodesLen = props.sceneNodes.length
const edgesLen = props.sceneEdges.length
if (nodesLen !== lastNodesLen || edgesLen !== lastEdgesLen) {
structuralRebuild()
} else {
styleOnlyRebuild()
}
})
watch(
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
() => {
const nc = props.sceneNodes.length
const ec = props.sceneEdges.length
if (nc !== prevNodeCount || ec !== prevEdgeCount) {
structuralRebuild()
} else {
inlineRebuild()
}
},
)
onNodeClick((event) => {
emit('selectNode', event.node.id)
})
onNodeClick((ev) => emit('selectNode', ev.node.id))
onConnect((connection: Connection) => {
if (connection.source && connection.target) {
emit('addEdge', connection.source, connection.target)
}
})
onMounted(() => {
setTimeout(() => fitView({ padding: 0.2 }), 100)
onConnect((conn: Connection) => {
if (conn.source && conn.target) emit('addEdge', conn.source, conn.target)
})
</script>