refactor: rewrite basic canvas using konva

This commit is contained in:
SikongJueluo 2025-06-06 21:05:46 +08:00
parent e0db12e0eb
commit b6fb7e05fa
No known key found for this signature in database
5 changed files with 944 additions and 822 deletions

29
package-lock.json generated
View File

@ -22,6 +22,7 @@
"ts-log": "^2.2.7",
"ts-results-es": "^5.0.1",
"vue": "^3.5.13",
"vue-konva": "^3.2.1",
"vue-router": "4",
"yocto-queue": "^1.2.1",
"zod": "^3.24.2"
@ -4036,6 +4037,34 @@
}
}
},
"node_modules/vue-konva": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/vue-konva/-/vue-konva-3.2.1.tgz",
"integrity": "sha512-gLF+VYnlrBfwtaN3NkgzzEqlj9nyCll80VZv2DdvLUM3cisUsdcRJJuMwGTBJOTebcnn6MB22r33IFd2m+m/ig==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
},
"peerDependencies": {
"konva": ">7",
"vue": "^3"
}
},
"node_modules/vue-router": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz",

View File

@ -28,6 +28,7 @@
"ts-log": "^2.2.7",
"ts-results-es": "^5.0.1",
"vue": "^3.5.13",
"vue-konva": "^3.2.1",
"vue-router": "4",
"yocto-queue": "^1.2.1",
"zod": "^3.24.2"

View File

@ -2,13 +2,10 @@ import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueKonva from "vue-konva"
import App from '@/App.vue'
import router from './router'
// import { Client } from './APIClient'
const app = createApp(App).use(router).use(createPinia()).mount('#app')
// 初始化约束通信
// initConstraintCommunication();
const app = createApp(App).use(router).use(createPinia()).use(VueKonva).mount('#app')

View File

