[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:
264
apps/web-antd/src/components/iot-dag/DagCanvas.vue
Normal file
264
apps/web-antd/src/components/iot-dag/DagCanvas.vue
Normal 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 为 camelCase(fitView/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>
|
||||
Reference in New Issue
Block a user