refactor: 重构canvas
This commit is contained in:
parent
14d8499f77
commit
c3bd61ed51
|
@ -3,7 +3,7 @@
|
|||
<v-stage
|
||||
class="h-full w-full"
|
||||
ref="stageRef"
|
||||
:config="stageSize"
|
||||
:config="labCanvasStore?.stageConfig"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
|
@ -29,12 +29,13 @@
|
|||
@mouseover="handleCanvasObjectMouseOver"
|
||||
@mouseout="handleCanvasObjectMouseOut"
|
||||
>
|
||||
<!-- Hover Box -->
|
||||
<v-rect
|
||||
v-show="!isUndefined(item.box)"
|
||||
v-show="!isUndefined(item.hoverBox)"
|
||||
:config="{
|
||||
...item.box,
|
||||
...item.hoverBox,
|
||||
visible:
|
||||
!isUndefined(item.box) &&
|
||||
!isUndefined(item.hoverBox) &&
|
||||
item.isHoverring &&
|
||||
!isDragging &&
|
||||
selectedIds.length == 0,
|
||||
|
@ -45,7 +46,13 @@
|
|||
}"
|
||||
>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -72,6 +79,7 @@
|
|||
<LabComponentsDrawer
|
||||
class="absolute top-10 right-20"
|
||||
v-model:open="isDrawerOpen"
|
||||
:add-component="labCanvasStore.addComponent"
|
||||
></LabComponentsDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -80,38 +88,13 @@
|
|||
import LabComponentsDrawer from "./LabComponentsDrawer.vue";
|
||||
import Konva from "konva";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
import type {
|
||||
VGroup,
|
||||
VLayer,
|
||||
VNode,
|
||||
VStage,
|
||||
VTransformer,
|
||||
} from "@/utils/VueKonvaType";
|
||||
import { ref, reactive, watch, onMounted, useTemplateRef } from "vue";
|
||||
import type { IRect } from "konva/lib/types";
|
||||
import type { VLayer, VNode, VStage, VTransformer } from "@/utils/VueKonvaType";
|
||||
import { ref, reactive, watch, onMounted, useTemplateRef, computed } from "vue";
|
||||
import type { Stage } from "konva/lib/Stage";
|
||||
import type { LabCanvasComponentConfig } from "./LabCanvasType";
|
||||
import { useLabCanvasStore } from "./composable/LabCanvasManager";
|
||||
|
||||
const stageSize = {
|
||||
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;
|
||||
};
|
||||
const labCanvasStore = useLabCanvasStore();
|
||||
|
||||
function calculateRectBounding(
|
||||
width: number,
|
||||
|
@ -164,31 +147,8 @@ function calculateRectBounding(
|
|||
}
|
||||
}
|
||||
|
||||
const objMap = reactive<Map<string, CanvasObject>>(new Map());
|
||||
onMounted(() => {
|
||||
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 objMap = computed(() => {
|
||||
return new Map(labCanvasStore?.components.value.map((item) => [item.id, item]));
|
||||
});
|
||||
|
||||
const layerRef = useTemplateRef<VLayer>("layerRef");
|
||||
|
@ -319,7 +279,7 @@ function handleMouseDown(e: Event) {
|
|||
|
||||
// 获取鼠标事件信息
|
||||
const mouseEvent = (e as any).evt as MouseEvent;
|
||||
|
||||
|
||||
// 如果是右键按下,开始右键拖拽
|
||||
if (mouseEvent.button === 2) {
|
||||
isRightDragging.value = true;
|
||||
|
@ -364,13 +324,13 @@ function handleMouseMove(e: Event) {
|
|||
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;
|
||||
|
@ -424,19 +384,19 @@ function handleMouseUp(e: Event) {
|
|||
|
||||
// console.log(`Stage Scale: ${stageScale.value}`);
|
||||
let currentSelectedIds = [];
|
||||
for (let [key, shape] of objMap) {
|
||||
const shapeConfig = objMap.get(shape.id);
|
||||
for (let [key, shape] of objMap.value) {
|
||||
const shapeConfig = objMap.value.get(shape.id);
|
||||
if (isUndefined(shapeConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUndefined(shapeConfig.box)) {
|
||||
if (isUndefined(shapeConfig.hoverBox)) {
|
||||
if (
|
||||
shapeConfig.config.width &&
|
||||
shapeConfig.config.height &&
|
||||
shapeConfig.config.rotation
|
||||
) {
|
||||
shapeConfig.box = calculateRectBounding(
|
||||
shapeConfig.hoverBox = calculateRectBounding(
|
||||
shapeConfig.config.width,
|
||||
shapeConfig.config.height,
|
||||
shapeConfig.config.rotation,
|
||||
|
@ -451,10 +411,10 @@ function handleMouseUp(e: Event) {
|
|||
|
||||
if (
|
||||
Konva.Util.haveIntersection(selBox, {
|
||||
x: shapeConfig.box.x + shapeConfig.x,
|
||||
y: shapeConfig.box.y + shapeConfig.y,
|
||||
width: shapeConfig.box.width,
|
||||
height: shapeConfig.box.height,
|
||||
x: shapeConfig.hoverBox.x + shapeConfig.x,
|
||||
y: shapeConfig.hoverBox.y + shapeConfig.y,
|
||||
width: shapeConfig.hoverBox.width,
|
||||
height: shapeConfig.hoverBox.height,
|
||||
})
|
||||
)
|
||||
currentSelectedIds.push(shapeConfig.id);
|
||||
|
@ -521,19 +481,19 @@ function handleCanvasObjectMouseOver(evt: Event) {
|
|||
}
|
||||
|
||||
// Get client rect
|
||||
const objectConfig = objMap.get(object.id());
|
||||
const objectConfig = objMap.value.get(object.id());
|
||||
if (isUndefined(objectConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get clientBox for first time
|
||||
if (isUndefined(objectConfig.box)) {
|
||||
if (isUndefined(objectConfig.hoverBox)) {
|
||||
if (
|
||||
objectConfig.config.width &&
|
||||
objectConfig.config.height &&
|
||||
objectConfig.config.rotation
|
||||
) {
|
||||
objectConfig.box = calculateRectBounding(
|
||||
objectConfig.hoverBox = calculateRectBounding(
|
||||
objectConfig.config.width,
|
||||
objectConfig.config.height,
|
||||
objectConfig.config.rotation,
|
||||
|
@ -562,7 +522,7 @@ function handleCanvasObjectMouseOut(evt: Event) {
|
|||
}
|
||||
|
||||
// Get client rect
|
||||
const objectConfig = objMap.get(object.id());
|
||||
const objectConfig = objMap.value.get(object.id());
|
||||
if (isUndefined(objectConfig)) {
|
||||
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组件管理系统 - 统一导出
|
||||
// 基于VueUse重构的Konva组件管理系统
|
||||
import LabCanvas from './LabCanvas.vue';
|
||||
import LabComponentsDrawer from './LabComponentsDrawer.vue';
|
||||
import { useProvideLabCanvasStore, useLabCanvasStore } from './composable/LabCanvasManager';
|
||||
|
||||
// 类型定义
|
||||
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'
|
||||
export {LabCanvas, LabComponentsDrawer};
|
|
@ -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>
|
||||
|
||||
<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 LabCanvas from "@/components/LabCanvas/LabCanvas.vue";
|
||||
|
||||
useProvideLabCanvasStore({width:window.innerWidth, height: window.innerHeight});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -1,533 +1,5 @@
|
|||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
.navbar {
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.range {
|
||||
background: linear-gradient(to right, #ddd 0%, #ddd var(--value), #ccc var(--value), #ccc 100%);
|
||||
}
|
||||
</style>
|
||||
</script>
|
Loading…
Reference in New Issue