@ -1,844 +1,95 @@
<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 h-full" :style="{ width: leftPanelWidth + '%' }">
<DiagramCanvas ref="diagramCanvas" :componentModules="componentModules" :showDocPanel="showDocPanel"
@component-selected="handleComponentSelected" @component-moved="handleComponentMoved"
@component-delete="handleComponentDelete" @wire-created="handleWireCreated" @wire-deleted="handleWireDeleted"
@diagram-updated="handleDiagramUpdated" @open-components="openComponentsMenu"
@load-component-module="handleLoadComponentModule" @toggle-doc-panel="toggleDocPanel" />
</div>
<!-- 拖拽分割线 -->
<div
class="resizer bg-base-100 hover:bg-primary hover:opacity-70 active:bg-primary active:opacity-90 transition-colors"
@mousedown="startResize"></div>
<!-- 右侧编辑区域 -->
<div class="bg-base-200 h-full overflow-hidden flex flex-col" :style="{ width: 100 - leftPanelWidth + '%' }">
<div class="overflow-y-auto flex-1">
<!-- 使用条件渲染显示不同的面板 -->
<PropertyPanel v-show="!showDocPanel" :componentData="selectedComponentData"
:componentConfig="selectedComponentConfig" @updateProp="updateComponentProp"
@updateDirectProp="updateComponentDirectProp" />
<div v-show="showDocPanel" class="doc-panel overflow-y-auto h-full">
<MarkdownRenderer :content="documentContent" />
</div>
</div>
</div>
<div class="h-screen w-screen">
<v-stage class="h-full w-full" ref="stage" :config="stageSize">
<v-layer ref="layer">
<v-group ref="group">
<v-star v-for="item in list" :key="item.id" :config="{
x: item.x,
y: item.y,
rotation: item.rotation,
id: item.id,
numPoints: 5,
innerRadius: 30,
outerRadius: 50,
fill: '#89b717',
opacity: 0.8,
shadowColor: 'black',
shadowBlur: 10,
shadowOpacity: 0.6,
scaleX: item.scale,
scaleY: item.scale,
}" />
</v-group>
</v-layer>
</v-stage>
<div class="absolute top-20 left-10">
<input type="checkbox" class="checkbox" @change="handleCacheChange" />
cache shapes
</div>
<!-- 元器件选择组件 -->
<ComponentSelector :open="showComponentsMenu" @update:open="showComponentsMenu = $event"
@add-component="handleAddComponent" @add-template="handleAddTemplate" @close="showComponentsMenu = false" />
</div>
</template>
<script setup lang="ts">
// wokwi-elements
// import "@wokwi/elements"; // wokwi
import { ref, computed, onMounted, onUnmounted, shallowRef } from "vue"; // defineAsyncComponent shallowRef
import DiagramCanvas from "@/components/DiagramCanvas.vue";
import ComponentSelector from "@/components/ComponentSelector.vue";
import PropertyPanel from "@/components/PropertyPanel.vue";
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
import type { DiagramData, DiagramPart } from "@/components/diagramManager";
import {
type PropertyConfig,
generatePropertyConfigs,
generatePropsFromDefault,
generatePropsFromAttrs,
} from "@/components/equipments/componentConfig"; //
import { isNull, isUndefined } from "mathjs";
import { ref, onMounted, useTemplateRef, type HTMLAttributes } from "vue";
// --- ---
const showDocPanel = ref(false);
const documentContent = ref("");
//
import { useRoute } from "vue-router";
const route = useRoute();
//
async function toggleDocPanel() {
showDocPanel.value = !showDocPanel.value;
//
if (showDocPanel.value) {
await loadDocumentContent();
}
}
//
async function loadDocumentContent() {
try {
// ID
const tutorialId = (route.query.tutorial as string) || "02"; // 02
//
let docPath = `/doc/${tutorialId}/doc.md`;
// 线 02_key
// 使
if (!tutorialId.includes("_")) {
docPath = `/doc/${tutorialId}/doc.md`;
}
//
const response = await fetch(docPath);
if (!response.ok) {
throw new Error(`Failed to load document: ${response.status}`);
}
//
documentContent.value = (await response.text()).replace(
/.\/images/gi,
`/doc/${tutorialId}/images`,
);
} catch (error) {
console.error("加载文档失败:", error);
documentContent.value = "# 文档加载失败\n\n无法加载请求的文档。";
}
}
//
onMounted(async () => {
if (route.query.tutorial) {
showDocPanel.value = true;
await loadDocumentContent();
}
});
// --- ---
const showComponentsMenu = ref(false);
const diagramData = ref<DiagramData>({
version: 1,
author: "admin",
editor: "me",
parts: [],
connections: [],
});
const selectedComponentId = ref<string | null>(null);
const selectedComponentData = computed(() => {
return (
diagramData.value.parts.find((p) => p.id === selectedComponentId.value) ||
null
);
});
const diagramCanvas = ref(null);
//
interface ComponentModule {
default: any;
getDefaultProps?: () => Record<string, any>;
config?: {
props?: Array<PropertyConfig>;
};
}
const componentModules = shallowRef<Record<string, ComponentModule>>({});
const selectedComponentConfig = shallowRef<{ props: PropertyConfig[] } | 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];
}
//
async function handleLoadComponentModule(type: string) {
console.log("Handling load component module request for:", type);
await loadComponentModule(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>;
}) {
// 便使
const componentModule = await loadComponentModule(componentData.type);
//
const canvasInstance = diagramCanvas.value as any;
//
let position = { x: 100, y: 100 };
let scale = 1;
try {
if (
canvasInstance &&
canvasInstance.getCanvasPosition &&
canvasInstance.getScale
) {
position = canvasInstance.getCanvasPosition();
scale = canvasInstance.getScale();
//
const canvasContainer = canvasInstance.$el as HTMLElement;
if (canvasContainer) {
//
const viewportWidth = canvasContainer.clientWidth;
const viewportHeight = canvasContainer.clientHeight;
//
position.x = (viewportWidth / 2 - position.x) / scale;
position.y = (viewportHeight / 2 - position.y) / scale;
}
}
} catch (error) {
console.error("获取画布位置时出错:", error);
}
//
const offsetX = Math.floor(Math.random() * 100) - 50;
const offsetY = Math.floor(Math.random() * 100) - 50;
//
let capsPage = null;
if (
componentModule &&
componentModule.default &&
typeof componentModule.default.getCapabilities === "function"
) {
try {
capsPage = componentModule.default.getCapabilities();
console.log(`获取到${componentData.type}组件的能力页面`);
} catch (error) {
console.error(`获取${componentData.type}组件能力页面失败:`, error);
}
}
// 使diagramManager
const newComponent: DiagramPart = {
id: `component-${Date.now()}`,
type: componentData.type,
x: Math.round(position.x + offsetX),
y: Math.round(position.y + offsetY),
attrs: componentData.props,
rotate: 0,
group: "",
positionlock: false,
hidepins: true,
isOn: true,
index: 0,
};
console.log("添加新组件:", newComponent);
//
if (
canvasInstance &&
canvasInstance.getDiagramData &&
canvasInstance.updateDiagramDataDirectly
) {
const currentData = canvasInstance.getDiagramData();
currentData.parts.push(newComponent);
canvasInstance.updateDiagramDataDirectly(currentData);
}
}
//
async function handleAddTemplate(templateData: {
type CanvasObject = {
id: string;
name: string;
template: any;
}) {
console.log("添加模板:", templateData);
console.log("=== 模板组件数量:", templateData.template?.parts?.length || 0);
//
const canvasInstance = diagramCanvas.value as any;
if (
!canvasInstance ||
!canvasInstance.getDiagramData ||
!canvasInstance.updateDiagramDataDirectly
) {
console.error("没有可用的画布实例添加模板");
return;
}
x: number;
y: number;
rotation: number;
scale: number;
};
//
const currentData = canvasInstance.getDiagramData();
console.log("=== 当前图表组件数量:", currentData.parts.length);
const stageSize = {
width: window.innerWidth,
height: window.innerHeight,
};
// IDID
const idPrefix = `template-${Date.now()}-`;
//
if (templateData.template && templateData.template.parts) {
//
let viewportCenter = { x: 300, y: 200 }; //
try {
if (
canvasInstance &&
canvasInstance.getCanvasPosition &&
canvasInstance.getScale
) {
const position = canvasInstance.getCanvasPosition();
const scale = canvasInstance.getScale();
const list = ref<CanvasObject[]>([]);
const group = useTemplateRef<HTMLAttributes>("group");
const dragItemId = ref(null);
//
const canvasContainer = canvasInstance.$el as HTMLElement;
if (canvasContainer) {
//
const viewportWidth = canvasContainer.clientWidth;
const viewportHeight = canvasContainer.clientHeight;
// (handleAddComponent)
viewportCenter.x = (viewportWidth / 2 - position.x) / scale;
viewportCenter.y = (viewportHeight / 2 - position.y) / scale;
console.log(
`=== 计算的视口中心: x=${viewportCenter.x}, y=${viewportCenter.y}, scale=${scale}`,
);
}
}
} catch (error) {
console.error("获取视口中心位置时出错:", error);
}
console.log("=== 使用视口中心添加模板组件:", viewportCenter);
//
const mainPart = templateData.template.parts[0];
//
const newParts = await Promise.all(
templateData.template.parts.map(async (part: any) => {
// ID
const newPart = JSON.parse(JSON.stringify(part));
newPart.id = `${idPrefix}${part.id}`;
//
try {
const componentModule = await loadComponentModule(part.type);
if (
componentModule &&
componentModule.default &&
typeof componentModule.default.getCapabilities === "function"
) {
newPart.capsPage = componentModule.default.getCapabilities();
console.log(`加载模板组件${part.type}组件的能力页面成功`);
}
} catch (error) {
console.error(`加载模板组件${part.type}的能力页面失败:`, error);
}
//
if (typeof newPart.x === "number" && typeof newPart.y === "number") {
const oldX = newPart.x;
const oldY = newPart.y;
//
const relativeX = part.x - mainPart.x;
const relativeY = part.y - mainPart.y;
//
newPart.x = viewportCenter.x + relativeX;
newPart.y = viewportCenter.y + relativeY;
console.log(
`=== 组件[${newPart.id}]位置调整: (${oldX},${oldY}) -> (${newPart.x},${newPart.y})`,
);
}
return newPart;
}),
);
//
currentData.parts.push(...newParts);
//
if (templateData.template.connections) {
// IDID
const idMap: Record<string, string> = {};
templateData.template.parts.forEach((part: any) => {
idMap[part.id] = `${idPrefix}${part.id}`;
});
// ID
const newConnections = templateData.template.connections.map(
(conn: any) => {
// ( [from, to, type, path])
if (Array.isArray(conn)) {
const [from, to, type, path] = conn;
// IDID
const fromParts = from.split(":");
const toParts = to.split(":");
if (fromParts.length === 2 && toParts.length === 2) {
const fromComponentId = fromParts[0];
const fromPinId = fromParts[1];
const toComponentId = toParts[0];
const toPinId = toParts[1];
// 使ID
const newFrom = `${idMap[fromComponentId] || fromComponentId}:${fromPinId}`;
const newTo = `${idMap[toComponentId] || toComponentId}:${toPinId}`;
return [newFrom, newTo, type, path];
}
}
return conn; //
},
);
//
currentData.connections.push(...newConnections);
}
//
canvasInstance.updateDiagramDataDirectly(currentData);
console.log("=== 更新图表数据完成,新组件数量:", currentData.parts.length);
//
showToast(`已添加 ${templateData.name} 模板`, "success");
function handleCacheChange(e: Event) {
const target = e.target as HTMLInputElement;
const shouldCache = isNull(target) ? false : target.checked;
if (shouldCache) {
group.getNode().cache();
} else {
console.error("模板格式错误缺少parts数组");
showToast("模板格式错误", "error");
group.getNode().clearCache();
}
}
//
async function handleComponentSelected(componentData: DiagramPart | null) {
selectedComponentId.value = componentData ? componentData.id : null;
selectedComponentConfig.value = null; //
function handleDragstart(e: Event) {
// save drag element:
const target = e.target as HTMLInputElement;
dragItemId.value = e.target?.id();
// move current element to the top:
const item = list.value.find((i) => i.id === dragItemId.value);
if (isUndefined(item)) return;
if (componentData) {
//
const moduleRef = await loadComponentModule(componentData.type);
if (moduleRef) {
try {
//
const propConfigs: PropertyConfig[] = [];
//
const addedProps = new Set<string>();
// 1. getDefaultProps
if (typeof moduleRef.getDefaultProps === "function") {
const defaultProps = moduleRef.getDefaultProps();
const defaultPropConfigs = generatePropsFromDefault(defaultProps);
//
defaultPropConfigs.forEach((config) => {
propConfigs.push(config);
addedProps.add(config.name);
});
}
// 2.
const directPropConfigs = generatePropertyConfigs(componentData);
//
const newDirectProps = directPropConfigs.filter(
(config) => !addedProps.has(config.name),
);
propConfigs.push(...newDirectProps);
// 3. attrs
if (componentData.attrs) {
const attrs = componentData.attrs;
const attrPropConfigs = generatePropsFromAttrs(attrs);
//
attrPropConfigs.forEach((attrConfig) => {
const existingIndex = propConfigs.findIndex(
(p) => p.name === attrConfig.name,
);
if (existingIndex >= 0) {
//
propConfigs[existingIndex] = attrConfig;
} else {
//
propConfigs.push(attrConfig);
}
});
}
selectedComponentConfig.value = { props: propConfigs };
console.log(
`Built config for ${componentData.type}:`,
selectedComponentConfig.value,
);
} catch (error) {
console.error(
`Error building config for ${componentData.type}:`,
error,
);
selectedComponentConfig.value = { props: [] };
}
} else {
console.warn(`Module for component ${componentData.type} not found.`);
selectedComponentConfig.value = { props: [] };
}
}
const index = list.value.indexOf(item);
list.value.splice(index, 1);
list.value.push(item);
}
//
function handleDiagramUpdated(data: DiagramData) {
diagramData.value = data;
console.log("Diagram data updated:", data);
function handleDragend() {
dragItemId.value = null;
}
//
function handleComponentMoved(moveData: { id: string; x: number; y: number }) {
const part = diagramData.value.parts.find((p) => p.id === moveData.id);
if (part) {
part.x = moveData.x;
part.y = moveData.y;
}
}
//
function handleComponentDelete(componentId: string) {
//
const component = diagramData.value.parts.find((p) => p.id === componentId);
if (!component) return;
// ID
const componentsToDelete: string[] = [componentId];
//
if (component.group && component.group !== "") {
const groupMembers = diagramData.value.parts.filter(
(p) => p.group === component.group && p.id !== componentId,
);
// ID
componentsToDelete.push(...groupMembers.map((p) => p.id));
console.log(
`删除组件 ${componentId} 及其组 ${component.group} 中的 ${groupMembers.length} 个组件`,
);
}
//
diagramData.value.parts = diagramData.value.parts.filter(
(p) => !componentsToDelete.includes(p.id),
);
//
diagramData.value.connections = diagramData.value.connections.filter(
(connection) => {
for (const id of componentsToDelete) {
if (
connection[0].startsWith(`${id}:`) ||
connection[1].startsWith(`${id}:`)
) {
return false;
}
}
return true;
},
);
//
if (
selectedComponentId.value &&
componentsToDelete.includes(selectedComponentId.value)
) {
selectedComponentId.value = null;
selectedComponentConfig.value = null;
}
}
//
function updateComponentProp(
componentId: string,
propName: string,
value: any,
) {
const canvasInstance = diagramCanvas.value as any;
if (
!canvasInstance ||
!canvasInstance.getDiagramData ||
!canvasInstance.updateDiagramDataDirectly
) {
console.error("没有可用的画布实例进行属性更新");
return;
}
// value使
if (value !== null && typeof value === "object" && "value" in value) {
value = value.value;
}
const currentData = canvasInstance.getDiagramData();
const part = currentData.parts.find((p: DiagramPart) => p.id === componentId);
if (part) {
//
if (propName in part) {
(part as any)[propName] = value;
} else {
// attrs
if (!part.attrs) {
part.attrs = {};
}
part.attrs[propName] = value;
}
canvasInstance.updateDiagramDataDirectly(currentData);
console.log(
`更新组件${componentId}的属性${propName}为:`,
value,
typeof value,
);
}
}
//
function updateComponentDirectProp(
componentId: string,
propName: string,
value: any,
) {
const canvasInstance = diagramCanvas.value as any;
if (
!canvasInstance ||
!canvasInstance.getDiagramData ||
!canvasInstance.updateDiagramDataDirectly
) {
console.error("没有可用的画布实例进行属性更新");
return;
}
const currentData = canvasInstance.getDiagramData();
const part = currentData.parts.find((p: DiagramPart) => p.id === componentId);
if (part) {
// @ts-ignore:
part[propName] = value;
canvasInstance.updateDiagramDataDirectly(currentData);
console.log(
`更新组件${componentId}的直接属性${propName}为:`,
value,
typeof value,
);
}
}
// - 使updateComponentProp
// 线
function handleWireCreated(wireData: any) {
console.log("Wire created:", wireData);
}
// 线
function handleWireDeleted(wireId: string) {
console.log("Wire deleted:", wireId);
}
// diagram
function exportDiagram() {
// 使DiagramCanvas
const canvasInstance = diagramCanvas.value as any;
if (canvasInstance && canvasInstance.exportDiagram) {
canvasInstance.exportDiagram();
}
}
// --- ---
const showNotification = ref(false);
const notificationMessage = ref("");
const notificationType = ref<"success" | "error" | "info">("info");
function showToast(
message: string,
type: "success" | "error" | "info" = "info",
duration = 3000,
) {
const canvasInstance = diagramCanvas.value as any;
if (canvasInstance && canvasInstance.showToast) {
canvasInstance.showToast(message, type, duration);
} else {
// 使
notificationMessage.value = message;
notificationType.value = type;
showNotification.value = true;
//
setTimeout(() => {
showNotification.value = false;
}, duration);
}
}
//
// --- ---
// 使 componentConfig.ts getPropValue
// --- ---
onMounted(async () => {
//
console.log("ProjectView mounted, diagram canvas ref:", diagramCanvas.value);
//
const canvasInstance = diagramCanvas.value as any;
if (canvasInstance && canvasInstance.getDiagramData) {
diagramData.value = canvasInstance.getDiagramData();
// 使
const componentTypes = new Set<string>();
diagramData.value.parts.forEach((part) => {
componentTypes.add(part.type);
onMounted(() => {
for (let n = 0; n < 30; n++) {
list.value.push({
id: Math.round(Math.random() * 10000).toString(),
x: Math.random() * stageSize.width,
y: Math.random() * stageSize.height,
rotation: Math.random() * 180,
scale: Math.random(),
});
console.log("Preloading component modules:", Array.from(componentTypes));
//
await Promise.all(
Array.from(componentTypes).map((type) => loadComponentModule(type)),
);
console.log("All component modules loaded");
}
});
onUnmounted(() => {
document.removeEventListener("mousemove", onResize);
document.removeEventListener("mouseup", stopResize);
});
</script>
<style scoped lang="postcss">
/* 样式保持不变 */
<style>
@import "../assets/main.css";
/* 分割线样式 */
.resizer {
width: 6px;
height: 100%;
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);
}
}
/* 确保滚动行为仅在需要时出现 */
html,
body {
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
}
/* 文档面板样式 */
.doc-panel {
padding: 1.5rem;
max-width: 100%;
margin: 0;
background-color: transparent;
/* 使用透明背景 */
border: none;
/* 确保没有边框 */
}
/* 文档切换按钮样式 */
.doc-toggle-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 50;
}
/* Markdown渲染样式调整 */
:deep(.markdown-content) {
padding: 1rem;
background-color: hsl(var(--b1));
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,844 @@
<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 h-full" :style="{ width: leftPanelWidth + '%' }">
<DiagramCanvas ref="diagramCanvas" :componentModules="componentModules" :showDocPanel="showDocPanel"
@component-selected="handleComponentSelected" @component-moved="handleComponentMoved"
@component-delete="handleComponentDelete" @wire-created="handleWireCreated" @wire-deleted="handleWireDeleted"
@diagram-updated="handleDiagramUpdated" @open-components="openComponentsMenu"
@load-component-module="handleLoadComponentModule" @toggle-doc-panel="toggleDocPanel" />
</div>
<!-- 拖拽分割线 -->
<div
class="resizer bg-base-100 hover:bg-primary hover:opacity-70 active:bg-primary active:opacity-90 transition-colors"
@mousedown="startResize"></div>
<!-- 右侧编辑区域 -->
<div class="bg-base-200 h-full overflow-hidden flex flex-col" :style="{ width: 100 - leftPanelWidth + '%' }">
<div class="overflow-y-auto flex-1">
<!-- 使用条件渲染显示不同的面板 -->
<PropertyPanel v-show="!showDocPanel" :componentData="selectedComponentData"
:componentConfig="selectedComponentConfig" @updateProp="updateComponentProp"
@updateDirectProp="updateComponentDirectProp" />
<div v-show="showDocPanel" class="doc-panel overflow-y-auto h-full">
<MarkdownRenderer :content="documentContent" />
</div>
</div>
</div>
</div>
<!-- 元器件选择组件 -->
<ComponentSelector :open="showComponentsMenu" @update:open="showComponentsMenu = $event"
@add-component="handleAddComponent" @add-template="handleAddTemplate" @close="showComponentsMenu = false" />
</div>
</template>
<script setup lang="ts">
// wokwi-elements
// import "@wokwi/elements"; // wokwi
import { ref, computed, onMounted, onUnmounted, shallowRef } from "vue"; // defineAsyncComponent shallowRef
import DiagramCanvas from "@/components/DiagramCanvas.vue";
import ComponentSelector from "@/components/ComponentSelector.vue";
import PropertyPanel from "@/components/PropertyPanel.vue";
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
import type { DiagramData, DiagramPart } from "@/components/diagramManager";
import {
type PropertyConfig,
generatePropertyConfigs,
generatePropsFromDefault,
generatePropsFromAttrs,
} from "@/components/equipments/componentConfig"; //
// --- ---
const showDocPanel = ref(false);
const documentContent = ref("");
//
import { useRoute } from "vue-router";
const route = useRoute();
//
async function toggleDocPanel() {
showDocPanel.value = !showDocPanel.value;
//
if (showDocPanel.value) {
await loadDocumentContent();
}
}
//
async function loadDocumentContent() {
try {
// ID
const tutorialId = (route.query.tutorial as string) || "02"; // 02
//
let docPath = `/doc/${tutorialId}/doc.md`;
// 线 02_key
// 使
if (!tutorialId.includes("_")) {
docPath = `/doc/${tutorialId}/doc.md`;
}
//
const response = await fetch(docPath);
if (!response.ok) {
throw new Error(`Failed to load document: ${response.status}`);
}
//
documentContent.value = (await response.text()).replace(
/.\/images/gi,
`/doc/${tutorialId}/images`,
);
} catch (error) {
console.error("加载文档失败:", error);
documentContent.value = "# 文档加载失败\n\n无法加载请求的文档。";
}
}
//
onMounted(async () => {
if (route.query.tutorial) {
showDocPanel.value = true;
await loadDocumentContent();
}
});
// --- ---
const showComponentsMenu = ref(false);
const diagramData = ref<DiagramData>({
version: 1,
author: "admin",
editor: "me",
parts: [],
connections: [],
});
const selectedComponentId = ref<string | null>(null);
const selectedComponentData = computed(() => {
return (
diagramData.value.parts.find((p) => p.id === selectedComponentId.value) ||
null
);
});
const diagramCanvas = ref(null);
//
interface ComponentModule {
default: any;
getDefaultProps?: () => Record<string, any>;
config?: {
props?: Array<PropertyConfig>;
};
}
const componentModules = shallowRef<Record<string, ComponentModule>>({});
const selectedComponentConfig = shallowRef<{ props: PropertyConfig[] } | 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];
}
//
async function handleLoadComponentModule(type: string) {
console.log("Handling load component module request for:", type);
await loadComponentModule(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>;
}) {
// 便使
const componentModule = await loadComponentModule(componentData.type);
//
const canvasInstance = diagramCanvas.value as any;
//
let position = { x: 100, y: 100 };
let scale = 1;
try {
if (
canvasInstance &&
canvasInstance.getCanvasPosition &&
canvasInstance.getScale
) {
position = canvasInstance.getCanvasPosition();
scale = canvasInstance.getScale();
//
const canvasContainer = canvasInstance.$el as HTMLElement;
if (canvasContainer) {
//
const viewportWidth = canvasContainer.clientWidth;
const viewportHeight = canvasContainer.clientHeight;
//
position.x = (viewportWidth / 2 - position.x) / scale;
position.y = (viewportHeight / 2 - position.y) / scale;
}
}
} catch (error) {
console.error("获取画布位置时出错:", error);
}
//
const offsetX = Math.floor(Math.random() * 100) - 50;
const offsetY = Math.floor(Math.random() * 100) - 50;
//
let capsPage = null;
if (
componentModule &&
componentModule.default &&
typeof componentModule.default.getCapabilities === "function"
) {
try {
capsPage = componentModule.default.getCapabilities();
console.log(`获取到${componentData.type}组件的能力页面`);
} catch (error) {
console.error(`获取${componentData.type}组件能力页面失败:`, error);
}
}
// 使diagramManager
const newComponent: DiagramPart = {
id: `component-${Date.now()}`,
type: componentData.type,
x: Math.round(position.x + offsetX),
y: Math.round(position.y + offsetY),
attrs: componentData.props,
rotate: 0,
group: "",
positionlock: false,
hidepins: true,
isOn: true,
index: 0,
};
console.log("添加新组件:", newComponent);
//
if (
canvasInstance &&
canvasInstance.getDiagramData &&
canvasInstance.updateDiagramDataDirectly
) {
const currentData = canvasInstance.getDiagramData();
currentData.parts.push(newComponent);
canvasInstance.updateDiagramDataDirectly(currentData);
}
}
//
async function handleAddTemplate(templateData: {
id: string;
name: string;
template: any;
}) {
console.log("添加模板:", templateData);
console.log("=== 模板组件数量:", templateData.template?.parts?.length || 0);
//
const canvasInstance = diagramCanvas.value as any;
if (
!canvasInstance ||
!canvasInstance.getDiagramData ||
!canvasInstance.updateDiagramDataDirectly
) {
console.error("没有可用的画布实例添加模板");
return;
}
//
const currentData = canvasInstance.getDiagramData();
console.log("=== 当前图表组件数量:", currentData.parts.length);
// IDID
const idPrefix = `template-${Date.now()}-`;
//
if (templateData.template && templateData.template.parts) {
//
let viewportCenter = { x: 300, y: 200 }; //
try {
if (
canvasInstance &&
canvasInstance.getCanvasPosition &&
canvasInstance.getScale
) {
const position = canvasInstance.getCanvasPosition();
const scale = canvasInstance.getScale();
//
const canvasContainer = canvasInstance.$el as HTMLElement;
if (canvasContainer) {
//
const viewportWidth = canvasContainer.clientWidth;
const viewportHeight = canvasContainer.clientHeight;
// (handleAddComponent)
viewportCenter.x = (viewportWidth / 2 - position.x) / scale;
viewportCenter.y = (viewportHeight / 2 - position.y) / scale;
console.log(
`=== 计算的视口中心: x=${viewportCenter.x}, y=${viewportCenter.y}, scale=${scale}`,
);
}
}
} catch (error) {
console.error("获取视口中心位置时出错:", error);
}
console.log("=== 使用视口中心添加模板组件:", viewportCenter);
//
const mainPart = templateData.template.parts[0];
//
const newParts = await Promise.all(
templateData.template.parts.map(async (part: any) => {
// ID
const newPart = JSON.parse(JSON.stringify(part));
newPart.id = `${idPrefix}${part.id}`;
//
try {
const componentModule = await loadComponentModule(part.type);
if (
componentModule &&
componentModule.default &&
typeof componentModule.default.getCapabilities === "function"
) {
newPart.capsPage = componentModule.default.getCapabilities();
console.log(`加载模板组件${part.type}组件的能力页面成功`);
}
} catch (error) {
console.error(`加载模板组件${part.type}的能力页面失败:`, error);
}
//
if (typeof newPart.x === "number" && typeof newPart.y === "number") {
const oldX = newPart.x;
const oldY = newPart.y;
//
const relativeX = part.x - mainPart.x;
const relativeY = part.y - mainPart.y;
//
newPart.x = viewportCenter.x + relativeX;
newPart.y = viewportCenter.y + relativeY;
console.log(
`=== 组件[${newPart.id}]位置调整: (${oldX},${oldY}) -> (${newPart.x},${newPart.y})`,
);
}
return newPart;
}),
);
//
currentData.parts.push(...newParts);
//
if (templateData.template.connections) {
// IDID
const idMap: Record<string, string> = {};
templateData.template.parts.forEach((part: any) => {
idMap[part.id] = `${idPrefix}${part.id}`;
});
// ID
const newConnections = templateData.template.connections.map(
(conn: any) => {
// ( [from, to, type, path])
if (Array.isArray(conn)) {
const [from, to, type, path] = conn;
// IDID
const fromParts = from.split(":");
const toParts = to.split(":");
if (fromParts.length === 2 && toParts.length === 2) {
const fromComponentId = fromParts[0];
const fromPinId = fromParts[1];
const toComponentId = toParts[0];
const toPinId = toParts[1];
// 使ID
const newFrom = `${idMap[fromComponentId] || fromComponentId}:${fromPinId}`;
const newTo = `${idMap[toComponentId] || toComponentId}:${toPinId}`;
return [newFrom, newTo, type, path];
}
}
return conn; //
},
);
//
currentData.connections.push(...newConnections);
}
//
canvasInstance.updateDiagramDataDirectly(currentData);
console.log("=== 更新图表数据完成,新组件数量:", currentData.parts.length);
//
showToast(`已添加 ${templateData.name} 模板`, "success");
} else {
console.error("模板格式错误缺少parts数组");
showToast("模板格式错误", "error");
}
}
//
async function handleComponentSelected(componentData: DiagramPart | null) {
selectedComponentId.value = componentData ? componentData.id : null;
selectedComponentConfig.value = null; //
if (componentData) {
//
const moduleRef = await loadComponentModule(componentData.type);
if (moduleRef) {
try {
//
const propConfigs: PropertyConfig[] = [];
//
const addedProps = new Set<string>();
// 1. getDefaultProps
if (typeof moduleRef.getDefaultProps === "function") {
const defaultProps = moduleRef.getDefaultProps();
const defaultPropConfigs = generatePropsFromDefault(defaultProps);
//
defaultPropConfigs.forEach((config) => {
propConfigs.push(config);
addedProps.add(config.name);
});
}
// 2.
const directPropConfigs = generatePropertyConfigs(componentData);
//
const newDirectProps = directPropConfigs.filter(
(config) => !addedProps.has(config.name),
);
propConfigs.push(...newDirectProps);
// 3. attrs
if (componentData.attrs) {
const attrs = componentData.attrs;
const attrPropConfigs = generatePropsFromAttrs(attrs);
//
attrPropConfigs.forEach((attrConfig) => {
const existingIndex = propConfigs.findIndex(
(p) => p.name === attrConfig.name,
);
if (existingIndex >= 0) {
//
propConfigs[existingIndex] = attrConfig;
} else {
//
propConfigs.push(attrConfig);
}
});
}
selectedComponentConfig.value = { props: propConfigs };
console.log(
`Built config for ${componentData.type}:`,
selectedComponentConfig.value,
);
} catch (error) {
console.error(
`Error building config for ${componentData.type}:`,
error,
);
selectedComponentConfig.value = { props: [] };
}
} else {
console.warn(`Module for component ${componentData.type} not found.`);
selectedComponentConfig.value = { props: [] };
}
}
}
//
function handleDiagramUpdated(data: DiagramData) {
diagramData.value = data;
console.log("Diagram data updated:", data);
}
//
function handleComponentMoved(moveData: { id: string; x: number; y: number }) {
const part = diagramData.value.parts.find((p) => p.id === moveData.id);
if (part) {
part.x = moveData.x;
part.y = moveData.y;
}
}
//
function handleComponentDelete(componentId: string) {
//
const component = diagramData.value.parts.find((p) => p.id === componentId);
if (!component) return;
// ID
const componentsToDelete: string[] = [componentId];
//
if (component.group && component.group !== "") {
const groupMembers = diagramData.value.parts.filter(
(p) => p.group === component.group && p.id !== componentId,
);
// ID
componentsToDelete.push(...groupMembers.map((p) => p.id));
console.log(
`删除组件 ${componentId} 及其组 ${component.group} 中的 ${groupMembers.length} 个组件`,
);
}
//
diagramData.value.parts = diagramData.value.parts.filter(
(p) => !componentsToDelete.includes(p.id),
);
//
diagramData.value.connections = diagramData.value.connections.filter(
(connection) => {
for (const id of componentsToDelete) {
if (
connection[0].startsWith(`${id}:`) ||
connection[1].startsWith(`${id}:`)
) {
return false;
}
}
return true;
},
);
//
if (
selectedComponentId.value &&
componentsToDelete.includes(selectedComponentId.value)
) {
selectedComponentId.value = null;
selectedComponentConfig.value = null;
}
}
//
function updateComponentProp(
componentId: string,
propName: string,
value: any,
) {
const canvasInstance = diagramCanvas.value as any;
if (
!canvasInstance ||
!canvasInstance.getDiagramData ||
!canvasInstance.updateDiagramDataDirectly
) {
console.error("没有可用的画布实例进行属性更新");
return;
}
// value使
if (value !== null && typeof value === "object" && "value" in value) {
value = value.value;
}
const currentData = canvasInstance.getDiagramData();
const part = currentData.parts.find((p: DiagramPart) => p.id === componentId);
if (part) {
//
if (propName in part) {
(part as any)[propName] = value;
} else {
// attrs
if (!part.attrs) {
part.attrs = {};
}
part.attrs[propName] = value;
}
canvasInstance.updateDiagramDataDirectly(currentData);
console.log(
`更新组件${componentId}的属性${propName}为:`,
value,
typeof value,
);
}
}
//
function updateComponentDirectProp(
componentId: string,
propName: string,
value: any,
) {
const canvasInstance = diagramCanvas.value as any;
if (
!canvasInstance ||
!canvasInstance.getDiagramData ||
!canvasInstance.updateDiagramDataDirectly
) {
console.error("没有可用的画布实例进行属性更新");
return;
}
const currentData = canvasInstance.getDiagramData();
const part = currentData.parts.find((p: DiagramPart) => p.id === componentId);
if (part) {
// @ts-ignore:
part[propName] = value;
canvasInstance.updateDiagramDataDirectly(currentData);
console.log(
`更新组件${componentId}的直接属性${propName}为:`,
value,
typeof value,
);
}
}
// - 使updateComponentProp
// 线
function handleWireCreated(wireData: any) {
console.log("Wire created:", wireData);
}
// 线
function handleWireDeleted(wireId: string) {
console.log("Wire deleted:", wireId);
}
// diagram
function exportDiagram() {
// 使DiagramCanvas
const canvasInstance = diagramCanvas.value as any;
if (canvasInstance && canvasInstance.exportDiagram) {
canvasInstance.exportDiagram();
}
}
// --- ---
const showNotification = ref(false);
const notificationMessage = ref("");
const notificationType = ref<"success" | "error" | "info">("info");
function showToast(
message: string,
type: "success" | "error" | "info" = "info",
duration = 3000,
) {
const canvasInstance = diagramCanvas.value as any;
if (canvasInstance && canvasInstance.showToast) {
canvasInstance.showToast(message, type, duration);
} else {
// 使
notificationMessage.value = message;
notificationType.value = type;
showNotification.value = true;
//
setTimeout(() => {
showNotification.value = false;
}, duration);
}
}
//
// --- ---
// 使 componentConfig.ts getPropValue
// --- ---
onMounted(async () => {
//
console.log("ProjectView mounted, diagram canvas ref:", diagramCanvas.value);
//
const canvasInstance = diagramCanvas.value as any;
if (canvasInstance && canvasInstance.getDiagramData) {
diagramData.value = canvasInstance.getDiagramData();
// 使
const componentTypes = new Set<string>();
diagramData.value.parts.forEach((part) => {
componentTypes.add(part.type);
});
console.log("Preloading component modules:", Array.from(componentTypes));
//
await Promise.all(
Array.from(componentTypes).map((type) => loadComponentModule(type)),
);
console.log("All component modules loaded");
}
});
onUnmounted(() => {
document.removeEventListener("mousemove", onResize);
document.removeEventListener("mouseup", stopResize);
});
</script>
<style scoped lang="postcss">
/* 样式保持不变 */
@import "../assets/main.css";
/* 分割线样式 */
.resizer {
width: 6px;
height: 100%;
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);
}
}
/* 确保滚动行为仅在需要时出现 */
html,
body {
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
}
/* 文档面板样式 */
.doc-panel {
padding: 1.5rem;
max-width: 100%;
margin: 0;
background-color: transparent;
/* 使用透明背景 */
border: none;
/* 确保没有边框 */
}
/* 文档切换按钮样式 */
.doc-toggle-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 50;
}
/* Markdown渲染样式调整 */
:deep(.markdown-content) {
padding: 1rem;
background-color: hsl(var(--b1));
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
</style>