feat: remake most of forntend

This commit is contained in:
alivender 2025-04-26 19:59:35 +08:00
parent 4e741f9ef8
commit bc4f44ecaa
41 changed files with 84095 additions and 672 deletions

93
package-lock.json generated
View File

@ -8,9 +8,8 @@
"name": "fpga-weblab",
"version": "0.1.0",
"dependencies": {
"@svgdotjs/svg.js": "^3.2.4",
"@types/lodash": "^4.17.16",
"@wokwi/elements": "^1.7.0",
"all": "^0.0.0",
"lodash": "^4.17.21",
"log-symbols": "^7.0.0",
"pinia": "^3.0.1",
@ -999,21 +998,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -1344,6 +1328,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@svgdotjs/svg.js": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz",
"integrity": "sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz",
@ -1642,21 +1636,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz",
@ -1997,19 +1976,6 @@
}
}
},
"node_modules/@wokwi/elements": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@wokwi/elements/-/elements-1.7.0.tgz",
"integrity": "sha512-8vgePMFmcmTldUvnCfkIMZ4tijy6H7e34JPQsdJPcJXUWdrp8XGEYnFETY6qrAI1Icm7SQChcarG/NpEV0Zn9Q==",
"license": "MIT",
"dependencies": {
"@types/react": ">=16",
"lit": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
@ -2017,12 +1983,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/all": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/all/-/all-0.0.0.tgz",
"integrity": "sha512-0oKlfNVv2d+d7c1gwjGspzgbwot47PGQ4b3v1ccx4mR8l9P/Y6E6Dr/yE8lNT63EcAKEbHo6UG3odDpC/NQcKw==",
"license": "MIT"
},
"node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
@ -3019,37 +2979,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lit": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",

View File

@ -14,9 +14,8 @@
"postgen-api": "pkill server"
},
"dependencies": {
"@svgdotjs/svg.js": "^3.2.4",
"@types/lodash": "^4.17.16",
"@wokwi/elements": "^1.7.0",
"all": "^0.0.0",
"lodash": "^4.17.21",
"log-symbols": "^7.0.0",
"pinia": "^3.0.1",

View File

@ -1,39 +1,56 @@
<script setup lang="ts">
import iconMenu from "./assets/menu.svg";
import Sidebar from "./components/Sidebar.vue";
import Navbar from "./components/Navbar.vue";
import { useThemeStore } from "./stores/theme";
import { ref, provide, onMounted } from "vue";
const theme = useThemeStore();
const items = [
{ id: 1, icon: iconMenu, text: "用户界面", page: "/user" },
{ id: 2, icon: iconMenu, text: "ComponentTest", page: "/test" },
{ id: 3, icon: iconMenu, text: "JtagTest", page: "/test/jtag" },
{ id: 4, icon: iconMenu, text: "工程界面", page: "/project" }, //
];
//
const isDarkMode = ref(window.matchMedia('(prefers-color-scheme: dark)').matches);
//
onMounted(() => {
//
applyTheme();
//
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
//
isDarkMode.value = e.matches;
applyTheme();
});
});
//
const applyTheme = () => {
document.documentElement.setAttribute('data-theme', isDarkMode.value ? 'night' : 'winter');
};
//
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value;
applyTheme();
};
//
provide('theme', {
isDarkMode,
toggleTheme
});
</script>
<template>
<div :data-theme="theme.currentTheme">
<div>
<header class="relative">
<div class="fixed left-0 top-0 z-50 hidden">
<Sidebar :items="items" />
</div>
<Navbar></Navbar>
</header>
<main>
<RouterView />
</main>
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
</main> <footer class="footer footer-center p-4 bg-base-300 text-base-content">
<div>
<p>Copyright © 2023 - All right reserved by OurEDA</p>
</div>
</footer>
</div>
</footer> </div>
</template>
<style scoped>
@import "./assets/main.css";
/* 特定于App.vue的样式 */
</style>

View File

@ -6,3 +6,26 @@
@custom-variant dark (&:where([data-theme=night], [data-theme=night] *));
@custom-variant light (&:where([data-theme=winter], [data-theme=winter] *));
/* 禁止所有图像和SVG选择 */
img, svg {
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
pointer-events: none !important;
}
/* 允许组件中的交互元素接收事件 */
.component-wrapper img,
.component-wrapper svg {
pointer-events: auto !important;
}
/* 禁止双击选择文本 */
.no-select {
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}

View File

@ -0,0 +1,277 @@
<template>
<div>
<!-- 元器件选择菜单 (Drawer) -->
<div class="drawer drawer-end z-50">
<input id="component-drawer" type="checkbox" class="drawer-toggle" v-model="showComponentsMenu" />
<div class="drawer-side">
<label for="component-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">
<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" class="text-primary">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 8v8"></path>
<path d="M8 12h8"></path>
</svg>
添加元器件
</h3>
<label for="component-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="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 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="(component, index) in filteredComponents" :key="index"
class="card bg-base-200 hover:bg-base-300 transition-all duration-300 hover:shadow-md cursor-pointer"
@click="addComponent(component)">
<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">
<!-- 直接使用组件作为预览 -->
<component
v-if="componentModules[component.type]"
:is="componentModules[component.type].default"
class="component-preview"
:size="getPreviewSize(component.type)"
/>
<!-- 加载中状态 -->
<span v-else class="text-xs text-gray-400">加载中...</span>
</div>
<h3 class="card-title text-sm mt-2">{{ component.name }}</h3>
<p class="text-xs opacity-70">{{ component.type }}</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="component-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="component-drawer" class="btn btn-sm btn-primary" @click="closeMenu">
完成
</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, onMounted } from 'vue';
import { getComponentConfig } from '@/components/equipments/componentConfig';
// Props
interface Props {
open: boolean;
}
const props = defineProps<Props>();
//
const emit = defineEmits(['close', 'add-component', 'update:open']);
// --- ---
const searchQuery = ref('');
// --- ---
const availableComponents = [
{ type: 'MechanicalButton', name: '机械按钮' },
{ type: 'Switch', name: '开关' },
{ type: 'Pin', name: '引脚' },
{ type: 'SMT_LED', name: '贴片LED' },
{ type: 'HDMI', name: 'HDMI接口' },
{ type: 'DDR', name: 'DDR内存' },
{ type: 'ETH', name: '以太网接口' },
{ type: 'SD', name: 'SD卡插槽' },
{ type: 'SFP', name: 'SFP光纤模块' },
{ type: 'SMA', name: 'SMA连接器' },
{ type: 'MotherBoard', name: '主板' }
];
// /
const showComponentsMenu = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
});
//
const componentModules = shallowRef<Record<string, any>>({});
//
async function loadComponentModule(type: string) {
if (!componentModules.value[type]) {
try {
// src/components/equipments/ type
const module = await import(`../components/equipments/${type}.vue`);
//
componentModules.value = {
...componentModules.value,
[type]: module
};
console.log(`Loaded module for ${type}:`, module);
} catch (error) {
console.error(`Failed to load component module ${type}:`, error);
return null;
}
}
return componentModules.value[type];
}
//
async function preloadComponentModules() {
for (const component of availableComponents) {
try {
await loadComponentModule(component.type);
} catch (error) {
console.error(`Failed to preload component ${component.type}:`, error);
}
}
}
//
function getPreviewSize(componentType: string): number {
//
const previewSizes: Record<string, number> = {
'MechanicalButton': 0.4, //
'Switch': 0.35, //
'Pin': 0.8, //
'SMT_LED': 0.7, // LED
'HDMI': 0.5, // HDMI
'DDR': 0.5, // DDR
'ETH': 0.5, //
'SD': 0.6, // SD
'SFP': 0.4, // SFP
'SMA': 0.7, // SMA
'MotherBoard': 0.13 //
};
// 0.5
return previewSizes[componentType] || 0.5;
}
//
function searchComponents() {
//
// filteredComponents
}
//
function closeMenu() {
showComponentsMenu.value = false;
emit('close');
}
//
async function addComponent(componentTemplate: { type: string; name: string }) {
//
const config = getComponentConfig(componentTemplate.type);
const defaultProps: Record<string, any> = {};
if (config && config.props) {
config.props.forEach(prop => {
defaultProps[prop.name] = prop.default;
});
}
// 便使
await loadComponentModule(componentTemplate.type);
//
emit('add-component', {
type: componentTemplate.type,
name: componentTemplate.name,
props: defaultProps
});
//
closeMenu();
}
// ()
const filteredComponents = computed(() => {
if (!searchQuery.value) {
return availableComponents;
}
const query = searchQuery.value.toLowerCase();
return availableComponents.filter(component =>
component.name.toLowerCase().includes(query) ||
component.type.toLowerCase().includes(query)
);
});
//
onMounted(() => {
//
preloadComponentModules();
});
</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

@ -0,0 +1,80 @@
.diagram-canvas {
position: relative;
width: 4000px;
height: 4000px;
transform-origin: 0 0;
}
/* 将网格线应用到容器而不是画布上,确保覆盖整个可视区域 */
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto {
background-image:
linear-gradient(to right, rgba(100, 100, 100, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(100, 100, 100, 0.1) 1px, transparent 1px),
linear-gradient(to right, rgba(80, 80, 80, 0.2) 100px, transparent 100px),
linear-gradient(to bottom, rgba(80, 80, 80, 0.2) 100px, transparent 100px);
background-size: 20px 20px, 20px 20px, 100px 100px, 100px 100px;
background-position: 0 0;
}
/* 为黑暗模式设置不同的网格线颜色 */
:root[data-theme="dark"] .flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto {
background-image:
linear-gradient(to right, rgba(200, 200, 200, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(200, 200, 200, 0.1) 1px, transparent 1px),
linear-gradient(to right, rgba(180, 180, 180, 0.15) 100px, transparent 100px),
linear-gradient(to bottom, rgba(180, 180, 180, 0.15) 100px, transparent 100px);
background-size: 20px 20px, 20px 20px, 100px 100px, 100px 100px;
background-position: 0 0;
}
/* 禁用滚动条 */
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* 元器件容器样式 */
.component-wrapper {
position: relative;
padding: 5px;
box-sizing: border-box;
display: inline-block; /* 确保元素宽度基于内容 */
max-width: fit-content; /* 强制宽度适应内容 */
max-height: fit-content; /* 强制高度适应内容 */
overflow: visible; /* 允许内容溢出(用于显示边框) */
}
/* 悬停状态 */
.component-hover::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 3px dashed #3498db;
pointer-events: none;
z-index: 1;
border-radius: 4px;
box-sizing: content-box;
}
/* 选中状态 */
.component-selected::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 4px dashed #e74c3c;
border-color: #e74c3c #f39c12 #3498db #2ecc71;
pointer-events: none;
z-index: 1;
border-radius: 4px;
box-sizing: content-box;
}

View File

