refactor: 重构canvas

This commit is contained in:
SikongJueluo 2025-07-03 21:56:58 +08:00
parent 14d8499f77
commit c3bd61ed51
No known key found for this signature in database
14 changed files with 186 additions and 4142 deletions

View File

@ -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");
@ -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;
}

View File

@ -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>

View File

@ -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;

View File

@ -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";
// PropsEvents
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>

View File

@ -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)。

View File

@ -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) => {
// 自定义渲染逻辑
}
})
```
这个重构版本提供了更好的类型安全、更清晰的架构分离,以及更强大的扩展性。

View File

@ -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 };

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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};

View File

@ -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
}

View File

@ -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>

View File

@ -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>