408 lines
15 KiB
Vue
408 lines
15 KiB
Vue
<template>
|
||
<div class="h-screen flex flex-col overflow-hidden">
|
||
<div class="flex flex-1 overflow-hidden relative">
|
||
<!-- 左侧图形化区域 -->
|
||
<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"
|
||
@wire-created="handleWireCreated"
|
||
@wire-deleted="handleWireDeleted"
|
||
/>
|
||
<!-- 添加元器件按钮 -->
|
||
<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
|
||
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);
|
||
if (selectedComponentData) {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>
|
||
|
||
<!-- 元器件选择组件 -->
|
||
<ComponentSelector
|
||
:open="showComponentsMenu"
|
||
@update:open="showComponentsMenu = $event"
|
||
@add-component="handleAddComponent"
|
||
@close="showComponentsMenu = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// 引入wokwi-elements和组件
|
||
// 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; // 现在是组件的文件名或标识符,例如 'MechanicalButton'
|
||
name: string;
|
||
x: number;
|
||
y: number;
|
||
props?: Record<string, any>; // 添加 props 字段来存储组件实例的属性
|
||
}
|
||
const components = ref<ComponentItem[]>([]);
|
||
const selectedComponentId = ref<string | null>(null); // 重命名为 selectedComponentId
|
||
const selectedComponentData = computed(() => { // 改为计算属性
|
||
return components.value.find(c => c.id === selectedComponentId.value) || null;
|
||
});
|
||
const diagramCanvas = ref(null);
|
||
|
||
// 存储动态导入的组件模块
|
||
interface ComponentModule {
|
||
default: any;
|
||
config?: {
|
||
props?: Array<{
|
||
name: string;
|
||
type: string;
|
||
label?: string;
|
||
default: any;
|
||
options?: Array<{ value: any; label: string }>; // 添加 options 字段用于 select 类型
|
||
}>;
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 处理 ComponentSelector 组件添加元器件事件
|
||
async function handleAddComponent(componentData: { type: string; name: string; props: Record<string, any> }) {
|
||
// 加载组件模块以便后续使用
|
||
await loadComponentModule(componentData.type);
|
||
|
||
// 获取画布容器和位置信息
|
||
const canvasInstance = diagramCanvas.value as any;
|
||
|
||
// 默认位置(当无法获取画布信息时使用)
|
||
let posX = 100;
|
||
let posY = 100;
|
||
|
||
try {
|
||
if (canvasInstance) {
|
||
// 获取画布容器
|
||
const canvasContainer = canvasInstance.$el as HTMLElement;
|
||
if (canvasContainer) {
|
||
// 获取当前画布的位置和缩放信息
|
||
const canvasPosition = canvasInstance.getCanvasPosition ?
|
||
canvasInstance.getCanvasPosition() :
|
||
{ x: 0, y: 0 };
|
||
const scale = canvasInstance.getScale ?
|
||
canvasInstance.getScale() :
|
||
1;
|
||
|
||
// 计算可视区域中心点在画布坐标系中的位置
|
||
const viewportWidth = canvasContainer.clientWidth;
|
||
const viewportHeight = canvasContainer.clientHeight;
|
||
|
||
// 计算画布中心点的坐标
|
||
posX = (viewportWidth / 2 - canvasPosition.x) / scale;
|
||
posY = (viewportHeight / 2 - canvasPosition.y) / scale;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error getting canvas position:', error);
|
||
// 使用默认位置
|
||
}
|
||
|
||
// 添加一些随机偏移,避免元器件重叠
|
||
const offsetX = Math.floor(Math.random() * 100) - 50;
|
||
const offsetY = Math.floor(Math.random() * 100) - 50;
|
||
|
||
const newComponent: ComponentItem = {
|
||
id: `component-${Date.now()}`,
|
||
type: componentData.type,
|
||
name: componentData.name,
|
||
x: Math.round(posX + offsetX),
|
||
y: Math.round(posY + offsetY),
|
||
props: componentData.props, // 使用从 ComponentSelector 传递的默认属性
|
||
};
|
||
|
||
components.value.push(newComponent);
|
||
}
|
||
|
||
// 处理组件选中事件
|
||
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: { id: string; x: number; y: number }) {
|
||
const component = components.value.find(c => c.id === moveData.id);
|
||
if (component) {
|
||
component.x = moveData.x;
|
||
component.y = moveData.y;
|
||
}
|
||
}
|
||
|
||
// 处理组件删除事件
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 处理连线创建事件
|
||
function handleWireCreated(wireData: any) {
|
||
console.log('Wire created:', wireData);
|
||
// 连线已在DiagramCanvas.vue中完成约束处理
|
||
}
|
||
|
||
// 处理连线删除事件
|
||
function handleWireDeleted(wireId: string) {
|
||
console.log('Wire deleted:', wireId);
|
||
// 可以在这里添加连线删除的相关逻辑
|
||
}
|
||
|
||
// --- 生命周期钩子 ---
|
||
onMounted(() => {
|
||
// 初始化画布设置
|
||
console.log('ProjectView mounted, diagram canvas ref:', diagramCanvas.value);
|
||
});
|
||
|
||
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>
|