Commit 61893ffb authored by fenghen777's avatar fenghen777

Initial commit: EPLAN Visualizer

- React + Vite 项目初始化
- 使用 @xyflow/react 进行流程图可视化
- 集成 xlsx 处理 Excel 文件
- 使用 zustand 进行状态管理
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parents
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EPLAN Visualizer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "eplan_gui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@xyflow/react": "^12.10.1",
"dagre": "^0.8.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.3.1"
}
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background: #121218;
color: #e0e0e0;
}
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
/**
* App - 主入口组件
* 支持两个视图:流程编辑器 / 元器件编辑器
*/
import { ReactFlowProvider } from '@xyflow/react';
import Toolbar from './components/Toolbar/Toolbar';
import Sidebar from './components/Sidebar/Sidebar';
import FlowCanvas from './components/Canvas/FlowCanvas';
import PropertiesPanel from './components/PropertiesPanel/PropertiesPanel';
import ComponentEditor from './components/ComponentEditor/ComponentEditor';
import useComponentLibrary from './hooks/useComponentLibrary';
import './App.css';
export default function App() {
const currentView = useComponentLibrary(s => s.currentView);
return (
<ReactFlowProvider>
<div className="app-container">
<Toolbar />
{currentView === 'flow' ? (
<div className="app-main">
<Sidebar />
<FlowCanvas />
<PropertiesPanel />
</div>
) : (
<ComponentEditor />
)}
</div>
</ReactFlowProvider>
);
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file
/**
* FlowCanvas - React Flow 画布容器
* 承载所有节点和边的渲染及交互
*/
import { useCallback, useRef, useEffect, useState } from 'react';
import {
ReactFlow,
Controls,
MiniMap,
Background,
BackgroundVariant,
reconnectEdge,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import DeviceNode from '../Nodes/DeviceNode';
import CustomDeviceNode from '../Nodes/CustomDeviceNode';
import GradientBezierEdge from '../Edges/GradientBezierEdge';
import CustomConnectionLine from '../Edges/CustomConnectionLine';
import useFlowStore from '../../hooks/useFlowStore';
import useComponentLibrary from '../../hooks/useComponentLibrary';
import styles from './FlowCanvas.module.css';
const nodeTypes = { deviceNode: DeviceNode, customDeviceNode: CustomDeviceNode };
const edgeTypes = { gradientBezier: GradientBezierEdge };
const defaultEdgeOptions = {
type: 'gradientBezier',
};
export default function FlowCanvas() {
const reactFlowWrapper = useRef(null);
const { screenToFlowPosition } = useReactFlow();
const {
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
setSelectedNode,
setSelectedEdge,
clearSelection,
addNode,
connectionError,
} = useFlowStore();
/** 连接错误 Toast 状态 */
const [showError, setShowError] = useState(null);
useEffect(() => {
if (connectionError) {
setShowError(connectionError);
const timer = setTimeout(() => setShowError(null), 1500);
return () => clearTimeout(timer);
}
}, [connectionError]);
const onNodeClick = useCallback((_event, node) => {
setSelectedNode(node);
}, [setSelectedNode]);
const onEdgeClick = useCallback((_event, edge) => {
setSelectedEdge(edge);
}, [setSelectedEdge]);
const onPaneClick = useCallback(() => {
clearSelection();
}, [clearSelection]);
/** 空格键旋转选中节点 */
useEffect(() => {
function handleKeyDown(e) {
if (e.code !== 'Space') return;
// 避免在输入框中触发
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const { selectedNode, nodes } = useFlowStore.getState();
if (!selectedNode) return;
e.preventDefault();
const currentRotation = selectedNode.data?.rotation || 0;
const newRotation = (currentRotation + 90) % 360;
useFlowStore.setState({
nodes: nodes.map(n =>
n.id === selectedNode.id
? { ...n, data: { ...n.data, rotation: newRotation } }
: n
),
selectedNode: { ...selectedNode, data: { ...selectedNode.data, rotation: newRotation } },
});
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
/** 双击画布空白处添加新节点 */
const onDoubleClick = useCallback((event) => {
if (!reactFlowWrapper.current) return;
// 只在空白处触发
if (event.target !== event.currentTarget) return;
}, []);
/** 拖拽放置新节点 */
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback((event) => {
event.preventDefault();
// 自定义模板拖入
const templateId = event.dataTransfer.getData('application/custom-template-id');
if (templateId) {
const template = useComponentLibrary.getState().getTemplateById(templateId);
if (!template) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
useFlowStore.getState().addCustomNode(position, template);
return;
}
// 内置元器件拖入
const type = event.dataTransfer.getData('application/eplan-device-type');
if (!type) return;
const functionCode = Number(type) || 100;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
addNode(position, functionCode);
}, [addNode, screenToFlowPosition]);
/**
* 实时连接校验:拖拽过程中判断目标 Handle 是否兼容
* 不兼容的 Handle 无法被连接(鼠标放上去不会高亮)
*/
const isValidConnection = useCallback(() => true, []);
/**
* 边重新连接:拖拽已连接的端点可以断开并重新连接到其他节点
* 如果拖到空白处则删除该边
*/
const edgeReconnectSuccessful = useRef(true);
const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false;
}, []);
const onReconnect = useCallback((oldEdge, newConnection) => {
edgeReconnectSuccessful.current = true;
const { nodes, edges } = useFlowStore.getState();
const sourceNode = nodes.find(n => n.id === newConnection.source);
const targetNode = nodes.find(n => n.id === newConnection.target);
const sourceColor = sourceNode?.data?.color || '#666';
const targetColor = targetNode?.data?.color || '#666';
const updatedEdge = {
...oldEdge,
...newConnection,
data: { ...oldEdge.data, sourceColor, targetColor },
};
const newEdges = reconnectEdge(oldEdge, newConnection, edges);
useFlowStore.setState({ edges: newEdges });
}, []);
const onReconnectEnd = useCallback((_event, edge) => {
// 拖到空白处,删除该边(断开连接)
if (!edgeReconnectSuccessful.current) {
const { edges } = useFlowStore.getState();
useFlowStore.setState({
edges: edges.filter(e => e.id !== edge.id),
});
}
edgeReconnectSuccessful.current = true;
}, []);
return (
<div className={styles.canvas} ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
onDoubleClick={onDoubleClick}
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
connectionLineComponent={CustomConnectionLine}
isValidConnection={isValidConnection}
edgesReconnectable
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
fitView
snapToGrid
snapGrid={[16, 16]}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode="Shift"
proOptions={{ hideAttribution: true }}
>
<Controls
position="bottom-right"
className={styles.controls}
/>
<MiniMap
position="bottom-left"
maskColor="rgba(0, 0, 0, 0.6)"
nodeColor={(node) => node.data?.color || '#666'}
className={styles.minimap}
/>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#333"
/>
</ReactFlow>
{/* 连接拒绝 Toast */}
{showError && (
<div className={styles.connectionErrorToast}>
{showError}
</div>
)}
</div>
);
}
.canvas {
flex: 1;
height: 100%;
background: #121218;
}
.controls {
background: #1e1e2e !important;
border: 1px solid #333 !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
.controls button {
background: #1e1e2e !important;
border-color: #333 !important;
color: #ccc !important;
fill: #ccc !important;
}
.controls button:hover {
background: #2a2a3e !important;
}
.minimap {
background: #1a1a28 !important;
border: 1px solid #333 !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
/* 连接被拒绝的 Toast 提示 */
@keyframes toastShake {
0%, 100% { transform: translate(-50%, 0); }
10%, 30%, 50%, 70%, 90% { transform: translate(calc(-50% - 4px), 0); }
20%, 40%, 60%, 80% { transform: translate(calc(-50% + 4px), 0); }
}
@keyframes toastFadeIn {
from { opacity: 0; transform: translate(-50%, -10px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.connectionErrorToast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(244, 67, 54, 0.95);
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 4px 16px rgba(244, 67, 54, 0.4);
z-index: 1000;
pointer-events: none;
animation: toastFadeIn 0.2s ease-out, toastShake 0.5s ease-in-out 0.2s;
}
This diff is collapsed.
/* ComponentEditor - Blender 风格暗色编辑器 */
.editor {
display: flex;
height: 100%;
width: 100%;
background: #0d0d14;
overflow: hidden;
}
/* ========== 左侧工具栏 ========== */
.toolBar {
width: 72px;
background: #14141e;
border-right: 1px solid #2a2a3a;
display: flex;
flex-direction: column;
padding: 8px 0;
flex-shrink: 0;
overflow-y: auto;
}
.toolSection {
padding: 4px 6px;
border-bottom: 1px solid #1e1e2e;
}
.toolSectionTitle {
font-size: 9px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.8px;
text-align: center;
margin-bottom: 4px;
padding: 2px 0;
}
.toolBtn {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 6px 2px;
border: none;
border-radius: 4px;
background: transparent;
color: #888;
cursor: pointer;
transition: all 0.12s ease;
margin-bottom: 2px;
}
.toolBtn:hover {
background: #1e1e30;
color: #ccc;
}
.toolBtn.toolActive {
background: #2a2a4a;
color: #6366f1;
}
.toolBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.toolIcon {
font-size: 16px;
line-height: 1;
}
.toolLabel {
font-size: 9px;
margin-top: 2px;
}
/* ========== 中间画布区 ========== */
.canvasArea {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.canvasHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #14141e;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
}
.canvasTitle {
font-size: 12px;
font-weight: 600;
color: #bbb;
}
.canvasHint {
font-size: 10px;
color: #555;
}
.shapeCanvas {
flex: 1;
width: 100%;
cursor: crosshair;
background: #0d0d14;
}
.canvasPort {
cursor: grab;
transition: r 0.15s ease, filter 0.15s ease, stroke-width 0.15s ease;
}
.canvasPort:hover {
r: 9;
filter: drop-shadow(0 0 6px currentColor);
}
.canvasPortHovered {
r: 9;
filter: drop-shadow(0 0 8px currentColor) drop-shadow(0 0 3px rgba(255,255,255,0.4));
stroke: #fff;
stroke-width: 2.5;
}
.canvasPortDragging {
r: 10;
filter: drop-shadow(0 0 12px currentColor) drop-shadow(0 0 4px rgba(255,255,255,0.6));
stroke: #fff;
stroke-width: 3;
cursor: grabbing;
}
.canvasPortHitArea {
cursor: grab;
}
.canvasPortHitArea:hover + .canvasPort {
r: 9;
filter: drop-shadow(0 0 6px currentColor);
}
/* ========== 右侧属性面板 ========== */
.propPanel {
background: #14141e;
border-left: 1px solid #2a2a3a;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
/* 拖拽调整宽度滑块 */
.resizer {
width: 5px;
cursor: col-resize;
background: #2a2a3a;
flex-shrink: 0;
transition: background 0.15s;
position: relative;
}
.resizer:hover,
.resizer:active {
background: #6366f1;
}
.resizer::after {
content: '';
position: absolute;
top: 50%;
left: 1px;
width: 3px;
height: 30px;
transform: translateY(-50%);
border-radius: 2px;
background: rgba(255,255,255,0.15);
}
.resizer:hover::after {
background: rgba(255,255,255,0.4);
}
.propSection {
padding: 12px;
border-bottom: 1px solid #1e1e2e;
}
.sectionLabel {
font-size: 10px;
font-weight: 700;
color: #666;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 8px;
}
.propLabel {
display: block;
font-size: 10px;
color: #666;
margin: 8px 0 3px;
}
.propLabel:first-of-type {
margin-top: 0;
}
.propInput {
width: 100%;
padding: 5px 8px;
border: 1px solid #2a2a3a;
border-radius: 4px;
background: #1a1a28;
color: #ccc;
font-size: 12px;
font-family: 'Consolas', monospace;
box-sizing: border-box;
}
.propInput:focus {
outline: none;
border-color: #6366f1;
}
/* 颜色预设 */
.colorPresets {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.colorSwatch {
width: 20px;
height: 20px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.1s ease, transform 0.1s ease;
}
.colorSwatch:hover {
transform: scale(1.15);
}
.colorSwatch.colorActive {
border-color: #fff;
}
/* 尺寸输入 */
.sizeInputs {
display: flex;
align-items: center;
gap: 4px;
}
.sizeInputs .propInput {
width: 80px;
text-align: center;
}
.sizeX {
color: #555;
font-size: 11px;
}
/* ========== 端点编辑器 ========== */
.portEditor {
padding: 12px;
border-bottom: 1px solid #1e1e2e;
}
.portEditorHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.addPortBtn {
padding: 3px 8px;
border: 1px solid #2a2a3a;
border-radius: 4px;
background: transparent;
color: #6366f1;
font-size: 10px;
cursor: pointer;
transition: all 0.12s ease;
}
.addPortBtn:hover {
background: #1e1e30;
border-color: #6366f1;
}
.emptyHint {
color: #444;
font-size: 11px;
text-align: center;
padding: 12px 0;
}
.portList {
display: flex;
flex-direction: column;
gap: 4px;
}
.portItem {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid #1e1e2e;
}
.portColorDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.portIdInput {
width: 36px;
padding: 3px 4px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: #bbb;
font-size: 11px;
text-align: center;
flex-shrink: 0;
}
.portIdInput:focus {
outline: none;
border-color: #6366f1;
background: #1a1a28;
}
.portNameInput {
flex: 1;
min-width: 0;
padding: 3px 5px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: #bbb;
font-size: 11px;
}
.portNameInput:focus {
outline: none;
border-color: #6366f1;
background: #1a1a28;
}
.portDescInput {
flex: 1;
min-width: 0;
padding: 3px 5px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: #888;
font-size: 10px;
}
.portDescInput:focus {
outline: none;
border-color: #6366f1;
background: #1a1a28;
}
.portTypeSelect,
.portSideSelect {
width: 52px;
padding: 3px 2px;
border: 1px solid #2a2a3a;
border-radius: 3px;
background: #1a1a28;
color: #aaa;
font-size: 10px;
cursor: pointer;
}
.portSideSelect {
width: 48px;
}
.portDeleteBtn {
width: 18px;
height: 18px;
border: none;
border-radius: 3px;
background: transparent;
color: #666;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.portDeleteBtn:hover {
background: #dc2626;
color: #fff;
}
/* ========== 预览 ========== */
.previewSection {
padding: 12px;
border-bottom: 1px solid #1e1e2e;
}
.previewContainer {
display: flex;
justify-content: center;
padding: 12px;
background: #0d0d14;
border-radius: 6px;
border: 1px solid #1e1e2e;
}
.previewSvg {
max-width: 100%;
}
/* ========== 操作按钮 ========== */
.actionBtns {
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.saveBtn {
width: 100%;
padding: 10px;
border: none;
border-radius: 6px;
background: #6366f1;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.saveBtn:hover {
background: #5558e6;
}
.cancelBtn {
width: 100%;
padding: 8px;
border: 1px solid #333;
border-radius: 6px;
background: transparent;
color: #888;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.cancelBtn:hover {
border-color: #666;
color: #ccc;
}
/* ========== 节点预览卡片 ========== */
.previewSection {
padding: 12px;
border-bottom: 1px solid #1e1e2e;
}
.previewContainer {
display: flex;
justify-content: center;
padding: 12px 0;
}
.previewCard {
background: #1e1e2e;
border: 2px solid #444;
border-radius: 8px;
min-width: 160px;
max-width: 240px;
font-family: 'Segoe UI', 'Inter', sans-serif;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.previewHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
}
.previewIcon {
font-size: 14px;
}
.previewName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewPorts {
display: flex;
flex-wrap: wrap;
gap: 3px;
padding: 6px 10px 8px;
}
.previewPortBadge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid #555;
color: #aaa;
font-family: 'Consolas', monospace;
background: rgba(255, 255, 255, 0.04);
}
/**
* NodePreview - 元器件节点实时预览
* 和编辑器画布完全一致的端点布局:
* 彩色 header + 端点按 side/position 分布在四边
*/
import { PORT_TYPES } from '../../utils/constants';
import useComponentLibrary from '../../hooks/useComponentLibrary';
import styles from './ComponentEditor.module.css';
/** 端点位置计算 - 和 ShapeCanvas 一致 */
function getPortPos(port, w, h) {
switch (port.side) {
case 'top': return { x: w * port.position, y: 0 };
case 'bottom': return { x: w * port.position, y: h };
case 'left': return { x: 0, y: h * port.position };
case 'right': return { x: w, y: h * port.position };
default: return { x: w * 0.5, y: 0 };
}
}
export default function NodePreview() {
const { editingTemplate } = useComponentLibrary();
if (!editingTemplate) return null;
const { name, color, icon, ports, width: tW, height: tH } = editingTemplate;
// 预览尺寸:header 高 24px + body
const headerH = 24;
const totalH = headerH + tH;
const totalW = tW;
const scale = Math.min(1, 220 / totalW, 180 / totalH);
const svgW = totalW * scale;
const svgH = totalH * scale;
return (
<div className={styles.previewSection}>
<div className={styles.sectionLabel}>节点预览</div>
<div className={styles.previewContainer}>
<svg
width={svgW} height={svgH}
viewBox={`0 0 ${totalW} ${totalH}`}
style={{ overflow: 'visible' }}
>
{/* 卡片背景 */}
<rect x={0} y={0} width={totalW} height={totalH}
rx={6} fill="#1e1e2e" stroke={color} strokeWidth={2}
/>
{/* 彩色 Header */}
<rect x={0} y={0} width={totalW} height={headerH}
rx={6} fill={color}
/>
{/* Header 底部补角 */}
<rect x={0} y={headerH - 6} width={totalW} height={6}
fill={color}
/>
{/* Header 文字 */}
<text x={10} y={headerH / 2 + 4}
fontSize={11} fontWeight={600} fill="#fff"
fontFamily="'Segoe UI', sans-serif"
>
{icon} {name}
</text>
{/* 端点 - 位置在 body 区域(y 偏移 headerH) */}
{ports.map(port => {
const raw = getPortPos(port, totalW, tH);
const pos = { x: raw.x, y: raw.y + headerH };
const portColor = PORT_TYPES[port.type]?.color || '#9E9E9E';
return (
<g key={port.id}>
{/* 端点圆 */}
<circle cx={pos.x} cy={pos.y} r={4}
fill={portColor} stroke="#1e1e2e" strokeWidth={1.5}
/>
{/* portId - 外侧 */}
{port.portId != null && (
<text
x={pos.x + (port.side === 'left' ? -12 : port.side === 'right' ? 12 : 0)}
y={pos.y + (port.side === 'top' ? -8 : port.side === 'bottom' ? 14 : 3.5)}
textAnchor="middle"
fill="#aaa" fontSize={8} fontWeight={600}
fontFamily="'Segoe UI', sans-serif"
>
{port.portId}
</text>
)}
{/* 名称 - 内侧 */}
<text
x={pos.x + (port.side === 'left' ? 10 : port.side === 'right' ? -10 : 0)}
y={pos.y + (port.side === 'top' ? 12 : port.side === 'bottom' ? -6 : 3.5)}
textAnchor={port.side === 'left' ? 'start' : port.side === 'right' ? 'end' : 'middle'}
fill="#888" fontSize={7}
fontFamily="'Segoe UI', sans-serif"
>
{port.name}
</text>
</g>
);
})}
</svg>
</div>
</div>
);
}
/**
* PortEditor - 端点编辑面板
* 显示当前元器件的所有端点,可编辑 ID、名称、描述、类型、位置
*/
import { PORT_TYPES } from '../../utils/constants';
import useComponentLibrary from '../../hooks/useComponentLibrary';
import styles from './ComponentEditor.module.css';
const SIDES = [
{ value: 'top', label: '上' },
{ value: 'bottom', label: '下' },
{ value: 'left', label: '左' },
{ value: 'right', label: '右' },
];
export default function PortEditor() {
const { editingTemplate, updatePort, removePort, addPort } = useComponentLibrary();
if (!editingTemplate) return null;
const { ports } = editingTemplate;
return (
<div className={styles.portEditor}>
<div className={styles.portEditorHeader}>
<span className={styles.sectionLabel}>端点列表</span>
<button
className={styles.addPortBtn}
onClick={() => addPort({})}
title="添加端点"
>
+ 添加
</button>
</div>
{ports.length === 0 && (
<div className={styles.emptyHint}>暂无端点,点击"添加"或在画布边缘点击</div>
)}
<div className={styles.portList}>
{ports.map(port => {
const typeInfo = PORT_TYPES[port.type] || PORT_TYPES.generic;
return (
<div key={port.id} className={styles.portItem}>
{/* 颜色指示器 */}
<div
className={styles.portColorDot}
style={{ background: typeInfo.color }}
/>
{/* ID(数字) */}
<input
className={styles.portIdInput}
type="number"
value={port.portId || ''}
onChange={(e) => updatePort(port.id, { portId: Number(e.target.value) || 0 })}
title="端点编号"
placeholder="ID"
/>
{/* 名称 */}
<input
className={styles.portNameInput}
value={port.name}
onChange={(e) => updatePort(port.id, { name: e.target.value })}
placeholder="名称"
/>
{/* 描述 */}
<input
className={styles.portDescInput}
value={port.description || ''}
onChange={(e) => updatePort(port.id, { description: e.target.value })}
placeholder="端点描述"
/>
{/* 类型选择 */}
<select
className={styles.portTypeSelect}
value={port.type}
onChange={(e) => updatePort(port.id, { type: e.target.value })}
>
{Object.entries(PORT_TYPES).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
{/* 位置选择 */}
<select
className={styles.portSideSelect}
value={port.side}
onChange={(e) => updatePort(port.id, { side: e.target.value })}
>
{SIDES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
{/* 删除 */}
<button
className={styles.portDeleteBtn}
onClick={() => removePort(port.id)}
title="删除端点"
>
x
</button>
</div>
);
})}
</div>
</div>
);
}
This diff is collapsed.
/**
* CustomConnectionLine - 拖拽过程中的贝塞尔曲线连线
* 使用起始节点颜色,风格与 GradientBezierEdge 一致
*/
import { useReactFlow } from '@xyflow/react';
export default function CustomConnectionLine({
fromX,
fromY,
toX,
toY,
fromNode,
}) {
const color = fromNode?.data?.color || '#6366f1';
// 与 GradientBezierEdge 相同的控制点计算
const dy = Math.abs(toY - fromY);
const offset = Math.max(80, Math.min(dy * 0.5, 200));
const path = `M ${fromX} ${fromY} C ${fromX} ${fromY + offset}, ${toX} ${toY - offset}, ${toX} ${toY}`;
return (
<g>
{/* 外层发光 */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={6}
strokeLinecap="round"
opacity={0.15}
/>
{/* 主线 */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeDasharray="8 4"
/>
{/* 端点圆圈 */}
<circle
cx={toX}
cy={toY}
r={4}
fill={color}
stroke="#1e1e2e"
strokeWidth={2}
/>
</g>
);
}
/**
* GradientBezierEdge - Blender 着色器风格的贝塞尔曲线连线
* 支持双端点颜色渐变,hover 发光效果
*
* Bug修复:
* - 使用方向感知的控制点计算,修复拖动时线消失的问题
* - SVG渐变使用 gradientUnits="userSpaceOnUse" 避免 bounding box 为零时渐变失效
*/
import { memo, useMemo } from 'react';
import styles from './GradientBezierEdge.module.css';
/**
* 计算 Blender 风格的贝塞尔曲线路径
* 根据实际方向动态调整控制点,支持任意位置关系
*/
function buildBezierPath(sourceX, sourceY, targetX, targetY) {
const dy = targetY - sourceY;
const dx = targetX - sourceX;
const absDy = Math.abs(dy);
const absDx = Math.abs(dx);
// 控制点偏移量:至少 60px,距离越远弧度越大,但有上限
const offset = Math.max(60, Math.min(absDy * 0.5, absDx * 0.3, 200));
// source 的 handle 在底部(向下出发),target 的 handle 在顶部(从上方进入)
// 所以控制点总是让 source 向下偏移,target 向上偏移
const cp1x = sourceX;
const cp1y = sourceY + offset;
const cp2x = targetX;
const cp2y = targetY - offset;
const path = `M ${sourceX} ${sourceY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${targetX} ${targetY}`;
const labelX = (sourceX + targetX) / 2;
const labelY = (sourceY + targetY) / 2;
return { path, labelX, labelY };
}
function GradientBezierEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
data,
selected,
}) {
const sourceColor = data?.sourceColor || '#666';
const targetColor = data?.targetColor || '#666';
const gradientId = `gradient-${id}`;
const { path } = useMemo(
() => buildBezierPath(sourceX, sourceY, targetX, targetY),
[sourceX, sourceY, targetX, targetY]
);
const isSameColor = sourceColor === targetColor;
const strokeColor = isSameColor ? sourceColor : `url(#${gradientId})`;
return (
<>
{/* SVG 渐变定义 - 使用 userSpaceOnUse 避免 bounding box 零尺寸问题 */}
{!isSameColor && (
<defs>
<linearGradient
id={gradientId}
gradientUnits="userSpaceOnUse"
x1={sourceX}
y1={sourceY}
x2={targetX}
y2={targetY}
>
<stop offset="0%" stopColor={sourceColor} />
<stop offset="100%" stopColor={targetColor} />
</linearGradient>
</defs>
)}
{/* 底层发光层(hover 时通过 CSS 显示) */}
<path
d={path}
fill="none"
className={styles.edgeGlow}
stroke={strokeColor}
/>
{/* 主路径 */}
<path
d={path}
className={`${styles.edgePath} ${selected ? styles.selected : ''}`}
stroke={strokeColor}
fill="none"
strokeWidth={selected ? 3 : 2}
/>
</>
);
}
export default memo(GradientBezierEdge);
/* GradientBezierEdge 样式 */
.edgePath {
stroke-linecap: round;
transition: stroke-width 0.15s ease, filter 0.15s ease;
pointer-events: stroke;
cursor: pointer;
}
.edgePath:hover {
stroke-width: 3.5;
filter: drop-shadow(0 0 4px currentColor);
}
.edgePath.selected {
filter: drop-shadow(0 0 6px currentColor);
}
.edgeGlow {
fill: none;
stroke-width: 8;
stroke-linecap: round;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.edgePath:hover ~ .edgeGlow,
.edgePath:hover + .edgeGlow {
opacity: 0.15;
}
/* 连接拒绝时的红色抖动动画 */
@keyframes rejectShake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-3px); }
20%, 40%, 60%, 80% { transform: translateX(3px); }
}
@keyframes rejectFlash {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
.rejectAnimation {
stroke: #F44336 !important;
stroke-width: 3 !important;
animation: rejectShake 0.5s ease-in-out, rejectFlash 0.5s ease-in-out;
}
/**
* CustomDeviceNode - 统一元器件节点(内置和自定义共用)
*
* 核心设计思路:
* - Handle 本身就是可见端点圆(连线端 = 视觉端 = 同一 DOM 元素)
* - 每个端口用一个绝对定位的容器 div 包裹 Handle + 标签
* - 容器 div 的 left/top 控制位置,Handle 不设任何 style.left/top/right
* 只设 position prop 和外观样式
* - 用 outline 代替 border(不影响盒模型)
*/
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { PORT_TYPES } from '../../utils/constants';
const HEADER_H = 28;
function sideToPosition(side) {
switch (side) {
case 'top': return Position.Top;
case 'bottom': return Position.Bottom;
case 'left': return Position.Left;
case 'right': return Position.Right;
default: return Position.Left;
}
}
function CustomDeviceNode({ data, selected }) {
const { color = '#6366f1', templateData } = data;
if (!templateData) return null;
const { name, icon, ports, width: tW, height: tH } = templateData;
const rotation = data.rotation || 0;
const totalH = HEADER_H + tH;
return (
<div style={{
width: tW,
height: totalH,
position: 'relative',
background: '#1e1e2e',
borderRadius: 8,
outline: `2px solid ${selected ? '#fff' : color}`,
outlineOffset: -1,
boxShadow: selected ? `0 0 12px ${color}88` : '0 4px 12px rgba(0,0,0,0.3)',
overflow: 'visible',
transform: rotation ? `rotate(${rotation}deg)` : undefined,
fontFamily: "'Segoe UI', sans-serif",
}}>
{/* 彩色 Header */}
<div style={{
background: color,
borderRadius: '7px 7px 0 0',
padding: '4px 10px',
height: HEADER_H,
display: 'flex',
alignItems: 'center',
gap: 6,
boxSizing: 'border-box',
}}>
<span style={{ fontSize: 14 }}>{icon}</span>
<span style={{
fontSize: 11, fontWeight: 600, color: '#fff',
letterSpacing: 0.5,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{name}</span>
</div>
{/* 每个端口:容器 div(绝对定位)内含 Handle + 标签 */}
{ports.map(port => {
const portColor = PORT_TYPES[port.type]?.color || '#9E9E9E';
const pos = sideToPosition(port.side);
// 容器 div 的位置 = 端口在节点上的像素坐标
const cx = port.side === 'left' ? 0
: port.side === 'right' ? tW
: tW * port.position;
const cy = (port.side === 'top' || port.side === 'bottom')
? (port.side === 'top' ? HEADER_H : totalH)
: HEADER_H + tH * port.position;
return (
<div
key={port.id}
style={{
position: 'absolute',
left: cx,
top: cy,
width: 0,
height: 0,
overflow: 'visible',
}}
>
{/* target Handle - 可见端点圆 */}
<Handle
type="target"
position={pos}
id={`t-${port.id}`}
style={{
position: 'relative',
left: 0,
top: 0,
width: 12,
height: 12,
background: portColor,
border: '2px solid #1e1e2e',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
{/* source Handle - 同位置透明叠加 */}
<Handle
type="source"
position={pos}
id={`s-${port.id}`}
style={{
position: 'relative',
left: 0,
top: -12,
width: 12,
height: 12,
background: 'transparent',
border: 'none',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
{/* portId 数字标签 - 外侧 */}
{port.portId != null && (
<div style={{
position: 'absolute',
...getIdPos(port.side),
fontSize: 9, fontWeight: 600, color: '#aaa',
pointerEvents: 'none', userSelect: 'none',
whiteSpace: 'nowrap',
}}>
{port.portId}
</div>
)}
{/* 端口名称标签 - 内侧 */}
{port.name && (
<div style={{
position: 'absolute',
...getNamePos(port.side),
fontSize: 9, color: '#888',
pointerEvents: 'none', userSelect: 'none',
whiteSpace: 'nowrap',
}}>
{port.name}
</div>
)}
</div>
);
})}
</div>
);
}
function getIdPos(side) {
switch (side) {
case 'left': return { right: 8, top: '50%', transform: 'translateY(-50%)' };
case 'right': return { left: 8, top: '50%', transform: 'translateY(-50%)' };
case 'top': return { left: '50%', bottom: 8, transform: 'translateX(-50%)' };
case 'bottom': return { left: '50%', top: 8, transform: 'translateX(-50%)' };
default: return {};
}
}
function getNamePos(side) {
switch (side) {
case 'left': return { left: 12, top: '50%', transform: 'translateY(-50%)' };
case 'right': return { right: 12, top: '50%', transform: 'translateY(-50%)' };
case 'top': return { left: '50%', top: 8, transform: 'translateX(-50%)' };
case 'bottom': return { left: '50%', bottom: 8, transform: 'translateX(-50%)' };
default: return {};
}
}
export default memo(CustomDeviceNode);
/**
* DeviceNode - 内置元器件节点
* 和 CustomDeviceNode / NodePreview 完全一致的 SVG 渲染:
* 彩色 header + 端点按位置分布在上下边缘
*/
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { PORT_TYPES } from '../../utils/constants';
function DeviceNode({ data, selected }) {
const {
label,
icon,
deviceTypeLabel,
color,
portType = 'generic',
ports = ['输入1', '输出1'],
} = data;
const rotation = data.rotation || 0;
const portTypeInfo = PORT_TYPES[portType] || PORT_TYPES.generic;
const handleColor = portTypeInfo.color;
// 内置节点固定尺寸
const totalW = 180;
const headerH = 24;
const bodyH = 60;
const totalH = headerH + bodyH;
return (
<div style={{
width: `${totalW}px`,
height: `${totalH}px`,
position: 'relative',
overflow: 'visible',
transform: rotation ? `rotate(${rotation}deg)` : undefined,
transition: 'transform 0.2s ease',
}}>
<svg
width={totalW} height={totalH}
viewBox={`0 0 ${totalW} ${totalH}`}
style={{ display: 'block', overflow: 'visible' }}
>
{/* 卡片背景 */}
<rect x={0} y={0} width={totalW} height={totalH}
rx={6} fill="#1e1e2e"
stroke={selected ? '#fff' : color} strokeWidth={2}
filter={selected ? `drop-shadow(0 0 6px ${color}88)` : undefined}
/>
{/* 彩色 Header */}
<rect x={0} y={0} width={totalW} height={headerH}
rx={6} fill={color}
/>
<rect x={0} y={headerH - 6} width={totalW} height={6}
fill={color}
/>
{/* Header 文字 */}
<text x={10} y={headerH / 2 + 4}
fontSize={11} fontWeight={600} fill="#fff"
fontFamily="'Segoe UI', sans-serif"
>
{icon} {label || deviceTypeLabel}
</text>
{/* 端点标注 - 上方 (target) */}
{ports.map((port, i) => {
const x = (i + 1) / (ports.length + 1) * totalW;
return (
<g key={`top-${port}`}>
<circle cx={x} cy={0} r={5}
fill={handleColor} stroke="#1e1e2e" strokeWidth={2}
/>
<text x={x} y={-10}
textAnchor="middle" fill="#aaa"
fontSize={9} fontWeight={600}
fontFamily="'Segoe UI', sans-serif"
>
{port}
</text>
</g>
);
})}
{/* 端点标注 - 下方 (source) */}
{ports.map((port, i) => {
const x = (i + 1) / (ports.length + 1) * totalW;
return (
<g key={`bottom-${port}`}>
<circle cx={x} cy={totalH} r={5}
fill={handleColor} stroke="#1e1e2e" strokeWidth={2}
/>
<text x={x} y={totalH + 16}
textAnchor="middle" fill="#aaa"
fontSize={9} fontWeight={600}
fontFamily="'Segoe UI', sans-serif"
>
{port}
</text>
</g>
);
})}
</svg>
{/* Handles - 上方 target */}
{ports.map((port, i) => {
const offset = (i + 1) / (ports.length + 1) * 100;
return (
<Handle
key={`top-${port}`}
type="target"
position={Position.Top}
id={port}
style={{
left: `${offset}%`,
background: 'transparent',
border: 'none',
width: 12,
height: 12,
}}
/>
);
})}
{/* Handles - 下方 source */}
{ports.map((port, i) => {
const offset = (i + 1) / (ports.length + 1) * 100;
return (
<Handle
key={`bottom-${port}`}
type="source"
position={Position.Bottom}
id={port}
style={{
left: `${offset}%`,
background: 'transparent',
border: 'none',
width: 12,
height: 12,
}}
/>
);
})}
</div>
);
}
export default memo(DeviceNode);
.deviceNode {
background: #1e1e2e;
border: 2px solid #444;
border-radius: 8px;
min-width: 180px;
font-family: 'Segoe UI', 'Inter', sans-serif;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.2s ease, transform 0.15s ease;
overflow: visible;
}
.deviceNode:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
transform: translateY(-1px);
}
.deviceNode.selected {
border-color: var(--device-color, #6366f1) !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.3), 0 6px 20px rgba(0, 0, 0, 0.4);
}
.header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.icon {
font-size: 14px;
}
.typeLabel {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portTypeBadge {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
color: #fff;
opacity: 0.85;
margin-left: auto;
font-weight: 500;
}
.body {
padding: 8px 10px 4px;
}
.deviceId {
font-size: 13px;
font-weight: 700;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Consolas', 'Fira Code', monospace;
}
.label {
font-size: 11px;
color: #888;
margin-top: 2px;
}
.ports {
display: flex;
flex-wrap: wrap;
gap: 3px;
padding: 4px 10px 8px;
}
.portBadge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid #555;
color: #aaa;
font-family: 'Consolas', monospace;
background: rgba(255, 255, 255, 0.04);
}
.handle {
width: 10px !important;
height: 10px !important;
border-radius: 50% !important;
border: 2px solid #1e1e2e !important;
cursor: crosshair !important;
z-index: 10;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* 透明扩大区域,方便鼠标命中 */
.handle::after {
content: '';
position: absolute;
top: -6px;
left: -6px;
right: -6px;
bottom: -6px;
border-radius: 50%;
cursor: crosshair;
}
.handle:hover {
transform: scale(1.5);
box-shadow: 0 0 6px rgba(99, 102, 241, 0.5);
}
/**
* PropertiesPanel - 右侧属性面板
* 选中节点或边时显示属性,未选中时自动隐藏
* 支持拖拽左边框调整宽度
*/
import { useCallback, useState, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore';
import styles from './PropertiesPanel.module.css';
export default function PropertiesPanel() {
const {
selectedNode,
selectedEdge,
updateNodeData,
removeSelectedNode,
removeSelectedEdge,
} = useFlowStore();
const [panelWidth, setPanelWidth] = useState(300);
const isDragging = useRef(false);
const handleLabelChange = useCallback((e) => {
if (!selectedNode) return;
updateNodeData(selectedNode.id, { label: e.target.value });
}, [selectedNode, updateNodeData]);
/** 拖拽调整宽度 */
const startResize = useCallback((e) => {
e.preventDefault();
isDragging.current = true;
const startX = e.clientX;
const startWidth = panelWidth;
function onMove(ev) {
const delta = startX - ev.clientX;
const newWidth = Math.max(200, Math.min(600, startWidth + delta));
setPanelWidth(newWidth);
}
function onUp() {
isDragging.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [panelWidth]);
if (!selectedNode && !selectedEdge) return null;
return (
<div className={styles.panel} style={{ width: `${panelWidth}px` }}>
{/* 拖拽手柄 */}
<div className={styles.resizeHandle} onMouseDown={startResize} />
{selectedNode ? (
<>
<div className={styles.title}>节点属性</div>
<label className={styles.label}>设备标识</label>
<input
className={styles.input}
value={selectedNode.data?.deviceId || ''}
readOnly
/>
<label className={styles.label}>名称</label>
<input
className={styles.input}
value={selectedNode.data?.label || ''}
onChange={handleLabelChange}
/>
{selectedNode.data?.deviceTypeLabel && (
<>
<label className={styles.label}>类型</label>
<input
className={styles.input}
value={selectedNode.data.deviceTypeLabel}
readOnly
/>
</>
)}
{selectedNode.data?.color && (
<>
<label className={styles.label}>颜色</label>
<div
className={styles.colorSwatch}
style={{ background: selectedNode.data.color }}
/>
</>
)}
<label className={styles.label}>端口</label>
<div className={styles.portList}>
{(selectedNode.data?.ports || []).map(p => (
<span key={typeof p === 'string' ? p : p.id || p} className={styles.portTag}>
{typeof p === 'string' ? p : (p.portId != null ? p.portId : p.name)}
</span>
))}
</div>
<button className={styles.deleteBtn} onClick={removeSelectedNode}>
删除此节点
</button>
</>
) : selectedEdge ? (
<>
<div className={styles.title}>连接属性</div>
<label className={styles.label}>连接 ID</label>
<input className={styles.input} value={selectedEdge.id} readOnly />
<label className={styles.label}>源设备</label>
<input className={styles.input} value={selectedEdge.source} readOnly />
<label className={styles.label}>源端口</label>
<input className={styles.input} value={selectedEdge.sourceHandle || '-'} readOnly />
<label className={styles.label}>目标设备</label>
<input className={styles.input} value={selectedEdge.target} readOnly />
<label className={styles.label}>目标端口</label>
<input className={styles.input} value={selectedEdge.targetHandle || '-'} readOnly />
<button className={styles.deleteBtn} onClick={removeSelectedEdge}>
删除此连接
</button>
</>
) : null}
</div>
);
}
.panel {
background: #16161e;
border-left: 1px solid #2a2a3a;
padding: 16px 16px 16px 20px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
flex-shrink: 0;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
animation: slideIn 0.15s ease-out;
position: relative;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
/* 拖拽调整宽度手柄 */
.resizeHandle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.15s;
z-index: 10;
}
.resizeHandle:hover,
.resizeHandle:active {
background: #6366f1;
}
.title {
font-size: 11px;
font-weight: 700;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.label {
font-size: 10px;
color: #888;
font-weight: 600;
margin-top: 4px;
}
.input {
width: 100%;
padding: 6px 8px;
background: #1e1e2e;
border: 1px solid #333;
border-radius: 4px;
color: #ccc;
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.input:focus {
border-color: #6366f1;
}
.input[readOnly] {
color: #777;
cursor: default;
}
.colorSwatch {
width: 28px;
height: 28px;
border-radius: 4px;
border: 2px solid #333;
}
.portList {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.portTag {
display: inline-block;
padding: 2px 6px;
background: #1e1e2e;
border: 1px solid #333;
border-radius: 3px;
color: #aaa;
font-size: 10px;
font-family: monospace;
}
.deleteBtn {
margin-top: 12px;
padding: 8px 12px;
background: rgba(244, 67, 54, 0.15);
border: 1px solid #f44336;
border-radius: 6px;
color: #f44336;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.deleteBtn:hover {
background: rgba(244, 67, 54, 0.3);
}
/**
* Sidebar - 侧边栏
* 功能:
* 1. 文件导入区(拖拽或点击上传 xlsx)
* 2. 属性面板(选中节点/边时显示详细信息,可编辑)
* 3. 元器件库(拖拽添加新元器件到画布)
*/
import { useState, useCallback, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore';
import useComponentLibrary from '../../hooks/useComponentLibrary';
import { FUNCTION_CODE_MAP } from '../../utils/constants';
import styles from './Sidebar.module.css';
export default function Sidebar() {
const [connectionFile, setConnectionFile] = useState(null);
const [activeTab, setActiveTab] = useState('library'); // library | import
const connInputRef = useRef(null);
const {
importFromConnectionOnly,
isLoading,
error,
nodes,
edges,
} = useFlowStore();
const { templates, startEditing, startNew, deleteTemplate, switchView } = useComponentLibrary();
// ==================== 文件导入 ====================
const handleImport = useCallback(async () => {
if (!connectionFile) return;
await importFromConnectionOnly(connectionFile, templates);
setConnectionFile(null);
}, [connectionFile, importFromConnectionOnly, templates]);
const handleFileDrop = useCallback((event) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files[0];
if (file) setConnectionFile(file);
}, []);
// ==================== 元器件拖拽 ====================
const onDragStart = useCallback((event, functionCode) => {
event.dataTransfer.setData('application/eplan-device-type', String(functionCode));
event.dataTransfer.effectAllowed = 'move';
}, []);
/** 自定义元器件拖拽 */
const onCustomDragStart = useCallback((event, templateId) => {
event.dataTransfer.setData('application/custom-template-id', templateId);
event.dataTransfer.effectAllowed = 'move';
}, []);
/** 编辑自定义元器件 */
const handleEditTemplate = useCallback((templateId) => {
startEditing(templateId);
switchView('editor');
}, [startEditing, switchView]);
/** 新建自定义元器件 */
const handleNewTemplate = useCallback(() => {
startNew();
switchView('editor');
}, [startNew, switchView]);
return (
<div className={styles.sidebar}>
{/* Tab 切换 */}
<div className={styles.tabs}>
<button
className={`${styles.tab} ${activeTab === 'library' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('library')}
>
创建原理图
</button>
<button
className={`${styles.tab} ${activeTab === 'import' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('import')}
>
导入
</button>
</div>
<div className={styles.content}>
{/* ==================== 导入面板 ==================== */}
{activeTab === 'import' && (
<div className={styles.panel}>
<div className={styles.sectionTitle}>EPLAN 数据导入</div>
<div
className={styles.dropZone}
onClick={() => connInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleFileDrop(e)}
>
<input
ref={connInputRef}
type="file"
accept=".xlsx,.xls"
style={{ display: 'none' }}
onChange={(e) => setConnectionFile(e.target.files?.[0] || null)}
/>
<div className={styles.dropLabel}>连接表 (.xlsx)</div>
<div className={styles.dropFile}>
{connectionFile ? connectionFile.name : '点击或拖拽文件'}
</div>
</div>
<button
className={styles.importBtn}
onClick={handleImport}
disabled={!connectionFile || isLoading}
>
{isLoading ? '解析中...' : '导入并生成原理图'}
</button>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.stats}>
<span>节点: {nodes.length}</span>
<span>连接: {edges.length}</span>
</div>
</div>
)}
{/* ==================== 元器件库 ==================== */}
{activeTab === 'library' && (
<div className={styles.panel}>
<div className={styles.sectionTitle}>内置元器件</div>
<div className={styles.libraryGrid}>
{Object.entries(FUNCTION_CODE_MAP).map(([code, info]) => (
<div
key={code}
className={styles.libraryItem}
draggable
onDragStart={(e) => onDragStart(e, code)}
style={{ '--item-color': info.color }}
>
<span className={styles.libIcon}>{info.icon}</span>
<span className={styles.libLabel}>{info.label}</span>
<span className={styles.libCode}>{code}</span>
</div>
))}
</div>
{/* 自定义元器件 */}
<div className={styles.sectionTitle} style={{ marginTop: 16 }}>
自定义元器件
<button className={styles.newTemplateBtn} onClick={handleNewTemplate}>
+ 新建
</button>
</div>
{templates.length === 0 ? (
<div className={styles.emptyCustom}>还没有自定义元器件,点击“新建”创建</div>
) : (
<div className={styles.libraryGrid}>
{templates.map(t => (
<div
key={t.id}
className={styles.libraryItem}
draggable
onDragStart={(e) => onCustomDragStart(e, t.id)}
style={{ '--item-color': t.color }}
>
<span className={styles.libIcon}>{t.icon}</span>
<span className={styles.libLabel}>{t.name}</span>
<span className={styles.customActions}>
<button
className={styles.editBtn}
onClick={(e) => { e.stopPropagation(); handleEditTemplate(t.id); }}
title="编辑"
>
&#9998;
</button>
<button
className={styles.deleteBtn}
onClick={(e) => { e.stopPropagation(); deleteTemplate(t.id); }}
title="删除"
>
&#10005;
</button>
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
.sidebar {
width: 280px;
background: #16161e;
border-right: 1px solid #2a2a3a;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
/* ===== Tabs ===== */
.tabs {
display: flex;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 10px 0;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #666;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.tab:hover {
color: #aaa;
background: #1a1a28;
}
.tab.activeTab {
color: #6366f1;
border-bottom-color: #6366f1;
}
/* ===== Content ===== */
.content {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.panel {
padding: 16px;
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
/* ===== Drop Zone ===== */
.dropZone {
border: 1px dashed #333;
border-radius: 8px;
padding: 14px;
margin-bottom: 10px;
cursor: pointer;
text-align: center;
transition: all 0.15s ease;
background: rgba(255, 255, 255, 0.02);
}
.dropZone:hover {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.05);
}
.dropLabel {
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.dropFile {
font-size: 12px;
color: #aaa;
word-break: break-all;
}
/* ===== Import Button ===== */
.importBtn {
width: 100%;
padding: 10px;
border: none;
border-radius: 6px;
background: #6366f1;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 8px;
}
.importBtn:hover:not(:disabled) {
background: #5558e6;
}
.importBtn:active:not(:disabled) {
transform: scale(0.98);
}
.importBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.importBtn.dangerBtn {
background: #dc2626;
margin-top: 16px;
}
.importBtn.dangerBtn:hover:not(:disabled) {
background: #b91c1c;
}
/* ===== Error ===== */
.error {
margin-top: 8px;
padding: 8px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
color: #f44336;
font-size: 12px;
}
/* ===== Stats ===== */
.stats {
display: flex;
justify-content: space-around;
margin-top: 12px;
padding: 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
font-size: 12px;
color: #888;
}
/* ===== Properties ===== */
.propGroup {
margin-bottom: 10px;
}
.propLabel {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.propInput {
width: 100%;
padding: 6px 8px;
border: 1px solid #333;
border-radius: 4px;
background: #1e1e2e;
color: #ccc;
font-size: 12px;
font-family: 'Consolas', monospace;
box-sizing: border-box;
}
.propInput:focus {
outline: none;
border-color: #6366f1;
}
.propInput[readonly] {
color: #888;
cursor: default;
}
.portList {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.portTag {
padding: 2px 6px;
border: 1px solid #444;
border-radius: 3px;
font-size: 11px;
color: #aaa;
font-family: 'Consolas', monospace;
background: rgba(255, 255, 255, 0.03);
}
/* ===== Empty State ===== */
.emptyState {
text-align: center;
padding: 40px 16px;
color: #555;
font-size: 13px;
}
.emptyIcon {
font-size: 36px;
margin-bottom: 12px;
opacity: 0.3;
}
/* ===== Library Grid ===== */
.libraryGrid {
display: flex;
flex-direction: column;
gap: 6px;
}
.libraryItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #2a2a3a;
border-radius: 6px;
cursor: grab;
transition: all 0.15s ease;
background: rgba(255, 255, 255, 0.02);
}
.libraryItem:hover {
border-color: var(--item-color, #6366f1);
background: rgba(255, 255, 255, 0.05);
}
.libraryItem:active {
cursor: grabbing;
}
.libIcon {
font-size: 16px;
color: var(--item-color, #ccc);
width: 20px;
text-align: center;
}
.libLabel {
flex: 1;
font-size: 12px;
color: #bbb;
}
.libCode {
font-size: 10px;
color: #555;
font-family: 'Consolas', monospace;
}
/* ===== Custom Template ===== */
.newTemplateBtn {
float: right;
padding: 2px 8px;
border: 1px solid #2a2a3a;
border-radius: 4px;
background: transparent;
color: #6366f1;
font-size: 10px;
cursor: pointer;
transition: all 0.12s;
}
.newTemplateBtn:hover {
background: #1e1e30;
border-color: #6366f1;
}
.emptyCustom {
text-align: center;
padding: 16px;
color: #444;
font-size: 11px;
}
.customActions {
display: flex;
gap: 2px;
margin-left: auto;
}
.editBtn,
.deleteBtn {
width: 22px;
height: 22px;
border: none;
border-radius: 3px;
background: transparent;
color: #666;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.editBtn:hover {
background: #2a2a4a;
color: #6366f1;
}
.deleteBtn:hover {
background: #dc2626;
color: #fff;
}
/**
* Toolbar - 顶部工具栏
* 提供布局切换、导入导出、清空等操作
*/
import { useCallback, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore';
import { LAYOUT_DIRECTION } from '../../utils/constants';
import styles from './Toolbar.module.css';
import useComponentLibrary from '../../hooks/useComponentLibrary';
export default function Toolbar() {
const jsonInputRef = useRef(null);
const {
autoLayout,
layoutDirection,
clearAll,
exportToJSON,
importFromJSON,
removeSelectedNode,
removeSelectedEdge,
selectedNode,
selectedEdge,
nodes,
} = useFlowStore();
const handleLayoutTB = useCallback(() => {
autoLayout(LAYOUT_DIRECTION.TB);
}, [autoLayout]);
const handleLayoutLR = useCallback(() => {
autoLayout(LAYOUT_DIRECTION.LR);
}, [autoLayout]);
const handleExportJSON = useCallback(() => {
const json = exportToJSON();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `eplan-flow-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}, [exportToJSON]);
const handleImportJSON = useCallback(() => {
jsonInputRef.current?.click();
}, []);
const handleJSONFileChange = useCallback((event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
importFromJSON(e.target.result);
};
reader.readAsText(file);
event.target.value = '';
}, [importFromJSON]);
const handleClear = useCallback(() => {
if (nodes.length === 0 || window.confirm('确认清空画布?所有未保存的数据将丢失。')) {
clearAll();
}
}, [clearAll, nodes.length]);
const handleDelete = useCallback(() => {
if (selectedNode) removeSelectedNode();
if (selectedEdge) removeSelectedEdge();
}, [selectedNode, selectedEdge, removeSelectedNode, removeSelectedEdge]);
return (
<div className={styles.toolbar}>
<div className={styles.brand}>
<span className={styles.logo}>&#9883;</span>
<span className={styles.title}>EPLAN Visualizer</span>
</div>
<div className={styles.divider} />
<div className={styles.group}>
<span className={styles.groupLabel}>布局</span>
<button
className={`${styles.btn} ${layoutDirection === LAYOUT_DIRECTION.TB ? styles.active : ''}`}
onClick={handleLayoutTB}
title="纵向布局(拓扑图)"
>
&#8597; 纵向
</button>
<button
className={`${styles.btn} ${layoutDirection === LAYOUT_DIRECTION.LR ? styles.active : ''}`}
onClick={handleLayoutLR}
title="横向布局(连接图)"
>
&#8596; 横向
</button>
</div>
<div className={styles.divider} />
<div className={styles.group}>
<button
className={`${styles.btn} ${useComponentLibrary.getState().currentView === 'editor' ? styles.active : ''}`}
onClick={() => {
const lib = useComponentLibrary.getState();
lib.switchView(lib.currentView === 'flow' ? 'editor' : 'flow');
}}
title="打开/关闭元器件编辑器"
>
&#9998; 元器件编辑器
</button>
</div>
<div className={styles.divider} />
<div className={styles.group}>
<button
className={styles.btn}
onClick={handleDelete}
disabled={!selectedNode && !selectedEdge}
title="删除选中项"
>
&#10005; 删除
</button>
</div>
<div className={styles.spacer} />
<div className={styles.group}>
<button className={styles.btn} onClick={handleExportJSON} title="导出 JSON 文件">
&#8615; 导出
</button>
<button className={styles.btn} onClick={handleImportJSON} title="导入 JSON 文件">
&#8613; 导入
</button>
<input
ref={jsonInputRef}
type="file"
accept=".json"
style={{ display: 'none' }}
onChange={handleJSONFileChange}
/>
<button className={`${styles.btn} ${styles.danger}`} onClick={handleClear} title="清空画布">
&#9746; 清空
</button>
</div>
</div>
);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 48px;
background: #16161e;
border-bottom: 1px solid #2a2a3a;
user-select: none;
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.logo {
font-size: 22px;
color: #6366f1;
}
.title {
font-size: 15px;
font-weight: 700;
color: #e0e0e0;
letter-spacing: 0.5px;
}
.divider {
width: 1px;
height: 24px;
background: #333;
margin: 0 4px;
}
.group {
display: flex;
align-items: center;
gap: 4px;
}
.groupLabel {
font-size: 11px;
color: #666;
margin-right: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.spacer {
flex: 1;
}
.btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1e1e2e;
color: #bbb;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: #2a2a3e;
color: #e0e0e0;
border-color: #555;
}
.btn:active:not(:disabled) {
transform: scale(0.97);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn.active {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.btn.danger:hover:not(:disabled) {
background: #3a1a1a;
border-color: #f44336;
color: #f44336;
}
/**
* useComponentLibrary - 自定义元器件模板库 Zustand Store
* 管理用户自定义元器件的 CRUD 操作,持久化到 localStorage
*
* 数据结构 ComponentTemplate:
* id, name, category, color, icon, width, height,
* shapes: [{ type, props, style }],
* ports: [{ id, name, type, side, position }]
*/
import { create } from 'zustand';
const STORAGE_KEY = 'eplan-component-library';
/** 从 localStorage 读取已保存的模板 */
function loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
/** 持久化到 localStorage */
function saveToStorage(templates) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
} catch (err) {
console.error('保存元器件库失败:', err);
}
}
/** 生成唯一 ID */
function generateId() {
return `cpt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
/** 创建空白模板 */
export function createBlankTemplate() {
return {
id: generateId(),
name: '新元器件',
category: '自定义',
color: '#6366f1',
icon: '■',
width: 180,
height: 100,
shapes: [
{
type: 'rect',
props: { x: 0, y: 0, width: 180, height: 100, rx: 8 },
style: { fill: '#1e1e2e', stroke: '#6366f1', strokeWidth: 2 },
},
],
ports: [
{ id: 'in1', portId: 1, name: '输入1', description: '端点描述', type: 'generic', side: 'left', position: 0.5 },
{ id: 'out1', portId: 2, name: '输出1', description: '端点描述', type: 'generic', side: 'right', position: 0.5 },
],
};
}
const useComponentLibrary = create((set, get) => ({
templates: loadFromStorage(),
/** 当前视图:'flow' | 'editor' */
currentView: 'flow',
/** 切换视图 */
switchView: (view) => set({ currentView: view }),
/** 当前正在编辑的模板(编辑器用) */
editingTemplate: null,
/** 编辑历史栈(用于 Ctrl+Z 撤销) */
editingHistory: [],
/** 开始编辑一个模板(传入副本) */
startEditing: (templateId) => {
const template = get().templates.find(t => t.id === templateId);
if (template) {
set({ editingTemplate: JSON.parse(JSON.stringify(template)), editingHistory: [] });
}
},
/** 开始创建新模板 */
startNew: () => {
set({ editingTemplate: createBlankTemplate(), editingHistory: [] });
},
/** 保存当前状态到历史栈 */
pushHistory: () => {
const current = get().editingTemplate;
if (!current) return;
const history = [...get().editingHistory, JSON.parse(JSON.stringify(current))];
// 最多保存 50 步
if (history.length > 50) history.shift();
set({ editingHistory: history });
},
/** 撤销 */
undo: () => {
const history = [...get().editingHistory];
if (history.length === 0) return;
const prev = history.pop();
set({ editingTemplate: prev, editingHistory: history });
},
/** 更新正在编辑的模板字段 */
updateEditing: (updates) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
set({ editingTemplate: { ...current, ...updates } });
},
/** 更新编辑中模板的某个形状 */
updateShape: (shapeIndex, updates) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
const shapes = [...current.shapes];
shapes[shapeIndex] = { ...shapes[shapeIndex], ...updates };
set({ editingTemplate: { ...current, shapes } });
},
/** 添加形状 */
addShape: (shape) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
set({ editingTemplate: { ...current, shapes: [...current.shapes, shape] } });
},
/** 删除形状 */
removeShape: (shapeIndex) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
const shapes = current.shapes.filter((_, i) => i !== shapeIndex);
set({ editingTemplate: { ...current, shapes } });
},
addPort: (port) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
const maxPortId = current.ports.reduce((max, p) => Math.max(max, p.portId || 0), 0);
const newPort = {
id: `port-${Date.now()}`,
portId: maxPortId + 1,
name: `端口${current.ports.length + 1}`,
description: '端点描述',
type: 'generic',
side: 'left',
position: 0.5,
...port,
};
set({ editingTemplate: { ...current, ports: [...current.ports, newPort] } });
},
/** 更新端点 */
updatePort: (portId, updates) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
const ports = current.ports.map(p => p.id === portId ? { ...p, ...updates } : p);
set({ editingTemplate: { ...current, ports } });
},
/** 删除端点 */
removePort: (portId) => {
const current = get().editingTemplate;
if (!current) return;
get().pushHistory();
const ports = current.ports.filter(p => p.id !== portId);
set({ editingTemplate: { ...current, ports } });
},
/** 保存当前编辑的模板到库中 */
saveEditing: () => {
const current = get().editingTemplate;
if (!current) return;
const templates = [...get().templates];
const existingIdx = templates.findIndex(t => t.id === current.id);
if (existingIdx >= 0) {
templates[existingIdx] = current;
} else {
templates.push(current);
}
saveToStorage(templates);
set({ templates, editingTemplate: null });
},
/** 取消编辑 */
cancelEditing: () => {
set({ editingTemplate: null });
},
/** 删除模板 */
deleteTemplate: (templateId) => {
const templates = get().templates.filter(t => t.id !== templateId);
saveToStorage(templates);
set({ templates });
},
/** 根据 ID 获取模板 */
getTemplateById: (templateId) => {
return get().templates.find(t => t.id === templateId) || null;
},
}));
export default useComponentLibrary;
/**
* Zustand store - 管理 React Flow 节点/边状态及所有操作方法
*/
import { create } from 'zustand';
import { applyNodeChanges, applyEdgeChanges, addEdge as rfAddEdge } from '@xyflow/react';
import { parseConnectionSheet, toReactFlowData } from '../utils/xlsxParser';
import { getLayoutedNodes } from '../utils/layoutEngine';
import { getDeviceType, LAYOUT_DIRECTION, PORT_TYPES, getPortTypeByFunctionCode } from '../utils/constants';
let nodeCounter = 0;
const useFlowStore = create((set, get) => ({
nodes: [],
edges: [],
selectedNode: null,
selectedEdge: null,
layoutDirection: LAYOUT_DIRECTION.TB,
isLoading: false,
error: null,
connectionError: null,
onNodesChange: (changes) => {
set({ nodes: applyNodeChanges(changes, get().nodes) });
},
onEdgesChange: (changes) => {
set({ edges: applyEdgeChanges(changes, get().edges) });
},
onConnect: (connection) => {
const { nodes } = get();
const sourceNode = nodes.find(n => n.id === connection.source);
const targetNode = nodes.find(n => n.id === connection.target);
const sourceColor = sourceNode?.data?.color || '#666';
const targetColor = targetNode?.data?.color || '#666';
const newEdge = {
...connection,
id: `edge-manual-${Date.now()}`,
type: 'gradientBezier',
data: { wireType: 0, sourceColor, targetColor },
};
set({ edges: rfAddEdge(newEdge, get().edges) });
},
clearConnectionError: () => set({ connectionError: null }),
setSelectedNode: (node) => set({ selectedNode: node, selectedEdge: null }),
setSelectedEdge: (edge) => set({ selectedEdge: edge, selectedNode: null }),
clearSelection: () => set({ selectedNode: null, selectedEdge: null }),
/** 从连接表导入(自动提取节点,生成 customDeviceNode 格式) */
importFromConnectionOnly: async (connectionFile, templates = []) => {
set({ isLoading: true, error: null });
try {
const connectionData = await parseConnectionSheet(connectionFile);
const { nodes, edges } = toReactFlowData(null, connectionData, templates);
const direction = get().layoutDirection;
const layoutedNodes = getLayoutedNodes(nodes, edges, direction);
set({ nodes: layoutedNodes, edges, isLoading: false });
} catch (err) {
console.error('连接表解析错误:', err);
set({ error: err.message, isLoading: false });
}
},
/** 自动布局 */
autoLayout: (direction) => {
const dir = direction || get().layoutDirection;
const layouted = getLayoutedNodes(get().nodes, get().edges, dir);
set({ nodes: layouted, layoutDirection: dir });
},
/** 手动添加节点 */
addNode: (position = { x: 100, y: 100 }, functionCode = 100) => {
const counter = ++nodeCounter;
const deviceType = getDeviceType(functionCode);
const id = `new-device-${counter}`;
// 构建和自定义节点完全相同格式的 templateData
const templateData = {
name: deviceType.label,
icon: deviceType.icon,
width: 180,
height: 80,
shapes: [
{
type: 'rect',
props: { x: 0, y: 0, width: 180, height: 80, rx: 6 },
style: { fill: 'none', stroke: deviceType.color, strokeWidth: 2 },
},
],
ports: [
{ id: 'p-in-1', name: '输入1', portId: 1, description: '', type: 'generic', side: 'left', position: 0.5 },
{ id: 'p-out-1', name: '输出1', portId: 2, description: '', type: 'generic', side: 'right', position: 0.5 },
],
};
const newNode = {
id,
type: 'customDeviceNode',
position,
data: {
label: deviceType.label,
deviceId: id,
color: deviceType.color,
templateData,
},
};
set({ nodes: [...get().nodes, newNode] });
return newNode;
},
/** 添加自定义模板节点 */
addCustomNode: (position = { x: 100, y: 100 }, template) => {
const counter = ++nodeCounter;
const id = `custom-${counter}`;
const defaultPortType = template.ports?.[0]?.type || 'generic';
const newNode = {
id,
type: 'customDeviceNode',
position,
data: {
label: template.name,
deviceId: id,
color: template.color,
icon: template.icon,
portType: defaultPortType,
templateData: template,
ports: template.ports.map(p => p.id),
},
};
set({ nodes: [...get().nodes, newNode] });
return newNode;
},
removeSelectedNode: () => {
const { selectedNode, nodes, edges } = get();
if (!selectedNode) return;
set({
nodes: nodes.filter(n => n.id !== selectedNode.id),
edges: edges.filter(e => e.source !== selectedNode.id && e.target !== selectedNode.id),
selectedNode: null,
});
},
removeSelectedEdge: () => {
const { selectedEdge, edges } = get();
if (!selectedEdge) return;
set({
edges: edges.filter(e => e.id !== selectedEdge.id),
selectedEdge: null,
});
},
updateNodeData: (nodeId, newData) => {
set({
nodes: get().nodes.map(n =>
n.id === nodeId ? { ...n, data: { ...n.data, ...newData } } : n
),
});
},
clearAll: () => {
set({ nodes: [], edges: [], selectedNode: null, selectedEdge: null });
},
exportToJSON: () => {
const { nodes, edges } = get();
return JSON.stringify({ nodes, edges }, null, 2);
},
importFromJSON: (jsonString) => {
try {
const { nodes, edges } = JSON.parse(jsonString);
set({ nodes: nodes || [], edges: edges || [], error: null });
} catch (err) {
set({ error: '无效的 JSON 文件' });
}
},
}));
export default useFlowStore;
/* Global styles and CSS reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: 'Segoe UI', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #121218;
color: #e0e0e0;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* React Flow overrides for dark theme */
.react-flow__edge-path {
stroke: #666 !important;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #6366f1 !important;
stroke-width: 3px !important;
}
.react-flow__edge-text {
fill: #aaa !important;
font-size: 11px;
}
.react-flow__connection-path {
stroke: #6366f1 !important;
stroke-width: 2px;
}
.react-flow__selection {
background: rgba(99, 102, 241, 0.08) !important;
border: 1px solid rgba(99, 102, 241, 0.4) !important;
}
/* Handle 连接点光标和层级 */
.react-flow__handle {
cursor: crosshair !important;
z-index: 10 !important;
}
.react-flow__handle:hover {
cursor: crosshair !important;
}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
/**
* EPLAN 功能代码到设备类型的映射表
* 以及对应的颜色/图标配置
*/
/** 功能代码 -> 设备类型映射 */
export const FUNCTION_CODE_MAP = {
100: { type: 'terminal', label: '端子排', color: '#4CAF50', icon: '⊞' },
200: { type: 'contactor', label: '接触器', color: '#2196F3', icon: '◈' },
300: { type: 'relay', label: '继电器', color: '#9C27B0', icon: '◇' },
400: { type: 'motor', label: '电动机', color: '#FF9800', icon: '◎' },
501: { type: 'breaker', label: '断路器', color: '#F44336', icon: '⊗' },
600: { type: 'switch', label: '开关', color: '#00BCD4', icon: '⊘' },
700: { type: 'sensor', label: '传感器', color: '#795548', icon: '◉' },
802: { type: 'transformer', label: '变压器', color: '#FF5722', icon: '⊜' },
900: { type: 'cable', label: '电缆/线缆', color: '#607D8B', icon: '⊟' },
};
/** 获取设备类型信息,未知功能代码返回默认值 */
export function getDeviceType(functionCode) {
return FUNCTION_CODE_MAP[functionCode] || {
type: 'unknown',
label: `未知(${functionCode})`,
color: '#9E9E9E',
icon: '□',
};
}
/** 信号名称集合,用于判断连接表中某值是否为信号标签而非设备端口 */
export const SIGNAL_NAMES = new Set([
'L1', 'L2', 'L3', 'N', 'PE',
'L1+', 'L2+', 'L3+',
'24V', '0V', '+24V', 'GND',
'DC+', 'DC-',
]);
/** 线缆颜色代码映射 */
export const WIRE_COLOR_MAP = {
1: { label: '黑色', color: '#333333' },
2: { label: '蓝色', color: '#2196F3' },
3: { label: '绿黄', color: '#8BC34A' },
4: { label: '红色', color: '#F44336' },
5: { label: '白色', color: '#BDBDBD' },
6: { label: '橙色', color: '#FF9800' },
7: { label: '棕色', color: '#795548' },
8: { label: '灰色', color: '#9E9E9E' },
9: { label: '紫色', color: '#9C27B0' },
};
/** 布局方向 */
export const LAYOUT_DIRECTION = {
TB: 'TB', // 上到下(拓扑图)
LR: 'LR', // 左到右(连接图)
};
/** 默认节点尺寸 */
export const NODE_DIMENSIONS = {
WIDTH: 200,
HEIGHT: 80,
PORT_HEIGHT: 24,
};
/**
* 端口类型 - 基于物理属性分类
* 每种类型有独立颜色,用于 Handle 着色和连线渐变
*/
export const PORT_TYPES = {
analog: { label: '模拟量', color: '#E91E63' },
digital: { label: '数字量', color: '#2196F3' },
water: { label: '水流', color: '#00BCD4' },
air: { label: '气流', color: '#8BC34A' },
power: { label: '电力', color: '#FF9800' },
generic: { label: '通用', color: '#9E9E9E' },
};
/**
* 端口类型兼容性校验
* 规则:同类型可连接,generic 可连任何类型
* @param {string} typeA - 源端口类型
* @param {string} typeB - 目标端口类型
* @returns {boolean}
*/
export function isPortCompatible(typeA, typeB) {
if (!typeA || !typeB) return true;
if (typeA === 'generic' || typeB === 'generic') return true;
return typeA === typeB;
}
/**
* 根据功能代码获取该设备默认的端口类型
* @param {number} functionCode
* @returns {string} 端口类型 key
*/
export function getPortTypeByFunctionCode(functionCode) {
const map = {
100: 'generic', // 端子排
200: 'digital', // 接触器
300: 'digital', // 继电器
400: 'power', // 电动机
501: 'power', // 断路器
600: 'power', // 开关
700: 'analog', // 传感器
802: 'power', // 变压器
900: 'generic', // 电缆
};
return map[functionCode] || 'generic';
}
/**
* 自动布局引擎
* 使用 dagre 对 React Flow 节点进行有向图自动布局
*/
import dagre from 'dagre';
import { NODE_DIMENSIONS } from './constants';
/**
* 使用 dagre 对节点和边进行自动布局
*
* @param {Array} nodes - React Flow 节点数组
* @param {Array} edges - React Flow 边数组
* @param {string} direction - 布局方向 'TB'(上下)或 'LR'(左右)
* @returns {Array} 更新了 position 的节点数组
*/
export function getLayoutedNodes(nodes, edges, direction = 'TB') {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
const nodeWidth = NODE_DIMENSIONS.WIDTH;
const baseHeight = NODE_DIMENSIONS.HEIGHT;
g.setGraph({
rankdir: direction,
nodesep: 80,
ranksep: 120,
edgesep: 40,
marginx: 40,
marginy: 40,
});
// 添加节点
for (const node of nodes) {
const portCount = node.data?.ports?.length || 1;
const height = baseHeight + Math.max(0, portCount - 2) * NODE_DIMENSIONS.PORT_HEIGHT;
g.setNode(node.id, { width: nodeWidth, height });
}
// 添加边
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
// 执行布局
dagre.layout(g);
// 将 dagre 计算的位置应用到 React Flow 节点
return nodes.map(node => {
const nodeWithPosition = g.node(node.id);
if (!nodeWithPosition) return node;
const portCount = node.data?.ports?.length || 1;
const height = baseHeight + Math.max(0, portCount - 2) * NODE_DIMENSIONS.PORT_HEIGHT;
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - height / 2,
},
};
});
}
This diff is collapsed.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment