- 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>
265 lines
7.2 KiB
Vue
265 lines
7.2 KiB
Vue
<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>
|