@ -1,370 +1,450 @@
<template>
<div class="flex-1 min-w-[60%] bg-base-200 relative overflow-auto" ref="canvasContainer"
@mousedown="handleCanvasMouseDown"
<div class="flex-1 min-w-[60%] bg-base-200 relative overflow-hidden diagram-container" ref="canvasContainer"
@mousedown="handleCanvasMouseDown"
@mousedown.middle.prevent="startMiddleDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@wheel="onZoom">
<div
ref="canvas"
class="diagram-canvas"
:style="{transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`}">
<!-- wokwi-elements FPGA开发板 -->
<wokwi-fpga-board class="absolute top-10 left-10" style="width:600px;height:400px;"></wokwi-fpga-board>
<!-- 放置其他元器件的区域 -->
<div v-for="component in components" :key="component.id"
class="absolute cursor-move component-wrapper"
@wheel.prevent="onZoom">
<div
ref="canvas"
class="diagram-canvas"
:style="{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }"> <!-- 渲染画布上的组件 -->
<div v-for="component in props.components" :key="component.id"
class="component-wrapper"
:class="{
'component-hover': hoveredComponent === component.id,
'component-selected': selectedComponent === component.id
'component-selected': selectedComponentId === component.id
}"
:style="{
top: component.y + 'px',
left: component.x + 'px',
zIndex: selectedComponentId === component.id ? 999 : 1
}"
:style="{top: component.y + 'px', left: component.x + 'px', width: 'auto', height: 'auto'}"
@mousedown.left.stop="startComponentDrag($event, component)"
@mouseover="hoveredComponent = component.id"
@mouseleave="hoveredComponent = null">
<component :is="component.type"></component>
@mouseleave="hoveredComponent = null"><!-- 动态渲染组件 -->
<component
:is="getComponentDefinition(component.type)"
v-if="props.componentModules[component.type]"
v-bind="prepareComponentProps(component.props || {})"
@update:bindKey="(value: string) => updateComponentProp(component.id, 'bindKey', value)"
:ref="el => { if (el) componentRefs[component.id] = el; }"
/>
<!-- Fallback if component module not loaded yet -->
<div v-else class="p-2 text-xs text-gray-400 border border-dashed border-gray-400">
Loading {{ component.type }}...
</div>
</div>
</div>
<!-- 缩放指示器 -->
<div class="absolute bottom-2 right-2 bg-base-100 px-2 py-1 rounded-md opacity-70">
{{ Math.round(scale * 100) }}%
<div class="absolute bottom-4 right-4 bg-base-100 px-3 py-1.5 rounded-md shadow-md z-20" style="opacity: 0.9;">
<span class="text-sm font-medium">{{ Math.round(scale * 100) }}%</span>
</div>
</div>
</template>
<script setup lang="ts">
// wokwi-elements
import "@wokwi/elements";
import { ref, reactive, onMounted, watch } from 'vue';
import { ref, reactive, onMounted, onUnmounted } from 'vue';
//
const props = defineProps<{
initialComponents?: Array<ComponentItem>,
}>();
//
const emit = defineEmits(['component-selected', 'component-moved']);
//
interface ComponentItem {
id: string;
type: string;
name: string;
x: number;
y: number;
props?: Record<string, any>;
}
//
const props = defineProps<{
components: ComponentItem[],
componentModules: Record<string, any>
}>();
//
const emit = defineEmits(['component-selected', 'component-moved', 'update-component-prop', 'component-delete']);
// --- ---
const canvasContainer = ref<HTMLElement | null>(null);
const canvas = ref<HTMLElement | null>(null);
const position = reactive({ x: 0, y: 0 });
const scale = ref(1);
const isDragging = ref(false);
const isMiddleDragging = ref(false); // 使
const isMiddleDragging = ref(false);
const dragStart = reactive({ x: 0, y: 0 });
const canvas = ref(null);
const canvasContainer = ref(null);
//
const components = ref<ComponentItem[]>([]);
const draggingComponent = ref<ComponentItem | null>(null);
// props
watch(() => props.initialComponents, (newComponents) => {
if (newComponents) {
//
components.value = JSON.parse(JSON.stringify(newComponents));
}
}, { immediate: true, deep: true });
const selectedComponentId = ref<string | null>(null);
const hoveredComponent = ref<string | null>(null);
const draggingComponentId = ref<string | null>(null);
const componentDragOffset = reactive({ x: 0, y: 0 });
const hoveredComponent = ref(null); // ID
const selectedComponent = ref(null); // ID
//
function startDrag(e) {
//
if (e.button !== 0) return;
//
const componentRefs = ref<Record<string, any>>({});
//
isMiddleDragging.value = false;
isDragging.value = true;
dragStart.x = e.clientX - position.x;
dragStart.y = e.clientY - position.y;
// --- ---
const MIN_SCALE = 0.2;
const MAX_SCALE = 3.0;
function onZoom(e: WheelEvent) {
e.preventDefault();
}
//
function startMiddleDrag(e) {
//
if (e.button !== 1) return;
e.preventDefault();
e.stopPropagation();
if (!canvasContainer.value) return;
//
isDragging.value = false;
//
const containerRect = canvasContainer.value.getBoundingClientRect();
//
isMiddleDragging.value = true;
//
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
//
dragStart.x = e.clientX - position.x;
dragStart.y = e.clientY - position.y;
}
//
function handleCanvasMouseDown(e) {
//
if (e.button !== 0) return;
//
const mouseXCanvas = (mouseX - position.x) / scale.value;
const mouseYCanvas = (mouseY - position.y) / scale.value;
//
if (e.target === canvasContainer.value || e.target === canvas.value) {
selectedComponent.value = null;
emit('component-selected', null);
}
//
const zoomFactor = 1.1; // /10%
const direction = e.deltaY > 0 ? -1 : 1;
//
startDrag(e);
}
function onDrag(e) {
//
if (isDragging.value || isMiddleDragging.value) {
//
e.preventDefault();
position.x = e.clientX - dragStart.x;
position.y = e.clientY - dragStart.y;
}
}
function stopDrag() {
isDragging.value = false;
isMiddleDragging.value = false;
}
//
function onZoom(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.3, Math.min(3, scale.value + delta));
//
let newScale = direction > 0 ? scale.value * zoomFactor : scale.value / zoomFactor;
newScale = Math.max(MIN_SCALE, Math.min(newScale, MAX_SCALE));
//
if (canvas.value && canvasContainer.value) {
const rect = canvasContainer.value.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
//
const mouseXInCanvas = (mouseX - position.x) / scale.value;
const mouseYInCanvas = (mouseY - position.y) / scale.value;
//
position.x = mouseX - mouseXInCanvas * newScale;
position.y = mouseY - mouseYInCanvas * newScale;
}
// 使
position.x = mouseX - mouseXCanvas * newScale;
position.y = mouseY - mouseYCanvas * newScale;
//
scale.value = newScale;
}
//
function startComponentDrag(e, component) {
//
if (e.button !== 0) return;
// --- ---
const getComponentDefinition = (type: string) => {
const module = props.componentModules[type];
if (!module) return null;
e.stopPropagation();
draggingComponent.value = component;
//
selectedComponent.value = component.id;
emit('component-selected', component);
//
const initialX = component.x;
const initialY = component.y;
const startX = e.clientX;
const startY = e.clientY;
const mouseMoveHandler = (moveEvent) => {
if (!draggingComponent.value) return;
//
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
//
const canvasDx = dx / scale.value;
const canvasDy = dy / scale.value;
//
draggingComponent.value.x = initialX + canvasDx;
draggingComponent.value.y = initialY + canvasDy;
//
emit('component-moved', {
id: draggingComponent.value.id,
x: draggingComponent.value.x,
y: draggingComponent.value.y
});
};
const mouseUpHandler = () => {
draggingComponent.value = null;
// selectedComponent
window.removeEventListener('mousemove', mouseMoveHandler);
window.removeEventListener('mouseup', mouseUpHandler);
};
window.addEventListener('mousemove', mouseMoveHandler);
window.addEventListener('mouseup', mouseUpHandler);
}
// FPGA
customElements.define('wokwi-fpga-board', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 600px;
height: 400px;
background-color: #2e7d32;
border: 8px solid #1b5e20;
border-radius: 10px;
position: relative;
}
.board-label {
position: absolute;
top: 20px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
font-size: 18px;
font-weight: bold;
}
.board-components {
position: absolute;
top: 60px;
left: 40px;
right: 40px;
bottom: 40px;
background-color: #388e3c;
border-radius: 5px;
}
.pins {
position: absolute;
height: 10px;
display: flex;
gap: 10px;
}
.pins.top {
top: 0;
left: 80px;
right: 80px;
}
.pins.bottom {
bottom: 0;
left: 80px;
right: 80px;
}
.pin {
width: 10px;
height: 20px;
background-color: silver;
border-radius: 2px;
}
</style>
<div class="board-label">FPGA开发板</div>
<div class="board-components"></div>
<div class="pins top">
${Array(20).fill().map(() => '<div class="pin"></div>').join('')}
</div>
<div class="pins bottom">
${Array(20).fill().map(() => '<div class="pin"></div>').join('')}
</div>
`;
//
if (module.default) {
return module.default;
} else if (module.__esModule && module.default) {
// Vue __esModule
return module.default;
} else {
console.warn(`Module for ${type} found but default export is missing`, module);
return null;
}
});
};
//
function addComponent(component: ComponentItem) {
components.value.push(component);
//
function prepareComponentProps(props: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const key in props) {
let value = props[key];
// null/undefined string
if (
(key === 'style' || key === 'direction' || key === 'type') &&
value != null &&
typeof value !== 'string'
) {
value = String(value);
}
result[key] = value;
}
return result;
}
//
function resetCanvas() {
position.x = 0;
position.y = 0;
scale.value = 1;
// --- ---
function handleCanvasMouseDown(e: MouseEvent) {
//
if (e.target === canvasContainer.value || e.target === canvas.value) {
if (selectedComponentId.value !== null) {
selectedComponentId.value = null;
emit('component-selected', null);
}
}
//
if (e.button === 0 && (e.target === canvasContainer.value || e.target === canvas.value)) {
startDrag(e);
}
}
//
function startDrag(e: MouseEvent) {
if (e.button !== 0 || draggingComponentId.value) return;
isDragging.value = true;
isMiddleDragging.value = false;
dragStart.x = e.clientX - position.x;
dragStart.y = e.clientY - position.y;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
//
function startMiddleDrag(e: MouseEvent) {
if (e.button !== 1) return;
isMiddleDragging.value = true;
isDragging.value = false;
draggingComponentId.value = null;
dragStart.x = e.clientX - position.x;
dragStart.y = e.clientY - position.y;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
//
function onDrag(e: MouseEvent) {
if (!isDragging.value && !isMiddleDragging.value) return;
position.x = e.clientX - dragStart.x;
position.y = e.clientY - dragStart.y;
}
//
function stopDrag() {
isDragging.value = false;
isMiddleDragging.value = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
// --- ---
function startComponentDrag(e: MouseEvent, component: ComponentItem) {
const target = e.target as HTMLElement;
// ()
const isInteractiveElement = (
target.tagName === 'rect' ||
target.tagName === 'circle' ||
target.tagName === 'path' ||
target.hasAttribute('fill-opacity') ||
(typeof target.className === 'string' &&
(target.className.includes('glow') || target.className.includes('interactive')))
);
//
if (selectedComponentId.value !== component.id) {
selectedComponentId.value = component.id;
emit('component-selected', component);
}
//
if (isInteractiveElement || e.button !== 0) {
return;
}
//
e.stopPropagation();
//
draggingComponentId.value = component.id;
isDragging.value = false;
isMiddleDragging.value = false;
//
if (!canvasContainer.value) return;
const containerRect = canvasContainer.value.getBoundingClientRect();
//
const mouseX_canvas = (e.clientX - containerRect.left - position.x) / scale.value;
const mouseY_canvas = (e.clientY - containerRect.top - position.y) / scale.value;
//
componentDragOffset.x = mouseX_canvas - component.x;
componentDragOffset.y = mouseY_canvas - component.y;
//
document.addEventListener('mousemove', onComponentDrag);
document.addEventListener('mouseup', stopComponentDrag);
}
//
function onComponentDrag(e: MouseEvent) {
if (!draggingComponentId.value || !canvasContainer.value) return;
//
e.stopPropagation();
e.preventDefault();
const containerRect = canvasContainer.value.getBoundingClientRect();
//
const mouseX_canvas = (e.clientX - containerRect.left - position.x) / scale.value;
const mouseY_canvas = (e.clientY - containerRect.top - position.y) / scale.value;
//
const newX = mouseX_canvas - componentDragOffset.x;
const newY = mouseY_canvas - componentDragOffset.y;
//
emit('component-moved', {
id: draggingComponentId.value,
x: Math.round(newX),
y: Math.round(newY),
});
}
//
function stopComponentDrag() {
draggingComponentId.value = null;
document.removeEventListener('mousemove', onComponentDrag);
document.removeEventListener('mouseup', stopComponentDrag);
}
//
function updateComponentProp(componentId: string, propName: string, value: any) {
emit('update-component-prop', { id: componentId, propName, value });
}
// 访
function getComponentRef(componentId: string) {
const component = props.components.find(c => c.id === componentId);
if (!component) return null;
//
return componentRefs.value[component.id] || null;
}
//
defineExpose({
addComponent,
resetCanvas
getComponentRef
});
// --- ---
onMounted(() => {
//
if (canvasContainer.value) {
position.x = canvasContainer.value.clientWidth / 2;
position.y = canvasContainer.value.clientHeight / 2;
}
if (canvasContainer.value) {
canvasContainer.value.addEventListener('wheel', onZoom);
}
//
window.addEventListener('keydown', handleKeyDown);
});
//
function handleKeyDown(e: KeyboardEvent) {
// Delete
if (selectedComponentId.value && (e.key === 'Delete' || e.key === 'Backspace')) {
//
emit('component-delete', selectedComponentId.value);
//
selectedComponentId.value = null;
}
}
onUnmounted(() => {
//
document.removeEventListener('mousemove', onComponentDrag);
document.removeEventListener('mouseup', stopComponentDrag);
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
if (canvasContainer.value) {
canvasContainer.value.removeEventListener('wheel', onZoom);
}
//
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<style scoped>
.diagram-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background-image:
linear-gradient(to right, rgba(100, 100, 100, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(100, 100, 100, 0.1) 1px, transparent 1px),
linear-gradient(to right, rgba(80, 80, 80, 0.2) 100px, transparent 100px),
linear-gradient(to bottom, rgba(80, 80, 80, 0.2) 100px, transparent 100px);
background-size: 20px 20px, 20px 20px, 100px 100px, 100px 100px;
background-position: 0 0;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.diagram-canvas {
position: relative;
width: 4000px;
height: 4000px;
transform-origin: 0 0;
}
/* 禁用滚动条 */
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* 元器件容器样式 */
.component-wrapper {
position: relative;
padding: 5px;
box-sizing: border-box;
display: inline-block; /* 确保元素宽度基于内容 */
max-width: fit-content; /* 强制宽度适应内容 */
max-height: fit-content; /* 强制高度适应内容 */
position: absolute;
padding: 0; /* 移除内边距,确保元素大小与内容完全匹配 */
box-sizing: content-box; /* 使用content-box确保内容尺寸不受padding影响 */
display: inline-block;
overflow: visible; /* 允许内容溢出(用于显示边框) */
cursor: move; /* 显示移动光标 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* 悬停状态 */
.component-hover::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 3px dashed #3498db;
pointer-events: none;
z-index: 1;
border-radius: 4px;
box-sizing: content-box;
/* 悬停状态 - 使用outline而非伪元素 */
.component-hover {
outline: 2px dashed #3498db;
outline-offset: 2px;
z-index: 2;
}
/* 选中状态 */
.component-selected::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 4px dashed #e74c3c;
border-color: #e74c3c #f39c12 #3498db #2ecc71;
pointer-events: none;
z-index: 1;
border-radius: 4px;
box-sizing: content-box;
/* 选中状态 - 使用outline而非伪元素 */
.component-selected {
outline: 3px dashed;
outline-color: #e74c3c #f39c12 #3498db #2ecc71;
outline-offset: 3px;
z-index: 999 !important; /* 使用更高的z-index确保始终在顶层 */
}
/* 为黑暗模式设置不同的网格线颜色 */
:root[data-theme="dark"] .diagram-container {
background-image:
linear-gradient(to right, rgba(200, 200, 200, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(200, 200, 200, 0.1) 1px, transparent 1px),
linear-gradient(to right, rgba(180, 180, 180, 0.15) 100px, transparent 100px),
linear-gradient(to bottom, rgba(180, 180, 180, 0.15) 100px, transparent 100px);
}
/* 深度选择器 - 默认阻止SVG内部元素的鼠标事件但允许SVG本身和特定交互元素 */
.component-wrapper :deep(svg) {
pointer-events: auto; /* 确保SVG本身可以接收鼠标事件用于拖拽 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.component-wrapper :deep(svg *:not([class*="interactive"]):not(rect.glow):not(circle[fill-opacity]):not([fill-opacity])) {
pointer-events: none; /* 非交互元素不接收鼠标事件 */
}
/* 允许特定SVG元素接收鼠标事件用于交互 */
.component-wrapper :deep(svg circle[fill-opacity]),
.component-wrapper :deep(svg rect[fill-opacity]),
.component-wrapper :deep(svg rect[class*="glow"]),
.component-wrapper :deep(svg rect.glow),
.component-wrapper :deep(svg [class*="interactive"]),
.component-wrapper :deep(button),
.component-wrapper :deep(input) {
pointer-events: auto !important;
}
</style>

View File

@ -1,35 +1,81 @@
<template>
<div class="navbar bg-base-100 shadow-sm">
<div class="navbar bg-base-100 shadow-xl">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost hidden">
<div tabindex="0" role="button" class="btn btn-ghost hover:bg-primary hover:bg-opacity-20 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li><a>Item 1</a></li>
<li>
<a>Parent</a>
<ul class="p-2">
<li><a>Submenu 1</a></li>
<li><a>Submenu 2</a></li>
</ul>
<ul tabindex="0" class="menu menu-sm dropdown-content bg-base-100 rounded-lg z-50 mt-3 w-52 p-2 shadow-lg transition-all duration-300 ease-in-out">
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
首页
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/user" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
用户界面
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/project" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
工程界面
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/test" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
测试功能
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/test/jtag" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
JTAG测试
</router-link>
</li>
<li><a>Item 3</a></li>
</ul>
</div>
</div>
<div class="navbar-center lg:flex">
<a class="btn btn-ghost text-xl">FPGA Web Lab</a>
<router-link to="/" class="btn btn-ghost text-xl font-bold transition-all duration-300 hover:scale-105">
<span class="text-primary">FPGA</span> Web Lab
</router-link>
</div>
<div class="navbar-end">
<a class="btn btn-soft w-20 mx-10">注册</a>
<a class="btn btn-primary w-25">登录</a>
<router-link to="/login" class="btn btn-primary text-base-100 transition-all duration-300 hover:scale-105 hover:shadow-lg mr-3">
登录
</router-link>
<div class="ml-2 transition-all duration-500 hover:rotate-12">
<ThemeControlButton />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ThemeControlButton from "./ThemeControlButton.vue";
</script>
<style scoped>
@import "../assets/main.css";
</style>

View File

@ -1,34 +1,46 @@
<template>
<div>
<label class="swap swap-rotate">
<!-- this hidden checkbox controls the state -->
<input type="checkbox" value="synthwave" @click="theme.toggleTheme" :checked="checkState" />
<div class="theme-control-wrapper">
<label class="swap swap-rotate theme-toggle"> <!-- this hidden checkbox controls the state -->
<input type="checkbox" value="synthwave" @click="toggleTheme" :checked="checkState" />
<!-- sun icon -->
<svg class="swap-off h-10 w-10 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
<div class="sun-wrapper swap-off">
<svg
class="sun-icon h-9 w-9 fill-current text-yellow-500 transform transition-all duration-500 ease-in-out hover:scale-110"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
</div>
<!-- moon icon -->
<svg class="swap-on h-10 w-10 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
<div class="moon-wrapper swap-on">
<svg
class="moon-icon h-9 w-9 fill-current text-indigo-400 transform transition-all duration-500 ease-in-out hover:scale-110"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</div>
</label>
</div>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme';
import { computed } from 'vue';
import { inject, computed } from 'vue';
const theme = useThemeStore();
// App.vue
const { isDarkMode, toggleTheme } = inject('theme') as {
isDarkMode: { value: boolean },
toggleTheme: () => void
};
//
const checkState = computed(() => {
return theme.isDarkTheme()
})
return isDarkMode.value;
});
</script>
<style scoped lang="postcss">

View File

@ -0,0 +1,48 @@
<template> <div class="ddr-component" :style="{ width: width + 'px', height: height + 'px' }">
<img
src="../equipments/svg/ddr.svg"
:width="width"
:height="height"
alt="DDR内存"
class="svg-image"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 120 * props.size);
const height = computed(() => 80 * props.size);
</script>
<style scoped>
.ddr-component {
display: block;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -0,0 +1,48 @@
<template> <div class="eth-component" :style="{ width: width + 'px', height: height + 'px' }">
<img
src="../equipments/svg/eth.svg"
:width="width"
:height="height"
alt="以太网接口"
class="svg-image"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 100 * props.size);
const height = computed(() => 60 * props.size);
</script>
<style scoped>
.eth-component {
display: block;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -0,0 +1,48 @@
<template> <div class="hdmi-component" :style="{ width: width + 'px', height: height + 'px' }">
<img
src="../equipments/svg/hdmi.svg"
:width="width"
:height="height"
alt="HDMI接口"
class="svg-image"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 100 * props.size);
const height = computed(() => 50 * props.size);
</script>
<style scoped>
.hdmi-component {
display: block;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -1,7 +1,13 @@
<template>
<div class="relative" :style="{width: `${props.width}px`, height: `${props.height}px`}">
<!-- 简化的SVG按钮 -->
<svg xmlns="http://www.w3.org/2000/svg" :width="props.width" :height="props.height" viewBox="0 0 1600 1600">
<template>
<div class="button-container" :style="{ width: width + 'px', height: height + 'px', position: 'relative' }">
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="400 400 800 800"
class="mechanical-button"
>
<!-- defs 和按钮底座保持不变 -->
<defs>
<filter id="btn-shadow">
<feGaussianBlur in="SourceAlpha" stdDeviation="20" result="blur" />
@ -11,7 +17,7 @@
<feMergeNode in="offsetBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</filter>
<linearGradient id="normal" gradientTransform="rotate(45 0 0)">
<stop stop-color="#4b4b4b" offset="0" />
<stop stop-color="#171717" offset="1" />
@ -21,35 +27,34 @@
<stop stop-color="#4b4b4b" offset="1" />
</linearGradient>
</defs>
<!-- 按钮底座 -->
<rect width="800" height="800" x="400" y="400" fill="#464646" rx="20" />
<rect width="700" height="700" x="450" y="450" fill="#eaeaea" rx="20" />
<!-- 装饰螺丝 -->
<circle r="20" cx="1075" cy="1075" fill="#171717" />
<circle r="20" cx="1075" cy="525" fill="#171717" />
<circle r="20" cx="525" cy="525" fill="#171717" />
<circle r="20" cx="525" cy="1075" fill="#171717" />
<!-- 按钮主体 -->
<circle r="220" cx="800" cy="800" fill="black" filter="url(#btn-shadow)" />
<circle
:r="btnHeight"
cx="800"
cy="800"
:fill="isKeyPressed ? 'url(#pressed)' : 'url(#normal)'"
<circle
:r="btnHeight"
cx="800"
cy="800"
:fill="isKeyPressed ? 'url(#pressed)' : 'url(#normal)'"
fill-opacity="0.9"
@mousedown="toggleButtonState(true)"
@mouseup="toggleButtonState(false)"
@contextmenu.prevent="openContextMenu($event)"
style="pointer-events: auto; transition: all 20ms ease-in-out;"
@mousedown="toggleButtonState(true)"
@mouseup="toggleButtonState(false)"
@mouseleave="toggleButtonState(false)"
style="pointer-events: auto; transition: all 20ms ease-in-out; cursor: pointer;"
/>
<!-- 按键文字 -->
<text
v-if="bindKey"
x="800"
<text
v-if="displayText"
x="800"
y="800"
font-size="310"
text-anchor="middle"
@ -57,98 +62,169 @@
fill="#ccc"
style="font-family: Arial; filter: url(#btn-shadow); user-select: none; pointer-events: none; mix-blend-mode: overlay;"
>
{{ bindKey.toUpperCase() }}
{{ displayText }}
</text>
</svg>
<!-- 使用DaisyUI的卡片组件实现上下文菜单 -->
<div v-if="showContextMenu"
class="card card-compact fixed z-50 shadow-lg bg-base-100 border border-base-300"
:style="{ top: contextMenuY + 'px', left: contextMenuX + 'px' }"
@click.stop>
<div class="card-body p-0">
<button class="btn btn-ghost justify-start normal-case w-full h-full" @click="startBinding">
<span v-if="isBinding">请输入</span>
<span v-else>绑定按键: {{ bindKey ? bindKey.toUpperCase() : '未绑定' }}</span>
</button>
</div>
<!-- 嵌入Pin组件覆盖在按钮上 -->
<div class="pin-wrapper" :style="{
position: 'absolute',
top: '80%',
left: '50%',
transform: 'translate(-50%, 20%)',
zIndex: 3,
pointerEvents: 'auto'
}">
<Pin
direction="output"
type="digital"
appearance="None"
:label="props.label"
:constraint="props.constraint"
:size="0.8"
@value-change="handlePinValueChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, onMounted, onUnmounted, computed } from 'vue';
import Pin from './Pin.vue';
interface Props {
width?: string | number
height?: string | number
// Pin
interface PinProps {
label?: string;
constraint?: string;
// 便
direction?: 'input' | 'output' | 'inout';
type?: 'digital' | 'analog';
appearance?: 'None' | 'Dip' | 'SMT';
}
//
interface ButtonProps {
size?: number;
bindKey?: string;
buttonText?: string;
}
//
interface Props extends PinProps, ButtonProps {}
const props = withDefaults(defineProps<Props>(), {
width: 160,
height: 160,
})
size: 1,
bindKey: '',
buttonText: '',
label: 'BTN',
constraint: '',
//
direction: 'output',
type: 'digital',
appearance: 'Dip'
});
const bindKey = ref('');
let isKeyPressed = false;
//
const width = computed(() => 160 * props.size);
const height = computed(() => 160 * props.size);
//
const displayText = computed(() => {
if (props.buttonText) return props.buttonText;
return props.bindKey ? props.bindKey.toUpperCase() : '';
});
//
const emit = defineEmits([
'update:bindKey',
'update:label',
'update:constraint',
'press',
'release',
'click',
'value-change'
]);
//
const isKeyPressed = ref(false);
const btnHeight = ref(200);
const colorMatrix = ref("1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0");
const isBinding = ref(false);
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
// Pin
function handlePinValueChange(value: any) {
emit('value-change', value);
}
// --- ---
function toggleButtonState(isPressed: boolean) {
btnHeight.value = isPressed ? 210 : 200;
colorMatrix.value = isPressed
? "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.7 0"
: "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0";
isKeyPressed.value = isPressed;
btnHeight.value = isPressed ? 180 : 200;
//
if (isPressed) {
emit('press');
} else {
emit('release');
emit('click');
}
}
function openContextMenu(e: MouseEvent) {
contextMenuX.value = e.clientX;
contextMenuY.value = e.clientY;
showContextMenu.value = true;
}
function closeContextMenu() {
showContextMenu.value = false;
}
function startBinding() {
if (isBinding.value) return;
isBinding.value = true;
window.addEventListener('keydown', onBindingKeyDown, { once: true });
}
function onBindingKeyDown(e: KeyboardEvent) {
bindKey.value = e.key.toLowerCase();
isBinding.value = false;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key.toLowerCase() === bindKey.value && !isKeyPressed) {
isKeyPressed = true;
//
function handleKeyDown(event: KeyboardEvent) {
if (event.key === props.bindKey) {
toggleButtonState(true);
setTimeout(() => toggleButtonState(false), 150);
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.key.toLowerCase() === bindKey.value) {
isKeyPressed = false;
toggleButtonState(false);
}
}
// --- ---
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('click', closeContextMenu);
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('click', closeContextMenu);
document.removeEventListener('keydown', handleKeyDown);
});
//
defineExpose({
toggleButtonState,
getInfo: () => ({
//
bindKey: props.bindKey,
buttonText: props.buttonText,
// Pin
label: props.label,
constraint: props.constraint,
// Pin
direction: 'output',
type: 'digital',
appearance: 'None'
})
});
</script>
<style scoped>
.button-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.mechanical-button {
display: block;
padding: 0;
margin: 0;
line-height: 0;
font-size: 0;
box-sizing: content-box;
overflow: visible;
}
.pin-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
</style>

View File

@ -0,0 +1,220 @@
<template> <div class="motherboard-container" :style="{ width: width + 'px', height: height + 'px', position: 'relative' }"> <!-- 主板 SVG -->
<img
src="../equipments/svg/motherboard.svg"
:width="width"
:height="height"
alt="主板"
class="svg-image"
draggable="false"
/>
<!-- 嵌入各种组件 --> <!-- HDMI -->
<div class="component-wrapper hdmi-wrapper" :style="{
position: 'absolute',
top: `${140 * props.size}px`,
left: `${-48 * props.size}px`,
zIndex: 10
}">
<HDMI :size="1.5*props.size" />
</div>
<!-- HDMI -->
<div class="component-wrapper hdmi-wrapper" :style="{
position: 'absolute',
top: `${260 * props.size}px`,
left: `${-48 * props.size}px`,
zIndex: 10
}">
<HDMI :size="1.5*props.size" />
</div> <!-- ETH -->
<div class="component-wrapper eth-wrapper" :style="{
position: 'absolute',
top: `${365 * props.size}px`,
left: `${-10 * props.size}px`,
zIndex: 10
}">
<ETH :size="1.5*props.size" />
</div>
<!-- DDR -->
<div class="component-wrapper ddr-wrapper" :style="{
position: 'absolute',
top: `${224 * props.size}px`,
right: `${250 * props.size}px`,
zIndex: 10
}">
<DDR :size="1.2*props.size" />
</div>
<!-- SD -->
<div class="component-wrapper sd-wrapper" :style="{
position: 'absolute',
bottom: `${130 * props.size}px`,
right: `${172 * props.size}px`,
zIndex: 10
}">
<SD :size="1.2*props.size" />
</div>
<!-- SFP -->
<div class="component-wrapper sfp-wrapper" :style="{
position: 'absolute',
bottom: `${210 * props.size}px`,
right: `${-46 * props.size}px`,
zIndex: 10
}">
<SFP :size="1.84*props.size" />
</div>
<!-- SFP -->
<div class="component-wrapper sfp-wrapper" :style="{
position: 'absolute',
bottom: `${290 * props.size}px`,
right: `${-46 * props.size}px`,
zIndex: 10
}">
<SFP :size="1.84*props.size" />
</div>
<!-- SMA -->
<div class="component-wrapper sma-wrapper" :style="{
position: 'absolute',
top: `${110 * props.size}px`,
right: `${204 * props.size}px`,
zIndex: 10
}">
<SMA :size="0.75*props.size" />
</div>
<!-- SMA -->
<div class="component-wrapper sma-wrapper" :style="{
position: 'absolute',
top: `${170 * props.size}px`,
right: `${204 * props.size}px`,
zIndex: 10
}">
<SMA :size="0.75*props.size" />
</div>
<!-- SMA -->
<div class="component-wrapper sma-wrapper" :style="{
position: 'absolute',
top: `${250 * props.size}px`,
right: `${204 * props.size}px`,
zIndex: 10
}">
<SMA :size="0.75*props.size" />
</div>
<!-- SMA -->
<div class="component-wrapper sma-wrapper" :style="{
position: 'absolute',
top: `${310 * props.size}px`,
right: `${204 * props.size}px`,
zIndex: 10
}">
<SMA :size="0.75*props.size" />
</div>
<!-- BUTTON -->
<div class="component-wrapper button-wrapper" :style="{
position: 'absolute',
bottom: `${140 * props.size}px`,
right: `${430 * props.size}px`,
zIndex: 10
}">
<MechanicalButton :size="0.175*props.size" />
</div>
<div class="component-wrapper button-wrapper" :style="{
position: 'absolute',
bottom: `${140 * props.size}px`,
right: `${397 * props.size}px`,
zIndex: 10
}">
<MechanicalButton :size="0.175*props.size" />
</div>
<div class="component-wrapper button-wrapper" :style="{
position: 'absolute',
bottom: `${140 * props.size}px`,
right: `${364 * props.size}px`,
zIndex: 10
}">
<MechanicalButton :size="0.175*props.size" />
</div>
<div class="component-wrapper button-wrapper" :style="{
position: 'absolute',
bottom: `${140 * props.size}px`,
right: `${331 * props.size}px`,
zIndex: 10
}">
<MechanicalButton :size="0.175*props.size" />
</div>
<div class="component-wrapper button-wrapper" :style="{
position: 'absolute',
bottom: `${140 * props.size}px`,
right: `${298 * props.size}px`,
zIndex: 10
}">
<MechanicalButton :size="0.175*props.size" />
</div>
<div class="component-wrapper button-wrapper" :style="{
position: 'absolute',
bottom: `${140 * props.size}px`,
right: `${265 * props.size}px`,
zIndex: 10
}">
<MechanicalButton :size="0.175*props.size" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import HDMI from './HDMI.vue';
import DDR from './DDR.vue';
import ETH from './ETH.vue';
import SD from './SD.vue';
import SFP from './SFP.vue';
import SMA from './SMA.vue';
import MechanicalButton from './MechanicalButton.vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 800 * props.size);
const height = computed(() => 600 * props.size);
//
defineExpose({
getInfo: () => ({
size: props.size
})
});
</script>
<style scoped>
.motherboard-container {
display: block;
position: relative;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.component-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
:viewBox="'0 0 ' + viewBoxWidth + ' ' + viewBoxHeight"
class="pin-component"
>
<g :transform="`translate(${viewBoxWidth/2}, ${viewBoxHeight/2})`">
<g v-if="props.appearance === 'None'">
<g transform="translate(-12.5, -12.5)" class="interactive">
<circle
style="fill:#909090"
cx="12.5"
cy="12.5"
r="3.75" />
</g>
</g>
<g v-else-if="props.appearance === 'Dip'">
<!-- 使用inkscape创建的SVG替代原有Dip样式 -->
<g transform="translate(-12.5, -12.5)" class="interactive">
<rect
:style="`fill:${props.type === 'analog' ? '#2a6099' : '#000000'};fill-opacity:0.772973`"
width="25"
height="25"
x="0"
y="0"
rx="2.5" />
<circle
style="fill:#ecececc5;fill-opacity:0.772973"
cx="12.5"
cy="12.5"
r="3.75" />
<text
style="font-size:6.85px;text-align:start;fill:#ffffff;fill-opacity:0.772973"
x="7.3"
y="7"
xml:space="preserve">{{ props.label }}</text>
</g>
</g>
<g v-else-if="props.appearance === 'SMT'">
<rect x="-20" y="-10" width="40" height="20" fill="#aaa" rx="2" ry="2" />
<rect x="-18" y="-8" width="36" height="16" :fill="getColorByType" rx="1" ry="1" />
<rect x="-16" y="-6" width="26" height="12" :fill="getColorByType" rx="1" ry="1" />
<text text-anchor="middle" dominant-baseline="middle" font-size="8" fill="white" x="-3">{{ props.label }}</text>
</g>
</g>
</svg>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface Props {
size?: number;
label?: string;
constraint?: string;
direction?: 'input' | 'output' | 'inout';
type?: 'digital' | 'analog';
appearance?: 'None' | 'Dip' | 'SMT';
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
label: 'PIN',
constraint: '',
direction: 'input',
type: 'digital',
appearance: 'Dip'
});
const emit = defineEmits([
'value-change'
]);
//
const analogValue = ref(0);
const width = computed(() => props.appearance === 'None' ? 40 * props.size : 30 * props.size);
const height = computed(() => {
if (props.appearance === 'None') return 20 * props.size;
if (props.appearance === 'Dip') return 30 * props.size; // Dip
return 60 * props.size;
});
const viewBoxWidth = computed(() => props.appearance === 'None' ? 40 : 30);
const viewBoxHeight = computed(() => {
if (props.appearance === 'None') return 20;
if (props.appearance === 'Dip') return 30; // Dip
return 60;
});
const getColorByType = computed(() => {
return props.type === 'analog' ? '#2a6099' : '#444';
});
function updateAnalogValue(value: number) {
if (props.type !== 'analog') return;
analogValue.value = Math.max(0, Math.min(1, value));
emit('value-change', {
label: props.label,
constraint: props.constraint,
value: analogValue.value
});
}
defineExpose({
setAnalogValue: updateAnalogValue,
getAnalogValue: () => analogValue.value,
getInfo: () => ({
label: props.label,
constraint: props.constraint,
direction: props.direction,
type: props.type,
appearance: props.appearance
})
});
</script>
<style scoped>
.pin-component {
display: block;
user-select: none;
}
.interactive {
cursor: pointer;
transition: filter 0.2s;
}
.interactive:hover {
filter: brightness(1.2);
}
</style>

View File

@ -0,0 +1,48 @@
<template> <div class="sd-component" :style="{ width: width + 'px', height: height + 'px' }">
<img
src="../equipments/svg/sd.svg"
:width="width"
:height="height"
alt="SD卡插槽"
class="svg-image"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 80 * props.size);
const height = computed(() => 60 * props.size);
</script>
<style scoped>
.sd-component {
display: block;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -0,0 +1,48 @@
<template> <div class="sfp-component" :style="{ width: width + 'px', height: height + 'px' }">
<img
src="../equipments/svg/sfp.svg"
:width="width"
:height="height"
alt="SFP光纤模块"
class="svg-image"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 120 * props.size);
const height = computed(() => 40 * props.size);
</script>
<style scoped>
.sfp-component {
display: block;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -0,0 +1,48 @@
<template> <div class="sma-component" :style="{ width: width + 'px', height: height + 'px' }">
<img
src="../equipments/svg/sma.svg"
:width="width"
:height="height"
alt="SMA连接器"
class="svg-image"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1
});
//
const width = computed(() => 40 * props.size);
const height = computed(() => 40 * props.size);
</script>
<style scoped>
.sma-component {
display: block;
user-select: none;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
.svg-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none; /* 禁止鼠标交互 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -0,0 +1,193 @@
<template>
<div class="led-container" :style="{ width: width + 'px', height: height + 'px', position: 'relative' }">
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="0 0 100 60"
class="smt-led"
>
<!-- LED 基座 -->
<rect width="100" height="60" x="0" y="0" fill="#333" rx="5" ry="5" />
<!-- LED 主体 -->
<rect width="90" height="50" x="5" y="5" fill="#222" rx="3" ry="3" />
<!-- LED 发光部分 -->
<rect
width="70"
height="30"
x="15"
y="15"
:fill="ledColor"
:style="{ opacity: isOn ? brightness/100 : 0.2 }"
rx="15"
ry="15"
@click="toggleLed"
class="interactive"
/>
<!-- LED 光晕效果 -->
<rect
v-if="isOn"
width="76"
height="36"
x="12"
y="12"
:fill="ledColor"
:style="{ opacity: brightness/100 * 0.3 }"
rx="18"
ry="18"
filter="blur(5px)"
class="glow"
/>
</svg>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// LED
interface Props {
size?: number;
color?: string;
initialOn?: boolean;
brightness?: number;
constraint?: string;
}
//
const props = withDefaults(defineProps<Props>(), {
size: 1,
color: 'red',
initialOn: false,
brightness: 80, // 80%
constraint: ''
});
//
const width = computed(() => 100 * props.size);
const height = computed(() => 60 * props.size);
//
const isOn = ref(props.initialOn);
const brightness = ref(props.brightness);
// LED
const colorMap: Record<string, string> = {
'red': '#ff3333',
'green': '#33ff33',
'blue': '#3333ff',
'yellow': '#ffff33',
'orange': '#ff9933',
'white': '#ffffff',
'purple': '#9933ff'
};
// LED
const ledColor = computed(() => {
return colorMap[props.color.toLowerCase()] || props.color;
});
//
const emit = defineEmits([
'toggle',
'brightness-change',
'value-change'
]);
// LED
function toggleLed() {
isOn.value = !isOn.value;
emit('toggle', isOn.value);
emit('value-change', {
isOn: isOn.value,
brightness: brightness.value
});
}
//
function setBrightness(value: number) {
// 0-100
brightness.value = Math.max(0, Math.min(100, value));
emit('brightness-change', brightness.value);
emit('value-change', {
isOn: isOn.value,
brightness: brightness.value
});
}
// LED
function setLedState(on: boolean) {
isOn.value = on;
emit('toggle', isOn.value);
emit('value-change', {
isOn: isOn.value,
brightness: brightness.value
});
}
// props
watch(() => props.brightness, (newVal) => {
brightness.value = newVal;
});
watch(() => props.initialOn, (newVal) => {
isOn.value = newVal;
});
//
defineExpose({
toggleLed,
setBrightness,
setLedState,
getInfo: () => ({
// LED
color: props.color,
isOn: isOn.value,
brightness: brightness.value,
constraint: props.constraint
})
});
</script>
<style scoped>
.led-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.smt-led {
display: block;
padding: 0;
margin: 0;
line-height: 0;
font-size: 0;
box-sizing: content-box;
overflow: visible;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.interactive {
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.interactive:hover {
filter: brightness(1.2);
}
.glow {
pointer-events: none;
}
</style>

View File

@ -1,6 +1,13 @@
// filepath: c:\_Project\FPGA_WebLab\FPGA_WebLab\src\components\equipments\Switch.vue
<template>
<svg xmlns="http://www.w3.org/2000/svg" :width="props.width" :height="props.height" viewBox="0 0 16 16">
<def>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
:viewBox="`4 6 ${props.switchCount + 2} 4`"
class="dip-switch"
>
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feFlood result="flood" flood-color="#f08a5d" flood-opacity="1"></feFlood>
<feComposite in="flood" result="mask" in2="SourceGraphic" operator="in"></feComposite>
@ -15,49 +22,130 @@
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</def>
</defs>
<g>
<rect width="8" height="4" x="4" y="6" fill="#c01401" rx="0.1" />
<text fill="white" font-size="0.7" x="4.25" y="6.75">ON</text>
<!-- 红色背景随开关数量变化宽度 -->
<rect :width="props.switchCount + 2" height="4" x="4" y="6" fill="#c01401" rx="0.1" />
<text v-if="props.showLabels" fill="white" font-size="0.7" x="4.25" y="6.75">ON</text>
<g>
<rect class="glow" @click="toggleBtnStatus(0)" width="0.7" height="2" fill="#68716f" x="5.15" y="7" rx="0.1" />
<rect class="glow" @click="toggleBtnStatus(1)" width="0.7" height="2" fill="#68716f" x="6.15" y="7" rx="0.1" />
<rect class="glow" @click="toggleBtnStatus(2)" width="0.7" height="2" fill="#68716f" x="7.15" y="7" rx="0.1" />
<rect class="glow" @click="toggleBtnStatus(3)" width="0.7" height="2" fill="#68716f" x="8.15" y="7" rx="0.1" />
<rect class="glow" @click="toggleBtnStatus(4)" width="0.7" height="2" fill="#68716f" x="9.15" y="7" rx="0.1" />
<rect class="glow" @click="toggleBtnStatus(5)" width="0.7" height="2" fill="#68716f" x="10.15" y="7" rx="0.1" />
<template v-for="(_, index) in Array(props.switchCount)" :key="index">
<rect
class="glow interactive"
@click="toggleBtnStatus(index)"
width="0.7"
height="2"
fill="#68716f"
:x="5.15 + index"
y="7"
rx="0.1"
/>
<text
v-if="props.showLabels"
:x="5.5 + index"
y="9.5"
font-size="0.4"
text-anchor="middle"
fill="#444"
>
{{ index + 1 }}
</text>
</template>
</g>
<g>
<rect @click="toggleBtnStatus(0)" width="0.65" height="0.65" fill="white" x="5.175" :y="btnLocation[0]" rx="0.1"
opacity="1" />
<rect @click="toggleBtnStatus(1)" width="0.65" height="0.65" fill="white" x="6.175" :y="btnLocation[1]" rx="0.1"
opacity="1" />
<rect @click="toggleBtnStatus(2)" width="0.65" height="0.65" fill="white" x="7.175" :y="btnLocation[2]" rx="0.1"
opacity="1" />
<rect @click="toggleBtnStatus(3)" width="0.65" height="0.65" fill="white" x="8.175" :y="btnLocation[3]" rx="0.1"
opacity="1" />
<rect @click="toggleBtnStatus(4)" width="0.65" height="0.65" fill="white" x="9.175" :y="btnLocation[4]" rx="0.1"
opacity="1" />
<rect @click="toggleBtnStatus(5)" width="0.65" height="0.65" fill="white" x="10.175" :y="btnLocation[5]"
rx="0.1" opacity="1" />
<template v-for="(location, index) in btnLocation" :key="`btn-${index}`">
<rect
class="interactive"
@click="toggleBtnStatus(index)"
width="0.65"
height="0.65"
fill="white"
:x="5.175 + index"
:y="location"
rx="0.1"
opacity="1"
/>
</template>
</g>
</g>
</svg>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
interface Props {
width?: string | number;
height?: string | number;
size?: number;
switchCount?: number;
//
initialValues?: boolean[] | string; //
showLabels?: boolean; //
}
const props = withDefaults(defineProps<Props>(), {
width: 160,
height: 160,
size: 1,
switchCount: 6,
initialValues: () => [],
showLabels: true
});
//
const width = computed(() => {
// 25px(20px)
return (props.switchCount * 25 + 20) * props.size;
});
const height = computed(() => 85 * props.size); //
//
const emit = defineEmits(['change', 'switch-toggle']);
//
const parseInitialValues = () => {
if (Array.isArray(props.initialValues)) {
return [...props.initialValues].slice(0, props.switchCount);
} else if (typeof props.initialValues === 'string' && props.initialValues.trim() !== '') {
//
const values = props.initialValues.split(',')
.map(val => val.trim() === '1' || val.trim().toLowerCase() === 'true')
.slice(0, props.switchCount);
// false
while (values.length < props.switchCount) {
values.push(false);
}
return values;
}
// false
return Array(props.switchCount).fill(false);
};
//
const btnStatus = ref(parseInitialValues());
// switchCount
watch(() => props.switchCount, (newCount) => {
if (newCount !== btnStatus.value.length) {
//
if (newCount > btnStatus.value.length) {
btnStatus.value = [
...btnStatus.value,
...Array(newCount - btnStatus.value.length).fill(false)
];
} else {
//
btnStatus.value = btnStatus.value.slice(0, newCount);
}
}
}, { immediate: true });
// initialValues
watch(() => props.initialValues, () => {
btnStatus.value = parseInitialValues();
});
const btnStatus = ref([false, false, false, false, false, false]);
const btnLocation = computed(() => {
return btnStatus.value.map((status) => {
return status ? 7.025 : 8.325;
@ -65,20 +153,58 @@ const btnLocation = computed(() => {
});
function setBtnStatus(btnNum: number, isOn: boolean): void {
btnStatus.value[btnNum] = isOn;
if (btnNum >= 0 && btnNum < btnStatus.value.length) {
btnStatus.value[btnNum] = isOn;
emit('change', { index: btnNum, value: isOn, states: [...btnStatus.value] });
}
}
function toggleBtnStatus(btnNum: number): void {
btnStatus.value[btnNum] = !btnStatus.value[btnNum];
if (btnNum >= 0 && btnNum < btnStatus.value.length) {
btnStatus.value[btnNum] = !btnStatus.value[btnNum];
emit('switch-toggle', {
index: btnNum,
value: btnStatus.value[btnNum],
states: [...btnStatus.value]
});
}
}
//
function setAllStates(states: boolean[]): void {
const newStates = states.slice(0, props.switchCount);
while (newStates.length < props.switchCount) {
newStates.push(false);
}
btnStatus.value = newStates;
emit('change', { states: [...btnStatus.value] });
}
//
defineExpose({
setBtnStatus,
toggleBtnStatus,
setAllStates,
getBtnStatus: () => [...btnStatus.value]
});
</script>
<style scoped lang="postcss">
.dip-switch {
display: block;
padding: 0;
margin: 0;
line-height: 0; /* 移除行高导致的额外间距 */
font-size: 0; /* 防止文本节点造成的间距 */
box-sizing: content-box;
overflow: visible;
}
rect {
transition: all 100ms ease-in-out;
}
.glow:hover {
filter: url(#glow);
.interactive {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,327 @@
// 组件配置声明
export type PropType = 'string' | 'number' | 'boolean' | 'select';
// 定义选择类型选项
export interface PropOption {
value: string | number | boolean;
label: string;
}
export interface PropConfig {
name: string;
type: string;
label: string;
default: any;
min?: number;
max?: number;
step?: number;
options?: PropOption[];
description?: string;
category?: string; // 用于在UI中分组属性
}
export interface ComponentConfig {
props: PropConfig[];
}
// 存储所有组件的配置
const componentConfigs: Record<string, ComponentConfig> = {
MechanicalButton: {
props: [
{
name: 'bindKey',
type: 'string',
label: '绑定按键',
default: '',
description: '触发按钮按下的键盘按键'
},
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: '按钮的相对大小1代表标准大小'
},
{
name: 'buttonText',
type: 'string',
label: '按钮文本',
default: '',
description: '按钮上显示的自定义文本,优先级高于绑定按键'
},
{
name: 'label',
type: 'string',
label: '引脚标签',
default: 'BTN',
description: '引脚的标签文本'
},
{
name: 'constraint',
type: 'string',
label: '引脚约束',
default: '',
description: '相同约束字符串的引脚将被视为有电气连接'
}
]
},
Switch: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: '开关的相对大小1代表标准大小'
},
{
name: 'switchCount',
type: 'number',
label: '开关数量',
default: 6,
min: 1,
max: 12,
step: 1,
description: '可翻转开关的数量'
},
{
name: 'showLabels',
type: 'boolean',
label: '显示标签',
default: true,
description: '是否显示开关编号标签'
},
{
name: 'initialValues',
type: 'string',
label: '初始状态',
default: '',
description: '开关的初始状态格式为逗号分隔的0/1如"1,0,1"表示第1、3个开关打开'
}
]
},
Pin: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: '引脚的相对大小1代表标准大小'
},
{
name: 'label',
type: 'string',
label: '引脚标签',
default: 'PIN',
description: '用于标识引脚的名称'
},
{
name: 'constraint',
type: 'string',
label: '引脚约束',
default: '',
description: '相同约束字符串的引脚将被视为有电气连接'
},
{
name: 'direction',
type: 'select',
label: '输入/输出特性',
default: 'input',
options: [
{ value: 'input', label: '输入' },
{ value: 'output', label: '输出' },
{ value: 'inout', label: '双向' }
],
description: '引脚的输入/输出特性'
},
{
name: 'type',
type: 'select',
label: '模数特性',
default: 'digital',
options: [
{ value: 'digital', label: 'digital' },
{ value: 'analog', label: 'analog' }
],
description: '引脚的模数特性,数字或模拟'
},
{
name: 'appearance',
type: 'select',
label: '引脚样式',
default: 'Dip',
options: [
{ value: 'None', label: 'None' },
{ value: 'Dip', label: 'Dip' },
{ value: 'SMT', label: 'SMT' }
],
description: '引脚的外观样式,不影响功能'
}
]
},
HDMI: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: 'HDMI接口的相对大小1代表标准大小'
}
]
},
DDR: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: 'DDR内存的相对大小1代表标准大小'
}
]
},
ETH: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: '以太网接口的相对大小1代表标准大小'
}
]
},
SD: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: 'SD卡插槽的相对大小1代表标准大小'
}
]
},
SFP: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: 'SFP光纤模块的相对大小1代表标准大小'
}
]
},
SMA: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: 'SMA连接器的相对大小1代表标准大小'
}
]
}, MotherBoard: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 2,
step: 0.1,
description: '主板的相对大小1代表标准大小'
}
]
}, SMT_LED: {
props: [
{
name: 'size',
type: 'number',
label: '大小',
default: 1,
min: 0.5,
max: 3,
step: 0.1,
description: 'LED的相对大小1代表标准大小'
},
{
name: 'color',
type: 'select',
label: '颜色',
default: 'red',
options: [
{ value: 'red', label: '红色' },
{ value: 'green', label: '绿色' },
{ value: 'blue', label: '蓝色' },
{ value: 'yellow', label: '黄色' },
{ value: 'orange', label: '橙色' },
{ value: 'white', label: '白色' },
{ value: 'purple', label: '紫色' }
],
description: 'LED的颜色'
},
{
name: 'initialOn',
type: 'boolean',
label: '初始状态',
default: false,
description: 'LED的初始开关状态'
},
{
name: 'brightness',
type: 'number',
label: '亮度(%)',
default: 80,
min: 0,
max: 100,
step: 5,
description: 'LED的亮度百分比范围0-100'
},
{
name: 'constraint',
type: 'string',
label: '连接约束',
default: '',
description: '相同约束字符串的组件将被视为有电气连接'
}
]
}
};
// 获取组件配置的函数
export function getComponentConfig(type: string): ComponentConfig | null {
return componentConfigs[type] || null;
}

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="160"
height="160"
viewBox="400 400 800 800"
>
<!-- 按钮底座 -->
<rect width="800" height="800" x="400" y="400" fill="#464646" rx="20" />
<rect width="700" height="700" x="450" y="450" fill="#eaeaea" rx="20" />
<!-- 装饰螺丝 -->
<circle r="20" cx="1075" cy="1075" fill="#171717" />
<circle r="20" cx="1075" cy="525" fill="#171717" />
<circle r="20" cx="525" cy="525" fill="#171717" />
<circle r="20" cx="525" cy="1075" fill="#171717" />
<!-- 按钮主体 -->
<circle r="220" cx="800" cy="800" fill="black" />
<circle
r="200"
cx="800"
cy="800"
fill="#4b4b4b"
fill-opacity="0.9"
/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
height="60"
viewBox="0 0 100 60"
>
<!-- LED 基座 -->
<rect width="100" height="60" x="0" y="0" fill="#333" rx="5" ry="5" />
<!-- LED 主体 -->
<rect width="90" height="50" x="5" y="5" fill="#222" rx="3" ry="3" />
<!-- LED 发光部分 -->
<rect
width="70"
height="30"
x="15"
y="15"
fill="#ff3333"
opacity="0.8"
rx="15"
ry="15"
/>
<!-- LED 光晕效果 -->
<rect
width="76"
height="36"
x="12"
y="12"
fill="#ff3333"
opacity="0.2"
rx="18"
ry="18"
filter="blur(3px)"
/>
</svg>

After

Width:  |  Height:  |  Size: 708 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="4.9999995mm"
height="5mm"
viewBox="0 0 4.9999995 5"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-10,-10.000002)">
<rect
style="fill:#000000;fill-opacity:0.772973;stroke-width:0.265;stroke-dasharray:none"
id="rect1"
width="5"
height="5"
x="10"
y="10"
rx="0.5"
onclick="" />
<circle
style="fill:#ececec;fill-opacity:0.772973;stroke-width:0.264999;stroke-dasharray:none"
id="path1"
cx="12.5"
cy="12.5"
r="0.75" />
<text
xml:space="preserve"
style="font-size:1.3717px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#ffffff;fill-opacity:0.772973;stroke-width:0.114488;stroke-dasharray:none"
x="11.473036"
y="11.410812"
id="text2"><tspan
id="tspan2"
style="fill:#ffffff;stroke-width:0.114488"
x="11.473036"
y="11.410812">Pn</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="53.600014mm"
height="16.279999mm"
viewBox="0 0 53.600014 16.279999"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
transform="translate(-86.17357,-29.131738)"><g
id="g4493"><path
d="m 89.863578,29.151737 v 0.44 h -3.690004 v 2.54 h 0.44 v 3.95 h -0.44 v 2.97 h 0.43 v 3.95 h -0.43 v 2.1 h 3.770004 v 0.31 h 3.95 v -0.38 h 3.95 v 0.38 h 4.220002 v -0.35 h 3.95 v 0.35 h 3.95 v -0.41 h 3.95 v 0.41 h 3.66 v -0.45 h 3.95 v 0.45 h 3.95 v -0.51 h 3.95 v 0.51 h 4.22 v -0.48 h 3.95 v 0.48 h 2.18 v -16.28 h -2.26 v 0.28 h -3.95 v -0.28 h -4.22 v 0.25 h -3.95 v -0.25 h -3.95 v 0.31 h -3.95 v -0.31 h -3.66 v 0.35 h -3.95 v -0.35 h -3.95 v 0.41 h -3.95 v -0.41 h -4.220002 v 0.38 h -3.95 v -0.38 z m 21.090002,2.82 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 9.82,0.02 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m -28.710002,0.03 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 9.680002,0.02 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 29.17,0.21 h 6.25 c 0.22,0 0.39,0.11 0.39,0.25 0,0.14 -0.17,0.25 -0.39,0.25 h -6.25 c -0.22,0 -0.39,-0.11 -0.39,-0.25 0,-0.14 0.17,-0.25 0.39,-0.25 z m -0.07,2 h 6.25 c 0.22,0 0.39,0.11 0.39,0.25 0,0.14 -0.17,0.25 -0.39,0.25 h -6.25 c -0.22,0 -0.39,-0.11 -0.39,-0.25 0,-0.14 0.17,-0.25 0.39,-0.25 z m -15.17,2.3 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 9.82,0.02 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m -28.710002,0.03 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 9.680002,0.02 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 24.42,3.36 h 6.25 c 0.22,0 0.39,0.11 0.39,0.25 0,0.14 -0.17,0.25 -0.39,0.25 h -6.25 c -0.22,0 -0.39,-0.11 -0.39,-0.25 0,-0.14 0.17,-0.25 0.39,-0.25 z m -19.84,1.31 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 9.82,0.02 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m -28.710002,0.03 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 9.680002,0.02 c 0.52,0 0.94,0.42 0.94,0.94 0,0.52 -0.42,0.94 -0.94,0.94 -0.52,0 -0.94,-0.42 -0.94,-0.94 0,-0.52 0.42,-0.94 0.94,-0.94 z m 28.99,0.63 h 6.25 c 0.22,0 0.39,0.11 0.39,0.25 0,0.14 -0.17,0.25 -0.39,0.25 h -6.25 c -0.22,0 -0.39,-0.11 -0.39,-0.25 0,-0.14 0.17,-0.25 0.39,-0.25 z"
style="font-variation-settings:'wght' 700;fill:#ececec;fill-opacity:1;stroke-width:0.518999;stroke-linecap:round;stroke-linejoin:round;paint-order:fill markers stroke"
id="path4484-9-9" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="200"
height="100"
viewBox="0 0 200 100"
>
<!-- 开关基座 -->
<rect width="200" height="100" x="0" y="0" fill="#444" rx="5" ry="5" />
<!-- 开关面板 -->
<rect width="180" height="80" x="10" y="10" fill="#333" rx="4" ry="4" />
<!-- 开关拨片示例 (6个) -->
<g fill="#ddd">
<rect width="20" height="40" x="25" y="30" rx="2" ry="2" />
<rect width="20" height="40" x="55" y="30" rx="2" ry="2" />
<rect width="20" height="40" x="85" y="30" rx="2" ry="2" />
<rect width="20" height="40" x="115" y="30" rx="2" ry="2" />
<rect width="20" height="40" x="145" y="30" rx="2" ry="2" />
<rect width="20" height="40" x="175" y="30" rx="2" ry="2" />
</g>
<!-- 开关标签 -->
<g fill="#fff" font-size="8" text-anchor="middle">
<text x="25" y="80">1</text>
<text x="55" y="80">2</text>
<text x="85" y="80">3</text>
<text x="115" y="80">4</text>
<text x="145" y="80">5</text>
<text x="175" y="80">6</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,4 +1,4 @@
import { createMemoryHistory, createRouter } from "vue-router";
import { createWebHistory, createRouter } from "vue-router";
import LoginView from "../views/LoginView.vue";
import UserView from "../views/UserView.vue";
import TestView from "../views/TestView.vue";
@ -16,7 +16,7 @@ const routes = [
];
const router = createRouter({
history: createMemoryHistory(),
history: createWebHistory(),
routes,
});

View File

@ -1,19 +1,67 @@
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
// 本地存储主题的键名
const THEME_STORAGE_KEY = 'fpga-weblab-theme'
export const useThemeStore = defineStore('theme', () => {
const allTheme = ["winter", "night"]
const darkTheme = "night";
const lightTheme = "winter";
const currentTheme = ref("night")
// 尝试从本地存储中获取保存的主题
const getSavedTheme = (): string | null => {
return localStorage.getItem(THEME_STORAGE_KEY)
}
// 检测系统主题偏好
const getPreferredTheme = (): string => {
const savedTheme = getSavedTheme()
// 如果有保存的主题设置,优先使用
if (savedTheme && allTheme.includes(savedTheme)) {
return savedTheme
}
// 否则检测系统主题模式
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? darkTheme : lightTheme
}
// 初始化主题为首选主题
const currentTheme = ref(getPreferredTheme())
// 保存主题到本地存储
const saveTheme = (theme: string) => {
localStorage.setItem(THEME_STORAGE_KEY, theme)
}
// 当主题变化时,保存到本地存储
watch(currentTheme, (newTheme) => {
saveTheme(newTheme)
})
// 添加系统主题变化的监听
const setupThemeListener = () => {
if (window.matchMedia) {
const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e: MediaQueryListEvent) => {
// 只有当用户没有手动设置过主题时,才跟随系统变化
if (!getSavedTheme()) {
currentTheme.value = e.matches ? darkTheme : lightTheme
}
}
// 添加主题变化监听器
colorSchemeQuery.addEventListener('change', handler)
}
}
function setTheme(theme: string) {
const isContained: boolean = allTheme.includes(theme)
if (isContained) {
currentTheme.value = theme
saveTheme(theme) // 保存主题到本地存储
}
else {
console.error('Not have such theme: ${theme}')
console.error(`Not have such theme: ${theme}`)
}
}
@ -25,6 +73,7 @@ export const useThemeStore = defineStore('theme', () => {
} else {
currentTheme.value = lightTheme;
}
// 主题切换时自动保存(通过 watch 函数实现)
}
function isDarkTheme(): boolean {
@ -35,13 +84,19 @@ export const useThemeStore = defineStore('theme', () => {
return currentTheme.value == lightTheme
}
// 初始化时设置系统主题变化监听器
if (typeof window !== 'undefined') {
setupThemeListener()
}
return {
allTheme,
currentTheme,
setTheme,
toggleTheme,
isDarkTheme,
isLightTheme
isLightTheme,
setupThemeListener
}
})

View File

@ -1,26 +1,79 @@
<template>
<div class="bg-base-200 min-h-screen">
<template> <div class="bg-base-200 min-h-screen">
<main class="hero min-h-screen bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse">
<img src="https://placehold.co/600x400" class="max-w-sm rounded-lg shadow-2xl" />
<div>
<h1 class="text-5xl font-bold">Welcome to FPGA Web Lab!</h1>
<p class="py-6">
Prototype and simulate electronic circuits in your browser.
<div class="hero-content flex-col lg:flex-row-reverse gap-8 lg:gap-12 py-10 px-4">
<!-- 图片容器 -->
<div class="image-container relative w-full max-w-sm hover:scale-105 hover:-rotate-1 transition-transform duration-500 ease-in-out">
<img src="https://placehold.co/600x400" class="w-full rounded-2xl shadow-2xl border-4 border-base-300 transition-shadow duration-300 hover:shadow-primary" />
<!-- 这里使用relative定位限制覆盖层只在图片容器内 -->
<div class="absolute inset-0 bg-primary opacity-10 rounded-2xl pointer-events-none"></div>
</div>
<!-- 内容容器 -->
<div class="content-container max-w-md lg:max-w-2xl transform transition-all duration-500 ease-in-out">
<h1 class="text-4xl md:text-5xl font-bold mb-3 relative group">
<span class="relative z-10 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Welcome to
</span>
<span class="text-base-content">FPGA Web Lab!</span>
<span class="absolute bottom-0 left-0 w-0 h-1 bg-primary transition-all duration-500 ease-in-out group-hover:w-3/4"></span>
</h1>
<p class="py-6 text-lg opacity-80 leading-relaxed">
Prototype and simulate electronic circuits in your browser with our modern, intuitive interface. Create, test, and share your FPGA designs seamlessly.
</p>
<button class="btn btn-primary">Get Started</button>
<div class="flex flex-wrap gap-4 actions-container">
<router-link to="/project" class="btn btn-primary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
进入工程界面
</router-link>
<router-link to="/login" class="btn btn-secondary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
登录
</router-link>
<router-link to="/user" class="btn btn-accent text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
用户中心
</router-link>
<router-link to="/test" class="btn btn-info text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
测试功能
</router-link>
<router-link to="/test/jtag" class="btn btn-warning text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
JTAG测试
</router-link>
</div>
<div class="mt-8 p-4 bg-base-300 rounded-lg shadow-inner opacity-80 transition-all duration-300 hover:opacity-100 hover:shadow-md">
<p class="text-sm">
<span class="font-semibold text-primary">提示</span> 您可以在工程界面中创建编辑和测试您的FPGA项目使用我们简洁直观的界面轻松进行硬件设计
</p>
</div>
</div>
</div>
</main>
<div class="fixed bottom-10 right-10 btn btn-circle">
<ThemeControlButton class=""></ThemeControlButton>
</div>
</div>
</template>
<script lang="ts" setup>
import ThemeControlButton from "@/components/ThemeControlButton.vue";
import "@/router";
</script>

View File

@ -1,9 +1,12 @@
<template>
<div class="h-screen w-screen flex justify-center">
<div class="h-full w-32"></div>
<div class="h-screen flex flex-col overflow-hidden">
<!-- 主要内容 -->
<div class="flex-1 flex justify-center">
<div class="h-full w-32"></div>
<div class="h-full w-[70%] shadow-2xl flex">
<button class="btn btn-primary h-10 w-30">获取ID Code</button>
<div class="h-full w-[70%] shadow-2xl flex items-start p-4">
<button class="btn btn-primary h-10 w-30">获取ID Code</button>
</div>
</div>
</div>
</template>

View File

@ -1,7 +1,9 @@
<template>
<main>
<div class="flex items-center justify-center min-h-screen">
<LoginCard />
<div class="relative w-full max-w-md">
<LoginCard />
</div>
</div>
</main>
</template>

View File

@ -1,127 +1,258 @@
<template>
<div class="h-screen w-screen flex flex-col">
<!-- 顶部工具栏 -->
<div class="flex items-center p-2 border-b border-base-300 bg-base-100">
<h2 class="text-xl font-bold mr-auto">FPGA 工程界面</h2>
<button class="btn btn-circle btn-primary" @click="openComponentsMenu">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div class="flex flex-1 overflow-hidden">
<div class="h-screen flex flex-col overflow-hidden">
<div class="flex flex-1 overflow-hidden relative">
<!-- 左侧图形化区域 -->
<DiagramCanvas
ref="diagramCanvas"
:initialComponents="components"
@component-selected="handleComponentSelected"
@component-moved="handleComponentMoved"
/>
<!-- 右侧编辑区域 -->
<div class="w-[40%] bg-base-100 border-l flex flex-col p-4">
<h3 class="text-lg font-bold mb-4">属性编辑器</h3>
<div v-if="!selectedComponent" class="text-gray-400">选择元器件以编辑属性</div>
<div v-else>
<div class="mb-2">编辑元器件: {{ getComponentName(selectedComponent) }}</div>
<!-- 这里可以添加元器件的属性编辑表单 -->
</div>
<div class="relative bg-base-200 overflow-hidden" :style="{ width: leftPanelWidth + '%' }"> <DiagramCanvas
ref="diagramCanvas" :components="components"
:componentModules="componentModules"
@component-selected="handleComponentSelected"
@component-moved="handleComponentMoved"
@update-component-prop="updateComponentProp"
@component-delete="handleComponentDelete"
/>
<!-- 添加元器件按钮 -->
<button class="btn btn-circle btn-primary absolute top-8 right-8 shadow-lg z-10" @click="openComponentsMenu">
<!-- SVG icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
</div>
<!-- 元器件选择菜单 -->
<div v-if="showComponentsMenu" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="showComponentsMenu = false">
<div class="bg-base-100 p-4 rounded-lg shadow-xl max-w-3xl max-h-[80vh] overflow-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">选择元器件</h3>
<button class="btn btn-ghost btn-sm" @click="showComponentsMenu = false">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div v-for="(component, index) in availableComponents" :key="index"
class="border p-4 rounded-lg flex flex-col items-center hover:bg-base-200 cursor-pointer"
@click="addComponent(component)">
<div class="flex-1 flex items-center justify-center p-2">
<component :is="component.type" style="transform: scale(0.5);"></component>
</div>
<div class="mt-2 text-center">{{ component.name }}</div>
<!-- 拖拽分割线 -->
<div
class="resizer cursor-col-resize bg-base-300 hover:bg-primary hover:opacity-70 active:bg-primary active:opacity-90 transition-colors"
@mousedown="startResize"
></div>
<!-- 右侧编辑区域 -->
<div class="bg-base-100 flex flex-col p-4 overflow-auto" :style="{ width: (100 - leftPanelWidth) + '%' }">
<h3 class="text-lg font-bold mb-4">属性编辑器</h3>
<div v-if="!selectedComponentData" class="text-gray-400">选择元器件以编辑属性</div>
<div v-else>
<div class="mb-4 pb-4 border-b border-base-300">
<h4 class="font-semibold text-lg mb-1">{{ selectedComponentData.name }}</h4>
<p class="text-xs text-gray-500">ID: {{ selectedComponentData.id }}</p>
<p class="text-xs text-gray-500">类型: {{ selectedComponentData.type }}</p>
</div>
<!-- 动态属性表单 -->
<div v-if="selectedComponentConfig && selectedComponentConfig.props" class="space-y-4">
<div v-for="prop in selectedComponentConfig.props" :key="prop.name" class="form-control">
<label class="label">
<span class="label-text">{{ prop.label || prop.name }}</span>
</label>
<!-- 根据 prop 类型选择输入控件 -->
<input
v-if="prop.type === 'string'"
type="text"
:placeholder="prop.label || prop.name"
class="input input-bordered input-sm w-full"
:value="selectedComponentData.props[prop.name]"
@input="updateComponentProp(selectedComponentData.id, prop.name, ($event.target as HTMLInputElement).value)"
/>
<input
v-else-if="prop.type === 'number'"
type="number"
:placeholder="prop.label || prop.name"
class="input input-bordered input-sm w-full"
:value="selectedComponentData.props[prop.name]"
@input="updateComponentProp(selectedComponentData.id, prop.name, parseFloat(($event.target as HTMLInputElement).value) || prop.default)"
/> <!-- boolean checkbox color color picker -->
<div v-else-if="prop.type === 'boolean'" class="flex items-center">
<input
type="checkbox"
class="checkbox checkbox-sm mr-2"
:checked="selectedComponentData.props[prop.name]"
@change="updateComponentProp(selectedComponentData.id, prop.name, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ prop.label || prop.name }}</span>
</div> <!-- 下拉选择框 -->
<select
v-else-if="prop.type === 'select' && prop.options"
class="select select-bordered select-sm w-full"
:value="selectedComponentData.props[prop.name]"
@change="(event) => {
const selectElement = event.target as HTMLSelectElement;
const value = selectElement.value;
console.log('选择的值:', value, '类型:', typeof value);
updateComponentProp(selectedComponentData.id, prop.name, value);
}"
>
<option v-for="option in prop.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<p v-else class="text-xs text-warning">不支持的属性类型: {{ prop.type }}</p>
</div>
</div>
<div v-else-if="selectedComponentData && !selectedComponentConfig" class="text-gray-500 text-sm">
正在加载组件配置...
</div>
<div v-else-if="selectedComponentData && selectedComponentConfig && (!selectedComponentConfig.props || selectedComponentConfig.props.length === 0)" class="text-gray-500 text-sm">
此组件没有可配置的属性
</div>
</div>
</div>
</div>
</div> </div>
<!-- 元器件选择组件 -->
<ComponentSelector
:open="showComponentsMenu"
@update:open="showComponentsMenu = $event"
@add-component="handleAddComponent"
@close="showComponentsMenu = false"
/>
</div>
</template>
<script setup lang="ts">
// wokwi-elements
import "@wokwi/elements";
import { ref, reactive } from 'vue';
// import "@wokwi/elements"; // wokwi
import { ref, reactive, computed, onMounted, onUnmounted, defineAsyncComponent, shallowRef } from 'vue'; // defineAsyncComponent shallowRef
import DiagramCanvas from '@/components/DiagramCanvas.vue';
import ComponentSelector from '@/components/ComponentSelector.vue';
import { getComponentConfig } from '@/components/equipments/componentConfig';
import type { ComponentConfig } from '@/components/equipments/componentConfig';
//
// --- ---
const showComponentsMenu = ref(false);
interface ComponentItem {
id: string;
type: string;
type: string; // 'MechanicalButton'
name: string;
x: number;
y: number;
props?: Record<string, any>; // props
}
const components = ref<ComponentItem[]>([]);
const selectedComponent = ref<string | null>(null);
const selectedComponentData = ref<ComponentItem | null>(null);
const selectedComponentId = ref<string | null>(null); // selectedComponentId
const selectedComponentData = computed(() => { //
return components.value.find(c => c.id === selectedComponentId.value) || null;
});
const diagramCanvas = ref(null);
//
const availableComponents = [
{ type: 'wokwi-led', name: 'LED灯' },
{ type: 'wokwi-resistor', name: '电阻' },
{ type: 'wokwi-pushbutton', name: '按钮' },
{ type: 'wokwi-7segment', name: '7段数码管' },
{ type: 'wokwi-arduino-uno', name: 'Arduino Uno' },
{ type: 'wokwi-servo', name: '舵机' },
{ type: 'wokwi-lcd1602', name: 'LCD显示屏' },
{ type: 'wokwi-dht22', name: '温湿度传感器' },
{ type: 'wokwi-buzzer', name: '蜂鸣器' }
];
//
interface ComponentModule {
default: any;
config?: {
props?: Array<{
name: string;
type: string;
label?: string;
default: any;
}>;
};
}
//
const componentModules = shallowRef<Record<string, ComponentModule>>({});
const selectedComponentConfig = shallowRef<ComponentModule['config'] | null>(null); //
//
async function loadComponentModule(type: string) {
if (!componentModules.value[type]) {
try {
// src/components/equipments/ type
const module = await import(`../components/equipments/${type}.vue`);
// 使 markRaw
componentModules.value = {
...componentModules.value,
[type]: module
};
console.log(`Loaded module for ${type}:`, module);
} catch (error) {
console.error(`Failed to load component module ${type}:`, error);
return null;
}
}
return componentModules.value[type];
}
// --- ---
const leftPanelWidth = ref(60);
const isResizing = ref(false);
//
function startResize(e: MouseEvent) {
isResizing.value = true;
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', stopResize);
e.preventDefault(); //
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return;
//
const container = document.querySelector('.flex-1.overflow-hidden') as HTMLElement;
if (!container) return;
const containerWidth = container.clientWidth;
const mouseX = e.clientX;
//
let newWidth = (mouseX / containerWidth) * 100;
//
newWidth = Math.max(20, Math.min(newWidth, 80));
//
leftPanelWidth.value = newWidth;
}
function stopResize() {
isResizing.value = false;
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', stopResize);
}
// --- ---
function openComponentsMenu() {
showComponentsMenu.value = true;
}
//
function addComponent(componentTemplate) {
const newComponent = {
// ComponentSelector
async function handleAddComponent(componentData: { type: string; name: string; props: Record<string, any> }) {
// 便使
await loadComponentModule(componentData.type);
const newComponent: ComponentItem = {
id: `component-${Date.now()}`,
type: componentTemplate.type,
name: componentTemplate.name,
x: 100,
y: 100
type: componentData.type,
name: componentData.name,
x: 100, //
y: 100,
props: componentData.props, // 使 ComponentSelector
};
components.value.push(newComponent);
// 使 diagramCanvas addComponent
// DiagramCanvas :initialComponents="components"
showComponentsMenu.value = false;
}
//
function handleComponentSelected(component) {
selectedComponent.value = component ? component.id : null;
selectedComponentData.value = component;
async function handleComponentSelected(componentData: ComponentItem | null) {
selectedComponentId.value = componentData ? componentData.id : null;
selectedComponentConfig.value = null; //
if (componentData) {
//
const config = getComponentConfig(componentData.type);
if (config) {
selectedComponentConfig.value = config;
console.log(`Config for ${componentData.type}:`, config);
} else {
console.warn(`No config found for component type ${componentData.type}`);
}
//
await loadComponentModule(componentData.type);
}
}
//
function handleComponentMoved(moveData) {
function handleComponentMoved(moveData: { id: string; x: number; y: number }) {
const component = components.value.find(c => c.id === moveData.id);
if (component) {
component.x = moveData.x;
@ -129,9 +260,95 @@ function handleComponentMoved(moveData) {
}
}
//
function getComponentName(componentId) {
const component = components.value.find(c => c.id === componentId);
return component ? component.name : '';
//
function handleComponentDelete(componentId: string) {
//
const index = components.value.findIndex(c => c.id === componentId);
if (index !== -1) {
//
components.value.splice(index, 1);
//
if (selectedComponentId.value === componentId) {
selectedComponentId.value = null;
selectedComponentConfig.value = null;
}
}
}
//
function updateComponentProp(componentId: string | { id: string; propName: string; value: any }, propName?: string, value?: any) {
// DiagramCanvas
if (typeof componentId === 'object') {
const { id, propName: name, value: val } = componentId;
componentId = id;
propName = name;
value = val;
}
const component = components.value.find(c => c.id === componentId);
if (component && propName !== undefined) {
if (!component.props) {
component.props = {};
}
// value使
if (value !== null && typeof value === 'object' && 'value' in value) {
value = value.value;
}
//
component.props[propName] = value;
console.log(`Updated ${componentId} prop ${propName} to:`, value, typeof value);
}
}
// --- ---
onMounted(() => {
// ComponentSelector
});
onUnmounted(() => {
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', stopResize);
});
</script>
<style scoped lang="postcss">
/* 样式保持不变 */
@import "../assets/main.css";
/* 分割线样式 */
.resizer {
width: 6px;
height: 100%;
background-color: var(--b3);
cursor: col-resize;
transition: background-color 0.3s;
z-index: 10;
}
.resizer:hover, .resizer:active {
width: 6px;
}
/* 调整大小时应用全局样式 */
:global(body.resizing) {
cursor: col-resize;
user-select: none;
}
.animate-slideRight {
animation: slideRight 0.3s ease-out forwards;
}
@keyframes slideRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="w-screen h-screen">
<div class="h-screen overflow-hidden">
<Switch width="1400" height="360" />
<MechanicalButton width="1400" height="360" />
<PopButton></PopButton>

View File

@ -9,7 +9,6 @@
<script setup lang="ts">
import UploadCard from "@/components/UploadCard.vue";
import Sidebar from "../components/Sidebar.vue";
</script>
<style scoped>

View File

@ -10,7 +10,14 @@ import autoprefixer from 'autoprefixer'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vue({
template: {
compilerOptions: {
// 将所有 wokwi- 开头的标签视为自定义元素
isCustomElement: (tag) => tag.startsWith('wokwi-')
}
}
}),
vueJsx(),
vueDevTools(),
],