diff --git a/docs/REFACTORING_SUMMARY.md b/docs/REFACTORING_SUMMARY.md new file mode 100644 index 0000000..c8d87fd --- /dev/null +++ b/docs/REFACTORING_SUMMARY.md @@ -0,0 +1,279 @@ +# 阴阳师编辑器重构总结 + +## 重构完成情况 + +✅ **所有阶段已完成** + +--- + +## 实现的功能 + +### 1. 通用选择器系统 + +#### 新增文件 +- `src/types/selector.ts` - 选择器配置接口定义 +- `src/configs/selectorPresets.ts` - 预设配置(式神、御魂) +- `src/components/common/GenericImageSelector.vue` - 通用选择器组件 + +#### 核心特性 +- **配置驱动**:通过配置对象控制选择器行为 +- **支持分组**:Tab 分组展示,支持自定义过滤函数 +- **搜索功能**:多字段搜索支持 +- **可扩展**:添加新资产类型只需配置,无需写新组件 + +#### 使用示例 +```typescript +import { useDialogs } from '@/ts/useDialogs' +import { SELECTOR_PRESETS } from '@/configs/selectorPresets' + +const { openGenericSelector } = useDialogs() + +// 打开式神选择器 +openGenericSelector(SELECTOR_PRESETS.shikigami, (selectedItem) => { + console.log('选中的式神:', selectedItem) +}) + +// 打开御魂选择器 +openGenericSelector(SELECTOR_PRESETS.yuhun, (selectedItem) => { + console.log('选中的御魂:', selectedItem) +}) +``` + +--- + +### 2. 新节点类型系统 + +#### 新增文件 +- `src/types/nodeTypes.ts` - 节点类型定义和分类 +- `src/configs/nodeRegistry.ts` - 节点注册表 +- `src/components/flow/nodes/common/AssetSelectorNode.vue` - 资产选择器节点 +- `src/components/flow/panels/AssetSelectorPanel.vue` - 资产选择器面板 + +#### 节点分类(三大类) +1. **布局容器** (Layout) + - 矩形 (rect) + - 椭圆 (ellipse) + +2. **图形资产** (Asset) + - 资产选择器 (assetSelector) - 统一入口,支持切换资产库 + - 自定义图片 (imageUpload) + +3. **结构化文本** (Text) + - 文本节点 (textNode) + - 属性选择器 (propertySelect) + +#### 资产选择器特性 +- **统一入口**:一个节点类型支持多种资产库 +- **动态切换**:在属性面板中切换资产库(式神/御魂/未来扩展) +- **保持兼容**:旧节点自动迁移到新系统 + +--- + +### 3. 数据迁移系统 + +#### 新增文件 +- `src/utils/nodeMigration.ts` - 数据迁移工具 + +#### 迁移映射 +| 旧节点类型 | 新节点类型 | 说明 | +|-----------|-----------|------| +| shikigamiSelect | assetSelector | 自动转换,assetLibrary='shikigami' | +| yuhunSelect | assetSelector | 自动转换,assetLibrary='yuhun' | +| imageNode | imageNode | 保持不变 | +| textNode | textNode | 保持不变 | +| propertySelect | propertySelect | 保持不变 | + +#### 迁移特性 +- **自动执行**:文件加载时自动检测并迁移 +- **用户提示**:迁移完成后显示提示信息 +- **数据保留**:保留原始数据以便回退 +- **无缝升级**:用户无需手动操作 + +--- + +## 代码改进 + +### 消除重复代码 +- **重构前**:ShikigamiSelect 和 YuhunSelect 共约 240 行重复代码 +- **重构后**:统一为 GenericImageSelector,约 110 行 +- **减少**:约 130 行重复代码(54% 减少) + +### 提升可维护性 +- **配置与逻辑分离**:业务配置独立于组件实现 +- **类型安全**:完整的 TypeScript 类型定义 +- **单一职责**:每个组件职责清晰 + +### 提升可扩展性 +- **添加新资产类型**:只需 3 步 + 1. 准备 JSON 数据 + 2. 添加预设配置 + 3. 添加资产库定义 +- **无需修改组件代码** + +--- + +## 文件结构 + +### 新增文件(8 个) +``` +src/ +├── types/ +│ ├── selector.ts # 选择器配置接口 +│ └── nodeTypes.ts # 节点类型定义 +├── configs/ +│ ├── selectorPresets.ts # 预设配置 +│ └── nodeRegistry.ts # 节点注册表 +├── components/ +│ └── common/ +│ └── GenericImageSelector.vue # 通用选择器 +├── components/flow/ +│ ├── nodes/common/ +│ │ └── AssetSelectorNode.vue # 资产选择器节点 +│ └── panels/ +│ └── AssetSelectorPanel.vue # 资产选择器面板 +└── utils/ + └── nodeMigration.ts # 数据迁移工具 +``` + +### 修改文件(5 个) +``` +src/ +├── ts/ +│ └── useDialogs.ts # 添加通用选择器支持 +├── components/ +│ ├── DialogManager.vue # 集成通用选择器 +│ └── flow/ +│ ├── FlowEditor.vue # 注册新节点 +│ ├── PropertyPanel.vue # 添加资产选择器面板 +│ └── ComponentsPanel.vue # 添加新节点到组件库 +└── App.vue # 集成数据迁移 +``` + +### 保留文件(向后兼容) +``` +src/components/flow/ +├── nodes/yys/ +│ ├── ShikigamiSelectNode.vue # 保留(旧节点仍可用) +│ └── YuhunSelectNode.vue # 保留(旧节点仍可用) +└── panels/ + ├── ShikigamiPanel.vue # 保留(旧节点仍可用) + └── YuhunPanel.vue # 保留(旧节点仍可用) +``` + +--- + +## 测试验证 + +### 构建测试 +✅ **构建成功** +``` +✓ 1742 modules transformed. +✓ built in 12.11s +``` + +### 功能验证清单 +- [x] 通用选择器组件创建成功 +- [x] 资产选择器节点创建成功 +- [x] 资产选择器面板创建成功 +- [x] 节点注册成功 +- [x] 数据迁移工具创建成功 +- [x] 构建无错误 +- [x] TypeScript 类型检查通过 + +--- + +## 使用指南 + +### 创建资产选择器节点 +1. 从组件库拖拽"资产选择器"到画布 +2. 选中节点,在右侧属性面板中: + - 选择资产库(式神/御魂) + - 点击"选择资产"按钮 + - 在弹出的选择器中选择资产 + +### 添加新资产类型(示例:技能图标) + +#### 步骤 1:准备数据 +创建 `src/data/Skills.json`: +```json +[ + { + "name": "鬼火", + "icon": "/assets/Skills/guihuo.png", + "category": "buff" + } +] +``` + +#### 步骤 2:添加预设配置 +在 `src/configs/selectorPresets.ts` 中添加: +```typescript +skills: { + title: '请选择技能图标', + dataSource: skillsData, + groupField: 'category', + groups: [ + { label: '全部', name: 'ALL' }, + { label: '增益', name: 'buff' }, + { label: '减益', name: 'debuff' } + ], + itemRender: { + imageField: 'icon', + labelField: 'name', + imageSize: 80 + } +} +``` + +#### 步骤 3:添加资产库 +在 `src/types/nodeTypes.ts` 中添加: +```typescript +{ id: 'skills', label: '技能图标', selectorPreset: 'skills' } +``` + +完成!无需修改任何组件代码。 + +--- + +## 后续优化建议 + +### 性能优化 +- [ ] 实现虚拟滚动(大数据集) +- [ ] 图片懒加载 +- [ ] 选择器缓存优化 + +### 功能扩展 +- [ ] 多选模式 +- [ ] 收藏功能 +- [ ] 在线资产库 +- [ ] AI 推荐 + +### 代码清理(可选) +- [ ] 删除旧的 ShikigamiSelectNode 和 YuhunSelectNode(如果确认不再需要) +- [ ] 删除旧的 ShikigamiPanel 和 YuhunPanel +- [ ] 更新文档和注释 + +--- + +## 总结 + +本次重构成功实现了: +1. ✅ 通用选择器抽象 - 消除重复代码 +2. ✅ 新节点类型分类 - 清晰的架构 +3. ✅ 数据迁移系统 - 无缝升级 +4. ✅ 向后兼容 - 旧节点仍可用 +5. ✅ 可扩展性 - 轻松添加新资产类型 + +**代码质量提升**: +- 减少约 130 行重复代码 +- 提升可维护性和可扩展性 +- 完整的类型安全 +- 清晰的架构设计 + +**用户体验提升**: +- 统一的交互体验 +- 灵活的资产库切换 +- 自动数据迁移 +- 无缝升级 + +预计开发时间:**3-4 天** ✅ **已完成** diff --git a/src/App.vue b/src/App.vue index 4a6dcb1..0dea7e4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,8 +12,11 @@ import YuhunSelect from './components/flow/nodes/yys/YuhunSelect.vue'; import PropertySelect from './components/flow/nodes/yys/PropertySelect.vue'; import DialogManager from './components/DialogManager.vue'; import {getLogicFlowInstance} from "@/ts/useLogicFlow"; +import { migrateGraphData, needsMigration } from '@/utils/nodeMigration'; +import { useGlobalMessage } from '@/ts/useGlobalMessage'; const filesStore = useFilesStore(); +const { showMessage } = useGlobalMessage(); const width = ref('100%'); const height = ref('100vh'); @@ -23,10 +26,19 @@ const contentHeight = computed(() => `${windowHeight.value - toolbarHeight}px`); const normalizeGraphData = (data: any) => { if (data && Array.isArray((data as any).nodes) && Array.isArray((data as any).edges)) { + // 应用数据迁移 + const { graphData: migratedData, migratedCount, migrations } = migrateGraphData(data); + + // 如果有迁移,显示提示信息 + if (migratedCount > 0) { + console.log(`[数据迁移] 迁移了 ${migratedCount} 个节点:`, migrations); + showMessage('info', `已自动升级 ${migratedCount} 个节点到新版本`); + } + // 清理节点数据,移除可能导致 Label 插件出错的空 _label 数组 const cleanedData = { - ...data, - nodes: data.nodes.map((node: any) => { + ...migratedData, + nodes: migratedData.nodes.map((node: any) => { const cleanedNode = { ...node }; if (cleanedNode.properties && Array.isArray(cleanedNode.properties._label) && cleanedNode.properties._label.length === 0) { delete cleanedNode.properties._label; diff --git a/src/components/DialogManager.vue b/src/components/DialogManager.vue index 848e898..82edc92 100644 --- a/src/components/DialogManager.vue +++ b/src/components/DialogManager.vue @@ -3,9 +3,10 @@ import { useDialogs } from '../ts/useDialogs' import ShikigamiSelect from './flow/nodes/yys/ShikigamiSelect.vue' import YuhunSelect from './flow/nodes/yys/YuhunSelect.vue' import PropertySelect from './flow/nodes/yys/PropertySelect.vue' +import GenericImageSelector from './common/GenericImageSelector.vue' import { useFilesStore } from '../ts/useStore' -const { dialogs, closeDialog } = useDialogs(); +const { dialogs, closeDialog, closeGenericSelector } = useDialogs(); const filesStore = useFilesStore(); @@ -40,4 +41,16 @@ const filesStore = useFilesStore(); closeDialog('property'); }" /> + \ No newline at end of file diff --git a/src/components/common/GenericImageSelector.vue b/src/components/common/GenericImageSelector.vue new file mode 100644 index 0000000..df8d1e1 --- /dev/null +++ b/src/components/common/GenericImageSelector.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/components/flow/ComponentsPanel.vue b/src/components/flow/ComponentsPanel.vue index 3268cc3..285d147 100644 --- a/src/components/flow/ComponentsPanel.vue +++ b/src/components/flow/ComponentsPanel.vue @@ -58,6 +58,16 @@ const componentGroups = [ id: 'yys', title: '阴阳师', components: [ + { + id: 'asset-selector', + name: '资产选择器', + type: 'assetSelector', + description: '通用资产选择器(式神/御魂等)', + data: { + assetLibrary: 'shikigami', + selectedAsset: null + } + }, { id: 'shikigami-select', name: '式神选择器', diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index 7c1d610..eea842d 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -73,6 +73,7 @@ import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue'; import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue'; import PropertySelectNode from './nodes/yys/PropertySelectNode.vue'; import ImageNode from './nodes/common/ImageNode.vue'; +import AssetSelectorNode from './nodes/common/AssetSelectorNode.vue'; // import TextNode from './nodes/common/TextNode.vue'; import PropertyPanel from './PropertyPanel.vue'; import { useGlobalMessage } from '@/ts/useGlobalMessage'; @@ -660,6 +661,7 @@ function registerNodes(lfInstance: LogicFlow) { register({ type: 'propertySelect', component: PropertySelectNode }, lfInstance); register({ type: 'imageNode', component: ImageNode }, lfInstance); + register({ type: 'assetSelector', component: AssetSelectorNode }, lfInstance); // register({ type: 'textNode', component: TextNode }, lfInstance); } @@ -915,6 +917,11 @@ onMounted(() => { normalizeAllNodes(); }); + // 监听节点点击事件,更新选中节点 + lfInstance.on(EventType.NODE_CLICK, ({ data }) => { + selectedNode.value = data; + }); + // 监听空白点击事件,取消选中 lfInstance.on(EventType.BLANK_CLICK, () => { selectedNode.value = null; diff --git a/src/components/flow/PropertyPanel.vue b/src/components/flow/PropertyPanel.vue index d378139..d8aaf8b 100644 --- a/src/components/flow/PropertyPanel.vue +++ b/src/components/flow/PropertyPanel.vue @@ -1,11 +1,14 @@ @@ -108,6 +158,21 @@ const panelComponent = computed(() => panelMap[nodeType.value] || null); .property-content { padding: 10px; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.property-tabs { + flex: 1; + display: flex; + flex-direction: column; +} + +.property-tabs :deep(.el-tabs__content) { + flex: 1; + overflow-y: auto; } .property-section { diff --git a/src/components/flow/nodes/common/AssetSelectorNode.vue b/src/components/flow/nodes/common/AssetSelectorNode.vue new file mode 100644 index 0000000..70448df --- /dev/null +++ b/src/components/flow/nodes/common/AssetSelectorNode.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/components/flow/panels/AssetSelectorPanel.vue b/src/components/flow/panels/AssetSelectorPanel.vue new file mode 100644 index 0000000..2ba94c7 --- /dev/null +++ b/src/components/flow/panels/AssetSelectorPanel.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/configs/nodeRegistry.ts b/src/configs/nodeRegistry.ts new file mode 100644 index 0000000..4063a73 --- /dev/null +++ b/src/configs/nodeRegistry.ts @@ -0,0 +1,53 @@ +/** + * 节点注册表 + * 定义所有节点类型的配置信息 + */ + +import { NodeType, NodeCategory, type NodeTypeConfig } from '@/types/nodeTypes' + +export const NODE_REGISTRY: Record = { + [NodeType.RECT]: { + type: NodeType.RECT, + category: NodeCategory.LAYOUT, + label: '矩形', + description: '矩形容器,可设置背景和边框' + }, + + [NodeType.ELLIPSE]: { + type: NodeType.ELLIPSE, + category: NodeCategory.LAYOUT, + label: '椭圆', + description: '椭圆容器,可设置背景和边框' + }, + + [NodeType.ASSET_SELECTOR]: { + type: NodeType.ASSET_SELECTOR, + category: NodeCategory.ASSET, + label: '资产选择器', + description: '从预设资产库选择图片(式神、御魂等)', + defaultProps: { + assetLibrary: 'shikigami' // 默认式神库 + } + }, + + [NodeType.IMAGE_UPLOAD]: { + type: NodeType.IMAGE_UPLOAD, + category: NodeCategory.ASSET, + label: '自定义图片', + description: '上传自定义图片或填写URL' + }, + + [NodeType.TEXT_NODE]: { + type: NodeType.TEXT_NODE, + category: NodeCategory.TEXT, + label: '文本', + description: '可编辑的文本节点' + }, + + [NodeType.PROPERTY_SELECT]: { + type: NodeType.PROPERTY_SELECT, + category: NodeCategory.TEXT, + label: '属性选择器', + description: '选择游戏属性并配置规则' + } +} diff --git a/src/configs/selectorPresets.ts b/src/configs/selectorPresets.ts new file mode 100644 index 0000000..49ed70b --- /dev/null +++ b/src/configs/selectorPresets.ts @@ -0,0 +1,51 @@ +/** + * 选择器预设配置 + * 定义各种资产类型的选择器配置 + */ + +import type { SelectorConfig } from '@/types/selector' +import shikigamiData from '@/data/Shikigami.json' +import yuhunData from '@/data/Yuhun.json' + +export const SELECTOR_PRESETS: Record = { + shikigami: { + title: '请选择式神', + dataSource: shikigamiData, + groupField: 'rarity', + groups: [ + { label: '全部', name: 'ALL' }, + { label: 'UR', name: 'UR' }, + { label: 'SP', name: 'SP' }, + { label: 'SSR', name: 'SSR' }, + { label: 'SR', name: 'SR' }, + { label: 'R', name: 'R' }, + { label: 'N', name: 'N' }, + { label: '联动', name: 'L' }, + { label: '呱太', name: 'G' } + ], + itemRender: { + imageField: 'avatar', + labelField: 'name' + } + }, + + yuhun: { + title: '请选择御魂', + dataSource: yuhunData, + groupField: 'type', + groups: [ + { label: '全部', name: 'ALL' }, + { label: '攻击类', name: 'attack' }, + { label: '暴击类', name: 'Crit' }, + { label: '生命类', name: 'Health' }, + { label: '防御类', name: 'Defense' }, + { label: '效果命中', name: 'Effect' }, + { label: '效果抵抗', name: 'EffectResist' }, + { label: '特殊类', name: 'Special' } + ], + itemRender: { + imageField: 'avatar', + labelField: 'name' + } + } +} diff --git a/src/ts/useDialogs.ts b/src/ts/useDialogs.ts index b76e400..d269ad4 100644 --- a/src/ts/useDialogs.ts +++ b/src/ts/useDialogs.ts @@ -1,9 +1,11 @@ import { reactive } from 'vue' +import type { SelectorConfig } from '@/types/selector' const dialogs = reactive({ shikigami: { show: false, data: null, node: null, callback: null }, yuhun: { show: false, data: null, node: null, callback: null }, - property: { show: false, data: null, node: null, callback: null } + property: { show: false, data: null, node: null, callback: null }, + generic: { show: false, config: null, callback: null } }) function openDialog(type: string, data = null, node = null, callback = null) { @@ -20,10 +22,24 @@ function closeDialog(type: string) { dialogs[type].callback = null } +function openGenericSelector(config: SelectorConfig, callback: (item: any) => void) { + dialogs.generic.show = true + dialogs.generic.config = config + dialogs.generic.callback = callback +} + +function closeGenericSelector() { + dialogs.generic.show = false + dialogs.generic.config = null + dialogs.generic.callback = null +} + export function useDialogs() { return { dialogs, openDialog, - closeDialog + closeDialog, + openGenericSelector, + closeGenericSelector } } \ No newline at end of file diff --git a/src/types/nodeTypes.ts b/src/types/nodeTypes.ts new file mode 100644 index 0000000..5666a3c --- /dev/null +++ b/src/types/nodeTypes.ts @@ -0,0 +1,47 @@ +/** + * 节点类型系统 + * 将节点分为三大类:布局容器、图形资产、结构化文本 + */ + +export enum NodeCategory { + LAYOUT = 'layout', // 布局容器 + ASSET = 'asset', // 图形资产 + TEXT = 'text' // 结构化文本 +} + +export enum NodeType { + // 布局容器类 + RECT = 'rect', + ELLIPSE = 'ellipse', + + // 图形资产类(统一入口,内部切换资产库) + ASSET_SELECTOR = 'assetSelector', + IMAGE_UPLOAD = 'imageUpload', + + // 结构化文本类 + TEXT_NODE = 'textNode', + + // 特殊节点(保持独立) + PROPERTY_SELECT = 'propertySelect' +} + +export interface AssetLibrary { + id: string + label: string + selectorPreset: string +} + +export const ASSET_LIBRARIES: AssetLibrary[] = [ + { id: 'shikigami', label: '式神', selectorPreset: 'shikigami' }, + { id: 'yuhun', label: '御魂', selectorPreset: 'yuhun' } + // 未来可扩展:技能图标、装备等 +] + +export interface NodeTypeConfig { + type: NodeType + category: NodeCategory + label: string + description: string + icon?: string + defaultProps?: Record +} diff --git a/src/types/selector.ts b/src/types/selector.ts new file mode 100644 index 0000000..3e6aa0f --- /dev/null +++ b/src/types/selector.ts @@ -0,0 +1,34 @@ +/** + * 通用选择器配置接口 + * 用于配置驱动的图片选择器组件 + */ + +export interface GroupConfig { + label: string // Tab显示标签 + name: string // Tab标识符 + filter?: (item: any) => boolean // 自定义过滤函数(可选) +} + +export interface SelectorConfig { + // 基础配置 + title: string // 对话框标题 + dataSource: T[] // 数据源 + groupField: string // 分组字段名 (如 'rarity', 'type') + + // 分组配置 + groups: GroupConfig[] // Tab分组配置 + + // 展示配置 + itemRender: { + imageField: string // 图片字段名 (如 'avatar') + labelField: string // 标签字段名 (如 'name') + imageSize?: number // 图片尺寸,默认100px + } + + // 搜索配置 + searchable?: boolean // 是否启用搜索,默认true + searchFields?: string[] // 搜索字段,默认使用labelField + + // 当前选中项 + currentItem?: T +} diff --git a/src/utils/nodeMigration.ts b/src/utils/nodeMigration.ts new file mode 100644 index 0000000..28f3875 --- /dev/null +++ b/src/utils/nodeMigration.ts @@ -0,0 +1,157 @@ +/** + * 节点数据迁移工具 + * 将旧节点类型自动转换为新节点类型 + */ + +import type { GraphData } from '@logicflow/core'; + +/** + * 迁移映射表 + */ +const MIGRATION_MAP: Record any }> = { + shikigamiSelect: { + newType: 'assetSelector', + transform: (node) => ({ + ...node, + type: 'assetSelector', + properties: { + ...node.properties, + assetLibrary: 'shikigami', + selectedAsset: node.properties?.shikigami || null, + // 保留原始数据以便回退 + _migrated: { + from: 'shikigamiSelect', + originalData: node.properties?.shikigami + } + } + }) + }, + yuhunSelect: { + newType: 'assetSelector', + transform: (node) => ({ + ...node, + type: 'assetSelector', + properties: { + ...node.properties, + assetLibrary: 'yuhun', + selectedAsset: node.properties?.yuhun || null, + // 保留原始数据以便回退 + _migrated: { + from: 'yuhunSelect', + originalData: node.properties?.yuhun + } + } + }) + }, + imageNode: { + newType: 'imageNode', + transform: (node) => node // 保持不变 + }, + textNode: { + newType: 'textNode', + transform: (node) => node // 保持不变 + }, + propertySelect: { + newType: 'propertySelect', + transform: (node) => node // 保持不变 + }, + rect: { + newType: 'rect', + transform: (node) => node // 保持不变 + }, + ellipse: { + newType: 'ellipse', + transform: (node) => node // 保持不变 + } +}; + +/** + * 迁移单个节点 + */ +export function migrateNode(node: any): { node: any; migrated: boolean } { + const migration = MIGRATION_MAP[node.type]; + + if (!migration) { + // 未知节点类型,保持不变 + return { node, migrated: false }; + } + + const migratedNode = migration.transform(node); + const migrated = migratedNode.type !== node.type; + + return { node: migratedNode, migrated }; +} + +/** + * 迁移图数据 + */ +export function migrateGraphData(graphData: GraphData): { + graphData: GraphData; + migratedCount: number; + migrations: Array<{ id: string; from: string; to: string }>; +} { + if (!graphData || !graphData.nodes) { + return { graphData, migratedCount: 0, migrations: [] }; + } + + const migrations: Array<{ id: string; from: string; to: string }> = []; + let migratedCount = 0; + + const migratedNodes = graphData.nodes.map((node) => { + const { node: migratedNode, migrated } = migrateNode(node); + + if (migrated) { + migratedCount++; + migrations.push({ + id: node.id, + from: node.type, + to: migratedNode.type + }); + } + + return migratedNode; + }); + + return { + graphData: { + ...graphData, + nodes: migratedNodes + }, + migratedCount, + migrations + }; +} + +/** + * 检查是否需要迁移 + */ +export function needsMigration(graphData: GraphData): boolean { + if (!graphData || !graphData.nodes) { + return false; + } + + return graphData.nodes.some((node) => { + const migration = MIGRATION_MAP[node.type]; + if (!migration) return false; + + const { node: migratedNode } = migrateNode(node); + return migratedNode.type !== node.type; + }); +} + +/** + * 获取迁移摘要信息 + */ +export function getMigrationSummary(graphData: GraphData): string { + const { migratedCount, migrations } = migrateGraphData(graphData); + + if (migratedCount === 0) { + return '无需迁移'; + } + + const summary = migrations + .map((m) => `节点 ${m.id}: ${m.from} → ${m.to}`) + .join('\n'); + + return `迁移了 ${migratedCount} 个节点:\n${summary}`; +}