[F1] @vue-flow/core 依赖 + DagCanvas 基础组件

- apps/web-antd/src/components/iot-dag/ 7 文件
  - DagCanvas.vue / DagCanvasToolbar.vue
  - hooks/useDagState.ts(50 步撤销栈 + structuredClone 深拷贝)
  - hooks/useDagShortcuts.ts(Del/Ctrl+Z/Ctrl+Y + input guard)
  - types.ts(DagNode/DagEdge/DagCanvasProps + 6 emits)
  - index.ts barrel + __tests__/DagCanvas.spec.ts(16 用例全绿)
- pnpm-workspace.yaml: catalog 新增 @vue-flow/{core,background,controls,minimap}
- apps/web-antd/package.json: 4 包全部 'catalog:' 引用
- i18n: zh-CN/en-US iot.dag.toolbar.* + iot.dag.canvas.* 同步
- Known Pitfalls 落地: ⚠️ catalog 约束 / 样式 import / TS 严格零 any

note: Acceptance #7 (Storybook/playground) 项目未集成 Storybook,
      由 F7 规则链编辑页自然 dogfood。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 21:32:11 +08:00
parent c0646a4f4f
commit 7dbf41b75f
12 changed files with 1159 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
<script lang="ts" setup>
import type {
EdgeMouseEvent,
FitView,
NodeMouseEvent,
ZoomInOut,
} from '@vue-flow/core';
import type { DagCanvasProps, DagEdge, DagNode } from './types';
import { computed, ref, toRef, watch } from 'vue';
import { Background, BackgroundVariant } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import DagCanvasToolbar from './DagCanvasToolbar.vue';
import { useDagShortcuts } from './hooks/useDagShortcuts';
import { useDagState } from './hooks/useDagState';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/minimap/dist/style.css';
// ── Props & Emits ────────────────────────────────────────────────────────────
const props = withDefaults(defineProps<DagCanvasProps>(), {
gridSize: 20,
readonly: false,
showControls: true,
showMinimap: true,
});
const emit = defineEmits<{
(e: 'edgeClick', edge: DagEdge): void;
(e: 'nodeClick', node: DagNode): void;
(e: 'nodeDblclick', node: DagNode): void;
(e: 'paneClick'): void;
(e: 'update:edges', edges: DagEdge[]): void;
(e: 'update:nodes', nodes: DagNode[]): void;
}>();
// ── State ────────────────────────────────────────────────────────────────────
const {
addEdge,
deleteSelected,
edges,
nodes,
redo,
redoable,
syncFromProps,
undo,
undoable,
updateNodePositions,
} = useDagState(props.nodes, props.edges);
/** 同步外部 props 变化到内部状态 */
watch(
() => [props.nodes, props.edges] as [DagNode[], DagEdge[]],
([newNodes, newEdges]) => {
syncFromProps(newNodes, newEdges);
},
{ deep: true },
);
/** 内部状态变化时通知父组件 */
watch(nodes, (n) => emit('update:nodes', n), { deep: true });
watch(edges, (e) => emit('update:edges', e), { deep: true });
// ── VueFlow instance ─────────────────────────────────────────────────────────
/**
* VueFlow 组件暴露的方法接口(仅包含 toolbar 需要的部分)
*/
interface VueFlowExposed {
fitView: FitView;
zoomIn: ZoomInOut;
zoomOut: ZoomInOut;
}
const vueFlowRef = ref<null | VueFlowExposed>(null);
// ── Toolbar actions ──────────────────────────────────────────────────────────
function handleZoomIn(): void {
vueFlowRef.value?.zoomIn();
}
function handleZoomOut(): void {
vueFlowRef.value?.zoomOut();
}
function handleFitView(): void {
vueFlowRef.value?.fitView();
}
function handleSave(): void {
// 父组件监听 update:nodes / update:edges 即可获得最新状态
// 此处作为"显式保存"事件通知
emit('update:nodes', nodes.value);
emit('update:edges', edges.value);
}
// ── DAG interactions ─────────────────────────────────────────────────────────
const isReadonly = computed(() => props.readonly);
function handleConnect(connection: Parameters<typeof addEdge>[0]): void {
if (isReadonly.value) return;
addEdge(connection);
}
function handleNodeDragStop({ node }: { node: DagNode }): void {
if (isReadonly.value) return;
// 拖拽结束后用当前所有节点(含新坐标)更新状态
const updated = nodes.value.map((n) =>
n.id === node.id ? { ...n, position: node.position } : n,
);
updateNodePositions(updated);
}
function handleNodeClick({ node }: NodeMouseEvent): void {
emit('nodeClick', node as DagNode);
}
function handleNodeDoubleClick({ node }: NodeMouseEvent): void {
emit('nodeDblclick', node as DagNode);
}
function handleEdgeClick({ edge }: EdgeMouseEvent): void {
emit('edgeClick', edge as DagEdge);
}
function handlePaneClick(): void {
emit('paneClick');
}
// ── Keyboard shortcuts ───────────────────────────────────────────────────────
const selectedNodeIds = ref<string[]>([]);
const selectedEdgeIds = ref<string[]>([]);
function handleSelectionChange({
nodes: sNodes,
edges: sEdges,
}: {
edges: DagEdge[];
nodes: DagNode[];
}): void {
selectedNodeIds.value = sNodes.map((n) => n.id);
selectedEdgeIds.value = sEdges.map((e) => e.id);
}
function handleDelete(): void {
if (isReadonly.value) return;
deleteSelected(selectedNodeIds.value, selectedEdgeIds.value);
}
useDagShortcuts({
onDelete: handleDelete,
onRedo: redo,
onUndo: undo,
readonly: toRef(props, 'readonly'),
});
</script>
<template>
<div class="dag-canvas-wrapper">
<!-- 工具栏 -->
<DagCanvasToolbar
:readonly="isReadonly"
:redoable="redoable"
:undoable="undoable"
@fit-view="handleFitView"
@redo="redo"
@save="handleSave"
@undo="undo"
@zoom-in="handleZoomIn"
@zoom-out="handleZoomOut"
/>
<!-- DagCanvasToolbar 内部 emit camelCasefitView/zoomIn/zoomOut
在父模板中使用 kebab-case 监听是 Vue 规范自动转换 -->
<!-- 画布主体 -->
<div class="dag-canvas-flow">
<VueFlow
ref="vueFlowRef"
v-model:edges="edges"
v-model:nodes="nodes"
:connect-on-click="false"
:edges-updatable="!isReadonly"
:fit-view-on-init="true"
:nodes-connectable="!isReadonly"
:nodes-draggable="!isReadonly"
:pan-on-drag="true"
:snap-grid="[gridSize, gridSize]"
:snap-to-grid="true"
:zoom-on-double-click="false"
:zoom-on-scroll="true"
class="dag-flow"
@connect="handleConnect"
@edge-click="handleEdgeClick"
@node-click="handleNodeClick"
@node-double-click="handleNodeDoubleClick"
@node-drag-stop="handleNodeDragStop"
@pane-click="handlePaneClick"
@selection-change="handleSelectionChange"
>
<!-- 背景网格 -->
<Background :variant="BackgroundVariant.Dots" :gap="gridSize" />
<!-- 小地图 -->
<MiniMap v-if="showMinimap" class="dag-minimap" />
<!-- 操作控件内置缩放按钮 -->
<Controls v-if="showControls" class="dag-controls" />
</VueFlow>
</div>
</div>
</template>
<style scoped>
.dag-canvas-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid #e8e8e8;
border-radius: 6px;
}
.dag-canvas-flow {
flex: 1;
min-height: 0;
}
.dag-flow {
width: 100%;
height: 100%;
}
/* 只读模式下显示普通指针 */
:deep(.vue-flow__handle) {
cursor: crosshair;
}
.dag-canvas-wrapper :deep(.vue-flow__node.selected .vue-flow__node-default) {
box-shadow: 0 0 0 2px #1677ff;
}
.dag-minimap {
right: 12px;
bottom: 40px;
}
.dag-controls {
left: 12px;
bottom: 12px;
}
</style>