From 14d8499f7784bbdbb471de33a119385aaeb4cad4 Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Wed, 2 Jul 2025 21:16:18 +0800 Subject: [PATCH] refactor: try to rewrite component manager --- components.d.ts | 2 + package-lock.json | 57 +- package.json | 1 + src/components/LabCanvas/LabCanvasNew.vue | 609 ++++++++++++++++ .../LabCanvas/LabComponentsDrawerNew.vue | 675 ++++++++++++++++++ src/components/LabCanvas/QUICKSTART.md | 236 ++++++ src/components/LabCanvas/README.md | 281 ++++++++ .../composables/useKonvaComponentManager.ts | 574 +++++++++++++++ .../LabCanvas/composables/useKonvaRenderer.ts | 406 +++++++++++ .../composables/useKonvaTemplateManager.ts | 323 +++++++++ src/components/LabCanvas/index.ts | 316 ++++++++ .../LabCanvas/types/KonvaComponent.ts | 118 +++ src/views/TestView.vue | 574 +++++++++++++-- 13 files changed, 4093 insertions(+), 79 deletions(-) create mode 100644 src/components/LabCanvas/LabCanvasNew.vue create mode 100644 src/components/LabCanvas/LabComponentsDrawerNew.vue create mode 100644 src/components/LabCanvas/QUICKSTART.md create mode 100644 src/components/LabCanvas/README.md create mode 100644 src/components/LabCanvas/composables/useKonvaComponentManager.ts create mode 100644 src/components/LabCanvas/composables/useKonvaRenderer.ts create mode 100644 src/components/LabCanvas/composables/useKonvaTemplateManager.ts create mode 100644 src/components/LabCanvas/index.ts create mode 100644 src/components/LabCanvas/types/KonvaComponent.ts diff --git a/components.d.ts b/components.d.ts index a547b96..b586813 100644 --- a/components.d.ts +++ b/components.d.ts @@ -20,7 +20,9 @@ declare module 'vue' { ETH: typeof import('./src/components/equipments/ETH.vue')['default'] HDMI: typeof import('./src/components/equipments/HDMI.vue')['default'] LabCanvas: typeof import('./src/components/LabCanvas/LabCanvas.vue')['default'] + LabCanvasNew: typeof import('./src/components/LabCanvas/LabCanvasNew.vue')['default'] LabComponentsDrawer: typeof import('./src/components/LabCanvas/LabComponentsDrawer.vue')['default'] + LabComponentsDrawerNew: typeof import('./src/components/LabCanvas/LabComponentsDrawerNew.vue')['default'] LoginCard: typeof import('./src/components/LoginCard.vue')['default'] MarkdownRenderer: typeof import('./src/components/MarkdownRenderer.vue')['default'] MechanicalButton: typeof import('./src/components/equipments/MechanicalButton.vue')['default'] diff --git a/package-lock.json b/package-lock.json index d05c4fb..8837735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@svgdotjs/svg.js": "^3.2.4", "@types/lodash": "^4.17.16", + "@vueuse/core": "^13.5.0", "async-mutex": "^0.5.0", "highlight.js": "^11.11.1", "konva": "^9.3.20", @@ -2116,24 +2117,38 @@ } }, "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.5.0.tgz", + "integrity": "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" + "@vueuse/metadata": "13.5.0", + "@vueuse/shared": "13.5.0" }, "funding": { "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/core/node_modules/@vueuse/shared": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.5.0.tgz", + "integrity": "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.5.0.tgz", + "integrity": "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -4008,6 +4023,30 @@ "vue": ">= 3.2.0" } }, + "node_modules/reka-ui/node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", diff --git a/package.json b/package.json index 449285b..25ddf1a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@svgdotjs/svg.js": "^3.2.4", "@types/lodash": "^4.17.16", + "@vueuse/core": "^13.5.0", "async-mutex": "^0.5.0", "highlight.js": "^11.11.1", "konva": "^9.3.20", diff --git a/src/components/LabCanvas/LabCanvasNew.vue b/src/components/LabCanvas/LabCanvasNew.vue new file mode 100644 index 0000000..610200f --- /dev/null +++ b/src/components/LabCanvas/LabCanvasNew.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/src/components/LabCanvas/LabComponentsDrawerNew.vue b/src/components/LabCanvas/LabComponentsDrawerNew.vue new file mode 100644 index 0000000..b336a49 --- /dev/null +++ b/src/components/LabCanvas/LabComponentsDrawerNew.vue @@ -0,0 +1,675 @@ + + + + + diff --git a/src/components/LabCanvas/QUICKSTART.md b/src/components/LabCanvas/QUICKSTART.md new file mode 100644 index 0000000..c1d2338 --- /dev/null +++ b/src/components/LabCanvas/QUICKSTART.md @@ -0,0 +1,236 @@ +# 快速开始指南 + +这个文档将帮助你快速开始使用重构后的LabCanvas组件管理系统。 + +## 基本集成 + +### 1. 简单使用 + +最简单的方式是直接使用 `LabCanvasNew` 组件: + +```vue + + + +``` + +### 2. 自定义集成 + +如果你需要更多控制,可以分别使用各个管理器: + +```vue + + + +``` + +## 主要功能 + +### 组件管理 + +```javascript +// 添加组件 +const component = componentManager.addComponent('MechanicalButton', { x: 100, y: 100 }) + +// 删除组件 +componentManager.deleteComponent(componentId) + +// 选择组件 +componentManager.selectComponent(componentId) + +// 更新组件属性 +componentManager.updateComponentProps(componentId, { color: 'red' }) + +// 撤销/重做 +componentManager.undo() +componentManager.redo() +``` + +### 模板管理 + +```javascript +// 从URL加载模板 +const template = await templateManager.loadTemplateFromUrl('/templates/button-panel.json') + +// 创建模板 +const template = templateManager.createTemplateFromComponents( + 'Button Panel', + 'A panel with multiple buttons', + 'custom', + selectedComponents +) + +// 实例化模板 +const instance = templateManager.instantiateTemplate(templateId, { x: 200, y: 200 }) +``` + +### 渲染管理 + +```javascript +// 渲染单个组件 +renderer.renderComponent(component) + +// 批量渲染 +renderer.renderComponents(components) + +// 清空画布 +renderer.clearComponents() + +// 查找组件 +const componentId = renderer.getComponentAtPosition(x, y) +``` + +## 事件处理 + +组件管理器支持各种事件回调: + +```javascript +const componentManager = useKonvaComponentManager({ + events: { + onAdd: (component) => { + console.log('组件添加:', component) + }, + onDelete: (componentId) => { + console.log('组件删除:', componentId) + }, + onSelect: (componentIds) => { + console.log('组件选择:', componentIds) + }, + onMove: (componentId, x, y) => { + console.log('组件移动:', componentId, x, y) + } + } +}) +``` + +## 键盘快捷键 + +系统内置了常用的键盘快捷键: + +- `Ctrl/Cmd + Z`: 撤销 +- `Ctrl/Cmd + Shift + Z`: 重做 +- `Ctrl/Cmd + A`: 全选 +- `Ctrl/Cmd + D`: 复制 +- `Delete`: 删除选中组件 +- `Escape`: 清除选择 + +## 自定义组件类型 + +要添加新的组件类型: + +1. 创建组件模板: + +```javascript +const customTemplate = { + type: 'MyCustomComponent', + name: '我的自定义组件', + category: 'custom', + defaultProps: { + customProp: 'default value' + } +} + +componentManager.registerTemplate(customTemplate) +``` + +2. 创建对应的Vue组件文件 `MyCustomComponent.vue` + +3. 在组件抽屉中添加该类型 + +## 项目保存和加载 + +```javascript +// 保存项目 +const projectData = componentManager.getProjectData() +localStorage.setItem('my-project', JSON.stringify(projectData)) + +// 加载项目 +const projectData = JSON.parse(localStorage.getItem('my-project')) +componentManager.loadProjectData(projectData) +``` + +## 注意事项 + +1. 确保已安装 `konva` 和 `vue-konva` 依赖 +2. 确保已安装 `@vueuse/core` +3. 组件ID必须唯一 +4. 模板JSON格式需要符合规范 +5. 大量组件时注意性能优化 + +## 故障排除 + +### 常见问题 + +1. **组件不显示**: 检查组件是否正确注册,位置是否在可视区域内 +2. **选择不工作**: 确保事件处理正确设置,检查z-index +3. **性能问题**: 考虑使用虚拟化或分页加载 +4. **保存失败**: 检查localStorage可用性和数据大小限制 + +### 调试技巧 + +```javascript +// 启用调试模式 +window.labCanvasDebug = true + +// 查看当前状态 +console.log('Components:', componentManager.components.value) +console.log('Selected:', componentManager.selectedComponents.value) +console.log('History:', componentManager.history.value) +``` + +更多详细信息请参考 [README.md](./README.md)。 diff --git a/src/components/LabCanvas/README.md b/src/components/LabCanvas/README.md new file mode 100644 index 0000000..f8572c7 --- /dev/null +++ b/src/components/LabCanvas/README.md @@ -0,0 +1,281 @@ +# LabCanvas 组件管理系统 + +这是一个基于 VueUse 和 Konva 的重构版本的组件管理系统,具有以下特性: + +## 功能特性 + +### 1. 组件管理 (`useKonvaComponentManager`) +- # 创建模板 +function createTemplate() { + const selectedComponents = getSelectedComponents() // 获取选中的组件 + const template = templateManager.createTemplateFromComponents( + 'My Template', + 'A custom template', + 'custom', + selectedComponents + ) + console.log('Template created:', template) +}复制 +- ✅ 组件选择(单选、多选、框选) +- ✅ 组件拖拽和位置更新 +- ✅ 撤销/重做功能 +- ✅ 自动保存到本地存储 +- ✅ 键盘快捷键支持 + +### 2. 模板管理 (`useKonvaTemplateManager`) +- ✅ 模板的创建、保存、加载 +- ✅ 从JSON文件导入模板 +- ✅ 模板实例化(支持批量组件添加) +- ✅ 模板分类和搜索 +- ✅ 本地存储模板 + +### 3. Konva渲染 (`useKonvaRenderer`) +- ✅ 自动渲染组件到Konva画布 +- ✅ 选择框和悬停效果 +- ✅ 组件事件处理 +- ✅ 层级管理 +- ✅ 边界框计算 + +### 4. 组件抽屉 (`LabComponentsDrawerNew`) +- ✅ 分类显示组件(基础、高级、虚拟外设、模板) +- ✅ 搜索功能 +- ✅ 组件预览 +- ✅ 模板支持 + +## 使用方法 + +### 基本用法 + +```vue + + + +``` + +### 高级用法 - 自定义组件管理 + +```vue + + + +``` + +### 模板管理 + +```vue + +``` + +## 组件类型定义 + +### KonvaComponent +```typescript +interface KonvaComponent { + id: string // 唯一标识符 + type: string // 组件类型 + name: string // 显示名称 + x: number // X坐标 + y: number // Y坐标 + rotation: number // 旋转角度 + scaleX: number // X缩放 + scaleY: number // Y缩放 + visible: boolean // 是否可见 + draggable: boolean // 是否可拖拽 + isSelected: boolean // 是否选中 + isHovered: boolean // 是否悬停 + zIndex: number // 层级 + groupId?: string // 组ID + locked: boolean // 是否锁定 + props: ComponentProps // 组件属性 + boundingBox?: IRect // 边界框 +} +``` + +### ComponentTemplate +```typescript +interface ComponentTemplate { + type: string // 组件类型 + name: string // 显示名称 + category: 'basic' | 'advanced' | 'virtual' | 'template' // 分类 + defaultProps: ComponentProps // 默认属性 + previewSize?: number // 预览大小 + icon?: string // 图标 +} +``` + +## 键盘快捷键 + +- `Ctrl/Cmd + Z`: 撤销 +- `Ctrl/Cmd + Shift + Z`: 重做 +- `Ctrl/Cmd + A`: 全选 +- `Ctrl/Cmd + D`: 复制选中组件 +- `Delete/Backspace`: 删除选中组件 +- `Escape`: 清除选择 + +## 鼠标操作 + +- **左键单击**: 选择组件 +- **左键拖拽**: 拖动组件 +- **右键拖拽**: 平移画布 +- **滚轮**: 缩放画布 +- **Shift/Ctrl + 左键**: 多选组件 +- **左键拖拽空白区域**: 框选组件 + +## 模板格式 + +模板JSON文件格式: +```json +{ + "id": "template-id", + "name": "模板名称", + "description": "模板描述", + "category": "template", + "components": [ + { + "id": "comp-1", + "type": "MechanicalButton", + "name": "按钮1", + "x": 0, + "y": 0, + "rotation": 0, + "scaleX": 1, + "scaleY": 1, + "visible": true, + "draggable": true, + "isSelected": false, + "isHovered": false, + "zIndex": 0, + "locked": false, + "props": { + "color": "blue", + "size": "medium" + } + } + ], + "groups": [] +} +``` + +## 扩展开发 + +### 添加自定义组件类型 + +1. 创建组件Vue文件 +2. 注册组件模板 +3. 添加到组件抽屉中 + +```typescript +// 注册新组件类型 +componentManager.registerTemplate({ + type: 'MyCustomComponent', + name: '我的自定义组件', + category: 'custom', + defaultProps: { + customProp: 'default value' + } +}) +``` + +### 自定义渲染 + +```typescript +// 自定义组件渲染逻辑 +renderer.registerComponentModule('MyCustomComponent', { + render: (component) => { + // 自定义渲染逻辑 + } +}) +``` + +这个重构版本提供了更好的类型安全、更清晰的架构分离,以及更强大的扩展性。 diff --git a/src/components/LabCanvas/composables/useKonvaComponentManager.ts b/src/components/LabCanvas/composables/useKonvaComponentManager.ts new file mode 100644 index 0000000..2b7c6d9 --- /dev/null +++ b/src/components/LabCanvas/composables/useKonvaComponentManager.ts @@ -0,0 +1,574 @@ +import { ref, reactive, computed, watch, nextTick } from 'vue' +import { useLocalStorage, useDebounceFn, useEventListener } from '@vueuse/core' +import type { + KonvaComponent, + ComponentTemplate, + ComponentProps, + SelectionState, + HistoryRecord, + ComponentEvents, + CanvasConfig, + ProjectData +} from '../types/KonvaComponent' +import type Konva from 'konva' +import type { IRect } from 'konva/lib/types' + +export interface UseKonvaComponentManagerOptions { + // 画布配置 + canvasConfig?: Partial + // 事件回调 + events?: ComponentEvents + // 是否启用历史记录 + enableHistory?: boolean + // 历史记录最大条数 + maxHistorySize?: number + // 是否自动保存到本地存储 + autoSave?: boolean + // 自动保存的键名 + saveKey?: string +} + +export function useKonvaComponentManager(options: UseKonvaComponentManagerOptions = {}) { + // 默认配置 + const defaultCanvas: CanvasConfig = { + width: window.innerWidth, + height: window.innerHeight, + scale: 1, + offsetX: 0, + offsetY: 0, + gridSize: 20, + showGrid: true, + snapToGrid: false + } + + // 组件存储 + const components = ref>(new Map()) + const componentTemplates = ref>(new Map()) + + // 画布配置 + const canvasConfig = reactive({ + ...defaultCanvas, + ...options.canvasConfig + }) + + // 选择状态 + const selectionState = reactive({ + selectedIds: [], + hoveredId: null, + isDragging: false, + dragTargetId: null, + isSelecting: false, + selectionBox: { + visible: false, + x1: 0, + y1: 0, + x2: 0, + y2: 0 + } + }) + + // 历史记录 + const history = ref([]) + const historyIndex = ref(-1) + const maxHistorySize = options.maxHistorySize || 50 + + // 自动保存 + const saveKey = options.saveKey || 'konva-component-manager' + const savedData = useLocalStorage(saveKey, null) + + // 计算属性 + const selectedComponents = computed(() => { + return selectionState.selectedIds + .map(id => components.value.get(id)) + .filter(Boolean) as KonvaComponent[] + }) + + const visibleComponents = computed(() => { + return Array.from(components.value.values()).filter(c => c.visible) + }) + + const canUndo = computed(() => historyIndex.value > 0) + const canRedo = computed(() => historyIndex.value < history.value.length - 1) + + // 生成唯一ID + function generateId(): string { + return `component-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + } + + // 添加历史记录 + function addHistoryRecord(record: Omit) { + if (!options.enableHistory) return + + // 移除当前索引之后的记录 + if (historyIndex.value < history.value.length - 1) { + history.value.splice(historyIndex.value + 1) + } + + // 添加新记录 + const newRecord: HistoryRecord = { + ...record, + id: generateId(), + timestamp: Date.now() + } + + history.value.push(newRecord) + historyIndex.value = history.value.length - 1 + + // 限制历史记录数量 + if (history.value.length > maxHistorySize) { + history.value.shift() + historyIndex.value-- + } + } + + // 注册组件模板 + function registerTemplate(template: ComponentTemplate) { + componentTemplates.value.set(template.type, template) + } + + // 批量注册组件模板 + function registerTemplates(templates: ComponentTemplate[]) { + templates.forEach(registerTemplate) + } + + // 创建组件实例 + function createComponent( + template: ComponentTemplate, + position: { x: number; y: number }, + customProps?: Partial + ): KonvaComponent { + const component: KonvaComponent = { + id: generateId(), + type: template.type, + name: template.name, + x: position.x, + y: position.y, + rotation: 0, + scaleX: 1, + scaleY: 1, + visible: true, + draggable: true, + isSelected: false, + isHovered: false, + zIndex: components.value.size, + locked: false, + props: { + ...template.defaultProps, + ...customProps + } + } + + return component + } + + // 添加组件 + function addComponent( + type: string, + position: { x: number; y: number }, + customProps?: Partial + ): KonvaComponent | null { + const template = componentTemplates.value.get(type) + if (!template) { + console.error(`Component template not found: ${type}`) + return null + } + + const component = createComponent(template, position, customProps) + components.value.set(component.id, component) + + // 添加历史记录 + addHistoryRecord({ + type: 'add', + components: [component] + }) + + // 触发事件 + options.events?.onAdd?.(component) + + return component + } + + // 删除组件 + function deleteComponent(componentId: string): boolean { + const component = components.value.get(componentId) + if (!component) return false + + // 添加历史记录 + addHistoryRecord({ + type: 'delete', + components: [component] + }) + + components.value.delete(componentId) + + // 从选择状态中移除 + const index = selectionState.selectedIds.indexOf(componentId) + if (index > -1) { + selectionState.selectedIds.splice(index, 1) + } + + // 触发事件 + options.events?.onDelete?.(componentId) + + return true + } + + // 批量删除组件 + function deleteComponents(componentIds: string[]): boolean { + const deletedComponents = componentIds + .map(id => components.value.get(id)) + .filter(Boolean) as KonvaComponent[] + + if (deletedComponents.length === 0) return false + + // 添加历史记录 + addHistoryRecord({ + type: 'delete', + components: deletedComponents + }) + + // 删除组件 + componentIds.forEach(id => { + components.value.delete(id) + const index = selectionState.selectedIds.indexOf(id) + if (index > -1) { + selectionState.selectedIds.splice(index, 1) + } + }) + + // 触发事件 + componentIds.forEach(id => options.events?.onDelete?.(id)) + + return true + } + + // 更新组件位置 + function updateComponentPosition(componentId: string, x: number, y: number) { + const component = components.value.get(componentId) + if (!component || component.locked) return + + const oldState = { x: component.x, y: component.y } + component.x = x + component.y = y + + // 触发事件 + options.events?.onMove?.(componentId, x, y) + } + + // 更新组件属性 + function updateComponentProps(componentId: string, props: Partial) { + const component = components.value.get(componentId) + if (!component) return + + const oldProps = { ...component.props } + Object.assign(component.props, props) + + // 触发事件 + options.events?.onModify?.(componentId, component.props) + } + + // 选择组件 + function selectComponent(componentId: string, multi = false) { + if (!multi) { + // 清除之前的选择 + selectionState.selectedIds.forEach(id => { + const comp = components.value.get(id) + if (comp) comp.isSelected = false + }) + selectionState.selectedIds = [] + } + + const component = components.value.get(componentId) + if (component) { + if (!selectionState.selectedIds.includes(componentId)) { + selectionState.selectedIds.push(componentId) + component.isSelected = true + } + } + + options.events?.onSelect?.(selectionState.selectedIds) + } + + // 取消选择组件 + function deselectComponent(componentId: string) { + const index = selectionState.selectedIds.indexOf(componentId) + if (index > -1) { + selectionState.selectedIds.splice(index, 1) + const component = components.value.get(componentId) + if (component) component.isSelected = false + } + + options.events?.onSelect?.(selectionState.selectedIds) + } + + // 清除所有选择 + function clearSelection() { + selectionState.selectedIds.forEach(id => { + const comp = components.value.get(id) + if (comp) comp.isSelected = false + }) + selectionState.selectedIds = [] + options.events?.onSelect?.([]) + } + + // 框选组件 + function selectComponentsInArea(area: IRect) { + const selectedIds: string[] = [] + + components.value.forEach(component => { + if (!component.visible) return + + // 计算组件边界框 + const bbox = component.boundingBox || { + x: component.x, + y: component.y, + width: 100, // 默认宽度 + height: 100 // 默认高度 + } + + // 检查是否与选择区域相交 + if ( + bbox.x < area.x + area.width && + bbox.x + bbox.width > area.x && + bbox.y < area.y + area.height && + bbox.y + bbox.height > area.y + ) { + selectedIds.push(component.id) + component.isSelected = true + } + }) + + selectionState.selectedIds = selectedIds + options.events?.onSelect?.(selectedIds) + } + + // 设置组件悬停状态 + function setComponentHover(componentId: string | null) { + // 清除之前的悬停状态 + if (selectionState.hoveredId) { + const prevHovered = components.value.get(selectionState.hoveredId) + if (prevHovered) prevHovered.isHovered = false + } + + selectionState.hoveredId = componentId + + // 设置新的悬停状态 + if (componentId) { + const component = components.value.get(componentId) + if (component) component.isHovered = true + } + } + + // 复制组件 + function duplicateComponent(componentId: string): KonvaComponent | null { + const original = components.value.get(componentId) + if (!original) return null + + const template = componentTemplates.value.get(original.type) + if (!template) return null + + const duplicate = createComponent(template, { + x: original.x + 20, + y: original.y + 20 + }, original.props) + + components.value.set(duplicate.id, duplicate) + + // 添加历史记录 + addHistoryRecord({ + type: 'add', + components: [duplicate] + }) + + return duplicate + } + + // 撤销 + function undo() { + if (!canUndo.value) return + + const record = history.value[historyIndex.value - 1] + if (!record) return + + // 根据操作类型执行撤销 + switch (record.type) { + case 'add': + // 撤销添加 = 删除 + record.components?.forEach(comp => { + components.value.delete(comp.id) + }) + break + case 'delete': + // 撤销删除 = 添加 + record.components?.forEach(comp => { + components.value.set(comp.id, comp) + }) + break + // TODO: 实现其他操作类型的撤销 + } + + historyIndex.value-- + } + + // 重做 + function redo() { + if (!canRedo.value) return + + const record = history.value[historyIndex.value + 1] + if (!record) return + + // 根据操作类型执行重做 + switch (record.type) { + case 'add': + // 重做添加 + record.components?.forEach(comp => { + components.value.set(comp.id, comp) + }) + break + case 'delete': + // 重做删除 + record.components?.forEach(comp => { + components.value.delete(comp.id) + }) + break + // TODO: 实现其他操作类型的重做 + } + + historyIndex.value++ + } + + // 获取项目数据 + function getProjectData(): ProjectData { + return { + version: '1.0.0', + name: 'Untitled Project', + canvas: canvasConfig, + components: Array.from(components.value.values()), + groups: [], // TODO: 实现组功能 + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + } + + // 加载项目数据 + function loadProjectData(data: ProjectData) { + // 清除现有数据 + components.value.clear() + clearSelection() + + // 加载画布配置 + Object.assign(canvasConfig, data.canvas) + + // 加载组件 + data.components.forEach(comp => { + components.value.set(comp.id, comp) + }) + + // 清除历史记录 + history.value = [] + historyIndex.value = -1 + } + + // 自动保存 + const debouncedSave = useDebounceFn(() => { + if (options.autoSave) { + savedData.value = getProjectData() + } + }, 1000) + + // 监听组件变化并自动保存 + watch( + () => components.value.size, + () => { + debouncedSave() + }, + { deep: true } + ) + + // 键盘事件处理 + useEventListener('keydown', (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key) { + case 'z': + e.preventDefault() + if (e.shiftKey) { + redo() + } else { + undo() + } + break + case 'a': + e.preventDefault() + // 全选 + const allIds = Array.from(components.value.keys()) + selectionState.selectedIds = allIds + allIds.forEach(id => { + const comp = components.value.get(id) + if (comp) comp.isSelected = true + }) + options.events?.onSelect?.(allIds) + break + case 'd': + e.preventDefault() + // 复制选中的组件 + selectedComponents.value.forEach(comp => { + duplicateComponent(comp.id) + }) + break + } + } else if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + // 删除选中的组件 + if (selectionState.selectedIds.length > 0) { + deleteComponents(selectionState.selectedIds) + } + } else if (e.key === 'Escape') { + e.preventDefault() + clearSelection() + } + }) + + // 初始化时加载保存的数据 + if (options.autoSave && savedData.value) { + loadProjectData(savedData.value) + } + + return { + // 状态 + components: computed(() => components.value), + selectedComponents, + visibleComponents, + selectionState, + canvasConfig, + canUndo, + canRedo, + + // 组件管理 + registerTemplate, + registerTemplates, + addComponent, + deleteComponent, + deleteComponents, + updateComponentPosition, + updateComponentProps, + duplicateComponent, + + // 选择管理 + selectComponent, + deselectComponent, + clearSelection, + selectComponentsInArea, + setComponentHover, + + // 历史管理 + undo, + redo, + + // 项目管理 + getProjectData, + loadProjectData, + + // 工具方法 + generateId + } +} diff --git a/src/components/LabCanvas/composables/useKonvaRenderer.ts b/src/components/LabCanvas/composables/useKonvaRenderer.ts new file mode 100644 index 0000000..95d2258 --- /dev/null +++ b/src/components/LabCanvas/composables/useKonvaRenderer.ts @@ -0,0 +1,406 @@ +import { ref, computed, onMounted, onUnmounted, watch, type Ref } from 'vue' +import Konva from 'konva' +import type { Stage } from 'konva/lib/Stage' +import type { Layer } from 'konva/lib/Layer' +import type { Group } from 'konva/lib/Group' +import type { Node } from 'konva/lib/Node' +import type { KonvaComponent } from '../types/KonvaComponent' + +export interface UseKonvaRendererOptions { + // 是否启用动画 + enableAnimations?: boolean + // 选择框样式 + selectionStyle?: { + stroke?: string + strokeWidth?: number + dash?: number[] + } + // 悬停样式 + hoverStyle?: { + stroke?: string + strokeWidth?: number + opacity?: number + } +} + +export function useKonvaRenderer( + stageRef: Ref, + layerRef: Ref, + options: UseKonvaRendererOptions = {} +) { + // 默认样式配置 + const defaultSelectionStyle = { + stroke: 'rgb(125,193,239)', + strokeWidth: 2, + dash: [5, 5] + } + + const defaultHoverStyle = { + stroke: 'rgb(125,193,239)', + strokeWidth: 1, + opacity: 0.8 + } + + const selectionStyle = { ...defaultSelectionStyle, ...options.selectionStyle } + const hoverStyle = { ...defaultHoverStyle, ...options.hoverStyle } + + // Konva节点缓存 + const nodeCache = ref>(new Map()) + const componentModules = ref>(new Map()) + + // 获取Konva舞台 + const getStage = (): Stage | null => { + return stageRef.value?.getNode?.() || null + } + + // 获取Konva图层 + const getLayer = (): Layer | null => { + return layerRef.value?.getNode?.() || null + } + + // 注册组件模块 + function registerComponentModule(type: string, module: any) { + componentModules.value.set(type, module) + } + + // 批量注册组件模块 + function registerComponentModules(modules: Map) { + modules.forEach((module, type) => { + componentModules.value.set(type, module) + }) + } + + // 动态加载组件模块 + async function loadComponentModule(type: string): Promise { + if (componentModules.value.has(type)) { + return componentModules.value.get(type) + } + + try { + // 尝试动态导入组件 + const module = await import(`../../equipments/${type}.vue`) + componentModules.value.set(type, module) + return module + } catch (error) { + console.error(`Failed to load component module: ${type}`, error) + return null + } + } + + // 创建组件对应的Konva节点 + function createKonvaNode(component: KonvaComponent): Konva.Group | null { + const module = componentModules.value.get(component.type) + if (!module) { + console.warn(`Component module not found: ${component.type}`) + return null + } + + // 创建组节点 + const group = new Konva.Group({ + id: `group-${component.id}`, + x: component.x, + y: component.y, + rotation: component.rotation, + scaleX: component.scaleX, + scaleY: component.scaleY, + visible: component.visible, + draggable: component.draggable && !component.locked + }) + + // 创建基础矩形(作为组件的占位符) + const rect = new Konva.Rect({ + width: component.boundingBox?.width || 100, + height: component.boundingBox?.height || 100, + fill: 'transparent', + stroke: 'transparent', + strokeWidth: 0 + }) + + group.add(rect) + + // 添加选择框 + const selectionRect = new Konva.Rect({ + width: component.boundingBox?.width || 100, + height: component.boundingBox?.height || 100, + fill: 'transparent', + stroke: selectionStyle.stroke, + strokeWidth: selectionStyle.strokeWidth, + dash: selectionStyle.dash, + visible: component.isSelected + }) + + group.add(selectionRect) + + // 添加悬停框 + const hoverRect = new Konva.Rect({ + width: component.boundingBox?.width || 100, + height: component.boundingBox?.height || 100, + fill: 'transparent', + stroke: hoverStyle.stroke, + strokeWidth: hoverStyle.strokeWidth, + opacity: hoverStyle.opacity, + visible: component.isHovered && !component.isSelected + }) + + group.add(hoverRect) + + // 缓存节点 + nodeCache.value.set(component.id, group) + + return group + } + + // 更新Konva节点 + function updateKonvaNode(component: KonvaComponent) { + const group = nodeCache.value.get(component.id) + if (!group) return + + // 更新基础属性 + group.setAttrs({ + x: component.x, + y: component.y, + rotation: component.rotation, + scaleX: component.scaleX, + scaleY: component.scaleY, + visible: component.visible, + draggable: component.draggable && !component.locked + }) + + // 更新选择框和悬停框 + const children = group.getChildren() + const selectionRect = children[1] as Konva.Rect + const hoverRect = children[2] as Konva.Rect + + if (selectionRect) { + selectionRect.visible(component.isSelected) + } + + if (hoverRect) { + hoverRect.visible(component.isHovered && !component.isSelected) + } + } + + // 删除Konva节点 + function removeKonvaNode(componentId: string) { + const group = nodeCache.value.get(componentId) + if (group) { + group.destroy() + nodeCache.value.delete(componentId) + } + } + + // 渲染组件到Konva舞台 + function renderComponent(component: KonvaComponent) { + const layer = getLayer() + if (!layer) return + + // 如果节点已存在,更新它 + if (nodeCache.value.has(component.id)) { + updateKonvaNode(component) + return + } + + // 创建新节点 + const group = createKonvaNode(component) + if (group) { + layer.add(group) + } + } + + // 批量渲染组件 + function renderComponents(components: KonvaComponent[]) { + const layer = getLayer() + if (!layer) return + + components.forEach(component => { + renderComponent(component) + }) + + layer.batchDraw() + } + + // 清除所有渲染的组件 + function clearComponents() { + const layer = getLayer() + if (!layer) return + + nodeCache.value.forEach(group => { + group.destroy() + }) + nodeCache.value.clear() + layer.removeChildren() + layer.batchDraw() + } + + // 获取组件的边界框 + function getComponentBounds(componentId: string): { x: number; y: number; width: number; height: number } | null { + const group = nodeCache.value.get(componentId) + if (!group) return null + + const bounds = group.getClientRect() + return bounds + } + + // 查找指定位置的组件 + function getComponentAtPosition(x: number, y: number): string | null { + const stage = getStage() + if (!stage) return null + + const shape = stage.getIntersection({ x, y }) + if (!shape) return null + + // 找到对应的组 + let current: Node | null = shape + while (current && current.nodeType !== 'Group') { + current = current.getParent() + } + + if (current && current.id().startsWith('group-')) { + return current.id().replace('group-', '') + } + + return null + } + + // 获取选择区域内的组件 + function getComponentsInArea(area: { x: number; y: number; width: number; height: number }): string[] { + const componentIds: string[] = [] + + nodeCache.value.forEach((group, componentId) => { + const bounds = group.getClientRect() + + // 检查边界框是否与选择区域相交 + if ( + bounds.x < area.x + area.width && + bounds.x + bounds.width > area.x && + bounds.y < area.y + area.height && + bounds.y + bounds.height > area.y + ) { + componentIds.push(componentId) + } + }) + + return componentIds + } + + // 设置组件层级 + function setComponentZIndex(componentId: string, zIndex: number) { + const group = nodeCache.value.get(componentId) + if (!group) return + + group.zIndex(zIndex) + group.getLayer()?.batchDraw() + } + + // 将组件移动到顶层 + function moveComponentToTop(componentId: string) { + const group = nodeCache.value.get(componentId) + if (!group) return + + group.moveToTop() + group.getLayer()?.batchDraw() + } + + // 将组件移动到底层 + function moveComponentToBottom(componentId: string) { + const group = nodeCache.value.get(componentId) + if (!group) return + + group.moveToBottom() + group.getLayer()?.batchDraw() + } + + // 添加组件事件监听器 + function addComponentEventListeners( + component: KonvaComponent, + events: { + onSelect?: (componentId: string) => void + onDeselect?: (componentId: string) => void + onHover?: (componentId: string) => void + onUnhover?: (componentId: string) => void + onDragStart?: (componentId: string) => void + onDragEnd?: (componentId: string, x: number, y: number) => void + onDoubleClick?: (componentId: string) => void + } + ) { + const group = nodeCache.value.get(component.id) + if (!group) return + + // 点击事件 + group.on('click', () => { + events.onSelect?.(component.id) + }) + + // 双击事件 + group.on('dblclick', () => { + events.onDoubleClick?.(component.id) + }) + + // 鼠标进入事件 + group.on('mouseenter', () => { + events.onHover?.(component.id) + document.body.style.cursor = 'pointer' + }) + + // 鼠标离开事件 + group.on('mouseleave', () => { + events.onUnhover?.(component.id) + document.body.style.cursor = 'default' + }) + + // 拖拽开始事件 + group.on('dragstart', () => { + events.onDragStart?.(component.id) + }) + + // 拖拽结束事件 + group.on('dragend', () => { + const pos = group.position() + events.onDragEnd?.(component.id, pos.x, pos.y) + }) + } + + // 刷新图层 + function redraw() { + const layer = getLayer() + layer?.batchDraw() + } + + return { + // 状态 + nodeCache: computed(() => nodeCache.value), + + // 模块管理 + registerComponentModule, + registerComponentModules, + loadComponentModule, + + // 渲染管理 + renderComponent, + renderComponents, + clearComponents, + redraw, + + // 节点操作 + createKonvaNode, + updateKonvaNode, + removeKonvaNode, + + // 查询方法 + getComponentBounds, + getComponentAtPosition, + getComponentsInArea, + + // 层级管理 + setComponentZIndex, + moveComponentToTop, + moveComponentToBottom, + + // 事件管理 + addComponentEventListeners, + + // 工具方法 + getStage, + getLayer + } +} diff --git a/src/components/LabCanvas/composables/useKonvaTemplateManager.ts b/src/components/LabCanvas/composables/useKonvaTemplateManager.ts new file mode 100644 index 0000000..589f598 --- /dev/null +++ b/src/components/LabCanvas/composables/useKonvaTemplateManager.ts @@ -0,0 +1,323 @@ +import { ref, computed } from 'vue' +import { useLocalStorage } from '@vueuse/core' +import type { + ComponentGroup, + KonvaComponent, + ComponentTemplate, + ProjectData +} from '../types/KonvaComponent' + +export interface TemplateData { + id: string + name: string + description?: string + thumbnailUrl?: string + category: string + components: KonvaComponent[] + groups?: ComponentGroup[] +} + +export interface UseKonvaTemplateManagerOptions { + // 模板存储键名 + storageKey?: string + // 是否启用本地存储 + enableStorage?: boolean +} + +export function useKonvaTemplateManager(options: UseKonvaTemplateManagerOptions = {}) { + const storageKey = options.storageKey || 'konva-templates' + + // 模板存储 + const templates = ref>(new Map()) + + // 本地存储 + const savedTemplates = useLocalStorage(storageKey, []) + + // 计算属性 + const templateList = computed(() => Array.from(templates.value.values())) + + const templatesByCategory = computed(() => { + const result: Record = {} + templateList.value.forEach(template => { + if (!result[template.category]) { + result[template.category] = [] + } + result[template.category].push(template) + }) + return result + }) + + // 生成唯一ID + function generateTemplateId(): string { + return `template-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + } + + // 注册模板 + function registerTemplate(template: TemplateData) { + templates.value.set(template.id, template) + saveToStorage() + } + + // 批量注册模板 + function registerTemplates(templateList: TemplateData[]) { + templateList.forEach(template => { + templates.value.set(template.id, template) + }) + saveToStorage() + } + + // 获取模板 + function getTemplate(templateId: string): TemplateData | undefined { + return templates.value.get(templateId) + } + + // 删除模板 + function deleteTemplate(templateId: string): boolean { + const success = templates.value.delete(templateId) + if (success) { + saveToStorage() + } + return success + } + + // 根据类别过滤模板 + function getTemplatesByCategory(category: string): TemplateData[] { + return templateList.value.filter(template => template.category === category) + } + + // 搜索模板 + function searchTemplates(query: string): TemplateData[] { + const lowercaseQuery = query.toLowerCase() + return templateList.value.filter(template => + template.name.toLowerCase().includes(lowercaseQuery) || + (template.description && template.description.toLowerCase().includes(lowercaseQuery)) || + template.category.toLowerCase().includes(lowercaseQuery) + ) + } + + // 从组件创建模板 + function createTemplateFromComponents( + name: string, + description: string, + category: string, + components: KonvaComponent[], + groups?: ComponentGroup[] + ): TemplateData { + // 计算所有组件的边界框 + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + components.forEach(comp => { + minX = Math.min(minX, comp.x) + minY = Math.min(minY, comp.y) + maxX = Math.max(maxX, comp.x + (comp.boundingBox?.width || 100)) + maxY = Math.max(maxY, comp.y + (comp.boundingBox?.height || 100)) + }) + + // 标准化组件位置(相对于左上角) + const normalizedComponents = components.map(comp => ({ + ...comp, + id: generateTemplateId(), // 给模板中的组件新的ID + x: comp.x - minX, + y: comp.y - minY, + isSelected: false, + isHovered: false + })) + + const template: TemplateData = { + id: generateTemplateId(), + name, + description, + category, + components: normalizedComponents, + groups: groups?.map(group => ({ + ...group, + id: generateTemplateId(), + x: group.x - minX, + y: group.y - minY + })) + } + + registerTemplate(template) + return template + } + + // 实例化模板组件 + function instantiateTemplate( + templateId: string, + position: { x: number; y: number }, + generateNewIds = true + ): { components: KonvaComponent[]; groups?: ComponentGroup[] } | null { + const template = getTemplate(templateId) + if (!template) return null + + // 生成新的ID映射表 + const idMapping = new Map() + + if (generateNewIds) { + template.components.forEach(comp => { + idMapping.set(comp.id, generateTemplateId()) + }) + template.groups?.forEach(group => { + idMapping.set(group.id, generateTemplateId()) + }) + } + + // 克隆组件并更新位置 + const components: KonvaComponent[] = template.components.map(comp => ({ + ...comp, + id: generateNewIds ? idMapping.get(comp.id)! : comp.id, + x: comp.x + position.x, + y: comp.y + position.y, + isSelected: false, + isHovered: false, + groupId: comp.groupId && generateNewIds ? idMapping.get(comp.groupId) : comp.groupId + })) + + // 克隆组 + const groups: ComponentGroup[] | undefined = template.groups?.map(group => ({ + ...group, + id: generateNewIds ? idMapping.get(group.id)! : group.id, + x: group.x + position.x, + y: group.y + position.y, + components: group.components.map(comp => ({ + ...comp, + id: generateNewIds ? idMapping.get(comp.id)! : comp.id, + x: comp.x + position.x, + y: comp.y + position.y, + groupId: generateNewIds ? idMapping.get(group.id)! : group.id + })) + })) + + return { components, groups } + } + + // 导出模板为JSON + function exportTemplate(templateId: string): string | null { + const template = getTemplate(templateId) + if (!template) return null + + return JSON.stringify(template, null, 2) + } + + // 从JSON导入模板 + function importTemplate(jsonData: string): TemplateData | null { + try { + const data = JSON.parse(jsonData) as TemplateData + + // 验证数据结构 + if (!data.id || !data.name || !data.components || !Array.isArray(data.components)) { + throw new Error('Invalid template format') + } + + // 生成新的ID以避免冲突 + const newTemplate: TemplateData = { + ...data, + id: generateTemplateId() + } + + registerTemplate(newTemplate) + return newTemplate + } catch (error) { + console.error('Failed to import template:', error) + return null + } + } + + // 保存到本地存储 + function saveToStorage() { + if (options.enableStorage !== false) { + savedTemplates.value = Array.from(templates.value.values()) + } + } + + // 从本地存储加载 + function loadFromStorage() { + if (options.enableStorage !== false) { + savedTemplates.value.forEach(template => { + templates.value.set(template.id, template) + }) + } + } + + // 从远程URL加载模板 + async function loadTemplateFromUrl(url: string): Promise { + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to load template: ${response.statusText}`) + } + + const data = await response.json() + + // 验证和转换数据格式 + const template: TemplateData = { + id: data.id || generateTemplateId(), + name: data.name || 'Unnamed Template', + description: data.description, + category: data.category || 'imported', + components: data.parts?.map((part: any) => ({ + id: part.id || generateTemplateId(), + type: part.type, + name: part.type, + x: part.x || 0, + y: part.y || 0, + rotation: part.rotate || 0, + scaleX: 1, + scaleY: 1, + visible: part.isOn !== false, + draggable: !part.positionlock, + isSelected: false, + isHovered: false, + zIndex: part.index || 0, + groupId: part.group || undefined, + locked: part.positionlock || false, + props: part.attrs || {} + })) || [], + thumbnailUrl: data.thumbnailUrl + } + + registerTemplate(template) + return template + } catch (error) { + console.error('Failed to load template from URL:', error) + return null + } + } + + // 初始化时从本地存储加载 + loadFromStorage() + + return { + // 状态 + templates: computed(() => templates.value), + templateList, + templatesByCategory, + + // 模板管理 + registerTemplate, + registerTemplates, + getTemplate, + deleteTemplate, + getTemplatesByCategory, + searchTemplates, + + // 模板创建和实例化 + createTemplateFromComponents, + instantiateTemplate, + + // 导入导出 + exportTemplate, + importTemplate, + loadTemplateFromUrl, + + // 存储管理 + saveToStorage, + loadFromStorage, + + // 工具方法 + generateTemplateId + } +} diff --git a/src/components/LabCanvas/index.ts b/src/components/LabCanvas/index.ts new file mode 100644 index 0000000..90dbf08 --- /dev/null +++ b/src/components/LabCanvas/index.ts @@ -0,0 +1,316 @@ +// LabCanvas组件管理系统 - 统一导出 +// 基于VueUse重构的Konva组件管理系统 + +// 类型定义 +export type { + KonvaComponent, + KonvaComponentBase, + ComponentTemplate, + ComponentProps, + ComponentGroup, + SelectionState, + HistoryRecord, + ComponentEvents, + CanvasConfig, + ProjectData +} from './types/KonvaComponent' + +// 核心管理器 +export { useKonvaComponentManager } from './composables/useKonvaComponentManager' +export type { UseKonvaComponentManagerOptions } from './composables/useKonvaComponentManager' + +// 模板管理器 +export { useKonvaTemplateManager } from './composables/useKonvaTemplateManager' +export type { + TemplateData, + UseKonvaTemplateManagerOptions +} from './composables/useKonvaTemplateManager' + +// 渲染器 +export { useKonvaRenderer } from './composables/useKonvaRenderer' +export type { UseKonvaRendererOptions } from './composables/useKonvaRenderer' + +// 组件 +export { default as LabCanvasNew } from './LabCanvasNew.vue' +export { default as LabComponentsDrawerNew } from './LabComponentsDrawerNew.vue' +export { default as ExampleUsage } from '../../views/TestView.vue' + +// 导入需要的类型 +import type { ProjectData, ComponentTemplate, CanvasConfig } from './types/KonvaComponent' + +// 工具函数 +export const KonvaLabUtils = { + /** + * 生成唯一ID + */ + generateId: (): string => { + return `component-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + }, + + /** + * 计算矩形的边界框(考虑旋转和缩放) + */ + calculateBoundingBox: ( + width: number, + height: number, + rotation: number = 0, + scaleX: number = 1, + scaleY: number = 1, + padding: number = 0 + ) => { + const scaledWidth = width * scaleX + const scaledHeight = height * scaleY + + const radians = (rotation * Math.PI) / 180 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + + const corners = [ + { x: 0, y: 0 }, + { x: scaledWidth, y: 0 }, + { x: scaledWidth, y: scaledHeight }, + { x: 0, y: scaledHeight }, + ].map((point) => ({ + x: point.x * cos - point.y * sin, + y: point.x * sin + point.y * cos, + })) + + const minX = Math.min(...corners.map((p) => p.x)) + const maxX = Math.max(...corners.map((p) => p.x)) + const minY = Math.min(...corners.map((p) => p.y)) + const maxY = Math.max(...corners.map((p) => p.y)) + + return { + x: minX - padding, + y: minY - padding, + width: maxX - minX + padding * 2, + height: maxY - minY + padding * 2, + } + }, + + /** + * 检查两个矩形是否相交 + */ + rectsIntersect: ( + rect1: { x: number; y: number; width: number; height: number }, + rect2: { x: number; y: number; width: number; height: number } + ): boolean => { + return ( + rect1.x < rect2.x + rect2.width && + rect1.x + rect1.width > rect2.x && + rect1.y < rect2.y + rect2.height && + rect1.y + rect1.height > rect2.y + ) + }, + + /** + * 计算两点之间的距离 + */ + distance: ( + point1: { x: number; y: number }, + point2: { x: number; y: number } + ): number => { + const dx = point2.x - point1.x + const dy = point2.y - point1.y + return Math.sqrt(dx * dx + dy * dy) + }, + + /** + * 格式化项目数据用于导出 + */ + formatProjectForExport: (projectData: ProjectData) => { + return { + ...projectData, + exportedAt: new Date().toISOString(), + components: projectData.components.map((comp: any) => ({ + ...comp, + // 移除运行时状态 + isSelected: false, + isHovered: false, + konvaNodeId: undefined + })) + } + }, + + /** + * 验证项目数据格式 + */ + validateProjectData: (data: any): { isValid: boolean; errors: string[] } => { + const errors: string[] = [] + + if (!data.version) errors.push('缺少版本信息') + if (!data.canvas) errors.push('缺少画布配置') + if (!Array.isArray(data.components)) errors.push('组件数据格式错误') + + // 验证组件 + if (Array.isArray(data.components)) { + data.components.forEach((comp: any, index: number) => { + if (!comp.id) errors.push(`组件 ${index} 缺少ID`) + if (!comp.type) errors.push(`组件 ${index} 缺少类型`) + if (typeof comp.x !== 'number') errors.push(`组件 ${index} X坐标无效`) + if (typeof comp.y !== 'number') errors.push(`组件 ${index} Y坐标无效`) + }) + } + + return { + isValid: errors.length === 0, + errors + } + } +} + +// 预定义的组件模板 +export const PredefinedTemplates: ComponentTemplate[] = [ + { + type: "MechanicalButton", + name: "机械按钮", + category: "basic", + defaultProps: { + size: "medium", + color: "blue", + pressed: false + }, + previewSize: 0.4 + }, + { + type: "Switch", + name: "开关", + category: "basic", + defaultProps: { + state: false, + style: "toggle" + }, + previewSize: 0.35 + }, + { + type: "Pin", + name: "引脚", + category: "basic", + defaultProps: { + type: "input", + label: "Pin" + }, + previewSize: 0.8 + }, + { + type: "SMT_LED", + name: "贴片LED", + category: "basic", + defaultProps: { + color: "red", + state: false, + brightness: 1 + }, + previewSize: 0.7 + }, + { + type: "SevenSegmentDisplay", + name: "数码管", + category: "advanced", + defaultProps: { + digits: 4, + value: "0000", + color: "red" + }, + previewSize: 0.4 + }, + { + type: "HDMI", + name: "HDMI接口", + category: "advanced", + defaultProps: { + version: "2.0", + connected: false + }, + previewSize: 0.5 + }, + { + type: "DDR", + name: "DDR内存", + category: "advanced", + defaultProps: { + type: "DDR4", + capacity: "8GB", + speed: "3200MHz" + }, + previewSize: 0.5 + }, + { + type: "ETH", + name: "以太网接口", + category: "advanced", + defaultProps: { + speed: "1000Mbps", + connected: false + }, + previewSize: 0.5 + }, + { + type: "SD", + name: "SD卡插槽", + category: "basic", + defaultProps: { + cardInserted: false, + type: "microSD" + }, + previewSize: 0.6 + }, + { + type: "SFP", + name: "SFP光纤模块", + category: "advanced", + defaultProps: { + type: "SFP+", + speed: "10Gbps", + connected: false + }, + previewSize: 0.4 + }, + { + type: "SMA", + name: "SMA连接器", + category: "basic", + defaultProps: { + type: "female", + impedance: "50ohm" + }, + previewSize: 0.7 + }, + { + type: "MotherBoard", + name: "主板", + category: "template", + defaultProps: { + model: "Generic", + size: "ATX" + }, + previewSize: 0.13 + }, + { + type: "DDS", + name: "信号发生器", + category: "virtual", + defaultProps: { + frequency: 1000, + amplitude: 1.0, + waveform: "sine", + enabled: false + }, + previewSize: 0.3 + } +] + +// 默认配置 +export const DefaultCanvasConfig: CanvasConfig = { + width: window.innerWidth, + height: window.innerHeight, + scale: 1, + offsetX: 0, + offsetY: 0, + gridSize: 20, + showGrid: true, + snapToGrid: false +} + +// 版本信息 +export const VERSION = '1.0.0' diff --git a/src/components/LabCanvas/types/KonvaComponent.ts b/src/components/LabCanvas/types/KonvaComponent.ts new file mode 100644 index 0000000..160175b --- /dev/null +++ b/src/components/LabCanvas/types/KonvaComponent.ts @@ -0,0 +1,118 @@ +import type { IRect } from 'konva/lib/types' + +// Konva组件的基础类型定义 +export interface KonvaComponentBase { + id: string + type: string + name: string + x: number + y: number + rotation: number + scaleX: number + scaleY: number + visible: boolean + draggable: boolean + isSelected: boolean + isHovered: boolean + zIndex: number + groupId?: string // 如果属于某个组 + locked: boolean // 是否锁定位置 +} + +// 组件属性配置 +export interface ComponentProps { + [key: string]: any +} + +// 具体的Konva组件实例 +export interface KonvaComponent extends KonvaComponentBase { + props: ComponentProps + konvaNodeId?: string // 对应的Konva节点ID + boundingBox?: IRect // 缓存的边界框 +} + +// 组件模板定义 +export interface ComponentTemplate { + type: string + name: string + description?: string + defaultProps: ComponentProps + category: 'basic' | 'advanced' | 'virtual' | 'template' + previewSize?: number + icon?: string +} + +// 组件组/模板定义 +export interface ComponentGroup { + id: string + name: string + description?: string + components: KonvaComponent[] + x: number + y: number + rotation: number + scaleX: number + scaleY: number + locked: boolean +} + +// 选择状态 +export interface SelectionState { + selectedIds: string[] + hoveredId: string | null + isDragging: boolean + dragTargetId: string | null + isSelecting: boolean + selectionBox: { + visible: boolean + x1: number + y1: number + x2: number + y2: number + } +} + +// 操作历史记录 +export interface HistoryRecord { + id: string + type: 'add' | 'delete' | 'move' | 'modify' | 'group' | 'ungroup' + timestamp: number + components?: KonvaComponent[] + oldState?: any + newState?: any +} + +// 组件事件类型 +export interface ComponentEvents { + onAdd?: (component: KonvaComponent) => void + onDelete?: (componentId: string) => void + onSelect?: (componentIds: string[]) => void + onMove?: (componentId: string, x: number, y: number) => void + onModify?: (componentId: string, props: ComponentProps) => void + onGroup?: (groupId: string, componentIds: string[]) => void + onUngroup?: (groupId: string) => void +} + +// 画布配置 +export interface CanvasConfig { + width: number + height: number + scale: number + offsetX: number + offsetY: number + gridSize: number + showGrid: boolean + snapToGrid: boolean +} + +// 导出/导入格式 +export interface ProjectData { + version: string + name: string + description?: string + canvas: CanvasConfig + components: KonvaComponent[] + groups: ComponentGroup[] + createdAt: string + updatedAt: string +} diff --git a/src/views/TestView.vue b/src/views/TestView.vue index d1335a3..62aef2d 100644 --- a/src/views/TestView.vue +++ b/src/views/TestView.vue @@ -1,99 +1,533 @@ -