Commit 3881f74d authored by fenghen777's avatar fenghen777

feat: 符号编辑器增强 - 层级目录/模型参数/术语统一

- 统一术语: 元器件 -> 符号
- 侧边栏: 内置符号/自定义符号两层树结构, 支持新建/删除分类
- 模型参数: 符号支持定义参数(key/label/defaultValue), 节点显示参数值
- 属性面板: 可编辑参数值, 支持折叠
- 编辑器: 画布内参数标签可拖拽定位, ESC退回选择, Delete删除形状
- 预览: NodePreview与CustomDeviceNode渲染保持一致
- 去重复边框: 内置符号shapes改为空数组
parent 3264c651
/**
* App - 主入口组件
* 支持两个视图:流程编辑器 / 元器件编辑器
* 支持两个视图:流程编辑器 / 符号编辑器
*/
import { ReactFlowProvider } from '@xyflow/react';
import Toolbar from './components/Toolbar/Toolbar';
......
......@@ -121,7 +121,7 @@ export default function FlowCanvas() {
return;
}
// 内置元器件拖入
// 内置符号拖入
const type = event.dataTransfer.getData('application/eplan-device-type');
if (!type) return;
const functionCode = Number(type) || 100;
......
/**
* NodePreview - 元器件节点实时预览
* NodePreview - 符号节点实时预览
* 和编辑器画布完全一致的端点布局:
* 彩色 header + 端点按 side/position 分布在四边
*/
......@@ -63,6 +63,35 @@ export default function NodePreview() {
{icon} {name}
</text>
{/* 编辑器中绘制的形状 - 偏移到 body 区域 */}
<g transform={`translate(0, ${headerH})`}>
{editingTemplate.shapes.map((shape, i) => {
const { type, props, style: sty } = shape;
const s = { fill: sty?.fill || 'none', stroke: sty?.stroke || '#666', strokeWidth: sty?.strokeWidth || 1 };
switch (type) {
case 'rect': return <rect key={i} {...props} style={s} />;
case 'circle': return <circle key={i} {...props} style={s} />;
case 'ellipse': return <ellipse key={i} {...props} style={s} />;
default: return null;
}
})}
</g>
{/* 参数文本 */}
{(editingTemplate.params || []).map((p, i) => (
<text
key={p.key}
x={(p.offsetX ?? 6)}
y={headerH + (p.offsetY ?? (4 + i * 14)) + 10}
fontSize={10}
fill="#aaa"
fontFamily="'Segoe UI', sans-serif"
>
<tspan fill="#777">{p.label}: </tspan>
<tspan fill="#ddd">{p.defaultValue}</tspan>
</text>
))}
{/* 端点 - 位置在 body 区域(y 偏移 headerH) */}
{ports.map(port => {
const raw = getPortPos(port, totalW, tH);
......@@ -76,19 +105,6 @@ export default function NodePreview() {
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)}
......
/**
* PortEditor - 端点编辑面板
* 显示当前元器件的所有端点,可编辑 ID、名称、描述、类型、位置
* 显示当前符号的所有端点,可编辑 ID、名称、描述、类型、位置
*/
import { PORT_TYPES } from '../../utils/constants';
import useComponentLibrary from '../../hooks/useComponentLibrary';
......@@ -47,22 +47,25 @@ export default function PortEditor() {
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="名称"
onBlur={(e) => {
const val = e.target.value.trim();
if (!val) return;
const isDup = ports.some(p => p.id !== port.id && p.name === val);
if (isDup) {
alert(`端点名称 "${val}" 已存在,请修改为不重复的名称!`);
}
}}
style={{
borderColor: ports.some(p => p.id !== port.id && p.name === port.name) ? '#f44336' : undefined,
borderWidth: ports.some(p => p.id !== port.id && p.name === port.name) ? '2px' : undefined,
}}
title={ports.some(p => p.id !== port.id && p.name === port.name) ? "名称不能重复" : "端点名称"}
placeholder="名称(唯一)"
/>
{/* 描述 */}
......
/**
* CustomConnectionLine - 拖拽过程中的贝塞尔曲线连线
* 使用起始节点颜色,风格与 GradientBezierEdge 一致
* 使用起始节点颜色,方向感知控制点与 GradientBezierEdge 一致
*/
import { useReactFlow } from '@xyflow/react';
export default function CustomConnectionLine({
fromX,
fromY,
fromPosition,
toX,
toY,
fromNode,
}) {
const color = fromNode?.data?.color || '#6366f1';
// 与 GradientBezierEdge 相同的控制点计算
const dx = Math.abs(toX - fromX);
const dy = Math.abs(toY - fromY);
const offset = Math.max(80, Math.min(dy * 0.5, 200));
const dist = Math.sqrt(dx * dx + dy * dy);
const offset = Math.max(50, Math.min(dist * 0.4, 250));
const path = `M ${fromX} ${fromY} C ${fromX} ${fromY + offset}, ${toX} ${toY - offset}, ${toX} ${toY}`;
// 根据 source 端口方向决定控制点偏移
let cp1x = fromX, cp1y = fromY;
switch (fromPosition) {
case 'left': cp1x -= offset; break;
case 'right': cp1x += offset; break;
case 'top': cp1y -= offset; break;
case 'bottom': cp1y += offset; break;
default: cp1y += offset;
}
// target 控制点:简单地朝 source 方向的反方向偏移
// 因为拖拽时没有 targetPosition 信息,用距离自适应
let cp2x = toX, cp2y = toY;
switch (fromPosition) {
case 'left': cp2x += offset; break;
case 'right': cp2x -= offset; break;
case 'top': cp2y += offset; break;
case 'bottom': cp2y -= offset; break;
default: cp2y -= offset;
}
const path = `M ${fromX} ${fromY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${toX} ${toY}`;
return (
<g>
......
/**
* GradientBezierEdge - Blender 着色器风格的贝塞尔曲线连线
* GradientBezierEdge - 方向感知的贝塞尔曲线连线
* 支持双端点颜色渐变,hover 发光效果
*
* Bug修复:
* - 使用方向感知的控制点计算,修复拖动时线消失的问题
* - SVG渐变使用 gradientUnits="userSpaceOnUse" 避免 bounding box 为零时渐变失效
* 控制点算法:
* 根据 sourcePosition/targetPosition (left/right/top/bottom)
* 动态决定控制点的偏移方向,使曲线始终自然流出端口。
*
* 例如 source 在右侧 → 控制点向右偏移
* target 在左侧 → 控制点向左偏移
*/
import { memo, useMemo } from 'react';
import { Position } from '@xyflow/react';
import styles from './GradientBezierEdge.module.css';
/**
* 计算 Blender 风格的贝塞尔曲线路径
* 根据实际方向动态调整控制点,支持任意位置关系
* 根据 Position 方向返回控制点的偏移向量 (dx, dy)
* offset 为偏移量大小
*/
function getControlOffset(position, offset) {
switch (position) {
case Position.Left: return { dx: -offset, dy: 0 };
case Position.Right: return { dx: offset, dy: 0 };
case Position.Top: return { dx: 0, dy: -offset };
case Position.Bottom: return { dx: 0, dy: offset };
default: return { dx: 0, dy: offset };
}
}
/**
* 构造方向感知的三次贝塞尔曲线路径
*/
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;
function buildSmartBezierPath(
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
) {
const dx = Math.abs(targetX - sourceX);
const dy = Math.abs(targetY - sourceY);
const dist = Math.sqrt(dx * dx + dy * dy);
// 控制点偏移量:距离越远弧度越大,但有上下限
const offset = Math.max(50, Math.min(dist * 0.4, 250));
const src = getControlOffset(sourcePosition, offset);
const tgt = getControlOffset(targetPosition, offset);
const cp1x = sourceX + src.dx;
const cp1y = sourceY + src.dy;
const cp2x = targetX + tgt.dx;
const cp2y = targetY + tgt.dy;
const path = `M ${sourceX} ${sourceY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${targetX} ${targetY}`;
......@@ -43,6 +63,8 @@ function GradientBezierEdge({
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}) {
......@@ -52,8 +74,11 @@ function GradientBezierEdge({
const gradientId = `gradient-${id}`;
const { path } = useMemo(
() => buildBezierPath(sourceX, sourceY, targetX, targetY),
[sourceX, sourceY, targetX, targetY]
() => buildSmartBezierPath(
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
),
[sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition]
);
const isSameColor = sourceColor === targetColor;
......@@ -61,7 +86,7 @@ function GradientBezierEdge({
return (
<>
{/* SVG 渐变定义 - 使用 userSpaceOnUse 避免 bounding box 零尺寸问题 */}
{/* SVG 渐变定义 */}
{!isSameColor && (
<defs>
<linearGradient
......
/**
* CustomDeviceNode - 统一元器件节点(内置和自定义共用)
* CustomDeviceNode - 统一符号节点(内置和自定义共用)
*
* 核心设计思路:
* - Handle 本身就是可见端点圆(连线端 = 视觉端 = 同一 DOM 元素)
* - 每个端口用一个绝对定位的容器 div 包裹 Handle + 标签
* - 容器 div 的 left/top 控制位置,Handle 不设任何 style.left/top/right
* 只设 position prop 和外观样式
* - 用 outline 代替 border(不影响盒模型)
* Handle 定位策略(核心!):
* React Flow 的 CSS 类(如 .react-flow__handle-left)会自动设置
* Handle 贴在节点边缘的那个轴(left/right/top/bottom)以及对应的
* transform 居中偏移。我们只需要覆盖"沿边缘移动"的那一个轴。
*
* Left handle: CSS 管 left:0 + transform → 我们只设 top
* Right handle: CSS 管 right:0 + transform → 我们只设 top
* Top handle: CSS 管 top:0 + transform → 我们只设 left
* Bottom handle: CSS 管 bottom:0 + transform → 我们只设 left
*/
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { PORT_TYPES } from '../../utils/constants';
const HEADER_H = 28;
const HANDLE_SIZE = 12;
function sideToPosition(side) {
switch (side) {
......@@ -65,88 +69,115 @@ function CustomDeviceNode({ data, selected }) {
}}>{name}</span>
</div>
{/* 每个端口:容器 div(绝对定位)内含 Handle + 标签 */}
{/* 编辑器中绘制的形状 - body 区域 */}
{templateData.shapes && templateData.shapes.length > 0 && (
<svg
style={{
position: 'absolute',
left: 0,
top: HEADER_H,
width: tW,
height: tH,
pointerEvents: 'none',
overflow: 'visible',
}}
viewBox={`0 0 ${tW} ${tH}`}
>
{templateData.shapes.map((shape, i) => {
const { type, props, style: sty } = shape;
const s = { fill: sty?.fill || 'none', stroke: sty?.stroke || '#666', strokeWidth: sty?.strokeWidth || 1 };
switch (type) {
case 'rect': return <rect key={i} {...props} style={s} />;
case 'circle': return <circle key={i} {...props} style={s} />;
case 'ellipse': return <ellipse key={i} {...props} style={s} />;
default: return null;
}
})}
</svg>
)}
{/* 模型参数文本 - body 区域 */}
{data.paramValues && templateData.params && templateData.params.length > 0 &&
templateData.params.map((p, i) => (
<div key={p.key} style={{
position: 'absolute',
left: (p.offsetX ?? 6),
top: HEADER_H + (p.offsetY ?? (4 + i * 14)),
fontSize: 10,
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<span style={{ color: '#777' }}>{p.label}: </span>
<span style={{ color: '#ddd' }}>{data.paramValues[p.key] || p.defaultValue}</span>
</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;
// 只设置"沿边缘滑动"的那个轴,不干扰 React Flow CSS 管的轴
let handlePos = {};
switch (port.side) {
case 'left':
case 'right':
// CSS 管水平贴边(left:0 / right:0) + transform 居中
// 我们只设 top(纵向位置)
handlePos = { top: `${((HEADER_H + tH * port.position) / totalH) * 100}%` };
break;
case 'top':
// CSS 管 top:0 + transform 居中,我们只设 left
handlePos = { left: `${port.position * 100}%` };
break;
case 'bottom':
// CSS 管 bottom:0 + transform 居中,我们只设 left
handlePos = { left: `${port.position * 100}%` };
break;
default:
handlePos = { top: '50%' };
}
const handleStyle = {
...handlePos,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
borderRadius: '50%',
};
// 计算名称标签位置
const labelStyle = getLabelStyle(port.side, port.position, tW, tH, totalH);
return (
<div
key={port.id}
style={{
position: 'absolute',
left: cx,
top: cy,
width: 0,
height: 0,
overflow: 'visible',
}}
>
{/* target Handle - 可见端点圆 */}
<div key={port.id}>
{/* target Handle - 透明,接收连接 */}
<Handle
type="target"
position={pos}
id={`t-${port.id}`}
id={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%)',
...handleStyle,
background: 'transparent',
border: 'none',
}}
/>
{/* source Handle - 同位置透明叠加 */}
{/* source Handle - 可见端点圆,发起连接 */}
<Handle
type="source"
position={pos}
id={`s-${port.id}`}
id={port.id}
style={{
position: 'relative',
left: 0,
top: -12,
width: 12,
height: 12,
background: 'transparent',
border: 'none',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
...handleStyle,
background: portColor,
border: '2px solid #1e1e2e',
cursor: 'crosshair',
}}
/>
{/* 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',
}}>
<div style={labelStyle}>
{port.name}
</div>
)}
......@@ -157,23 +188,34 @@ function CustomDeviceNode({ data, selected }) {
);
}
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 getLabelStyle(side, position, tW, tH, totalH) {
const base = {
position: 'absolute',
fontSize: 9,
color: '#888',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
};
const topPct = `${((HEADER_H + tH * position) / totalH) * 100}%`;
const leftPct = `${position * 100}%`;
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 {};
case 'left':
return { ...base, left: 14, top: topPct, transform: 'translateY(-50%)' };
case 'right':
return { ...base, right: 14, top: topPct, transform: 'translateY(-50%)' };
case 'top':
return { ...base, left: leftPct, top: HEADER_H + 6, transform: 'translateX(-50%)' };
case 'bottom':
return { ...base, left: leftPct, bottom: 6, transform: 'translateX(-50%)' };
default:
return base;
}
}
......
/**
* DeviceNode - 内置元器件节点
* 和 CustomDeviceNode / NodePreview 完全一致的 SVG 渲染:
* 彩色 header + 端点按位置分布在上下边缘
* DeviceNode - 内置符号节点
*
* Handle 直接作为节点根 div 的子元素,
* 不额外添加 margin / transform,让 React Flow 内置 CSS 自行居中。
* 只通过 style.left 覆盖水平位置。
*/
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { PORT_TYPES } from '../../utils/constants';
const HANDLE_SIZE = 12;
function DeviceNode({ data, selected }) {
const {
label,
......@@ -14,7 +18,7 @@ function DeviceNode({ data, selected }) {
deviceTypeLabel,
color,
portType = 'generic',
ports = ['输入1', '输出1'],
ports = ['1', '2'],
} = data;
const rotation = data.rotation || 0;
......@@ -64,41 +68,31 @@ function DeviceNode({ data, selected }) {
{icon} {label || deviceTypeLabel}
</text>
{/* 端点标注 - 上方 (target) */}
{/* 端点文字标签 - 上方 (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}
<text key={`top-label-${port}`} x={x} y={-10}
textAnchor="middle" fill="#aaa"
fontSize={9} fontWeight={600}
fontFamily="'Segoe UI', sans-serif"
>
{port}
</text>
</g>
);
})}
{/* 端点标注 - 下方 (source) */}
{/* 端点文字标签 - 下方 (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}
<text key={`bottom-label-${port}`} x={x} y={totalH + 16}
textAnchor="middle" fill="#aaa"
fontSize={9} fontWeight={600}
fontFamily="'Segoe UI', sans-serif"
>
{port}
</text>
</g>
);
})}
</svg>
......@@ -114,11 +108,16 @@ function DeviceNode({ data, selected }) {
id={port}
style={{
left: `${offset}%`,
background: 'transparent',
border: 'none',
width: 12,
height: 12,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: handleColor,
border: '2px solid #1e1e2e',
borderRadius: '50%',
cursor: 'crosshair',
}}
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
/>
);
})}
......@@ -134,11 +133,16 @@ function DeviceNode({ data, selected }) {
id={port}
style={{
left: `${offset}%`,
background: 'transparent',
border: 'none',
width: 12,
height: 12,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: handleColor,
border: '2px solid #1e1e2e',
borderRadius: '50%',
cursor: 'crosshair',
}}
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
/>
);
})}
......
......@@ -12,11 +12,13 @@ export default function PropertiesPanel() {
selectedNode,
selectedEdge,
updateNodeData,
updateNodeParam,
removeSelectedNode,
removeSelectedEdge,
} = useFlowStore();
const [panelWidth, setPanelWidth] = useState(300);
const [paramsCollapsed, setParamsCollapsed] = useState(false);
const isDragging = useRef(false);
const handleLabelChange = useCallback((e) => {
......@@ -93,13 +95,37 @@ export default function PropertiesPanel() {
<label className={styles.label}>端口</label>
<div className={styles.portList}>
{(selectedNode.data?.ports || []).map(p => (
{(selectedNode.data?.ports || selectedNode.data?.templateData?.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>
{/* 模型参数 */}
{selectedNode.data?.templateData?.params?.length > 0 && (
<>
<div
className={styles.label}
style={{ cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center', gap: 4 }}
onClick={() => setParamsCollapsed(prev => !prev)}
>
<span style={{ fontSize: 8 }}>{paramsCollapsed ? '\u25B6' : '\u25BC'}</span>
模型参数 ({selectedNode.data.templateData.params.length})
</div>
{!paramsCollapsed && selectedNode.data.templateData.params.map(p => (
<div key={p.key} style={{ marginBottom: 6 }}>
<label className={styles.label} style={{ marginBottom: 2 }}>{p.label}</label>
<input
className={styles.input}
value={selectedNode.data.paramValues?.[p.key] ?? p.defaultValue ?? ''}
onChange={(e) => updateNodeParam(selectedNode.id, p.key, e.target.value)}
/>
</div>
))}
</>
)}
<button className={styles.deleteBtn} onClick={removeSelectedNode}>
删除此节点
</button>
......
This diff is collapsed.
......@@ -259,6 +259,95 @@
font-family: 'Consolas', monospace;
}
.libPortCount {
font-size: 9px;
color: #555;
font-family: 'Consolas', monospace;
background: rgba(255, 255, 255, 0.05);
padding: 1px 4px;
border-radius: 3px;
}
/* ===== Category Block ===== */
.categoryBlock {
margin-bottom: 8px;
}
.categoryHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 4px;
cursor: pointer;
border-radius: 4px;
transition: background 0.12s;
user-select: none;
}
.categoryHeader:hover {
background: rgba(255, 255, 255, 0.04);
}
.categoryArrow {
font-size: 8px;
color: #555;
width: 12px;
text-align: center;
}
.categoryName {
font-size: 11px;
font-weight: 600;
color: #999;
flex: 1;
letter-spacing: 0.5px;
}
.categoryCount {
font-size: 9px;
color: #555;
background: rgba(255, 255, 255, 0.05);
padding: 1px 5px;
border-radius: 8px;
}
/* ===== Tree hierarchy ===== */
.rootHeader {
padding: 8px 4px;
margin-top: 4px;
}
.rootHeader .categoryName {
font-size: 12px;
font-weight: 700;
color: #bbb;
}
.subTree {
padding-left: 12px;
border-left: 1px solid #2a2a3a;
margin-left: 8px;
}
.addCategoryBtn {
width: 100%;
padding: 6px 8px;
margin-top: 4px;
border: 1px dashed #333;
border-radius: 4px;
background: transparent;
color: #6366f1;
font-size: 11px;
cursor: pointer;
transition: all 0.12s;
text-align: center;
}
.addCategoryBtn:hover {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.05);
}
/* ===== Custom Template ===== */
.newTemplateBtn {
float: right;
......
......@@ -104,9 +104,9 @@ export default function Toolbar() {
const lib = useComponentLibrary.getState();
lib.switchView(lib.currentView === 'flow' ? 'editor' : 'flow');
}}
title="打开/关闭元器件编辑器"
title="打开/关闭符号编辑器"
>
&#9998; 元器件编辑器
&#9998; 符号编辑器
</button>
</div>
......
/**
* useComponentLibrary - 自定义元器件模板库 Zustand Store
* 管理用户自定义元器件的 CRUD 操作,持久化到 localStorage
* useComponentLibrary - 自定义符号模板库 Zustand Store
* 管理用户自定义符号的 CRUD 操作,持久化到 localStorage
*
* 数据结构 ComponentTemplate:
* id, name, category, color, icon, width, height,
......@@ -10,6 +10,7 @@
import { create } from 'zustand';
const STORAGE_KEY = 'eplan-component-library';
const CATEGORIES_KEY = 'eplan-custom-categories';
/** 从 localStorage 读取已保存的模板 */
function loadFromStorage() {
......@@ -26,7 +27,26 @@ function saveToStorage(templates) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
} catch (err) {
console.error('保存元器件库失败:', err);
console.error('保存符号库失败:', err);
}
}
/** 从 localStorage 读取自定义分类 */
function loadCategories() {
try {
const raw = localStorage.getItem(CATEGORIES_KEY);
return raw ? JSON.parse(raw) : ['未分类'];
} catch {
return ['未分类'];
}
}
/** 持久化自定义分类 */
function saveCategories(cats) {
try {
localStorage.setItem(CATEGORIES_KEY, JSON.stringify(cats));
} catch (err) {
console.error('保存分类失败:', err);
}
}
......@@ -39,28 +59,24 @@ function generateId() {
export function createBlankTemplate() {
return {
id: generateId(),
name: '新元器件',
category: '自定义',
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 },
},
],
shapes: [],
params: [],
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 },
{ id: 'in1', portId: 1, name: '1', description: '端点描述', type: 'generic', side: 'left', position: 0.5 },
{ id: 'out1', portId: 2, name: '2', description: '端点描述', type: 'generic', side: 'right', position: 0.5 },
],
};
}
const useComponentLibrary = create((set, get) => ({
templates: loadFromStorage(),
customCategories: loadCategories(),
/** 当前视图:'flow' | 'editor' */
currentView: 'flow',
......@@ -148,7 +164,7 @@ const useComponentLibrary = create((set, get) => ({
const newPort = {
id: `port-${Date.now()}`,
portId: maxPortId + 1,
name: `端口${current.ports.length + 1}`,
name: `${maxPortId + 1}`,
description: '端点描述',
type: 'generic',
side: 'left',
......@@ -208,6 +224,45 @@ const useComponentLibrary = create((set, get) => ({
getTemplateById: (templateId) => {
return get().templates.find(t => t.id === templateId) || null;
},
// ==================== 自定义分类管理 ====================
/** 新建分类 */
addCategory: (name) => {
const cats = [...get().customCategories];
if (!name || cats.includes(name)) return false;
cats.push(name);
saveCategories(cats);
set({ customCategories: cats });
return true;
},
/** 删除分类(将其下的符号移到"未分类") */
deleteCategory: (name) => {
if (name === '未分类') return; // 不能删除默认分类
const cats = get().customCategories.filter(c => c !== name);
if (!cats.includes('未分类')) cats.unshift('未分类');
saveCategories(cats);
// 把该分类下的模板移到"未分类"
const templates = get().templates.map(t =>
t.category === name ? { ...t, category: '未分类' } : t
);
saveToStorage(templates);
set({ customCategories: cats, templates });
},
/** 重命名分类 */
renameCategory: (oldName, newName) => {
if (oldName === '未分类' || !newName || oldName === newName) return;
const cats = get().customCategories.map(c => c === oldName ? newName : c);
if (new Set(cats).size !== cats.length) return; // 重名
saveCategories(cats);
const templates = get().templates.map(t =>
t.category === oldName ? { ...t, category: newName } : t
);
saveToStorage(templates);
set({ customCategories: cats, templates });
},
}));
export default useComponentLibrary;
......@@ -5,7 +5,7 @@ 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';
import { getDeviceType, getDeviceDefinition, LAYOUT_DIRECTION, PORT_TYPES, getPortTypeByFunctionCode } from '../utils/constants';
let nodeCounter = 0;
......@@ -78,27 +78,38 @@ const useFlowStore = create((set, get) => ({
addNode: (position = { x: 100, y: 100 }, functionCode = 100) => {
const counter = ++nodeCounter;
const deviceType = getDeviceType(functionCode);
const deviceDef = getDeviceDefinition(Number(functionCode));
const id = `new-device-${counter}`;
// 构建和自定义节点完全相同格式的 templateData
// 使用设备定义的端口和尺寸,或回退到默认值
const defW = deviceDef?.width || 180;
const defH = deviceDef?.height || 80;
const defPorts = deviceDef?.ports
? deviceDef.ports.map(p => ({
...p,
id: `${p.id}-${counter}`, // 确保端口 id 全局唯一
portId: p.id,
description: '',
}))
: [
{ id: `p-in-1-${counter}`, name: '输入1', portId: 1, description: '', type: 'generic', side: 'left', position: 0.5 },
{ id: `p-out-1-${counter}`, name: '输出1', portId: 2, description: '', type: 'generic', side: 'right', position: 0.5 },
];
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 },
],
width: defW,
height: defH,
shapes: [],
ports: defPorts,
params: deviceDef?.params || [],
};
// 用 defaultValue 初始化参数值
const paramValues = {};
(deviceDef?.params || []).forEach(p => { paramValues[p.key] = p.defaultValue; });
const newNode = {
id,
type: 'customDeviceNode',
......@@ -108,6 +119,7 @@ const useFlowStore = create((set, get) => ({
deviceId: id,
color: deviceType.color,
templateData,
paramValues,
},
};
set({ nodes: [...get().nodes, newNode] });
......@@ -129,7 +141,8 @@ const useFlowStore = create((set, get) => ({
color: template.color,
icon: template.icon,
portType: defaultPortType,
templateData: template,
templateData: { ...template, params: template.params || [] },
paramValues: (template.params || []).reduce((acc, p) => ({ ...acc, [p.key]: p.defaultValue }), {}),
ports: template.ports.map(p => p.id),
},
};
......@@ -137,6 +150,20 @@ const useFlowStore = create((set, get) => ({
return newNode;
},
/** 更新节点的模型参数值 */
updateNodeParam: (nodeId, paramKey, value) => {
const nodes = get().nodes.map(n => {
if (n.id !== nodeId) return n;
const paramValues = { ...(n.data.paramValues || {}), [paramKey]: value };
return { ...n, data: { ...n.data, paramValues } };
});
// 同步更新 selectedNode
const selectedNode = get().selectedNode;
const updatedSelected = selectedNode?.id === nodeId
? nodes.find(n => n.id === nodeId) : selectedNode;
set({ nodes, selectedNode: updatedSelected });
},
removeSelectedNode: () => {
const { selectedNode, nodes, edges } = get();
if (!selectedNode) return;
......
This diff is collapsed.
......@@ -133,9 +133,9 @@ export async function parseConnectionSheet(file) {
edges.push({
id: `edge-${edgeIndex++}`,
source: sourceId,
sourceHandle: source.port ? `s-port-${source.port}` : 's-port-default',
sourceHandle: source.port ? `port-${source.port}` : 'port-default',
target: targetId,
targetHandle: target.port ? `t-port-${target.port}` : 't-port-default',
targetHandle: target.port ? `port-${target.port}` : 'port-default',
});
}
......
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