Files
iot-device-management-frontend/apps/web-antd/src/components/iot-dag/DagCanvas.vue
lzh 7dbf41b75f [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>
2026-04-23 21:32:11 +08:00

265 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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