refactor: try to rewrite component manager
This commit is contained in:
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -20,7 +20,9 @@ declare module 'vue' {
|
|||||||
ETH: typeof import('./src/components/equipments/ETH.vue')['default']
|
ETH: typeof import('./src/components/equipments/ETH.vue')['default']
|
||||||
HDMI: typeof import('./src/components/equipments/HDMI.vue')['default']
|
HDMI: typeof import('./src/components/equipments/HDMI.vue')['default']
|
||||||
LabCanvas: typeof import('./src/components/LabCanvas/LabCanvas.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']
|
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']
|
LoginCard: typeof import('./src/components/LoginCard.vue')['default']
|
||||||
MarkdownRenderer: typeof import('./src/components/MarkdownRenderer.vue')['default']
|
MarkdownRenderer: typeof import('./src/components/MarkdownRenderer.vue')['default']
|
||||||
MechanicalButton: typeof import('./src/components/equipments/MechanicalButton.vue')['default']
|
MechanicalButton: typeof import('./src/components/equipments/MechanicalButton.vue')['default']
|
||||||
|
|||||||
57
package-lock.json
generated
57
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@svgdotjs/svg.js": "^3.2.4",
|
"@svgdotjs/svg.js": "^3.2.4",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
|
"@vueuse/core": "^13.5.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"konva": "^9.3.20",
|
"konva": "^9.3.20",
|
||||||
@@ -2116,24 +2117,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "12.8.2",
|
"version": "13.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.5.0.tgz",
|
||||||
"integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==",
|
"integrity": "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vueuse/metadata": "12.8.2",
|
"@vueuse/metadata": "13.5.0",
|
||||||
"@vueuse/shared": "12.8.2",
|
"@vueuse/shared": "13.5.0"
|
||||||
"vue": "^3.5.13"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/@vueuse/metadata": {
|
||||||
"version": "12.8.2",
|
"version": "13.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.5.0.tgz",
|
||||||
"integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==",
|
"integrity": "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -4008,6 +4023,30 @@
|
|||||||
"vue": ">= 3.2.0"
|
"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": {
|
"node_modules/rfdc": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@svgdotjs/svg.js": "^3.2.4",
|
"@svgdotjs/svg.js": "^3.2.4",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
|
"@vueuse/core": "^13.5.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"konva": "^9.3.20",
|
"konva": "^9.3.20",
|
||||||
|
|||||||
609
src/components/LabCanvas/LabCanvasNew.vue
Normal file
609
src/components/LabCanvas/LabCanvasNew.vue
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative" @contextmenu.prevent>
|
||||||
|
<v-stage
|
||||||
|
class="h-full w-full"
|
||||||
|
ref="stageRef"
|
||||||
|
:config="stageSize"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
@click="handleStageClick"
|
||||||
|
@contextmenu="handleContextMenu"
|
||||||
|
>
|
||||||
|
<v-layer ref="layerRef">
|
||||||
|
<!-- 渲染所有可见组件 -->
|
||||||
|
<template
|
||||||
|
v-for="component in visibleComponents"
|
||||||
|
:key="component.id"
|
||||||
|
>
|
||||||
|
<v-group
|
||||||
|
:config="{
|
||||||
|
x: component.x,
|
||||||
|
y: component.y,
|
||||||
|
rotation: component.rotation,
|
||||||
|
scaleX: component.scaleX,
|
||||||
|
scaleY: component.scaleY,
|
||||||
|
draggable: component.draggable && !component.locked,
|
||||||
|
visible: component.visible,
|
||||||
|
id: `group-${component.id}`,
|
||||||
|
}"
|
||||||
|
@dragstart="handleComponentDragStart"
|
||||||
|
@dragend="handleComponentDragEnd"
|
||||||
|
@mouseover="handleComponentMouseOver"
|
||||||
|
@mouseout="handleComponentMouseOut"
|
||||||
|
@click="handleComponentClick"
|
||||||
|
@dblclick="handleComponentDoubleClick"
|
||||||
|
>
|
||||||
|
<!-- 组件占位符矩形 -->
|
||||||
|
<v-rect
|
||||||
|
:config="{
|
||||||
|
width: component.boundingBox?.width || 100,
|
||||||
|
height: component.boundingBox?.height || 100,
|
||||||
|
fill: getComponentFill(component),
|
||||||
|
stroke: 'transparent',
|
||||||
|
strokeWidth: 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 选择框 -->
|
||||||
|
<v-rect
|
||||||
|
v-if="component.isSelected"
|
||||||
|
:config="{
|
||||||
|
width: component.boundingBox?.width || 100,
|
||||||
|
height: component.boundingBox?.height || 100,
|
||||||
|
fill: 'transparent',
|
||||||
|
stroke: selectionStyle.stroke,
|
||||||
|
strokeWidth: selectionStyle.strokeWidth,
|
||||||
|
dash: selectionStyle.dash,
|
||||||
|
cornerRadius: 5,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 悬停框 -->
|
||||||
|
<v-rect
|
||||||
|
v-if="component.isHovered && !component.isSelected"
|
||||||
|
:config="{
|
||||||
|
width: component.boundingBox?.width || 100,
|
||||||
|
height: component.boundingBox?.height || 100,
|
||||||
|
fill: 'transparent',
|
||||||
|
stroke: hoverStyle.stroke,
|
||||||
|
strokeWidth: hoverStyle.strokeWidth,
|
||||||
|
opacity: hoverStyle.opacity,
|
||||||
|
cornerRadius: 5,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 组件文本标签 -->
|
||||||
|
<v-text
|
||||||
|
:config="{
|
||||||
|
x: 5,
|
||||||
|
y: 5,
|
||||||
|
text: component.name,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fill: '#333',
|
||||||
|
visible: showLabels,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</v-group>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 变换器用于调整大小和旋转 -->
|
||||||
|
<v-transformer
|
||||||
|
ref="transformerRef"
|
||||||
|
:config="{
|
||||||
|
borderStroke: selectionStyle.stroke,
|
||||||
|
borderStrokeWidth: selectionStyle.strokeWidth,
|
||||||
|
anchorStroke: selectionStyle.stroke,
|
||||||
|
anchorFill: 'white',
|
||||||
|
anchorSize: 8,
|
||||||
|
rotateEnabled: enableRotation,
|
||||||
|
resizeEnabled: enableResize,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 选择矩形 -->
|
||||||
|
<v-rect
|
||||||
|
v-if="selectionState.selectionBox.visible"
|
||||||
|
:config="{
|
||||||
|
x: Math.min(selectionState.selectionBox.x1, selectionState.selectionBox.x2),
|
||||||
|
y: Math.min(selectionState.selectionBox.y1, selectionState.selectionBox.y2),
|
||||||
|
width: Math.abs(selectionState.selectionBox.x2 - selectionState.selectionBox.x1),
|
||||||
|
height: Math.abs(selectionState.selectionBox.y2 - selectionState.selectionBox.y1),
|
||||||
|
fill: 'rgba(0, 105, 255, 0.2)',
|
||||||
|
stroke: 'rgb(0, 105, 255)',
|
||||||
|
strokeWidth: 1,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</v-layer>
|
||||||
|
</v-stage>
|
||||||
|
|
||||||
|
<!-- 组件抽屉 -->
|
||||||
|
<LabComponentsDrawerNew
|
||||||
|
class="absolute top-10 right-20"
|
||||||
|
v-model:open="isDrawerOpen"
|
||||||
|
@add-component="handleAddComponent"
|
||||||
|
@add-template="handleAddTemplate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="absolute top-4 left-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
@click="isDrawerOpen = true"
|
||||||
|
title="添加组件"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
@click="clearAllComponents"
|
||||||
|
title="清空画布"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
@click="undo"
|
||||||
|
:disabled="!canUndo"
|
||||||
|
title="撤销"
|
||||||
|
>
|
||||||
|
<Undo class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
@click="redo"
|
||||||
|
:disabled="!canRedo"
|
||||||
|
title="重做"
|
||||||
|
>
|
||||||
|
<Redo class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
@click="showLabels = !showLabels"
|
||||||
|
title="切换标签显示"
|
||||||
|
>
|
||||||
|
<Type class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态信息 -->
|
||||||
|
<div class="absolute bottom-4 left-4 bg-base-100 p-2 rounded shadow text-sm">
|
||||||
|
<div>组件数量: {{ visibleComponents.length }}</div>
|
||||||
|
<div>选中: {{ selectedComponents.length }}</div>
|
||||||
|
<div>缩放: {{ Math.round(stageScale * 100) }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch, onMounted, useTemplateRef } from 'vue'
|
||||||
|
import { Plus, Trash2, Undo, Redo, Type } from 'lucide-vue-next'
|
||||||
|
import Konva from 'konva'
|
||||||
|
import type { Stage } from 'konva/lib/Stage'
|
||||||
|
import type { VStage, VLayer, VTransformer } from '@/utils/VueKonvaType'
|
||||||
|
|
||||||
|
import LabComponentsDrawerNew from './LabComponentsDrawerNew.vue'
|
||||||
|
import { useKonvaComponentManager } from './composables/useKonvaComponentManager'
|
||||||
|
import { useKonvaTemplateManager } from './composables/useKonvaTemplateManager'
|
||||||
|
import { useKonvaRenderer } from './composables/useKonvaRenderer'
|
||||||
|
import type { ComponentTemplate, KonvaComponent } from './types/KonvaComponent'
|
||||||
|
import type { TemplateData } from './composables/useKonvaTemplateManager'
|
||||||
|
|
||||||
|
// 画布配置
|
||||||
|
const stageSize = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件引用
|
||||||
|
const stageRef = useTemplateRef<VStage>('stageRef')
|
||||||
|
const layerRef = useTemplateRef<VLayer>('layerRef')
|
||||||
|
const transformerRef = useTemplateRef<VTransformer>('transformerRef')
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const isDrawerOpen = ref(false)
|
||||||
|
const showLabels = ref(true)
|
||||||
|
const enableRotation = ref(true)
|
||||||
|
const enableResize = ref(true)
|
||||||
|
const stageScale = ref(1)
|
||||||
|
|
||||||
|
// 拖拽状态
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragItemId = ref<string | null>(null)
|
||||||
|
const isRightDragging = ref(false)
|
||||||
|
const lastPointerPosition = ref<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
// 样式配置
|
||||||
|
const selectionStyle = {
|
||||||
|
stroke: 'rgb(125,193,239)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
dash: [5, 5]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverStyle = {
|
||||||
|
stroke: 'rgb(125,193,239)',
|
||||||
|
strokeWidth: 1,
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化管理器
|
||||||
|
const componentManager = useKonvaComponentManager({
|
||||||
|
enableHistory: true,
|
||||||
|
autoSave: true,
|
||||||
|
saveKey: 'lab-canvas-project',
|
||||||
|
events: {
|
||||||
|
onAdd: (component) => {
|
||||||
|
console.log('Component added:', component)
|
||||||
|
updateTransformer()
|
||||||
|
},
|
||||||
|
onDelete: (componentId) => {
|
||||||
|
console.log('Component deleted:', componentId)
|
||||||
|
updateTransformer()
|
||||||
|
},
|
||||||
|
onSelect: (componentIds) => {
|
||||||
|
console.log('Components selected:', componentIds)
|
||||||
|
updateTransformer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const templateManager = useKonvaTemplateManager()
|
||||||
|
const renderer = useKonvaRenderer(stageRef, layerRef)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const {
|
||||||
|
components,
|
||||||
|
selectedComponents,
|
||||||
|
visibleComponents,
|
||||||
|
selectionState,
|
||||||
|
canUndo,
|
||||||
|
canRedo
|
||||||
|
} = componentManager
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function getComponentFill(component: KonvaComponent): string {
|
||||||
|
// 根据组件类型返回不同的填充色
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
'MechanicalButton': '#ff6b6b',
|
||||||
|
'Switch': '#4ecdc4',
|
||||||
|
'Pin': '#45b7d1',
|
||||||
|
'SMT_LED': '#96ceb4',
|
||||||
|
'SevenSegmentDisplay': '#ffeaa7',
|
||||||
|
'HDMI': '#dda0dd',
|
||||||
|
'DDR': '#98d8c8',
|
||||||
|
'ETH': '#a8e6cf',
|
||||||
|
'SD': '#ffb3ba',
|
||||||
|
'SFP': '#bae1ff',
|
||||||
|
'SMA': '#ffffba',
|
||||||
|
'MotherBoard': '#ffdfba',
|
||||||
|
'DDS': '#c7ceea'
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeColors[component.type] || '#e0e0e0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加组件
|
||||||
|
function handleAddComponent(template: ComponentTemplate, position: { x: number; y: number }) {
|
||||||
|
// 注册组件模板
|
||||||
|
componentManager.registerTemplate(template)
|
||||||
|
|
||||||
|
// 添加组件实例
|
||||||
|
const component = componentManager.addComponent(template.type, position)
|
||||||
|
if (component) {
|
||||||
|
// 渲染组件
|
||||||
|
renderer.renderComponent(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加模板
|
||||||
|
function handleAddTemplate(template: TemplateData, position: { x: number; y: number }) {
|
||||||
|
const instance = templateManager.instantiateTemplate(template.id, position)
|
||||||
|
if (instance) {
|
||||||
|
// 添加所有组件
|
||||||
|
instance.components.forEach(comp => {
|
||||||
|
components.value.set(comp.id, comp)
|
||||||
|
renderer.renderComponent(comp)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: 处理组的创建和管理
|
||||||
|
if (instance.groups) {
|
||||||
|
console.log('Template groups:', instance.groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有组件
|
||||||
|
function clearAllComponents() {
|
||||||
|
if (confirm('确定要清空所有组件吗?')) {
|
||||||
|
renderer.clearComponents()
|
||||||
|
components.value.clear()
|
||||||
|
componentManager.clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤销/重做
|
||||||
|
function undo() {
|
||||||
|
componentManager.undo()
|
||||||
|
renderer.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
componentManager.redo()
|
||||||
|
renderer.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新变换器
|
||||||
|
function updateTransformer() {
|
||||||
|
if (!transformerRef.value) return
|
||||||
|
|
||||||
|
const transformer = transformerRef.value.getNode()
|
||||||
|
const layer = layerRef.value?.getNode()
|
||||||
|
if (!layer) return
|
||||||
|
|
||||||
|
// 获取选中组件的Konva节点
|
||||||
|
const selectedNodes = selectedComponents.value
|
||||||
|
.map(comp => layer.findOne(`#group-${comp.id}`))
|
||||||
|
.filter(Boolean) as Konva.Node[]
|
||||||
|
|
||||||
|
transformer.nodes(selectedNodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件拖拽事件
|
||||||
|
function handleComponentDragStart(e: any) {
|
||||||
|
isDragging.value = true
|
||||||
|
const target = e.target as Konva.Group
|
||||||
|
dragItemId.value = target.id().replace('group-', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComponentDragEnd(e: any) {
|
||||||
|
isDragging.value = false
|
||||||
|
|
||||||
|
if (dragItemId.value) {
|
||||||
|
const target = e.target as Konva.Group
|
||||||
|
const pos = target.position()
|
||||||
|
componentManager.updateComponentPosition(dragItemId.value, pos.x, pos.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
dragItemId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件鼠标事件
|
||||||
|
function handleComponentMouseOver(e: any) {
|
||||||
|
const target = e.target as Konva.Group
|
||||||
|
const componentId = target.id().replace('group-', '')
|
||||||
|
componentManager.setComponentHover(componentId)
|
||||||
|
document.body.style.cursor = 'pointer'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComponentMouseOut() {
|
||||||
|
componentManager.setComponentHover(null)
|
||||||
|
document.body.style.cursor = 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComponentClick(e: any) {
|
||||||
|
e.cancelBubble = true
|
||||||
|
const target = e.target as Konva.Group
|
||||||
|
const componentId = target.id().replace('group-', '')
|
||||||
|
|
||||||
|
const isMultiSelect = e.evt.shiftKey || e.evt.ctrlKey
|
||||||
|
componentManager.selectComponent(componentId, isMultiSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComponentDoubleClick(e: any) {
|
||||||
|
const target = e.target as Konva.Group
|
||||||
|
const componentId = target.id().replace('group-', '')
|
||||||
|
console.log('Double clicked component:', componentId)
|
||||||
|
// TODO: 打开组件属性编辑器
|
||||||
|
}
|
||||||
|
|
||||||
|
// 舞台事件处理
|
||||||
|
function handleStageClick(e: any) {
|
||||||
|
const target = e.target as Konva.Node
|
||||||
|
const stage = target.getStage()
|
||||||
|
|
||||||
|
if (selectionState.selectionBox.visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果点击的是舞台背景,清除选择
|
||||||
|
if (target === stage) {
|
||||||
|
componentManager.clearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(e: any) {
|
||||||
|
const target = e.target as Konva.Node
|
||||||
|
const stage = target.getStage()
|
||||||
|
const mouseEvent = e.evt as MouseEvent
|
||||||
|
|
||||||
|
// 右键拖拽画布
|
||||||
|
if (mouseEvent.button === 2) {
|
||||||
|
isRightDragging.value = true
|
||||||
|
const pos = stage?.getPointerPosition()
|
||||||
|
if (pos) {
|
||||||
|
lastPointerPosition.value = { x: pos.x, y: pos.y }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果点击的不是舞台背景,不启动框选
|
||||||
|
if (target !== stage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动框选
|
||||||
|
selectionState.isSelecting = true
|
||||||
|
const pos = stage?.getPointerPosition()
|
||||||
|
if (pos && stage) {
|
||||||
|
const relativePos = {
|
||||||
|
x: (pos.x - stage.x()) / stage.scaleX(),
|
||||||
|
y: (pos.y - stage.y()) / stage.scaleY(),
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionState.selectionBox.visible = true
|
||||||
|
selectionState.selectionBox.x1 = relativePos.x
|
||||||
|
selectionState.selectionBox.y1 = relativePos.y
|
||||||
|
selectionState.selectionBox.x2 = relativePos.x
|
||||||
|
selectionState.selectionBox.y2 = relativePos.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: any) {
|
||||||
|
const target = e.target as Konva.Node
|
||||||
|
const stage = target.getStage()
|
||||||
|
|
||||||
|
// 右键拖拽画布
|
||||||
|
if (isRightDragging.value && lastPointerPosition.value && stage) {
|
||||||
|
const pos = stage.getPointerPosition()
|
||||||
|
if (pos) {
|
||||||
|
const dx = pos.x - lastPointerPosition.value.x
|
||||||
|
const dy = pos.y - lastPointerPosition.value.y
|
||||||
|
|
||||||
|
const currentPos = stage.position()
|
||||||
|
stage.position({
|
||||||
|
x: currentPos.x + dx,
|
||||||
|
y: currentPos.y + dy,
|
||||||
|
})
|
||||||
|
|
||||||
|
lastPointerPosition.value = { x: pos.x, y: pos.y }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 框选
|
||||||
|
if (!selectionState.isSelecting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = stage?.getPointerPosition()
|
||||||
|
if (pos && stage) {
|
||||||
|
const relativePos = {
|
||||||
|
x: (pos.x - stage.x()) / stage.scaleX(),
|
||||||
|
y: (pos.y - stage.y()) / stage.scaleY(),
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionState.selectionBox.x2 = relativePos.x
|
||||||
|
selectionState.selectionBox.y2 = relativePos.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp(e: any) {
|
||||||
|
const mouseEvent = e.evt as MouseEvent
|
||||||
|
|
||||||
|
// 右键拖拽结束
|
||||||
|
if (mouseEvent.button === 2 && isRightDragging.value) {
|
||||||
|
isRightDragging.value = false
|
||||||
|
lastPointerPosition.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 框选结束
|
||||||
|
if (!selectionState.isSelecting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionState.isSelecting = false
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
selectionState.selectionBox.visible = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算选择区域
|
||||||
|
const selBox = {
|
||||||
|
x: Math.min(selectionState.selectionBox.x1, selectionState.selectionBox.x2),
|
||||||
|
y: Math.min(selectionState.selectionBox.y1, selectionState.selectionBox.y2),
|
||||||
|
width: Math.abs(selectionState.selectionBox.x2 - selectionState.selectionBox.x1),
|
||||||
|
height: Math.abs(selectionState.selectionBox.y2 - selectionState.selectionBox.y1),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择区域内的组件
|
||||||
|
componentManager.selectComponentsInArea(selBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: any) {
|
||||||
|
e.evt.preventDefault()
|
||||||
|
|
||||||
|
const stage = e.target as Stage
|
||||||
|
if (!stage) return
|
||||||
|
|
||||||
|
const oldScale = stage.scaleX()
|
||||||
|
const pointer = stage.getPointerPosition()
|
||||||
|
if (!pointer) return
|
||||||
|
|
||||||
|
const mousePointTo = {
|
||||||
|
x: (pointer.x - stage.x()) / oldScale,
|
||||||
|
y: (pointer.y - stage.y()) / oldScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction = e.evt.deltaY < 0 ? 1 : -1
|
||||||
|
|
||||||
|
if (e.evt.ctrlKey) {
|
||||||
|
direction = -direction
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleBy = 1.05
|
||||||
|
const newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy
|
||||||
|
|
||||||
|
stage.scale({ x: newScale, y: newScale })
|
||||||
|
stageScale.value = newScale
|
||||||
|
|
||||||
|
const newPos = {
|
||||||
|
x: pointer.x - mousePointTo.x * newScale,
|
||||||
|
y: pointer.y - mousePointTo.y * newScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.position(newPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextMenu(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听选择变化,更新变换器
|
||||||
|
watch(
|
||||||
|
() => selectedComponents.value.length,
|
||||||
|
() => {
|
||||||
|
updateTransformer()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
// 注册预定义的组件模板
|
||||||
|
const predefinedTemplates: ComponentTemplate[] = [
|
||||||
|
{
|
||||||
|
type: "MechanicalButton",
|
||||||
|
name: "机械按钮",
|
||||||
|
category: "basic",
|
||||||
|
defaultProps: { size: "medium", color: "blue", pressed: false }
|
||||||
|
},
|
||||||
|
// ... 更多模板
|
||||||
|
]
|
||||||
|
|
||||||
|
componentManager.registerTemplates(predefinedTemplates)
|
||||||
|
|
||||||
|
// 禁用变换器的默认行为
|
||||||
|
if (transformerRef.value) {
|
||||||
|
const transformer = transformerRef.value.getNode()
|
||||||
|
transformer.resizeEnabled(enableResize.value)
|
||||||
|
transformer.rotateEnabled(enableRotation.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-stage {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-stage:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
675
src/components/LabCanvas/LabComponentsDrawerNew.vue
Normal file
675
src/components/LabCanvas/LabComponentsDrawerNew.vue
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 元器件选择菜单 (Drawer) -->
|
||||||
|
<div class="drawer drawer-end z-50">
|
||||||
|
<input
|
||||||
|
id="Lab-drawer"
|
||||||
|
type="checkbox"
|
||||||
|
class="drawer-toggle"
|
||||||
|
v-model="showComponentsMenu"
|
||||||
|
/>
|
||||||
|
<div class="drawer-content">
|
||||||
|
<!-- Page content here -->
|
||||||
|
<label
|
||||||
|
for="Lab-drawer"
|
||||||
|
class="drawer-button btn btn-primary rounded-2xl"
|
||||||
|
><Plus></Plus
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-side">
|
||||||
|
<label
|
||||||
|
for="Lab-drawer"
|
||||||
|
aria-label="close sidebar"
|
||||||
|
class="drawer-overlay !bg-opacity-50"
|
||||||
|
></label>
|
||||||
|
<div
|
||||||
|
class="menu p-0 w-[460px] min-h-full bg-base-100 shadow-xl flex flex-col"
|
||||||
|
>
|
||||||
|
<!-- 菜单头部 -->
|
||||||
|
<div
|
||||||
|
class="p-6 border-b border-base-300 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-bold text-primary flex items-center gap-2">
|
||||||
|
<Plus class="text-primary w-5 h-5"></Plus>
|
||||||
|
添加实验元器件
|
||||||
|
</h3>
|
||||||
|
<label
|
||||||
|
for="Lab-drawer"
|
||||||
|
class="btn btn-ghost btn-sm btn-circle"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<div class="tabs tabs-boxed bg-base-200 mx-6 mt-4 rounded-box">
|
||||||
|
<a
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'components' }"
|
||||||
|
@click="activeTab = 'components'"
|
||||||
|
>元器件</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'templates' }"
|
||||||
|
@click="activeTab = 'templates'"
|
||||||
|
>模板</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'virtual' }"
|
||||||
|
@click="activeTab = 'virtual'"
|
||||||
|
>虚拟外设</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div class="px-6 py-4 border-b border-base-300">
|
||||||
|
<div class="join w-full">
|
||||||
|
<div class="join-item flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索..."
|
||||||
|
class="input input-bordered input-sm w-full pl-10"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@keyup.enter="searchComponents"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content opacity-60"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm join-item" @click="searchComponents">
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 元器件列表 (组件选项卡) -->
|
||||||
|
<div
|
||||||
|
v-if="activeTab === 'components'"
|
||||||
|
class="px-6 py-4 overflow-auto flex-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="filteredComponents.length > 0"
|
||||||
|
class="grid grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="template in filteredComponents"
|
||||||
|
:key="template.type"
|
||||||
|
class="card bg-base-200 hover:bg-base-300 transition-all duration-300 hover:shadow-md cursor-pointer"
|
||||||
|
@click="addComponent(template)"
|
||||||
|
>
|
||||||
|
<div class="card-body p-3 items-center text-center">
|
||||||
|
<div
|
||||||
|
class="bg-base-100 rounded-lg w-full h-[90px] flex items-center justify-center overflow-hidden p-2"
|
||||||
|
>
|
||||||
|
<!-- 组件预览 -->
|
||||||
|
<div
|
||||||
|
v-if="componentPreviews.has(template.type)"
|
||||||
|
class="component-preview"
|
||||||
|
v-html="componentPreviews.get(template.type)"
|
||||||
|
></div>
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<span v-else class="text-xs text-gray-400">{{ template.type }}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title text-sm mt-2">{{ template.name }}</h3>
|
||||||
|
<p class="text-xs opacity-70">{{ template.category }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 无搜索结果 -->
|
||||||
|
<div v-else class="py-16 text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mx-auto text-base-300 mb-3"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||||
|
</svg>
|
||||||
|
<p class="text-base-content opacity-70">没有找到匹配的元器件</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost mt-3"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
>
|
||||||
|
清除搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模板列表 (模板选项卡) -->
|
||||||
|
<div
|
||||||
|
v-if="activeTab === 'templates'"
|
||||||
|
class="px-6 py-4 overflow-auto flex-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="filteredTemplates.length > 0"
|
||||||
|
class="grid grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="template in filteredTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
class="card bg-base-200 hover:bg-base-300 transition-all duration-300 hover:shadow-md cursor-pointer"
|
||||||
|
@click="addTemplate(template)"
|
||||||
|
>
|
||||||
|
<div class="card-body p-3 items-center text-center">
|
||||||
|
<div
|
||||||
|
class="bg-base-100 rounded-lg w-full h-[90px] flex items-center justify-center overflow-hidden p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="template.thumbnailUrl || '/placeholder-template.png'"
|
||||||
|
alt="Template thumbnail"
|
||||||
|
class="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title text-sm mt-2">{{ template.name }}</h3>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
{{ template.description || "模板" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 无搜索结果 -->
|
||||||
|
<div v-else class="py-16 text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mx-auto text-base-300 mb-3"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||||
|
</svg>
|
||||||
|
<p class="text-base-content opacity-70">没有找到匹配的模板</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost mt-3"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
>
|
||||||
|
清除搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 虚拟外设列表 (虚拟外设选项卡) -->
|
||||||
|
<div
|
||||||
|
v-if="activeTab === 'virtual'"
|
||||||
|
class="px-6 py-4 overflow-auto flex-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="filteredVirtualDevices.length > 0"
|
||||||
|
class="grid grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="template in filteredVirtualDevices"
|
||||||
|
:key="template.type"
|
||||||
|
class="card bg-base-200 hover:bg-base-300 transition-all duration-300 hover:shadow-md cursor-pointer"
|
||||||
|
@click="addComponent(template)"
|
||||||
|
>
|
||||||
|
<div class="card-body p-3 items-center text-center">
|
||||||
|
<div
|
||||||
|
class="bg-base-100 rounded-lg w-full h-[90px] flex items-center justify-center overflow-hidden p-2"
|
||||||
|
>
|
||||||
|
<!-- 组件预览 -->
|
||||||
|
<div
|
||||||
|
v-if="componentPreviews.has(template.type)"
|
||||||
|
class="component-preview"
|
||||||
|
v-html="componentPreviews.get(template.type)"
|
||||||
|
></div>
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<span v-else class="text-xs text-gray-400">{{ template.type }}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title text-sm mt-2">{{ template.name }}</h3>
|
||||||
|
<p class="text-xs opacity-70">{{ template.category }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 无搜索结果 -->
|
||||||
|
<div v-else class="py-16 text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mx-auto text-base-300 mb-3"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||||
|
</svg>
|
||||||
|
<p class="text-base-content opacity-70">没有找到匹配的虚拟外设</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost mt-3"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
>
|
||||||
|
清除搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作区 -->
|
||||||
|
<div
|
||||||
|
class="p-4 border-t border-base-300 bg-base-200 flex justify-between"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="Lab-drawer"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mr-1"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
返回
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
for="Lab-drawer"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus } from "lucide-vue-next";
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { useKonvaTemplateManager } from "./composables/useKonvaTemplateManager";
|
||||||
|
import type { ComponentTemplate } from "./types/KonvaComponent";
|
||||||
|
import type { TemplateData } from "./composables/useKonvaTemplateManager";
|
||||||
|
|
||||||
|
// Props和Events
|
||||||
|
const isOpen = defineModel<boolean>("open", { default: false });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
addComponent: [template: ComponentTemplate, position: { x: number; y: number }]
|
||||||
|
addTemplate: [template: TemplateData, position: { x: number; y: number }]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 模板管理器
|
||||||
|
const templateManager = useKonvaTemplateManager();
|
||||||
|
|
||||||
|
// 显示/隐藏组件菜单
|
||||||
|
const showComponentsMenu = computed({
|
||||||
|
get: () => isOpen.value,
|
||||||
|
set: (value) => (isOpen.value = value),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前激活的选项卡
|
||||||
|
const activeTab = ref("components");
|
||||||
|
|
||||||
|
// 搜索功能
|
||||||
|
const searchQuery = ref("");
|
||||||
|
|
||||||
|
// 组件预览缓存
|
||||||
|
const componentPreviews = ref<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// 可用元器件模板
|
||||||
|
const componentTemplates = ref<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
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 虚拟外设模板
|
||||||
|
const virtualDeviceTemplates = ref<ComponentTemplate[]>([
|
||||||
|
{
|
||||||
|
type: "DDS",
|
||||||
|
name: "信号发生器",
|
||||||
|
category: "virtual",
|
||||||
|
defaultProps: {
|
||||||
|
frequency: 1000,
|
||||||
|
amplitude: 1.0,
|
||||||
|
waveform: "sine",
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
previewSize: 0.3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 计算属性 - 过滤后的列表
|
||||||
|
const filteredComponents = computed(() => {
|
||||||
|
let components = componentTemplates.value;
|
||||||
|
if (searchQuery.value && activeTab.value === "components") {
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
components = components.filter(
|
||||||
|
(template) =>
|
||||||
|
template.name.toLowerCase().includes(query) ||
|
||||||
|
template.type.toLowerCase().includes(query) ||
|
||||||
|
template.category.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return components;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredVirtualDevices = computed(() => {
|
||||||
|
let devices = virtualDeviceTemplates.value;
|
||||||
|
if (searchQuery.value && activeTab.value === "virtual") {
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
devices = devices.filter(
|
||||||
|
(template) =>
|
||||||
|
template.name.toLowerCase().includes(query) ||
|
||||||
|
template.type.toLowerCase().includes(query) ||
|
||||||
|
template.category.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTemplates = computed(() => {
|
||||||
|
let templates = templateManager.templateList.value;
|
||||||
|
if (searchQuery.value && activeTab.value === "templates") {
|
||||||
|
templates = templateManager.searchTemplates(searchQuery.value);
|
||||||
|
}
|
||||||
|
return templates;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function searchComponents() {
|
||||||
|
// 搜索逻辑已在计算属性中实现
|
||||||
|
console.log("Searching for:", searchQuery.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
showComponentsMenu.value = false;
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加组件
|
||||||
|
function addComponent(template: ComponentTemplate) {
|
||||||
|
// 计算添加位置(画布中心)
|
||||||
|
const position = {
|
||||||
|
x: window.innerWidth / 2 - 50,
|
||||||
|
y: window.innerHeight / 2 - 50
|
||||||
|
};
|
||||||
|
|
||||||
|
emit("addComponent", template, position);
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加模板
|
||||||
|
function addTemplate(template: TemplateData) {
|
||||||
|
// 计算添加位置(画布中心)
|
||||||
|
const position = {
|
||||||
|
x: window.innerWidth / 2 - 100,
|
||||||
|
y: window.innerHeight / 2 - 100
|
||||||
|
};
|
||||||
|
|
||||||
|
emit("addTemplate", template, position);
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载组件预览
|
||||||
|
async function preloadComponentPreviews() {
|
||||||
|
const allTemplates = [
|
||||||
|
...componentTemplates.value,
|
||||||
|
...virtualDeviceTemplates.value
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const template of allTemplates) {
|
||||||
|
try {
|
||||||
|
// 这里可以生成组件的SVG预览或者加载组件图标
|
||||||
|
const preview = `<div class="w-12 h-12 bg-blue-100 rounded flex items-center justify-center text-xs font-mono">${template.type.slice(0, 3)}</div>`;
|
||||||
|
componentPreviews.value.set(template.type, preview);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load preview for ${template.type}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化预定义模板
|
||||||
|
function initializePredefinedTemplates() {
|
||||||
|
// 注册组件模板到模板管理器
|
||||||
|
componentTemplates.value.forEach(template => {
|
||||||
|
templateManager.registerTemplate({
|
||||||
|
id: template.type,
|
||||||
|
name: template.name,
|
||||||
|
description: `${template.name} - ${template.category}`,
|
||||||
|
category: template.category,
|
||||||
|
components: [],
|
||||||
|
thumbnailUrl: `/icons/${template.type}.svg`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载预定义的模板文件
|
||||||
|
const predefinedTemplates = [
|
||||||
|
{
|
||||||
|
name: "PG2L100H 基础开发板",
|
||||||
|
id: "PG2L100H_Pango100pro",
|
||||||
|
description: "包含主板和两个LED的基本设置",
|
||||||
|
path: "/EquipmentTemplates/PG2L100H_Pango100pro.json",
|
||||||
|
category: "template"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "矩阵键盘",
|
||||||
|
id: "MatrixKey",
|
||||||
|
description: "包含4x4,共16个按键的矩阵键盘",
|
||||||
|
path: "/EquipmentTemplates/MatrixKey.json",
|
||||||
|
category: "template"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
predefinedTemplates.forEach(async (templateInfo) => {
|
||||||
|
try {
|
||||||
|
await templateManager.loadTemplateFromUrl(templateInfo.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load template ${templateInfo.name}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(() => {
|
||||||
|
preloadComponentPreviews();
|
||||||
|
initializePredefinedTemplates();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 组件预览样式 */
|
||||||
|
.component-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.animate-slideUp {
|
||||||
|
animation: slideUp 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
src/components/LabCanvas/QUICKSTART.md
Normal file
236
src/components/LabCanvas/QUICKSTART.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# 快速开始指南
|
||||||
|
|
||||||
|
这个文档将帮助你快速开始使用重构后的LabCanvas组件管理系统。
|
||||||
|
|
||||||
|
## 基本集成
|
||||||
|
|
||||||
|
### 1. 简单使用
|
||||||
|
|
||||||
|
最简单的方式是直接使用 `LabCanvasNew` 组件:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="h-screen">
|
||||||
|
<LabCanvasNew />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { LabCanvasNew } from '@/components/LabCanvas'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 自定义集成
|
||||||
|
|
||||||
|
如果你需要更多控制,可以分别使用各个管理器:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="h-screen flex">
|
||||||
|
<!-- 左侧工具栏 -->
|
||||||
|
<div class="w-64 bg-gray-100">
|
||||||
|
<LabComponentsDrawerNew
|
||||||
|
v-model:open="drawerOpen"
|
||||||
|
@add-component="handleAddComponent"
|
||||||
|
@add-template="handleAddTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主画布 -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<v-stage ref="stageRef" :config="stageConfig">
|
||||||
|
<v-layer ref="layerRef">
|
||||||
|
<!-- 组件将在这里渲染 -->
|
||||||
|
</v-layer>
|
||||||
|
</v-stage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
useKonvaComponentManager,
|
||||||
|
useKonvaRenderer,
|
||||||
|
LabComponentsDrawerNew
|
||||||
|
} from '@/components/LabCanvas'
|
||||||
|
|
||||||
|
const stageRef = ref()
|
||||||
|
const layerRef = ref()
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
|
||||||
|
const componentManager = useKonvaComponentManager({
|
||||||
|
enableHistory: true,
|
||||||
|
autoSave: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderer = useKonvaRenderer(stageRef, layerRef)
|
||||||
|
|
||||||
|
function handleAddComponent(template, position) {
|
||||||
|
const component = componentManager.addComponent(template.type, position)
|
||||||
|
if (component) {
|
||||||
|
renderer.renderComponent(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddTemplate(template, position) {
|
||||||
|
// 处理模板添加
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### 组件管理
|
||||||
|
|
||||||
|
```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)。
|
||||||
281
src/components/LabCanvas/README.md
Normal file
281
src/components/LabCanvas/README.md
Normal file
@@ -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
|
||||||
|
<template>
|
||||||
|
<LabCanvasNew />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import LabCanvasNew from '@/components/LabCanvas/LabCanvasNew.vue'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 高级用法 - 自定义组件管理
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="canvas-container">
|
||||||
|
<v-stage ref="stageRef" :config="stageConfig">
|
||||||
|
<v-layer ref="layerRef">
|
||||||
|
<!-- 组件将自动渲染到这里 -->
|
||||||
|
</v-layer>
|
||||||
|
</v-stage>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useKonvaComponentManager } from '@/components/LabCanvas/composables/useKonvaComponentManager'
|
||||||
|
import { useKonvaRenderer } from '@/components/LabCanvas/composables/useKonvaRenderer'
|
||||||
|
|
||||||
|
const stageRef = ref()
|
||||||
|
const layerRef = ref()
|
||||||
|
|
||||||
|
// 初始化组件管理器
|
||||||
|
const componentManager = useKonvaComponentManager({
|
||||||
|
enableHistory: true,
|
||||||
|
autoSave: true,
|
||||||
|
events: {
|
||||||
|
onAdd: (component) => {
|
||||||
|
console.log('Component added:', component)
|
||||||
|
},
|
||||||
|
onSelect: (componentIds) => {
|
||||||
|
console.log('Selection changed:', componentIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化渲染器
|
||||||
|
const renderer = useKonvaRenderer(stageRef, layerRef)
|
||||||
|
|
||||||
|
// 注册组件模板
|
||||||
|
componentManager.registerTemplate({
|
||||||
|
type: 'CustomButton',
|
||||||
|
name: '自定义按钮',
|
||||||
|
category: 'custom',
|
||||||
|
defaultProps: {
|
||||||
|
color: 'blue',
|
||||||
|
size: 'medium'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加组件
|
||||||
|
function addCustomComponent() {
|
||||||
|
const component = componentManager.addComponent('CustomButton', { x: 100, y: 100 })
|
||||||
|
if (component) {
|
||||||
|
renderer.renderComponent(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板管理
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
import { useKonvaTemplateManager } from '@/components/LabCanvas/composables/useKonvaTemplateManager'
|
||||||
|
|
||||||
|
const templateManager = useKonvaTemplateManager()
|
||||||
|
|
||||||
|
// 从URL加载模板
|
||||||
|
async function loadTemplate() {
|
||||||
|
const template = await templateManager.loadTemplateFromUrl('/templates/my-template.json')
|
||||||
|
if (template) {
|
||||||
|
console.log('Template loaded:', template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建模板
|
||||||
|
function createTemplate() {
|
||||||
|
const selectedComponents = [] // 获取选中的组件
|
||||||
|
const template = templateManager.createTemplateFromComponents(
|
||||||
|
'My Template',
|
||||||
|
'A custom template',
|
||||||
|
'custom',
|
||||||
|
selectedComponents
|
||||||
|
)
|
||||||
|
console.log('Template created:', template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实例化模板
|
||||||
|
function instantiateTemplate(templateId) {
|
||||||
|
const instance = templateManager.instantiateTemplate(templateId, { x: 200, y: 200 })
|
||||||
|
if (instance) {
|
||||||
|
// 添加所有组件到画布
|
||||||
|
instance.components.forEach(comp => {
|
||||||
|
componentManager.components.value.set(comp.id, comp)
|
||||||
|
renderer.renderComponent(comp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 组件类型定义
|
||||||
|
|
||||||
|
### 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) => {
|
||||||
|
// 自定义渲染逻辑
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
这个重构版本提供了更好的类型安全、更清晰的架构分离,以及更强大的扩展性。
|
||||||
574
src/components/LabCanvas/composables/useKonvaComponentManager.ts
Normal file
574
src/components/LabCanvas/composables/useKonvaComponentManager.ts
Normal file
@@ -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<CanvasConfig>
|
||||||
|
// 事件回调
|
||||||
|
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<Map<string, KonvaComponent>>(new Map())
|
||||||
|
const componentTemplates = ref<Map<string, ComponentTemplate>>(new Map())
|
||||||
|
|
||||||
|
// 画布配置
|
||||||
|
const canvasConfig = reactive<CanvasConfig>({
|
||||||
|
...defaultCanvas,
|
||||||
|
...options.canvasConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
// 选择状态
|
||||||
|
const selectionState = reactive<SelectionState>({
|
||||||
|
selectedIds: [],
|
||||||
|
hoveredId: null,
|
||||||
|
isDragging: false,
|
||||||
|
dragTargetId: null,
|
||||||
|
isSelecting: false,
|
||||||
|
selectionBox: {
|
||||||
|
visible: false,
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 历史记录
|
||||||
|
const history = ref<HistoryRecord[]>([])
|
||||||
|
const historyIndex = ref(-1)
|
||||||
|
const maxHistorySize = options.maxHistorySize || 50
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
const saveKey = options.saveKey || 'konva-component-manager'
|
||||||
|
const savedData = useLocalStorage<ProjectData | null>(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<HistoryRecord, 'id' | 'timestamp'>) {
|
||||||
|
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<ComponentProps>
|
||||||
|
): 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<ComponentProps>
|
||||||
|
): 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<ComponentProps>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
406
src/components/LabCanvas/composables/useKonvaRenderer.ts
Normal file
406
src/components/LabCanvas/composables/useKonvaRenderer.ts
Normal file
@@ -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<any>,
|
||||||
|
layerRef: Ref<any>,
|
||||||
|
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<Map<string, Konva.Group>>(new Map())
|
||||||
|
const componentModules = ref<Map<string, any>>(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<string, any>) {
|
||||||
|
modules.forEach((module, type) => {
|
||||||
|
componentModules.value.set(type, module)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态加载组件模块
|
||||||
|
async function loadComponentModule(type: string): Promise<any> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/components/LabCanvas/composables/useKonvaTemplateManager.ts
Normal file
323
src/components/LabCanvas/composables/useKonvaTemplateManager.ts
Normal file
@@ -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<Map<string, TemplateData>>(new Map())
|
||||||
|
|
||||||
|
// 本地存储
|
||||||
|
const savedTemplates = useLocalStorage<TemplateData[]>(storageKey, [])
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const templateList = computed(() => Array.from(templates.value.values()))
|
||||||
|
|
||||||
|
const templatesByCategory = computed(() => {
|
||||||
|
const result: Record<string, TemplateData[]> = {}
|
||||||
|
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<string, string>()
|
||||||
|
|
||||||
|
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<TemplateData | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
316
src/components/LabCanvas/index.ts
Normal file
316
src/components/LabCanvas/index.ts
Normal file
@@ -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'
|
||||||
118
src/components/LabCanvas/types/KonvaComponent.ts
Normal file
118
src/components/LabCanvas/types/KonvaComponent.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,99 +1,533 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex w-screen h-screen justify-center">
|
<div class="h-screen flex flex-col">
|
||||||
<div class="flex flex-col w-3/5 h-screen shadow-2xl p-10">
|
<!-- 头部工具栏 -->
|
||||||
<div class="flex justify-center">
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
<h1 class="font-bold text-3xl">Jtag 下载</h1>
|
<div class="navbar-start">
|
||||||
|
<h1 class="text-xl font-bold">FPGA WebLab - 实验画布</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="w-full">
|
<div class="navbar-center">
|
||||||
<div class="collapse bg-primary">
|
<div class="join">
|
||||||
<input type="checkbox" />
|
<button
|
||||||
<div class="collapse-title font-semibold text-lg text-white">
|
class="btn btn-sm join-item"
|
||||||
自定义开发板参数
|
:class="{ 'btn-active': currentMode === 'design' }"
|
||||||
|
@click="currentMode = 'design'"
|
||||||
|
>
|
||||||
|
设计模式
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item"
|
||||||
|
:class="{ 'btn-active': currentMode === 'simulate' }"
|
||||||
|
@click="currentMode = 'simulate'"
|
||||||
|
>
|
||||||
|
仿真模式
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse-content bg-primary-content text-sm">
|
</div>
|
||||||
<div class="form-control w-full my-3">
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
<div class="join">
|
||||||
|
<button class="btn btn-sm join-item" @click="saveProject">
|
||||||
|
保存项目
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm join-item" @click="loadProject">
|
||||||
|
加载项目
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm join-item" @click="exportProject">
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主要内容区域 -->
|
||||||
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
<!-- 左侧属性面板 -->
|
||||||
|
<div
|
||||||
|
v-if="showPropertyPanel && selectedComponents.length > 0"
|
||||||
|
class="w-80 bg-base-100 border-r border-base-300 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">组件属性</h3>
|
||||||
|
|
||||||
|
<!-- 单个组件属性 -->
|
||||||
|
<div v-if="selectedComponents.length === 1" class="space-y-4">
|
||||||
|
<div>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-gray-700">开发板IP地址</span>
|
<span class="label-text">组件名称</span>
|
||||||
</label>
|
|
||||||
<label class="input w-full">
|
|
||||||
<img class="h-[1em] opacity-50" src="@/assets/pwd.svg" alt="User img" />
|
|
||||||
<input type="text" class="grow" placeholder="IP地址" v-model="boardAddress" />
|
|
||||||
</label>
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
v-model="selectedComponents[0].name"
|
||||||
|
@change="updateComponentName"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control w-full my-3">
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-gray-700">开发板端口号</span>
|
<span class="label-text">X 位置</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="input w-full">
|
<input
|
||||||
<img class="h-[1em] opacity-50" src="@/assets/pwd.svg" alt="User img" />
|
type="number"
|
||||||
<input type="text" class="grow" placeholder="端口号" v-model="boardPort" />
|
class="input input-bordered input-sm w-full"
|
||||||
|
v-model.number="selectedComponents[0].x"
|
||||||
|
@change="updateComponentPosition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Y 位置</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
v-model.number="selectedComponents[0].y"
|
||||||
|
@change="updateComponentPosition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">旋转角度</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
class="range range-sm"
|
||||||
|
v-model.number="selectedComponents[0].rotation"
|
||||||
|
@change="updateComponentRotation"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-center">{{ selectedComponents[0].rotation }}°</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text">锁定位置</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
v-model="selectedComponents[0].locked"
|
||||||
|
@change="updateComponentLock"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 组件特有属性 -->
|
||||||
|
<div v-if="componentSpecificProps.length > 0" class="divider">特有属性</div>
|
||||||
|
<div v-for="prop in componentSpecificProps" :key="prop.key" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">{{ prop.label }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- 根据属性类型渲染不同的输入控件 -->
|
||||||
|
<input
|
||||||
|
v-if="prop.type === 'text'"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:value="selectedComponents[0].props[prop.key]"
|
||||||
|
@input="updateComponentProp(prop.key, ($event.target as HTMLInputElement)?.value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="prop.type === 'number'"
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:value="selectedComponents[0].props[prop.key]"
|
||||||
|
@input="updateComponentProp(prop.key, Number(($event.target as HTMLInputElement)?.value))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="prop.type === 'checkbox'"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
:checked="selectedComponents[0].props[prop.key]"
|
||||||
|
@change="updateComponentProp(prop.key, ($event.target as HTMLInputElement)?.checked)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-else-if="prop.type === 'select'"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
:value="selectedComponents[0].props[prop.key]"
|
||||||
|
@change="updateComponentProp(prop.key, ($event.target as HTMLSelectElement)?.value)"
|
||||||
|
>
|
||||||
|
<option v-for="option in prop.options" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 多个组件属性 -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>已选择 {{ selectedComponents.length }} 个组件</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-outline w-full" @click="groupSelectedComponents">
|
||||||
|
组合组件
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-outline w-full" @click="alignSelectedComponents('left')">
|
||||||
|
左对齐
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-outline w-full" @click="alignSelectedComponents('center')">
|
||||||
|
居中对齐
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-outline w-full" @click="alignSelectedComponents('right')">
|
||||||
|
右对齐
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
|
||||||
<UploadCard :upload-event="uploadBitstream" :download-event="downloadBitstream">
|
<!-- 中央画布区域 -->
|
||||||
</UploadCard>
|
<div class="flex-1 relative">
|
||||||
|
<LabCanvasNew
|
||||||
|
v-if="currentMode === 'design'"
|
||||||
|
@component-selected="handleComponentSelected"
|
||||||
|
@components-changed="handleComponentsChanged"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 仿真模式画布 -->
|
||||||
|
<div v-else class="w-full h-full bg-base-200 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">仿真模式</h2>
|
||||||
|
<p class="text-gray-600 mb-4">在此模式下可以与组件进行交互测试</p>
|
||||||
|
<button class="btn btn-primary" @click="startSimulation">
|
||||||
|
开始仿真
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧图层面板 -->
|
||||||
|
<div
|
||||||
|
v-if="showLayerPanel"
|
||||||
|
class="w-64 bg-base-100 border-l border-base-300 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">图层管理</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="component in sortedComponents"
|
||||||
|
:key="component.id"
|
||||||
|
class="flex items-center p-2 rounded hover:bg-base-200 cursor-pointer"
|
||||||
|
:class="{ 'bg-primary text-primary-content': component.isSelected }"
|
||||||
|
@click="selectComponent(component.id)"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-medium">{{ component.name }}</div>
|
||||||
|
<div class="text-xs opacity-70">{{ component.type }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
@click.stop="toggleComponentVisibility(component.id)"
|
||||||
|
:title="component.visible ? '隐藏' : '显示'"
|
||||||
|
>
|
||||||
|
{{ component.visible ? '👁️' : '🙈' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
@click.stop="toggleComponentLock(component.id)"
|
||||||
|
:title="component.locked ? '解锁' : '锁定'"
|
||||||
|
>
|
||||||
|
{{ component.locked ? '🔒' : '🔓' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部状态栏 -->
|
||||||
|
<div class="bg-base-100 border-t border-base-300 px-4 py-2 flex justify-between items-center text-sm">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<span>组件总数: {{ totalComponents }}</span>
|
||||||
|
<span>选中: {{ selectedComponents.length }}</span>
|
||||||
|
<span>当前模式: {{ currentMode === 'design' ? '设计' : '仿真' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
@click="showPropertyPanel = !showPropertyPanel"
|
||||||
|
>
|
||||||
|
{{ showPropertyPanel ? '隐藏' : '显示' }}属性面板
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
@click="showLayerPanel = !showLayerPanel"
|
||||||
|
>
|
||||||
|
{{ showLayerPanel ? '隐藏' : '显示' }}图层面板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { JtagClient, type FileParameter } from "@/APIClient";
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import UploadCard from "@/components/UploadCard.vue";
|
import LabCanvasNew from '@/components/LabCanvas/LabCanvasNew.vue'
|
||||||
import { useDialogStore } from "@/stores/dialog";
|
import { useKonvaComponentManager } from '@/components/LabCanvas/composables/useKonvaComponentManager'
|
||||||
import { Common } from "@/utils/Common";
|
import type { KonvaComponent } from '@/components/LabCanvas/types/KonvaComponent'
|
||||||
import { toNumber, isUndefined } from "lodash";
|
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
const jtagController = new JtagClient();
|
// 模式状态
|
||||||
const dialog = useDialogStore();
|
const currentMode = ref<'design' | 'simulate'>('design')
|
||||||
|
|
||||||
// Models with default values
|
// 面板显示状态
|
||||||
const boardAddress = ref("127.0.0.1"); // 默认IP地址
|
const showPropertyPanel = ref(true)
|
||||||
const boardPort = ref("1234"); // 默认端口号
|
const showLayerPanel = ref(true)
|
||||||
|
|
||||||
async function uploadBitstream(bitstream: File): Promise<boolean> {
|
// 组件管理器
|
||||||
if (isUndefined(boardAddress.value) || isUndefined(boardPort.value)) {
|
const componentManager = useKonvaComponentManager({
|
||||||
dialog.error("开发板地址或端口空缺");
|
enableHistory: true,
|
||||||
return false;
|
autoSave: true,
|
||||||
|
saveKey: 'fpga-weblab-project'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { components, selectedComponents } = componentManager
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalComponents = computed(() => components.value.size)
|
||||||
|
|
||||||
|
const sortedComponents = computed(() => {
|
||||||
|
return Array.from(components.value.values())
|
||||||
|
.sort((a, b) => b.zIndex - a.zIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件特有属性配置
|
||||||
|
const componentPropertyConfigs: Record<string, Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'number' | 'checkbox' | 'select'
|
||||||
|
options?: Array<{ value: any; label: string }>
|
||||||
|
}>> = {
|
||||||
|
MechanicalButton: [
|
||||||
|
{ key: 'color', label: '颜色', type: 'select', options: [
|
||||||
|
{ value: 'red', label: '红色' },
|
||||||
|
{ value: 'blue', label: '蓝色' },
|
||||||
|
{ value: 'green', label: '绿色' }
|
||||||
|
]},
|
||||||
|
{ key: 'size', label: '尺寸', type: 'select', options: [
|
||||||
|
{ value: 'small', label: '小' },
|
||||||
|
{ value: 'medium', label: '中' },
|
||||||
|
{ value: 'large', label: '大' }
|
||||||
|
]},
|
||||||
|
{ key: 'pressed', label: '按下状态', type: 'checkbox' }
|
||||||
|
],
|
||||||
|
Switch: [
|
||||||
|
{ key: 'state', label: '开关状态', type: 'checkbox' },
|
||||||
|
{ key: 'style', label: '样式', type: 'select', options: [
|
||||||
|
{ value: 'toggle', label: '切换' },
|
||||||
|
{ value: 'rocker', label: '摇杆' }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
SMT_LED: [
|
||||||
|
{ key: 'color', label: '颜色', type: 'select', options: [
|
||||||
|
{ value: 'red', label: '红色' },
|
||||||
|
{ value: 'green', label: '绿色' },
|
||||||
|
{ value: 'blue', label: '蓝色' },
|
||||||
|
{ value: 'yellow', label: '黄色' }
|
||||||
|
]},
|
||||||
|
{ key: 'state', label: '点亮状态', type: 'checkbox' },
|
||||||
|
{ key: 'brightness', label: '亮度', type: 'number' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const componentSpecificProps = computed(() => {
|
||||||
|
if (selectedComponents.value.length !== 1) return []
|
||||||
|
|
||||||
|
const component = selectedComponents.value[0]
|
||||||
|
return componentPropertyConfigs[component.type] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 事件处理
|
||||||
|
function handleComponentSelected(componentIds: string[]) {
|
||||||
|
// 组件选择已由管理器处理
|
||||||
|
console.log('Components selected:', componentIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComponentsChanged() {
|
||||||
|
// 组件变化处理
|
||||||
|
console.log('Components changed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 属性更新方法
|
||||||
|
function updateComponentName() {
|
||||||
|
if (selectedComponents.value.length === 1) {
|
||||||
|
const component = selectedComponents.value[0]
|
||||||
|
// 名称已通过v-model自动更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponentPosition() {
|
||||||
|
if (selectedComponents.value.length === 1) {
|
||||||
|
const component = selectedComponents.value[0]
|
||||||
|
componentManager.updateComponentPosition(component.id, component.x, component.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponentRotation() {
|
||||||
|
if (selectedComponents.value.length === 1) {
|
||||||
|
const component = selectedComponents.value[0]
|
||||||
|
// 旋转角度已通过v-model自动更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponentLock() {
|
||||||
|
if (selectedComponents.value.length === 1) {
|
||||||
|
const component = selectedComponents.value[0]
|
||||||
|
// 锁定状态已通过v-model自动更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponentProp(propKey: string, value: any) {
|
||||||
|
if (selectedComponents.value.length === 1) {
|
||||||
|
const component = selectedComponents.value[0]
|
||||||
|
componentManager.updateComponentProps(component.id, { [propKey]: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件操作
|
||||||
|
function selectComponent(componentId: string) {
|
||||||
|
componentManager.selectComponent(componentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComponentVisibility(componentId: string) {
|
||||||
|
const component = components.value.get(componentId)
|
||||||
|
if (component) {
|
||||||
|
component.visible = !component.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComponentLock(componentId: string) {
|
||||||
|
const component = components.value.get(componentId)
|
||||||
|
if (component) {
|
||||||
|
component.locked = !component.locked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多选组件操作
|
||||||
|
function groupSelectedComponents() {
|
||||||
|
if (selectedComponents.value.length < 2) return
|
||||||
|
|
||||||
|
// TODO: 实现组合功能
|
||||||
|
console.log('Grouping components:', selectedComponents.value.map(c => c.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignSelectedComponents(alignment: 'left' | 'center' | 'right') {
|
||||||
|
if (selectedComponents.value.length < 2) return
|
||||||
|
|
||||||
|
const components = selectedComponents.value
|
||||||
|
let targetX: number
|
||||||
|
|
||||||
|
switch (alignment) {
|
||||||
|
case 'left':
|
||||||
|
targetX = Math.min(...components.map(c => c.x))
|
||||||
|
break
|
||||||
|
case 'center':
|
||||||
|
const minX = Math.min(...components.map(c => c.x))
|
||||||
|
const maxX = Math.max(...components.map(c => c.x + (c.boundingBox?.width || 100)))
|
||||||
|
targetX = (minX + maxX) / 2
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
targetX = Math.max(...components.map(c => c.x + (c.boundingBox?.width || 100)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
components.forEach(component => {
|
||||||
|
if (alignment === 'right') {
|
||||||
|
componentManager.updateComponentPosition(
|
||||||
|
component.id,
|
||||||
|
targetX - (component.boundingBox?.width || 100),
|
||||||
|
component.y
|
||||||
|
)
|
||||||
|
} else if (alignment === 'center') {
|
||||||
|
componentManager.updateComponentPosition(
|
||||||
|
component.id,
|
||||||
|
targetX - (component.boundingBox?.width || 100) / 2,
|
||||||
|
component.y
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
componentManager.updateComponentPosition(component.id, targetX, component.y)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目管理
|
||||||
|
function saveProject() {
|
||||||
|
const projectData = componentManager.getProjectData()
|
||||||
|
const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `fpga-project-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProject() {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.json'
|
||||||
|
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
try {
|
try {
|
||||||
const resp = await jtagController.uploadBitstream(
|
const projectData = JSON.parse(e.target?.result as string)
|
||||||
boardAddress.value,
|
componentManager.loadProjectData(projectData)
|
||||||
Common.toFileParameterOrNull(bitstream),
|
} catch (error) {
|
||||||
);
|
console.error('Failed to load project:', error)
|
||||||
return resp;
|
alert('项目文件格式错误')
|
||||||
} catch (e) {
|
|
||||||
dialog.error("上传错误");
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadBitstream(): Promise<boolean> {
|
input.click()
|
||||||
if (isUndefined(boardAddress.value) || isUndefined(boardPort.value)) {
|
|
||||||
dialog.error("开发板地址或端口空缺");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
function exportProject() {
|
||||||
const resp = await jtagController.downloadBitstream(
|
// TODO: 实现导出功能(如导出为图片、PDF等)
|
||||||
boardAddress.value,
|
console.log('Exporting project...')
|
||||||
toNumber(boardPort.value),
|
|
||||||
);
|
|
||||||
return resp;
|
|
||||||
} catch (e) {
|
|
||||||
dialog.error("上传错误");
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startSimulation() {
|
||||||
|
// TODO: 实现仿真功能
|
||||||
|
console.log('Starting simulation...')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('FPGA WebLab Example loaded')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
@import "../assets/main.css";
|
/* 自定义样式 */
|
||||||
|
.navbar {
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
background: linear-gradient(to right, #ddd 0%, #ddd var(--value), #ccc var(--value), #ccc 100%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user