refactor: 重构canvas
This commit is contained in:
parent
14d8499f77
commit
c3bd61ed51
|
@ -3,7 +3,7 @@
|
||||||
<v-stage
|
<v-stage
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
ref="stageRef"
|
ref="stageRef"
|
||||||
:config="stageSize"
|
:config="labCanvasStore?.stageConfig"
|
||||||
@mousedown="handleMouseDown"
|
@mousedown="handleMouseDown"
|
||||||
@mousemove="handleMouseMove"
|
@mousemove="handleMouseMove"
|
||||||
@mouseup="handleMouseUp"
|
@mouseup="handleMouseUp"
|
||||||
|
@ -29,12 +29,13 @@
|
||||||
@mouseover="handleCanvasObjectMouseOver"
|
@mouseover="handleCanvasObjectMouseOver"
|
||||||
@mouseout="handleCanvasObjectMouseOut"
|
@mouseout="handleCanvasObjectMouseOut"
|
||||||
>
|
>
|
||||||
|
<!-- Hover Box -->
|
||||||
<v-rect
|
<v-rect
|
||||||
v-show="!isUndefined(item.box)"
|
v-show="!isUndefined(item.hoverBox)"
|
||||||
:config="{
|
:config="{
|
||||||
...item.box,
|
...item.hoverBox,
|
||||||
visible:
|
visible:
|
||||||
!isUndefined(item.box) &&
|
!isUndefined(item.hoverBox) &&
|
||||||
item.isHoverring &&
|
item.isHoverring &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
selectedIds.length == 0,
|
selectedIds.length == 0,
|
||||||
|
@ -45,7 +46,13 @@
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
</v-rect>
|
</v-rect>
|
||||||
<v-rect :config="item.config" />
|
<v-shape :config="item.config">
|
||||||
|
<component
|
||||||
|
:is="item.component"
|
||||||
|
@mouseover="handleCanvasObjectMouseOver"
|
||||||
|
@mouseout="handleCanvasObjectMouseOut"
|
||||||
|
></component>
|
||||||
|
</v-shape>
|
||||||
</v-group>
|
</v-group>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -72,6 +79,7 @@
|
||||||
<LabComponentsDrawer
|
<LabComponentsDrawer
|
||||||
class="absolute top-10 right-20"
|
class="absolute top-10 right-20"
|
||||||
v-model:open="isDrawerOpen"
|
v-model:open="isDrawerOpen"
|
||||||
|
:add-component="labCanvasStore.addComponent"
|
||||||
></LabComponentsDrawer>
|
></LabComponentsDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -80,38 +88,13 @@
|
||||||
import LabComponentsDrawer from "./LabComponentsDrawer.vue";
|
import LabComponentsDrawer from "./LabComponentsDrawer.vue";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import { isNull, isUndefined } from "lodash";
|
import { isNull, isUndefined } from "lodash";
|
||||||
import type {
|
import type { VLayer, VNode, VStage, VTransformer } from "@/utils/VueKonvaType";
|
||||||
VGroup,
|
import { ref, reactive, watch, onMounted, useTemplateRef, computed } from "vue";
|
||||||
VLayer,
|
|
||||||
VNode,
|
|
||||||
VStage,
|
|
||||||
VTransformer,
|
|
||||||
} from "@/utils/VueKonvaType";
|
|
||||||
import { ref, reactive, watch, onMounted, useTemplateRef } from "vue";
|
|
||||||
import type { IRect } from "konva/lib/types";
|
|
||||||
import type { Stage } from "konva/lib/Stage";
|
import type { Stage } from "konva/lib/Stage";
|
||||||
|
import type { LabCanvasComponentConfig } from "./LabCanvasType";
|
||||||
|
import { useLabCanvasStore } from "./composable/LabCanvasManager";
|
||||||
|
|
||||||
const stageSize = {
|
const labCanvasStore = useLabCanvasStore();
|
||||||
width: window.innerWidth,
|
|
||||||
height: window.innerHeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
type CanvasObjectBox = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CanvasObject = {
|
|
||||||
type: "Rect";
|
|
||||||
config: Konva.RectConfig;
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
isHoverring: boolean;
|
|
||||||
box?: CanvasObjectBox;
|
|
||||||
};
|
|
||||||
|
|
||||||
function calculateRectBounding(
|
function calculateRectBounding(
|
||||||
width: number,
|
width: number,
|
||||||
|
@ -164,31 +147,8 @@ function calculateRectBounding(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const objMap = reactive<Map<string, CanvasObject>>(new Map());
|
const objMap = computed(() => {
|
||||||
onMounted(() => {
|
return new Map(labCanvasStore?.components.value.map((item) => [item.id, item]));
|
||||||
for (let n = 0; n < 100; n++) {
|
|
||||||
const id = Math.round(Math.random() * 10000).toString();
|
|
||||||
const x = Math.random() * stageSize.width;
|
|
||||||
const y = Math.random() * stageSize.height;
|
|
||||||
const width = 30 + Math.random() * 30;
|
|
||||||
const height = 30 + Math.random() * 30;
|
|
||||||
const rotation = Math.random() * 180;
|
|
||||||
|
|
||||||
objMap.set(id, {
|
|
||||||
type: "Rect",
|
|
||||||
config: {
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
rotation: rotation,
|
|
||||||
fill: "grey",
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
id: id,
|
|
||||||
x: x,
|
|
||||||
y: y,
|
|
||||||
isHoverring: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const layerRef = useTemplateRef<VLayer>("layerRef");
|
const layerRef = useTemplateRef<VLayer>("layerRef");
|
||||||
|
@ -424,19 +384,19 @@ function handleMouseUp(e: Event) {
|
||||||
|
|
||||||
// console.log(`Stage Scale: ${stageScale.value}`);
|
// console.log(`Stage Scale: ${stageScale.value}`);
|
||||||
let currentSelectedIds = [];
|
let currentSelectedIds = [];
|
||||||
for (let [key, shape] of objMap) {
|
for (let [key, shape] of objMap.value) {
|
||||||
const shapeConfig = objMap.get(shape.id);
|
const shapeConfig = objMap.value.get(shape.id);
|
||||||
if (isUndefined(shapeConfig)) {
|
if (isUndefined(shapeConfig)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUndefined(shapeConfig.box)) {
|
if (isUndefined(shapeConfig.hoverBox)) {
|
||||||
if (
|
if (
|
||||||
shapeConfig.config.width &&
|
shapeConfig.config.width &&
|
||||||
shapeConfig.config.height &&
|
shapeConfig.config.height &&
|
||||||
shapeConfig.config.rotation
|
shapeConfig.config.rotation
|
||||||
) {
|
) {
|
||||||
shapeConfig.box = calculateRectBounding(
|
shapeConfig.hoverBox = calculateRectBounding(
|
||||||
shapeConfig.config.width,
|
shapeConfig.config.width,
|
||||||
shapeConfig.config.height,
|
shapeConfig.config.height,
|
||||||
shapeConfig.config.rotation,
|
shapeConfig.config.rotation,
|
||||||
|
@ -451,10 +411,10 @@ function handleMouseUp(e: Event) {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Konva.Util.haveIntersection(selBox, {
|
Konva.Util.haveIntersection(selBox, {
|
||||||
x: shapeConfig.box.x + shapeConfig.x,
|
x: shapeConfig.hoverBox.x + shapeConfig.x,
|
||||||
y: shapeConfig.box.y + shapeConfig.y,
|
y: shapeConfig.hoverBox.y + shapeConfig.y,
|
||||||
width: shapeConfig.box.width,
|
width: shapeConfig.hoverBox.width,
|
||||||
height: shapeConfig.box.height,
|
height: shapeConfig.hoverBox.height,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
currentSelectedIds.push(shapeConfig.id);
|
currentSelectedIds.push(shapeConfig.id);
|
||||||
|
@ -521,19 +481,19 @@ function handleCanvasObjectMouseOver(evt: Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client rect
|
// Get client rect
|
||||||
const objectConfig = objMap.get(object.id());
|
const objectConfig = objMap.value.get(object.id());
|
||||||
if (isUndefined(objectConfig)) {
|
if (isUndefined(objectConfig)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get clientBox for first time
|
// Get clientBox for first time
|
||||||
if (isUndefined(objectConfig.box)) {
|
if (isUndefined(objectConfig.hoverBox)) {
|
||||||
if (
|
if (
|
||||||
objectConfig.config.width &&
|
objectConfig.config.width &&
|
||||||
objectConfig.config.height &&
|
objectConfig.config.height &&
|
||||||
objectConfig.config.rotation
|
objectConfig.config.rotation
|
||||||
) {
|
) {
|
||||||
objectConfig.box = calculateRectBounding(
|
objectConfig.hoverBox = calculateRectBounding(
|
||||||
objectConfig.config.width,
|
objectConfig.config.width,
|
||||||
objectConfig.config.height,
|
objectConfig.config.height,
|
||||||
objectConfig.config.rotation,
|
objectConfig.config.rotation,
|
||||||
|
@ -562,7 +522,7 @@ function handleCanvasObjectMouseOut(evt: Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client rect
|
// Get client rect
|
||||||
const objectConfig = objMap.get(object.id());
|
const objectConfig = objMap.value.get(object.id());
|
||||||
if (isUndefined(objectConfig)) {
|
if (isUndefined(objectConfig)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,609 +0,0 @@
|
||||||
<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>
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { VNode } from "@/utils/VueKonvaType";
|
||||||
|
import Konva from "konva";
|
||||||
|
import BaseBoard from "../equipments/BaseBoard.vue";
|
||||||
|
|
||||||
|
export type LabCanvasComponentConfig = {
|
||||||
|
id: string;
|
||||||
|
component: VNode;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
config: Konva.ShapeConfig;
|
||||||
|
isHoverring: boolean;
|
||||||
|
hoverBox: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LabCanvasComponent = typeof BaseBoard;
|
|
@ -1,675 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,236 +0,0 @@
|
||||||
# 快速开始指南
|
|
||||||
|
|
||||||
这个文档将帮助你快速开始使用重构后的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)。
|
|
|
@ -1,281 +0,0 @@
|
||||||
# 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) => {
|
|
||||||
// 自定义渲染逻辑
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
这个重构版本提供了更好的类型安全、更清晰的架构分离,以及更强大的扩展性。
|
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { computed, shallowRef } from "vue";
|
||||||
|
import { createInjectionState, useStorage } from "@vueuse/core";
|
||||||
|
import { type LabCanvasComponentConfig } from "../LabCanvasType";
|
||||||
|
import type Konva from "konva";
|
||||||
|
|
||||||
|
const [useProvideLabCanvasStore, useLabCanvasStore] = createInjectionState(
|
||||||
|
(initialStageConfig: Konva.StageConfig) => {
|
||||||
|
const components = useStorage(
|
||||||
|
"LabCanvasComponents",
|
||||||
|
[] as LabCanvasComponentConfig[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// state
|
||||||
|
const stageConfig = shallowRef<Konva.StageConfig>(initialStageConfig);
|
||||||
|
|
||||||
|
// getters
|
||||||
|
const getComponentById = computed(() => (id: string) => {
|
||||||
|
return components.value.find(component => component.id === id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const componentCount = computed(() => components.value.length);
|
||||||
|
|
||||||
|
// actions
|
||||||
|
function addComponent(componentData: {
|
||||||
|
id: string;
|
||||||
|
component: any;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
config: Konva.ShapeConfig;
|
||||||
|
}) {
|
||||||
|
const newComponent: LabCanvasComponentConfig = {
|
||||||
|
id: componentData.id,
|
||||||
|
component: componentData.component,
|
||||||
|
x: componentData.x ?? 100,
|
||||||
|
y: componentData.y ?? 100,
|
||||||
|
config: componentData.config,
|
||||||
|
isHoverring: false,
|
||||||
|
hoverBox: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: componentData.config.width || 100,
|
||||||
|
height: componentData.config.height || 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
components.value.push(newComponent);
|
||||||
|
return newComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComponent(id: string) {
|
||||||
|
const index = components.value.findIndex(component => component.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const removedComponent = components.value[index];
|
||||||
|
components.value.splice(index, 1);
|
||||||
|
return removedComponent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComponents(ids: string[]) {
|
||||||
|
const removedComponents: LabCanvasComponentConfig[] = [];
|
||||||
|
|
||||||
|
ids.forEach(id => {
|
||||||
|
const removed = removeComponent(id);
|
||||||
|
if (removed) {
|
||||||
|
removedComponents.push(removed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return removedComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponent(id: string, updates: Partial<LabCanvasComponentConfig>) {
|
||||||
|
const component = components.value.find(comp => comp.id === id);
|
||||||
|
if (component) {
|
||||||
|
Object.assign(component, updates);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponentPosition(id: string, x: number, y: number) {
|
||||||
|
return updateComponent(id, { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComponentConfig(id: string, config: Partial<Konva.ShapeConfig>) {
|
||||||
|
const component = components.value.find(comp => comp.id === id);
|
||||||
|
if (component) {
|
||||||
|
component.config = { ...component.config, ...config };
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearComponents() {
|
||||||
|
const clearedComponents = [...components.value];
|
||||||
|
components.value.splice(0);
|
||||||
|
return clearedComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setComponents(newComponents: LabCanvasComponentConfig[]) {
|
||||||
|
components.value.splice(0, components.value.length, ...newComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stageConfig,
|
||||||
|
components,
|
||||||
|
// getters
|
||||||
|
getComponentById,
|
||||||
|
componentCount,
|
||||||
|
// actions
|
||||||
|
addComponent,
|
||||||
|
removeComponent,
|
||||||
|
removeComponents,
|
||||||
|
updateComponent,
|
||||||
|
updateComponentPosition,
|
||||||
|
updateComponentConfig,
|
||||||
|
clearComponents,
|
||||||
|
setComponents,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export { useProvideLabCanvasStore, useLabCanvasStore };
|
|
@ -1,574 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,406 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,323 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,316 +1,5 @@
|
||||||
// LabCanvas组件管理系统 - 统一导出
|
import LabCanvas from './LabCanvas.vue';
|
||||||
// 基于VueUse重构的Konva组件管理系统
|
import LabComponentsDrawer from './LabComponentsDrawer.vue';
|
||||||
|
import { useProvideLabCanvasStore, useLabCanvasStore } from './composable/LabCanvasManager';
|
||||||
|
|
||||||
// 类型定义
|
export {LabCanvas, LabComponentsDrawer};
|
||||||
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'
|
|
|
@ -1,118 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -26,9 +26,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LabComponentsDrawer from "@/components/LabCanvas/LabComponentsDrawer.vue";
|
import { useProvideLabCanvasStore } from "@/components/LabCanvas/composable/LabCanvasManager";
|
||||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from "reka-ui";
|
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from "reka-ui";
|
||||||
import LabCanvas from "@/components/LabCanvas/LabCanvas.vue";
|
import LabCanvas from "@/components/LabCanvas/LabCanvas.vue";
|
||||||
|
|
||||||
|
useProvideLabCanvasStore({width:window.innerWidth, height: window.innerHeight});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -1,533 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col">
|
|
||||||
<!-- 头部工具栏 -->
|
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
|
||||||
<div class="navbar-start">
|
|
||||||
<h1 class="text-xl font-bold">FPGA WebLab - 实验画布</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="navbar-center">
|
|
||||||
<div class="join">
|
|
||||||
<button
|
|
||||||
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="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">
|
|
||||||
<span class="label-text">组件名称</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm w-full"
|
|
||||||
v-model="selectedComponents[0].name"
|
|
||||||
@change="updateComponentName"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">X 位置</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
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>
|
|
||||||
</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 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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import LabCanvasNew from '@/components/LabCanvas/LabCanvasNew.vue'
|
|
||||||
import { useKonvaComponentManager } from '@/components/LabCanvas/composables/useKonvaComponentManager'
|
|
||||||
import type { KonvaComponent } from '@/components/LabCanvas/types/KonvaComponent'
|
|
||||||
|
|
||||||
// 模式状态
|
|
||||||
const currentMode = ref<'design' | 'simulate'>('design')
|
|
||||||
|
|
||||||
// 面板显示状态
|
|
||||||
const showPropertyPanel = ref(true)
|
|
||||||
const showLayerPanel = ref(true)
|
|
||||||
|
|
||||||
// 组件管理器
|
|
||||||
const componentManager = useKonvaComponentManager({
|
|
||||||
enableHistory: true,
|
|
||||||
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 {
|
|
||||||
const projectData = JSON.parse(e.target?.result as string)
|
|
||||||
componentManager.loadProjectData(projectData)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load project:', error)
|
|
||||||
alert('项目文件格式错误')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportProject() {
|
|
||||||
// TODO: 实现导出功能(如导出为图片、PDF等)
|
|
||||||
console.log('Exporting project...')
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSimulation() {
|
|
||||||
// TODO: 实现仿真功能
|
|
||||||
console.log('Starting simulation...')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
console.log('FPGA WebLab Example loaded')
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 自定义样式 */
|
|
||||||
.navbar {
|
|
||||||
min-height: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range {
|
|
||||||
background: linear-gradient(to right, #ddd 0%, #ddd var(--value), #ccc var(--value), #ccc 100%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
Loading…
Reference in New Issue