Commit 3881f74d authored by fenghen777's avatar fenghen777

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

- 统一术语: 元器件 -> 符号
- 侧边栏: 内置符号/自定义符号两层树结构, 支持新建/删除分类
- 模型参数: 符号支持定义参数(key/label/defaultValue), 节点显示参数值
- 属性面板: 可编辑参数值, 支持折叠
- 编辑器: 画布内参数标签可拖拽定位, ESC退回选择, Delete删除形状
- 预览: NodePreview与CustomDeviceNode渲染保持一致
- 去重复边框: 内置符号shapes改为空数组
parent 3264c651
/** /**
* App - 主入口组件 * App - 主入口组件
* 支持两个视图:流程编辑器 / 元器件编辑器 * 支持两个视图:流程编辑器 / 符号编辑器
*/ */
import { ReactFlowProvider } from '@xyflow/react'; import { ReactFlowProvider } from '@xyflow/react';
import Toolbar from './components/Toolbar/Toolbar'; import Toolbar from './components/Toolbar/Toolbar';
......
...@@ -121,7 +121,7 @@ export default function FlowCanvas() { ...@@ -121,7 +121,7 @@ export default function FlowCanvas() {
return; return;
} }
// 内置元器件拖入 // 内置符号拖入
const type = event.dataTransfer.getData('application/eplan-device-type'); const type = event.dataTransfer.getData('application/eplan-device-type');
if (!type) return; if (!type) return;
const functionCode = Number(type) || 100; const functionCode = Number(type) || 100;
......
/** /**
* ComponentEditor - 自定义元器件编辑器主组件 * ComponentEditor - 自定义符号编辑器主组件
* Blender 着色器编辑器风格:左侧工具栏 + 中间 SVG 画布 + 右侧属性面板 * Blender 着色器编辑器风格:左侧工具栏 + 中间 SVG 画布 + 右侧属性面板
* *
* 功能: * 功能:
...@@ -14,7 +14,7 @@ import PortEditor from './PortEditor'; ...@@ -14,7 +14,7 @@ import PortEditor from './PortEditor';
import NodePreview from './NodePreview'; import NodePreview from './NodePreview';
import styles from './ComponentEditor.module.css'; import styles from './ComponentEditor.module.css';
const CATEGORIES = ['电气', '液压', '气动', '自定义'];
const COLOR_PRESETS = [ const COLOR_PRESETS = [
'#6366f1', '#F44336', '#E91E63', '#9C27B0', '#6366f1', '#F44336', '#E91E63', '#9C27B0',
...@@ -58,6 +58,7 @@ export default function ComponentEditor() { ...@@ -58,6 +58,7 @@ export default function ComponentEditor() {
updateShape, updateShape,
switchView, switchView,
undo, undo,
customCategories,
} = useComponentLibrary(); } = useComponentLibrary();
const [tool, setTool] = useState(TOOLS.SELECT); const [tool, setTool] = useState(TOOLS.SELECT);
...@@ -90,7 +91,7 @@ export default function ComponentEditor() { ...@@ -90,7 +91,7 @@ export default function ComponentEditor() {
} }
}, []); }, []);
// 空格键旋转选中形状 // 快捷键:空格旋转 / ESC退回选择 / Ctrl+Z撤销
useEffect(() => { useEffect(() => {
function handleKeyDown(e) { function handleKeyDown(e) {
// Ctrl+Z 撤销 // Ctrl+Z 撤销
...@@ -99,6 +100,12 @@ export default function ComponentEditor() { ...@@ -99,6 +100,12 @@ export default function ComponentEditor() {
undo(); undo();
return; return;
} }
// ESC 退回选择工具
if (e.key === 'Escape') {
setTool(TOOLS.SELECT);
setSelectedShapeIndex(-1);
return;
}
if (e.code === 'Space' && selectedShapeIndex >= 0 && editingTemplate) { if (e.code === 'Space' && selectedShapeIndex >= 0 && editingTemplate) {
e.preventDefault(); e.preventDefault();
const shape = editingTemplate.shapes[selectedShapeIndex]; const shape = editingTemplate.shapes[selectedShapeIndex];
...@@ -213,52 +220,77 @@ export default function ComponentEditor() { ...@@ -213,52 +220,77 @@ export default function ComponentEditor() {
</button> </button>
</div> </div>
<div className={styles.toolSection}> {/* 常用模板 */}
<div className={styles.toolSectionTitle}>操作</div>
<button
className={styles.toolBtn}
onClick={handleDeleteShape}
disabled={selectedShapeIndex < 0}
title="删除选中形状"
>
<span className={styles.toolIcon}>&#10005;</span>
<span className={styles.toolLabel}>删除</span>
</button>
</div>
{/* 已有自定义库列表 */}
<div className={styles.toolSection} style={{ flex: 1, overflowY: 'auto' }}> <div className={styles.toolSection} style={{ flex: 1, overflowY: 'auto' }}>
<div className={styles.toolSectionTitle}>自定义库</div> <div className={styles.toolSectionTitle}>模板</div>
{templates.length === 0 && ( {[
<div style={{ color: '#555', fontSize: 9, textAlign: 'center', padding: '8px 0' }}> {
暂无模板 name: '双端口方块',
</div> icon: '▢',
)} apply: () => {
{templates.map(t => ( startNew();
setSelectedShapeIndex(-1);
setTimeout(() => {
const s = useComponentLibrary.getState();
s.updateEditing({ name: '双端口方块', icon: '■', width: 120, height: 80 });
s.addShape({ type: 'rect', props: { x: 10, y: 10, width: 100, height: 60, rx: 4 }, style: { fill: 'rgba(255,255,255,0.05)', stroke: '#6366f1', strokeWidth: 1.5 } });
}, 0);
},
},
{
name: '圆形传感器',
icon: '○',
apply: () => {
startNew();
setSelectedShapeIndex(-1);
setTimeout(() => {
const s = useComponentLibrary.getState();
s.updateEditing({ name: '圆形传感器', icon: '◉', width: 120, height: 120 });
s.addShape({ type: 'circle', props: { cx: 60, cy: 60, r: 48 }, style: { fill: 'rgba(255,255,255,0.05)', stroke: '#00BCD4', strokeWidth: 1.5 } });
s.addPort({ id: `in-${Date.now()}`, portId: Date.now(), name: '信号入', description: '', type: 'analog', side: 'left', position: 0.5 });
s.addPort({ id: `out-${Date.now()+1}`, portId: Date.now()+1, name: '信号出', description: '', type: 'analog', side: 'right', position: 0.5 });
s.addPort({ id: `pwr-${Date.now()+2}`, portId: Date.now()+2, name: 'VCC', description: '', type: 'power', side: 'top', position: 0.5 });
}, 0);
},
},
{
name: '多端口模块',
icon: '▣',
apply: () => {
startNew();
setSelectedShapeIndex(-1);
setTimeout(() => {
const s = useComponentLibrary.getState();
s.updateEditing({ name: '多端口模块', icon: '▣', width: 160, height: 120 });
s.addShape({ type: 'rect', props: { x: 8, y: 8, width: 144, height: 104, rx: 6 }, style: { fill: 'rgba(255,255,255,0.05)', stroke: '#4CAF50', strokeWidth: 1.5 } });
s.addShape({ type: 'rect', props: { x: 24, y: 40, width: 112, height: 40, rx: 3 }, style: { fill: 'rgba(76,175,80,0.1)', stroke: '#4CAF50', strokeWidth: 0.5 } });
s.addPort({ id: `i1-${Date.now()}`, portId: Date.now(), name: 'IN1', description: '', type: 'digital', side: 'left', position: 0.3 });
s.addPort({ id: `i2-${Date.now()+1}`, portId: Date.now()+1, name: 'IN2', description: '', type: 'digital', side: 'left', position: 0.7 });
s.addPort({ id: `o1-${Date.now()+2}`, portId: Date.now()+2, name: 'OUT1', description: '', type: 'digital', side: 'right', position: 0.3 });
s.addPort({ id: `o2-${Date.now()+3}`, portId: Date.now()+3, name: 'OUT2', description: '', type: 'digital', side: 'right', position: 0.7 });
}, 0);
},
},
].map(tpl => (
<button <button
key={t.id} key={tpl.name}
className={`${styles.toolBtn} ${editingTemplate.id === t.id ? styles.toolActive : ''}`} className={styles.toolBtn}
onClick={() => handleLoadTemplate(t.id)} onClick={tpl.apply}
title={`编辑: ${t.name}`} title={tpl.name}
style={{ fontSize: 9, padding: '4px 2px' }} style={{ fontSize: 9, padding: '4px 2px' }}
> >
<span className={styles.toolIcon} style={{ fontSize: 12 }}> <span className={styles.toolIcon} style={{ fontSize: 12 }}>{tpl.icon}</span>
<span style={{ <span className={styles.toolLabel}>{tpl.name}</span>
display: 'inline-block', width: 8, height: 8,
borderRadius: 2, background: t.color, marginRight: 2,
}}/>
</span>
<span className={styles.toolLabel}>{t.name}</span>
</button> </button>
))} ))}
<button <button
className={styles.toolBtn} className={styles.toolBtn}
onClick={() => { startNew(); setSelectedShapeIndex(-1); }} onClick={() => { startNew(); setSelectedShapeIndex(-1); }}
title="新建模板" title="新建空白"
style={{ marginTop: 2 }} style={{ marginTop: 4 }}
> >
<span className={styles.toolIcon}>+</span> <span className={styles.toolIcon}>+</span>
<span className={styles.toolLabel}>新建</span> <span className={styles.toolLabel}>空白</span>
</button> </button>
</div> </div>
</div> </div>
...@@ -304,7 +336,7 @@ export default function ComponentEditor() { ...@@ -304,7 +336,7 @@ export default function ComponentEditor() {
value={editingTemplate.category} value={editingTemplate.category}
onChange={(e) => updateEditing({ category: e.target.value })} onChange={(e) => updateEditing({ category: e.target.value })}
> >
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)} {customCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
<label className={styles.propLabel}>图标</label> <label className={styles.propLabel}>图标</label>
...@@ -351,6 +383,80 @@ export default function ComponentEditor() { ...@@ -351,6 +383,80 @@ export default function ComponentEditor() {
</div> </div>
<PortEditor /> <PortEditor />
{/* 参数定义 */}
<div className={styles.propSection}>
<div className={styles.sectionLabel} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>参数定义</span>
<button
style={{ background: 'none', border: '1px solid #333', borderRadius: 3, color: '#6366f1', fontSize: 10, cursor: 'pointer', padding: '1px 6px' }}
onClick={() => {
const params = [...(editingTemplate.params || [])];
params.push({ key: `p${params.length + 1}`, label: '参数', unit: '', defaultValue: '' });
updateEditing({ params });
}}
>+ 添加</button>
</div>
{(editingTemplate.params || []).length === 0 && (
<div style={{ color: '#555', fontSize: 10, textAlign: 'center', padding: 6 }}>无参数</div>
)}
{(editingTemplate.params || []).map((p, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr auto', gap: 4, alignItems: 'end' }}>
<div>
<label className={styles.propLabel} style={{ fontSize: 9 }}>英文key</label>
<input
className={styles.propInput}
value={p.key}
placeholder="key"
style={{ fontSize: 10 }}
onChange={(e) => {
const params = [...editingTemplate.params];
params[i] = { ...params[i], key: e.target.value };
updateEditing({ params });
}}
/>
</div>
<div>
<label className={styles.propLabel} style={{ fontSize: 9 }}>名称</label>
<input
className={styles.propInput}
value={p.label}
style={{ fontSize: 10 }}
onChange={(e) => {
const params = [...editingTemplate.params];
params[i] = { ...params[i], label: e.target.value };
updateEditing({ params });
}}
/>
</div>
<div>
<label className={styles.propLabel} style={{ fontSize: 9 }}>默认值</label>
<input
className={styles.propInput}
value={p.defaultValue}
style={{ fontSize: 10 }}
onChange={(e) => {
const params = [...editingTemplate.params];
params[i] = { ...params[i], defaultValue: e.target.value };
updateEditing({ params });
}}
/>
</div>
<button
style={{ background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 12, padding: '2px 4px' }}
onClick={() => {
const params = editingTemplate.params.filter((_, j) => j !== i);
updateEditing({ params });
}}
title="删除参数"
>&#10005;</button>
</div>
</div>
))}
</div>
<NodePreview /> <NodePreview />
<div className={styles.actionBtns}> <div className={styles.actionBtns}>
......
/** /**
* NodePreview - 元器件节点实时预览 * NodePreview - 符号节点实时预览
* 和编辑器画布完全一致的端点布局: * 和编辑器画布完全一致的端点布局:
* 彩色 header + 端点按 side/position 分布在四边 * 彩色 header + 端点按 side/position 分布在四边
*/ */
...@@ -63,6 +63,35 @@ export default function NodePreview() { ...@@ -63,6 +63,35 @@ export default function NodePreview() {
{icon} {name} {icon} {name}
</text> </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) */} {/* 端点 - 位置在 body 区域(y 偏移 headerH) */}
{ports.map(port => { {ports.map(port => {
const raw = getPortPos(port, totalW, tH); const raw = getPortPos(port, totalW, tH);
...@@ -76,19 +105,6 @@ export default function NodePreview() { ...@@ -76,19 +105,6 @@ export default function NodePreview() {
fill={portColor} stroke="#1e1e2e" strokeWidth={1.5} 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 <text
x={pos.x + (port.side === 'left' ? 10 : port.side === 'right' ? -10 : 0)} x={pos.x + (port.side === 'left' ? 10 : port.side === 'right' ? -10 : 0)}
......
/** /**
* PortEditor - 端点编辑面板 * PortEditor - 端点编辑面板
* 显示当前元器件的所有端点,可编辑 ID、名称、描述、类型、位置 * 显示当前符号的所有端点,可编辑 ID、名称、描述、类型、位置
*/ */
import { PORT_TYPES } from '../../utils/constants'; import { PORT_TYPES } from '../../utils/constants';
import useComponentLibrary from '../../hooks/useComponentLibrary'; import useComponentLibrary from '../../hooks/useComponentLibrary';
...@@ -47,22 +47,25 @@ export default function PortEditor() { ...@@ -47,22 +47,25 @@ export default function PortEditor() {
style={{ background: typeInfo.color }} 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 <input
className={styles.portNameInput} className={styles.portNameInput}
value={port.name} value={port.name}
onChange={(e) => updatePort(port.id, { name: e.target.value })} 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="名称(唯一)"
/> />
{/* 描述 */} {/* 描述 */}
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
* 浏览器原生 SVG 命中检测,不再手动计算距离。 * 浏览器原生 SVG 命中检测,不再手动计算距离。
* mouseDown 后挂 window.mousemove / mouseup 实现拖拽。 * mouseDown 后挂 window.mousemove / mouseup 实现拖拽。
*/ */
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { PORT_TYPES } from '../../utils/constants'; import { PORT_TYPES } from '../../utils/constants';
import useComponentLibrary from '../../hooks/useComponentLibrary'; import useComponentLibrary from '../../hooks/useComponentLibrary';
import styles from './ComponentEditor.module.css'; import styles from './ComponentEditor.module.css';
...@@ -39,6 +39,21 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -39,6 +39,21 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
}); });
}; };
// Delete 键删除选中形状
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedShapeIndex >= 0) {
// 避免在 input 中误触
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
useComponentLibrary.getState().removeShape(selectedShapeIndex);
onSelectShape(-1);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedShapeIndex, onSelectShape]);
const [drawPreview, setDrawPreview] = useState(null); const [drawPreview, setDrawPreview] = useState(null);
const [selectionBox, setSelectionBox] = useState(null); // { start, current } for 框选 const [selectionBox, setSelectionBox] = useState(null); // { start, current } for 框选
const [hoveredPortId, setHoveredPortId] = useState(null); const [hoveredPortId, setHoveredPortId] = useState(null);
...@@ -117,7 +132,7 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -117,7 +132,7 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
} }
// ===== 形状拖拽 ===== // ===== 形状拖拽(移动) =====
function startShapeDrag(shapeIndex, startPt, e) { function startShapeDrag(shapeIndex, startPt, e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
...@@ -147,6 +162,45 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -147,6 +162,45 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
} }
// ===== 形状调整大小 =====
function startResizeDrag(shapeIndex, handleDir, e) {
e.preventDefault();
e.stopPropagation();
const startPt = toSvg(e.clientX, e.clientY);
const store = useComponentLibrary.getState();
const origShape = store.editingTemplate.shapes[shapeIndex];
const origProps = { ...origShape.props };
const shapeType = origShape.type;
const MIN_SIZE = 8;
function onMove(ev) {
const pt = toSvg(ev.clientX, ev.clientY);
const dx = pt.x - startPt.x;
const dy = pt.y - startPt.y;
const s = useComponentLibrary.getState();
const shape = s.editingTemplate?.shapes[shapeIndex];
if (!shape) return;
let newProps;
if (shapeType === 'rect') {
newProps = resizeRect(origProps, handleDir, dx, dy, MIN_SIZE);
} else if (shapeType === 'circle') {
newProps = resizeCircle(origProps, handleDir, dx, dy, MIN_SIZE);
} else if (shapeType === 'ellipse') {
newProps = resizeEllipse(origProps, handleDir, dx, dy, MIN_SIZE);
}
if (newProps) s.updateShape(shapeIndex, { props: { ...shape.props, ...newProps } });
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
// ===== 绘制新形状 ===== // ===== 绘制新形状 =====
function startDraw(startPt, e) { function startDraw(startPt, e) {
e.preventDefault(); e.preventDefault();
...@@ -271,6 +325,45 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -271,6 +325,45 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
return null; return null;
} }
// ===== 选中图形时渲染 8 个 resize handle =====
function renderSelectionOverlay(shape, shapeIndex) {
const bounds = getShapeBounds(shape);
if (!bounds) return null;
const { x, y, w, h } = bounds;
const PAD = 2;
const bx = x - PAD, by = y - PAD, bw = w + PAD * 2, bh = h + PAD * 2;
// 8 个控制点的方向和位置
const handles = [
{ dir: 'nw', cx: bx, cy: by, cursor: 'nwse-resize' },
{ dir: 'n', cx: bx + bw/2, cy: by, cursor: 'ns-resize' },
{ dir: 'ne', cx: bx + bw, cy: by, cursor: 'nesw-resize' },
{ dir: 'w', cx: bx, cy: by + bh/2, cursor: 'ew-resize' },
{ dir: 'e', cx: bx + bw, cy: by + bh/2, cursor: 'ew-resize' },
{ dir: 'sw', cx: bx, cy: by + bh, cursor: 'nesw-resize' },
{ dir: 's', cx: bx + bw/2, cy: by + bh, cursor: 'ns-resize' },
{ dir: 'se', cx: bx + bw, cy: by + bh, cursor: 'nwse-resize' },
];
// handle 大小根据 viewBox 缩放自适应
const handleR = Math.max(2, viewBox.w * 0.008);
return (
<g>
<rect x={bx} y={by} width={bw} height={bh}
fill="none" stroke="#6366f1" strokeWidth={1} strokeDasharray="3 2" />
{isSelect && handles.map(h => (
<circle key={h.dir}
cx={h.cx} cy={h.cy} r={handleR}
fill="#6366f1" stroke="#fff" strokeWidth={0.5}
style={{ cursor: h.cursor }}
onMouseDown={(e) => startResizeDrag(shapeIndex, h.dir, e)}
/>
))}
</g>
);
}
const isSelect = tool === TOOLS.SELECT; const isSelect = tool === TOOLS.SELECT;
return ( return (
...@@ -301,7 +394,7 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -301,7 +394,7 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
{shapes.map((shape, i) => ( {shapes.map((shape, i) => (
<g key={i} opacity={selectedShapeIndex === i ? 1 : 0.8}> <g key={i} opacity={selectedShapeIndex === i ? 1 : 0.8}>
{renderShape(shape)} {renderShape(shape)}
{selectedShapeIndex === i && renderSelectionBox(shape)} {selectedShapeIndex === i && renderSelectionOverlay(shape, i)}
</g> </g>
))} ))}
...@@ -315,7 +408,61 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -315,7 +408,61 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
return <rect x={sx} y={sy} width={sw} height={sh} fill="rgba(99,102,241,0.1)" stroke="#6366f1" strokeWidth={1} strokeDasharray="4 2" />; return <rect x={sx} y={sy} width={sw} height={sh} fill="rgba(99,102,241,0.1)" stroke="#6366f1" strokeWidth={1} strokeDasharray="4 2" />;
})()} })()}
{/* ========== 端点(每个 circle 直接绑定事件) ========== */} {/* ========== 参数标签(可拖拽) ========== */}
{(editingTemplate.params || []).map((p, i) => {
const px = p.offsetX ?? 6;
const py = p.offsetY ?? (4 + i * 14);
return (
<g key={`param-${p.key}-${i}`}>
<text
x={px}
y={py + 10}
fontSize={10}
fontFamily="'Segoe UI', sans-serif"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
<tspan fill="#777">{p.label}: </tspan>
<tspan fill="#ddd">{p.defaultValue}</tspan>
</text>
{/* 透明拖拽区域 */}
{isSelect && (
<rect
x={px - 2}
y={py - 2}
width={80}
height={14}
fill="transparent"
stroke="none"
style={{ cursor: 'move' }}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
const startSvg = toSvg(e.clientX, e.clientY);
const startOx = p.offsetX ?? 6;
const startOy = p.offsetY ?? (4 + i * 14);
function onMove(ev) {
const cur = toSvg(ev.clientX, ev.clientY);
const dx = cur.x - startSvg.x;
const dy = cur.y - startSvg.y;
const store = useComponentLibrary.getState();
const tpl = store.editingTemplate;
if (!tpl) return;
const params = [...tpl.params];
params[i] = { ...params[i], offsetX: snap(startOx + dx), offsetY: snap(startOy + dy) };
store.updateEditing({ params });
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}}
/>
)}
</g>
);
})}
{ports.map(port => { {ports.map(port => {
// 拖拽中:用自由坐标;否则:用边缘位置 // 拖拽中:用自由坐标;否则:用边缘位置
const isDrag = draggingPortId === port.id; const isDrag = draggingPortId === port.id;
...@@ -358,22 +505,6 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape }) ...@@ -358,22 +505,6 @@ export default function ShapeCanvas({ tool, selectedShapeIndex, onSelectShape })
/> />
)} )}
{/* portId 数字 - 在端点外侧 */}
{port.portId != null && (
<text
x={pos.x + (port.side === 'left' ? -18 : port.side === 'right' ? 18 : 0)}
y={pos.y + (port.side === 'top' ? -16 : port.side === 'bottom' ? 22 : 4)}
textAnchor="middle"
fill="#aaa"
fontSize={10}
fontWeight={600}
fontFamily="'Segoe UI', sans-serif"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{port.portId}
</text>
)}
{/* 端点名称 - 在端点内侧 */} {/* 端点名称 - 在端点内侧 */}
<text <text
x={pos.x + (port.side === 'left' ? 14 : port.side === 'right' ? -14 : 0)} x={pos.x + (port.side === 'left' ? 14 : port.side === 'right' ? -14 : 0)}
...@@ -424,15 +555,76 @@ function renderShape(shape) { ...@@ -424,15 +555,76 @@ function renderShape(shape) {
} }
} }
function renderSelectionBox(shape) { // renderSelectionBox 已被 renderSelectionOverlay 替代
let x, y, w, h;
if (shape.type === 'rect') { x = shape.props.x-2; y = shape.props.y-2; w = shape.props.width+4; h = shape.props.height+4; } /** 获取形状的 bounding box */
else if (shape.type === 'circle') { x = shape.props.cx-shape.props.r-2; y = shape.props.cy-shape.props.r-2; w = h = (shape.props.r+2)*2; } function getShapeBounds(shape) {
else if (shape.type === 'ellipse') { x = shape.props.cx-shape.props.rx-2; y = shape.props.cy-shape.props.ry-2; w = (shape.props.rx+2)*2; h = (shape.props.ry+2)*2; } if (shape.type === 'rect') {
else return null; return { x: shape.props.x, y: shape.props.y, w: shape.props.width, h: shape.props.height };
return <rect x={x} y={y} width={w} height={h} fill="none" stroke="#6366f1" strokeWidth={1} strokeDasharray="3 2" />; }
if (shape.type === 'circle') {
const r = shape.props.r;
return { x: shape.props.cx - r, y: shape.props.cy - r, w: r * 2, h: r * 2 };
}
if (shape.type === 'ellipse') {
return {
x: shape.props.cx - shape.props.rx,
y: shape.props.cy - shape.props.ry,
w: shape.props.rx * 2,
h: shape.props.ry * 2,
};
}
return null;
}
/**
* 矩形 resize:根据拖拽的方向调整 x/y/width/height
*/
function resizeRect(orig, dir, dx, dy, min) {
let { x, y, width, height } = orig;
// 北:上边下移
if (dir.includes('n')) { y = snap(orig.y + dy); height = snap(orig.height - dy); }
// 南:下边下移
if (dir.includes('s')) { height = snap(orig.height + dy); }
// 西:左边右移
if (dir.includes('w')) { x = snap(orig.x + dx); width = snap(orig.width - dx); }
// 东:右边右移
if (dir.includes('e')) { width = snap(orig.width + dx); }
// 保证最小尺寸
if (width < min) { width = min; if (dir.includes('w')) x = orig.x + orig.width - min; }
if (height < min) { height = min; if (dir.includes('n')) y = orig.y + orig.height - min; }
return { x, y, width, height, rx: orig.rx };
}
/**
* 圆形 resize:只调整半径(取 dx/dy 中更大的变化量)
*/
function resizeCircle(orig, dir, dx, dy, min) {
let dr = 0;
if (dir === 'e' || dir === 'ne' || dir === 'se') dr = dx;
else if (dir === 'w' || dir === 'nw' || dir === 'sw') dr = -dx;
else if (dir === 's') dr = dy;
else if (dir === 'n') dr = -dy;
// 对角线取平均
if (dir === 'se' || dir === 'ne') dr = Math.max(dx, (dir === 'se' ? dy : -dy));
if (dir === 'sw' || dir === 'nw') dr = Math.max(-dx, (dir === 'sw' ? dy : -dy));
const r = Math.max(min / 2, snap(orig.r + dr));
return { cx: orig.cx, cy: orig.cy, r };
} }
/**
* 椭圆 resize:独立调整 rx 和 ry
*/
function resizeEllipse(orig, dir, dx, dy, min) {
let { cx, cy, rx, ry } = orig;
if (dir.includes('e')) rx = Math.max(min / 2, snap(orig.rx + dx));
if (dir.includes('w')) rx = Math.max(min / 2, snap(orig.rx - dx));
if (dir.includes('s')) ry = Math.max(min / 2, snap(orig.ry + dy));
if (dir.includes('n')) ry = Math.max(min / 2, snap(orig.ry - dy));
return { cx, cy, rx, ry };
}
function hitTestShape(shape, pt) { function hitTestShape(shape, pt) {
if (shape.type === 'rect') { const { x, y, width, height } = shape.props; return pt.x >= x && pt.x <= x+width && pt.y >= y && pt.y <= y+height; } if (shape.type === 'rect') { const { x, y, width, height } = shape.props; return pt.x >= x && pt.x <= x+width && pt.y >= y && pt.y <= y+height; }
if (shape.type === 'circle') { const dx = pt.x-shape.props.cx, dy = pt.y-shape.props.cy; return dx*dx+dy*dy <= shape.props.r*shape.props.r; } if (shape.type === 'circle') { const dx = pt.x-shape.props.cx, dy = pt.y-shape.props.cy; return dx*dx+dy*dy <= shape.props.r*shape.props.r; }
......
/** /**
* CustomConnectionLine - 拖拽过程中的贝塞尔曲线连线 * CustomConnectionLine - 拖拽过程中的贝塞尔曲线连线
* 使用起始节点颜色,风格与 GradientBezierEdge 一致 * 使用起始节点颜色,方向感知控制点与 GradientBezierEdge 一致
*/ */
import { useReactFlow } from '@xyflow/react';
export default function CustomConnectionLine({ export default function CustomConnectionLine({
fromX, fromX,
fromY, fromY,
fromPosition,
toX, toX,
toY, toY,
fromNode, fromNode,
}) { }) {
const color = fromNode?.data?.color || '#6366f1'; const color = fromNode?.data?.color || '#6366f1';
// 与 GradientBezierEdge 相同的控制点计算 const dx = Math.abs(toX - fromX);
const dy = Math.abs(toY - fromY); 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 ( return (
<g> <g>
......
/** /**
* GradientBezierEdge - Blender 着色器风格的贝塞尔曲线连线 * GradientBezierEdge - 方向感知的贝塞尔曲线连线
* 支持双端点颜色渐变,hover 发光效果 * 支持双端点颜色渐变,hover 发光效果
* *
* Bug修复: * 控制点算法:
* - 使用方向感知的控制点计算,修复拖动时线消失的问题 * 根据 sourcePosition/targetPosition (left/right/top/bottom)
* - SVG渐变使用 gradientUnits="userSpaceOnUse" 避免 bounding box 为零时渐变失效 * 动态决定控制点的偏移方向,使曲线始终自然流出端口。
*
* 例如 source 在右侧 → 控制点向右偏移
* target 在左侧 → 控制点向左偏移
*/ */
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { Position } from '@xyflow/react';
import styles from './GradientBezierEdge.module.css'; 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) { function buildSmartBezierPath(
const dy = targetY - sourceY; sourceX, sourceY, sourcePosition,
const dx = targetX - sourceX; targetX, targetY, targetPosition,
const absDy = Math.abs(dy); ) {
const absDx = Math.abs(dx); const dx = Math.abs(targetX - sourceX);
const dy = Math.abs(targetY - sourceY);
// 控制点偏移量:至少 60px,距离越远弧度越大,但有上限 const dist = Math.sqrt(dx * dx + dy * dy);
const offset = Math.max(60, Math.min(absDy * 0.5, absDx * 0.3, 200));
// 控制点偏移量:距离越远弧度越大,但有上下限
// source 的 handle 在底部(向下出发),target 的 handle 在顶部(从上方进入) const offset = Math.max(50, Math.min(dist * 0.4, 250));
// 所以控制点总是让 source 向下偏移,target 向上偏移
const cp1x = sourceX; const src = getControlOffset(sourcePosition, offset);
const cp1y = sourceY + offset; const tgt = getControlOffset(targetPosition, offset);
const cp2x = targetX;
const cp2y = targetY - 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}`; const path = `M ${sourceX} ${sourceY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${targetX} ${targetY}`;
...@@ -43,6 +63,8 @@ function GradientBezierEdge({ ...@@ -43,6 +63,8 @@ function GradientBezierEdge({
sourceY, sourceY,
targetX, targetX,
targetY, targetY,
sourcePosition,
targetPosition,
data, data,
selected, selected,
}) { }) {
...@@ -52,8 +74,11 @@ function GradientBezierEdge({ ...@@ -52,8 +74,11 @@ function GradientBezierEdge({
const gradientId = `gradient-${id}`; const gradientId = `gradient-${id}`;
const { path } = useMemo( const { path } = useMemo(
() => buildBezierPath(sourceX, sourceY, targetX, targetY), () => buildSmartBezierPath(
[sourceX, sourceY, targetX, targetY] sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
),
[sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition]
); );
const isSameColor = sourceColor === targetColor; const isSameColor = sourceColor === targetColor;
...@@ -61,7 +86,7 @@ function GradientBezierEdge({ ...@@ -61,7 +86,7 @@ function GradientBezierEdge({
return ( return (
<> <>
{/* SVG 渐变定义 - 使用 userSpaceOnUse 避免 bounding box 零尺寸问题 */} {/* SVG 渐变定义 */}
{!isSameColor && ( {!isSameColor && (
<defs> <defs>
<linearGradient <linearGradient
......
/** /**
* CustomDeviceNode - 统一元器件节点(内置和自定义共用) * CustomDeviceNode - 统一符号节点(内置和自定义共用)
* *
* 核心设计思路: * Handle 定位策略(核心!):
* - Handle 本身就是可见端点圆(连线端 = 视觉端 = 同一 DOM 元素) * React Flow 的 CSS 类(如 .react-flow__handle-left)会自动设置
* - 每个端口用一个绝对定位的容器 div 包裹 Handle + 标签 * Handle 贴在节点边缘的那个轴(left/right/top/bottom)以及对应的
* - 容器 div 的 left/top 控制位置,Handle 不设任何 style.left/top/right * transform 居中偏移。我们只需要覆盖"沿边缘移动"的那一个轴。
* 只设 position prop 和外观样式 *
* - 用 outline 代替 border(不影响盒模型) * 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 { memo } from 'react';
import { Handle, Position } from '@xyflow/react'; import { Handle, Position } from '@xyflow/react';
import { PORT_TYPES } from '../../utils/constants'; import { PORT_TYPES } from '../../utils/constants';
const HEADER_H = 28; const HEADER_H = 28;
const HANDLE_SIZE = 12;
function sideToPosition(side) { function sideToPosition(side) {
switch (side) { switch (side) {
...@@ -65,88 +69,115 @@ function CustomDeviceNode({ data, selected }) { ...@@ -65,88 +69,115 @@ function CustomDeviceNode({ data, selected }) {
}}>{name}</span> }}>{name}</span>
</div> </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 => { {ports.map(port => {
const portColor = PORT_TYPES[port.type]?.color || '#9E9E9E'; const portColor = PORT_TYPES[port.type]?.color || '#9E9E9E';
const pos = sideToPosition(port.side); const pos = sideToPosition(port.side);
// 容器 div 的位置 = 端口在节点上的像素坐标 // 只设置"沿边缘滑动"的那个轴,不干扰 React Flow CSS 管的轴
const cx = port.side === 'left' ? 0 let handlePos = {};
: port.side === 'right' ? tW switch (port.side) {
: tW * port.position; case 'left':
const cy = (port.side === 'top' || port.side === 'bottom') case 'right':
? (port.side === 'top' ? HEADER_H : totalH) // CSS 管水平贴边(left:0 / right:0) + transform 居中
: HEADER_H + tH * port.position; // 我们只设 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 ( return (
<div <div key={port.id}>
key={port.id} {/* target Handle - 透明,接收连接 */}
style={{
position: 'absolute',
left: cx,
top: cy,
width: 0,
height: 0,
overflow: 'visible',
}}
>
{/* target Handle - 可见端点圆 */}
<Handle <Handle
type="target" type="target"
position={pos} position={pos}
id={`t-${port.id}`} id={port.id}
style={{ style={{
position: 'relative', ...handleStyle,
left: 0, background: 'transparent',
top: 0, border: 'none',
width: 12,
height: 12,
background: portColor,
border: '2px solid #1e1e2e',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
}} }}
/> />
{/* source Handle - 同位置透明叠加 */} {/* source Handle - 可见端点圆,发起连接 */}
<Handle <Handle
type="source" type="source"
position={pos} position={pos}
id={`s-${port.id}`} id={port.id}
style={{ style={{
position: 'relative', ...handleStyle,
left: 0, background: portColor,
top: -12, border: '2px solid #1e1e2e',
width: 12, cursor: 'crosshair',
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 && ( {port.name && (
<div style={{ <div style={labelStyle}>
position: 'absolute',
...getNamePos(port.side),
fontSize: 9, color: '#888',
pointerEvents: 'none', userSelect: 'none',
whiteSpace: 'nowrap',
}}>
{port.name} {port.name}
</div> </div>
)} )}
...@@ -157,23 +188,34 @@ function CustomDeviceNode({ data, selected }) { ...@@ -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%)' }; function getLabelStyle(side, position, tW, tH, totalH) {
case 'bottom': return { left: '50%', top: 8, transform: 'translateX(-50%)' }; const base = {
default: return {}; 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) { switch (side) {
case 'left': return { left: 12, top: '50%', transform: 'translateY(-50%)' }; case 'left':
case 'right': return { right: 12, top: '50%', transform: 'translateY(-50%)' }; return { ...base, left: 14, top: topPct, transform: 'translateY(-50%)' };
case 'top': return { left: '50%', top: 8, transform: 'translateX(-50%)' }; case 'right':
case 'bottom': return { left: '50%', bottom: 8, transform: 'translateX(-50%)' }; return { ...base, right: 14, top: topPct, transform: 'translateY(-50%)' };
default: return {}; 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 - 内置元器件节点 * DeviceNode - 内置符号节点
* 和 CustomDeviceNode / NodePreview 完全一致的 SVG 渲染: *
* 彩色 header + 端点按位置分布在上下边缘 * Handle 直接作为节点根 div 的子元素,
* 不额外添加 margin / transform,让 React Flow 内置 CSS 自行居中。
* 只通过 style.left 覆盖水平位置。
*/ */
import { memo } from 'react'; import { memo } from 'react';
import { Handle, Position } from '@xyflow/react'; import { Handle, Position } from '@xyflow/react';
import { PORT_TYPES } from '../../utils/constants'; import { PORT_TYPES } from '../../utils/constants';
const HANDLE_SIZE = 12;
function DeviceNode({ data, selected }) { function DeviceNode({ data, selected }) {
const { const {
label, label,
...@@ -14,7 +18,7 @@ function DeviceNode({ data, selected }) { ...@@ -14,7 +18,7 @@ function DeviceNode({ data, selected }) {
deviceTypeLabel, deviceTypeLabel,
color, color,
portType = 'generic', portType = 'generic',
ports = ['输入1', '输出1'], ports = ['1', '2'],
} = data; } = data;
const rotation = data.rotation || 0; const rotation = data.rotation || 0;
...@@ -64,41 +68,31 @@ function DeviceNode({ data, selected }) { ...@@ -64,41 +68,31 @@ function DeviceNode({ data, selected }) {
{icon} {label || deviceTypeLabel} {icon} {label || deviceTypeLabel}
</text> </text>
{/* 端点标注 - 上方 (target) */} {/* 端点文字标签 - 上方 (target) */}
{ports.map((port, i) => { {ports.map((port, i) => {
const x = (i + 1) / (ports.length + 1) * totalW; const x = (i + 1) / (ports.length + 1) * totalW;
return ( return (
<g key={`top-${port}`}> <text key={`top-label-${port}`} x={x} y={-10}
<circle cx={x} cy={0} r={5}
fill={handleColor} stroke="#1e1e2e" strokeWidth={2}
/>
<text x={x} y={-10}
textAnchor="middle" fill="#aaa" textAnchor="middle" fill="#aaa"
fontSize={9} fontWeight={600} fontSize={9} fontWeight={600}
fontFamily="'Segoe UI', sans-serif" fontFamily="'Segoe UI', sans-serif"
> >
{port} {port}
</text> </text>
</g>
); );
})} })}
{/* 端点标注 - 下方 (source) */} {/* 端点文字标签 - 下方 (source) */}
{ports.map((port, i) => { {ports.map((port, i) => {
const x = (i + 1) / (ports.length + 1) * totalW; const x = (i + 1) / (ports.length + 1) * totalW;
return ( return (
<g key={`bottom-${port}`}> <text key={`bottom-label-${port}`} x={x} y={totalH + 16}
<circle cx={x} cy={totalH} r={5}
fill={handleColor} stroke="#1e1e2e" strokeWidth={2}
/>
<text x={x} y={totalH + 16}
textAnchor="middle" fill="#aaa" textAnchor="middle" fill="#aaa"
fontSize={9} fontWeight={600} fontSize={9} fontWeight={600}
fontFamily="'Segoe UI', sans-serif" fontFamily="'Segoe UI', sans-serif"
> >
{port} {port}
</text> </text>
</g>
); );
})} })}
</svg> </svg>
...@@ -114,11 +108,16 @@ function DeviceNode({ data, selected }) { ...@@ -114,11 +108,16 @@ function DeviceNode({ data, selected }) {
id={port} id={port}
style={{ style={{
left: `${offset}%`, left: `${offset}%`,
background: 'transparent', width: HANDLE_SIZE,
border: 'none', height: HANDLE_SIZE,
width: 12, background: handleColor,
height: 12, border: '2px solid #1e1e2e',
borderRadius: '50%',
cursor: 'crosshair',
}} }}
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
/> />
); );
})} })}
...@@ -134,11 +133,16 @@ function DeviceNode({ data, selected }) { ...@@ -134,11 +133,16 @@ function DeviceNode({ data, selected }) {
id={port} id={port}
style={{ style={{
left: `${offset}%`, left: `${offset}%`,
background: 'transparent', width: HANDLE_SIZE,
border: 'none', height: HANDLE_SIZE,
width: 12, background: handleColor,
height: 12, border: '2px solid #1e1e2e',
borderRadius: '50%',
cursor: 'crosshair',
}} }}
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
/> />
); );
})} })}
......
...@@ -12,11 +12,13 @@ export default function PropertiesPanel() { ...@@ -12,11 +12,13 @@ export default function PropertiesPanel() {
selectedNode, selectedNode,
selectedEdge, selectedEdge,
updateNodeData, updateNodeData,
updateNodeParam,
removeSelectedNode, removeSelectedNode,
removeSelectedEdge, removeSelectedEdge,
} = useFlowStore(); } = useFlowStore();
const [panelWidth, setPanelWidth] = useState(300); const [panelWidth, setPanelWidth] = useState(300);
const [paramsCollapsed, setParamsCollapsed] = useState(false);
const isDragging = useRef(false); const isDragging = useRef(false);
const handleLabelChange = useCallback((e) => { const handleLabelChange = useCallback((e) => {
...@@ -93,13 +95,37 @@ export default function PropertiesPanel() { ...@@ -93,13 +95,37 @@ export default function PropertiesPanel() {
<label className={styles.label}>端口</label> <label className={styles.label}>端口</label>
<div className={styles.portList}> <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}> <span key={typeof p === 'string' ? p : p.id || p} className={styles.portTag}>
{typeof p === 'string' ? p : (p.portId != null ? p.portId : p.name)} {typeof p === 'string' ? p : (p.portId != null ? p.portId : p.name)}
</span> </span>
))} ))}
</div> </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 className={styles.deleteBtn} onClick={removeSelectedNode}>
删除此节点 删除此节点
</button> </button>
......
...@@ -3,17 +3,23 @@ ...@@ -3,17 +3,23 @@
* 功能: * 功能:
* 1. 文件导入区(拖拽或点击上传 xlsx) * 1. 文件导入区(拖拽或点击上传 xlsx)
* 2. 属性面板(选中节点/边时显示详细信息,可编辑) * 2. 属性面板(选中节点/边时显示详细信息,可编辑)
* 3. 元器件库(拖拽添加新元器件到画布) * 3. 符号库(拖拽添加新符号到画布)
*/ */
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore'; import useFlowStore from '../../hooks/useFlowStore';
import useComponentLibrary from '../../hooks/useComponentLibrary'; import useComponentLibrary from '../../hooks/useComponentLibrary';
import { FUNCTION_CODE_MAP } from '../../utils/constants'; import { FUNCTION_CODE_MAP, DEVICE_CATEGORIES } from '../../utils/constants';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
export default function Sidebar() { export default function Sidebar() {
const [connectionFile, setConnectionFile] = useState(null); const [connectionFile, setConnectionFile] = useState(null);
const [activeTab, setActiveTab] = useState('library'); // library | import const [activeTab, setActiveTab] = useState('library'); // library | import
// 内置符号展开,子分类默认折叠
const [collapsedCats, setCollapsedCats] = useState(() => {
const init = {};
DEVICE_CATEGORIES.forEach(cat => { init[`__b_${cat.category}`] = true; });
return init;
});
const connInputRef = useRef(null); const connInputRef = useRef(null);
...@@ -25,7 +31,7 @@ export default function Sidebar() { ...@@ -25,7 +31,7 @@ export default function Sidebar() {
edges, edges,
} = useFlowStore(); } = useFlowStore();
const { templates, startEditing, startNew, deleteTemplate, switchView } = useComponentLibrary(); const { templates, customCategories, startEditing, startNew, deleteTemplate, addCategory, deleteCategory, switchView } = useComponentLibrary();
// ==================== 文件导入 ==================== // ==================== 文件导入 ====================
const handleImport = useCallback(async () => { const handleImport = useCallback(async () => {
...@@ -41,30 +47,35 @@ export default function Sidebar() { ...@@ -41,30 +47,35 @@ export default function Sidebar() {
if (file) setConnectionFile(file); if (file) setConnectionFile(file);
}, []); }, []);
// ==================== 元器件拖拽 ==================== // ==================== 符号拖拽 ====================
const onDragStart = useCallback((event, functionCode) => { const onDragStart = useCallback((event, functionCode) => {
event.dataTransfer.setData('application/eplan-device-type', String(functionCode)); event.dataTransfer.setData('application/eplan-device-type', String(functionCode));
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
}, []); }, []);
/** 自定义元器件拖拽 */ /** 自定义符号拖拽 */
const onCustomDragStart = useCallback((event, templateId) => { const onCustomDragStart = useCallback((event, templateId) => {
event.dataTransfer.setData('application/custom-template-id', templateId); event.dataTransfer.setData('application/custom-template-id', templateId);
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
}, []); }, []);
/** 编辑自定义元器件 */ /** 编辑自定义符号 */
const handleEditTemplate = useCallback((templateId) => { const handleEditTemplate = useCallback((templateId) => {
startEditing(templateId); startEditing(templateId);
switchView('editor'); switchView('editor');
}, [startEditing, switchView]); }, [startEditing, switchView]);
/** 新建自定义元器件 */ /** 新建自定义符号 */
const handleNewTemplate = useCallback(() => { const handleNewTemplate = useCallback(() => {
startNew(); startNew();
switchView('editor'); switchView('editor');
}, [startNew, switchView]); }, [startNew, switchView]);
/** 切换分类折叠 */
const toggleCategory = (catName) => {
setCollapsedCats(prev => ({ ...prev, [catName]: !prev[catName] }));
};
return ( return (
<div className={styles.sidebar}> <div className={styles.sidebar}>
{/* Tab 切换 */} {/* Tab 切换 */}
...@@ -125,38 +136,123 @@ export default function Sidebar() { ...@@ -125,38 +136,123 @@ export default function Sidebar() {
</div> </div>
)} )}
{/* ==================== 元器件库 ==================== */} {/* ==================== 符号库 ==================== */}
{activeTab === 'library' && ( {activeTab === 'library' && (
<div className={styles.panel}> <div className={styles.panel}>
<div className={styles.sectionTitle}>内置元器件</div>
{/* ===== 顶级:内置符号 ===== */}
<div className={styles.categoryBlock}>
<div
className={`${styles.categoryHeader} ${styles.rootHeader}`}
onClick={() => toggleCategory('__builtin__')}
>
<span className={styles.categoryArrow}>
{collapsedCats['__builtin__'] ? '\u25B6' : '\u25BC'}
</span>
<span className={styles.categoryName}>内置符号</span>
</div>
{!collapsedCats['__builtin__'] && (
<div className={styles.subTree}>
{DEVICE_CATEGORIES.map(cat => {
const key = `__b_${cat.category}`;
const isCollapsed = collapsedCats[key];
return (
<div key={key} className={styles.categoryBlock}>
<div
className={styles.categoryHeader}
onClick={() => toggleCategory(key)}
>
<span className={styles.categoryArrow}>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
<span className={styles.categoryName}>{cat.category}</span>
<span className={styles.categoryCount}>{cat.items.length}</span>
</div>
{!isCollapsed && (
<div className={styles.libraryGrid}> <div className={styles.libraryGrid}>
{Object.entries(FUNCTION_CODE_MAP).map(([code, info]) => ( {cat.items.map(item => (
<div <div
key={code} key={item.code}
className={styles.libraryItem} className={styles.libraryItem}
draggable draggable
onDragStart={(e) => onDragStart(e, code)} onDragStart={(e) => onDragStart(e, item.code)}
style={{ '--item-color': info.color }} style={{ '--item-color': item.color }}
> >
<span className={styles.libIcon}>{info.icon}</span> <span className={styles.libIcon}>{item.icon}</span>
<span className={styles.libLabel}>{info.label}</span> <span className={styles.libLabel}>{item.label}</span>
<span className={styles.libCode}>{code}</span> <span className={styles.libPortCount}>{item.ports.length}P</span>
</div> </div>
))} ))}
</div> </div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 自定义元器件 */} {/* ===== 顶级:自定义符号 ===== */}
<div className={styles.sectionTitle} style={{ marginTop: 16 }}> <div className={styles.categoryBlock}>
自定义元器件 <div
<button className={styles.newTemplateBtn} onClick={handleNewTemplate}> className={`${styles.categoryHeader} ${styles.rootHeader}`}
+ 新建 onClick={() => toggleCategory('__custom__')}
>
<span className={styles.categoryArrow}>
{collapsedCats['__custom__'] ? '\u25B6' : '\u25BC'}
</span>
<span className={styles.categoryName}>自定义符号</span>
<button
className={styles.newTemplateBtn}
onClick={(e) => { e.stopPropagation(); handleNewTemplate(); }}
>
+ 新建符号
</button> </button>
</div> </div>
{templates.length === 0 ? ( {!collapsedCats['__custom__'] && (
<div className={styles.emptyCustom}>还没有自定义元器件,点击“新建”创建</div> <div className={styles.subTree}>
) : ( {/* 按自定义分类分组 */}
{customCategories.map(catName => {
const key = `__c_${catName}`;
const isCollapsed = collapsedCats[key];
const catTemplates = catName === '未分类'
? templates.filter(t => {
const tc = t.category || '未分类';
return tc === '未分类' || !customCategories.includes(tc);
})
: templates.filter(t => (t.category || '未分类') === catName);
return (
<div key={key} className={styles.categoryBlock}>
<div
className={styles.categoryHeader}
onClick={() => toggleCategory(key)}
>
<span className={styles.categoryArrow}>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
<span className={styles.categoryName}>{catName}</span>
<span className={styles.categoryCount}>{catTemplates.length}</span>
{catName !== '未分类' && (
<button
className={styles.deleteBtn}
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`删除分类"${catName}"?其中的符号将移到"未分类"`))
deleteCategory(catName);
}}
title="删除分类"
style={{ width: 18, height: 18, fontSize: 9 }}
>
&#10005;
</button>
)}
</div>
{!isCollapsed && (
<div className={styles.libraryGrid}> <div className={styles.libraryGrid}>
{templates.map(t => ( {catTemplates.length === 0 ? (
<div className={styles.emptyCustom}></div>
) : (
catTemplates.map(t => (
<div <div
key={t.id} key={t.id}
className={styles.libraryItem} className={styles.libraryItem}
...@@ -183,10 +279,29 @@ export default function Sidebar() { ...@@ -183,10 +279,29 @@ export default function Sidebar() {
</button> </button>
</span> </span>
</div> </div>
))} ))
)}
</div> </div>
)} )}
</div> </div>
);
})}
{/* 新建分类按钮 */}
<button
className={styles.addCategoryBtn}
onClick={() => {
const name = window.prompt('请输入新分类名称:');
if (name) addCategory(name);
}}
>
+ 新建分类
</button>
</div>
)}
</div>
</div>
)} )}
</div> </div>
</div> </div>
......
...@@ -259,6 +259,95 @@ ...@@ -259,6 +259,95 @@
font-family: 'Consolas', monospace; 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 ===== */ /* ===== Custom Template ===== */
.newTemplateBtn { .newTemplateBtn {
float: right; float: right;
......
...@@ -104,9 +104,9 @@ export default function Toolbar() { ...@@ -104,9 +104,9 @@ export default function Toolbar() {
const lib = useComponentLibrary.getState(); const lib = useComponentLibrary.getState();
lib.switchView(lib.currentView === 'flow' ? 'editor' : 'flow'); lib.switchView(lib.currentView === 'flow' ? 'editor' : 'flow');
}} }}
title="打开/关闭元器件编辑器" title="打开/关闭符号编辑器"
> >
&#9998; 元器件编辑器 &#9998; 符号编辑器
</button> </button>
</div> </div>
......
/** /**
* useComponentLibrary - 自定义元器件模板库 Zustand Store * useComponentLibrary - 自定义符号模板库 Zustand Store
* 管理用户自定义元器件的 CRUD 操作,持久化到 localStorage * 管理用户自定义符号的 CRUD 操作,持久化到 localStorage
* *
* 数据结构 ComponentTemplate: * 数据结构 ComponentTemplate:
* id, name, category, color, icon, width, height, * id, name, category, color, icon, width, height,
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
const STORAGE_KEY = 'eplan-component-library'; const STORAGE_KEY = 'eplan-component-library';
const CATEGORIES_KEY = 'eplan-custom-categories';
/** 从 localStorage 读取已保存的模板 */ /** 从 localStorage 读取已保存的模板 */
function loadFromStorage() { function loadFromStorage() {
...@@ -26,7 +27,26 @@ function saveToStorage(templates) { ...@@ -26,7 +27,26 @@ function saveToStorage(templates) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates)); localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
} catch (err) { } 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() { ...@@ -39,28 +59,24 @@ function generateId() {
export function createBlankTemplate() { export function createBlankTemplate() {
return { return {
id: generateId(), id: generateId(),
name: '新元器件', name: '新符号',
category: '自定义', category: '未分类',
color: '#6366f1', color: '#6366f1',
icon: '■', icon: '■',
width: 180, width: 180,
height: 100, height: 100,
shapes: [ shapes: [],
{ params: [],
type: 'rect',
props: { x: 0, y: 0, width: 180, height: 100, rx: 8 },
style: { fill: '#1e1e2e', stroke: '#6366f1', strokeWidth: 2 },
},
],
ports: [ ports: [
{ id: 'in1', portId: 1, name: '输入1', description: '端点描述', type: 'generic', side: 'left', position: 0.5 }, { 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: 'out1', portId: 2, name: '2', description: '端点描述', type: 'generic', side: 'right', position: 0.5 },
], ],
}; };
} }
const useComponentLibrary = create((set, get) => ({ const useComponentLibrary = create((set, get) => ({
templates: loadFromStorage(), templates: loadFromStorage(),
customCategories: loadCategories(),
/** 当前视图:'flow' | 'editor' */ /** 当前视图:'flow' | 'editor' */
currentView: 'flow', currentView: 'flow',
...@@ -148,7 +164,7 @@ const useComponentLibrary = create((set, get) => ({ ...@@ -148,7 +164,7 @@ const useComponentLibrary = create((set, get) => ({
const newPort = { const newPort = {
id: `port-${Date.now()}`, id: `port-${Date.now()}`,
portId: maxPortId + 1, portId: maxPortId + 1,
name: `端口${current.ports.length + 1}`, name: `${maxPortId + 1}`,
description: '端点描述', description: '端点描述',
type: 'generic', type: 'generic',
side: 'left', side: 'left',
...@@ -208,6 +224,45 @@ const useComponentLibrary = create((set, get) => ({ ...@@ -208,6 +224,45 @@ const useComponentLibrary = create((set, get) => ({
getTemplateById: (templateId) => { getTemplateById: (templateId) => {
return get().templates.find(t => t.id === templateId) || null; 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; export default useComponentLibrary;
...@@ -5,7 +5,7 @@ import { create } from 'zustand'; ...@@ -5,7 +5,7 @@ import { create } from 'zustand';
import { applyNodeChanges, applyEdgeChanges, addEdge as rfAddEdge } from '@xyflow/react'; import { applyNodeChanges, applyEdgeChanges, addEdge as rfAddEdge } from '@xyflow/react';
import { parseConnectionSheet, toReactFlowData } from '../utils/xlsxParser'; import { parseConnectionSheet, toReactFlowData } from '../utils/xlsxParser';
import { getLayoutedNodes } from '../utils/layoutEngine'; 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; let nodeCounter = 0;
...@@ -78,27 +78,38 @@ const useFlowStore = create((set, get) => ({ ...@@ -78,27 +78,38 @@ const useFlowStore = create((set, get) => ({
addNode: (position = { x: 100, y: 100 }, functionCode = 100) => { addNode: (position = { x: 100, y: 100 }, functionCode = 100) => {
const counter = ++nodeCounter; const counter = ++nodeCounter;
const deviceType = getDeviceType(functionCode); const deviceType = getDeviceType(functionCode);
const deviceDef = getDeviceDefinition(Number(functionCode));
const id = `new-device-${counter}`; 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 = { const templateData = {
name: deviceType.label, name: deviceType.label,
icon: deviceType.icon, icon: deviceType.icon,
width: 180, width: defW,
height: 80, height: defH,
shapes: [ shapes: [],
{ ports: defPorts,
type: 'rect', params: deviceDef?.params || [],
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 },
],
}; };
// 用 defaultValue 初始化参数值
const paramValues = {};
(deviceDef?.params || []).forEach(p => { paramValues[p.key] = p.defaultValue; });
const newNode = { const newNode = {
id, id,
type: 'customDeviceNode', type: 'customDeviceNode',
...@@ -108,6 +119,7 @@ const useFlowStore = create((set, get) => ({ ...@@ -108,6 +119,7 @@ const useFlowStore = create((set, get) => ({
deviceId: id, deviceId: id,
color: deviceType.color, color: deviceType.color,
templateData, templateData,
paramValues,
}, },
}; };
set({ nodes: [...get().nodes, newNode] }); set({ nodes: [...get().nodes, newNode] });
...@@ -129,7 +141,8 @@ const useFlowStore = create((set, get) => ({ ...@@ -129,7 +141,8 @@ const useFlowStore = create((set, get) => ({
color: template.color, color: template.color,
icon: template.icon, icon: template.icon,
portType: defaultPortType, 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), ports: template.ports.map(p => p.id),
}, },
}; };
...@@ -137,6 +150,20 @@ const useFlowStore = create((set, get) => ({ ...@@ -137,6 +150,20 @@ const useFlowStore = create((set, get) => ({
return newNode; 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: () => { removeSelectedNode: () => {
const { selectedNode, nodes, edges } = get(); const { selectedNode, nodes, edges } = get();
if (!selectedNode) return; if (!selectedNode) return;
......
/** /**
* EPLAN 功能代码到设备类型的映射表 * 分类符号库
* 以及对应的颜色/图标配置 * 每个符号定义包含:端口模板 (ports)、默认尺寸 (width/height)
*
* ports 说明:
* side: 'left' | 'right' | 'top' | 'bottom'
* position: 0~1 表示沿边缘的位置比例
* type: 端口物理类型 (power/analog/digital/generic/water/air)
*/ */
/** 功能代码 -> 设备类型映射 */ /** 分类符号定义 */
export const FUNCTION_CODE_MAP = { export const DEVICE_CATEGORIES = [
100: { type: 'terminal', label: '端子排', color: '#4CAF50', icon: '⊞' }, {
200: { type: 'contactor', label: '接触器', color: '#2196F3', icon: '◈' }, category: '基本电子元件',
300: { type: 'relay', label: '继电器', color: '#9C27B0', icon: '◇' }, items: [
400: { type: 'motor', label: '电动机', color: '#FF9800', icon: '◎' }, {
501: { type: 'breaker', label: '断路器', color: '#F44336', icon: '⊗' }, code: 1001, type: 'resistor', label: '电阻', color: '#FF9800', icon: 'R',
600: { type: 'switch', label: '开关', color: '#00BCD4', icon: '⊘' }, width: 120, height: 60,
700: { type: 'sensor', label: '传感器', color: '#795548', icon: '◉' }, ports: [
802: { type: 'transformer', label: '变压器', color: '#FF5722', icon: '⊜' }, { id: 'p1', name: '1', side: 'left', position: 0.5, type: 'power' },
900: { type: 'cable', label: '电缆/线缆', color: '#607D8B', icon: '⊟' }, { id: 'p2', name: '2', side: 'right', position: 0.5, type: 'power' },
}; ],
params: [
{ key: 'R', label: '阻值', unit: 'Ω', defaultValue: '10k' },
{ key: 'P', label: '功率', unit: 'W', defaultValue: '0.25' },
],
},
{
code: 1002, type: 'capacitor', label: '电容', color: '#2196F3', icon: 'C',
width: 120, height: 60,
ports: [
{ id: 'p-pos', name: '+', side: 'left', position: 0.5, type: 'power' },
{ id: 'p-neg', name: '-', side: 'right', position: 0.5, type: 'power' },
],
params: [
{ key: 'C', label: '容值', unit: 'F', defaultValue: '100u' },
{ key: 'V', label: '耐压', unit: 'V', defaultValue: '50' },
],
},
{
code: 1003, type: 'inductor', label: '电感', color: '#9C27B0', icon: 'L',
width: 120, height: 60,
ports: [
{ id: 'p1', name: '1', side: 'left', position: 0.5, type: 'power' },
{ id: 'p2', name: '2', side: 'right', position: 0.5, type: 'power' },
],
params: [
{ key: 'L', label: '感值', unit: 'H', defaultValue: '10m' },
],
},
{
code: 1004, type: 'diode', label: '二极管', color: '#E91E63', icon: 'D',
width: 120, height: 60,
ports: [
{ id: 'p-a', name: 'A', side: 'left', position: 0.5, type: 'power' },
{ id: 'p-k', name: 'K', side: 'right', position: 0.5, type: 'power' },
],
},
{
code: 1005, type: 'voltage_source', label: '电压源', color: '#4CAF50', icon: 'V',
width: 120, height: 80,
ports: [
{ id: 'p-pos', name: 'V+', side: 'left', position: 0.5, type: 'power' },
{ id: 'p-neg', name: 'V-', side: 'right', position: 0.5, type: 'power' },
],
params: [
{ key: 'V', label: '电压', unit: 'V', defaultValue: '24' },
],
},
{
code: 1006, type: 'ground', label: '接地', color: '#607D8B', icon: 'GND',
width: 80, height: 60,
ports: [
{ id: 'p-gnd', name: 'GND', side: 'top', position: 0.5, type: 'power' },
],
},
],
},
{
category: '电气控制',
items: [
{
code: 100, type: 'terminal', label: '端子排', color: '#4CAF50', icon: '⊞',
width: 180, height: 80,
ports: [
{ id: 'p-in-1', name: '输入1', side: 'left', position: 0.5, type: 'generic' },
{ id: 'p-out-1', name: '输出1', side: 'right', position: 0.5, type: 'generic' },
],
},
{
code: 200, type: 'contactor', label: '接触器', color: '#2196F3', icon: '◈',
width: 180, height: 100,
ports: [
{ id: 'p-l1', name: 'L1', side: 'top', position: 0.25, type: 'power' },
{ id: 'p-l2', name: 'L2', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-l3', name: 'L3', side: 'top', position: 0.75, type: 'power' },
{ id: 'p-t1', name: 'T1', side: 'bottom', position: 0.25, type: 'power' },
{ id: 'p-t2', name: 'T2', side: 'bottom', position: 0.5, type: 'power' },
{ id: 'p-t3', name: 'T3', side: 'bottom', position: 0.75, type: 'power' },
{ id: 'p-a1', name: 'A1', side: 'left', position: 0.5, type: 'digital' },
{ id: 'p-a2', name: 'A2', side: 'right', position: 0.5, type: 'digital' },
],
},
{
code: 300, type: 'relay', label: '继电器', color: '#9C27B0', icon: '◇',
width: 160, height: 80,
ports: [
{ id: 'p-a1', name: 'A1', side: 'left', position: 0.3, type: 'digital' },
{ id: 'p-a2', name: 'A2', side: 'left', position: 0.7, type: 'digital' },
{ id: 'p-11', name: '11', side: 'right', position: 0.3, type: 'digital' },
{ id: 'p-14', name: '14', side: 'right', position: 0.7, type: 'digital' },
],
},
{
code: 501, type: 'breaker', label: '断路器', color: '#F44336', icon: '⊗',
width: 140, height: 80,
ports: [
{ id: 'p-in', name: '进线', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-out', name: '出线', side: 'bottom', position: 0.5, type: 'power' },
],
params: [
{ key: 'In', label: '额定电流', unit: 'A', defaultValue: '16' },
],
},
{
code: 600, type: 'switch', label: '开关', color: '#00BCD4', icon: '⊘',
width: 120, height: 60,
ports: [
{ id: 'p-com', name: 'COM', side: 'left', position: 0.5, type: 'power' },
{ id: 'p-no', name: 'NO', side: 'right', position: 0.3, type: 'power' },
{ id: 'p-nc', name: 'NC', side: 'right', position: 0.7, type: 'power' },
],
},
{
code: 400, type: 'motor', label: '电动机', color: '#FF9800', icon: '◎',
width: 160, height: 100,
ports: [
{ id: 'p-u', name: 'U', side: 'top', position: 0.25, type: 'power' },
{ id: 'p-v', name: 'V', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-w', name: 'W', side: 'top', position: 0.75, type: 'power' },
{ id: 'p-pe',name: 'PE', side: 'bottom', position: 0.5, type: 'power' },
],
params: [
{ key: 'Pw', label: '功率', unit: 'kW', defaultValue: '2.2' },
{ key: 'Rpm', label: '转速', unit: 'rpm', defaultValue: '1450' },
],
},
{
code: 802, type: 'transformer', label: '变压器', color: '#FF5722', icon: '⊜',
width: 160, height: 100,
ports: [
{ id: 'p-pri1', name: '初级1', side: 'left', position: 0.3, type: 'power' },
{ id: 'p-pri2', name: '初级2', side: 'left', position: 0.7, type: 'power' },
{ id: 'p-sec1', name: '次级1', side: 'right', position: 0.3, type: 'power' },
{ id: 'p-sec2', name: '次级2', side: 'right', position: 0.7, type: 'power' },
],
},
{
code: 700, type: 'sensor', label: '传感器', color: '#795548', icon: '◉',
width: 140, height: 80,
ports: [
{ id: 'p-vcc', name: 'VCC', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-gnd', name: 'GND', side: 'bottom',position: 0.5, type: 'power' },
{ id: 'p-sig', name: 'SIG', side: 'right', position: 0.5, type: 'analog' },
],
},
{
code: 900, type: 'cable', label: '电缆/线缆', color: '#607D8B', icon: '⊟',
width: 160, height: 60,
ports: [
{ id: 'p-in', name: '输入', side: 'left', position: 0.5, type: 'generic' },
{ id: 'p-out', name: '输出', side: 'right', position: 0.5, type: 'generic' },
],
},
],
},
{
category: 'PLC',
items: [
{
code: 2001, type: 'plc_cpu', label: 'PLC CPU', color: '#3F51B5', icon: 'CPU',
width: 200, height: 120,
ports: [
{ id: 'p-24v', name: '24V', side: 'top', position: 0.25, type: 'power' },
{ id: 'p-0v', name: '0V', side: 'top', position: 0.75, type: 'power' },
{ id: 'p-di1', name: 'DI1', side: 'left', position: 0.25, type: 'digital' },
{ id: 'p-di2', name: 'DI2', side: 'left', position: 0.5, type: 'digital' },
{ id: 'p-di3', name: 'DI3', side: 'left', position: 0.75, type: 'digital' },
{ id: 'p-do1', name: 'DO1', side: 'right', position: 0.25, type: 'digital' },
{ id: 'p-do2', name: 'DO2', side: 'right', position: 0.5, type: 'digital' },
{ id: 'p-do3', name: 'DO3', side: 'right', position: 0.75, type: 'digital' },
],
},
{
code: 2002, type: 'plc_di', label: 'PLC 数字输入', color: '#2196F3', icon: 'DI',
width: 180, height: 120,
ports: [
{ id: 'p-di1', name: 'DI1', side: 'left', position: 0.15, type: 'digital' },
{ id: 'p-di2', name: 'DI2', side: 'left', position: 0.35, type: 'digital' },
{ id: 'p-di3', name: 'DI3', side: 'left', position: 0.55, type: 'digital' },
{ id: 'p-di4', name: 'DI4', side: 'left', position: 0.75, type: 'digital' },
{ id: 'p-com', name: 'COM', side: 'right',position: 0.5, type: 'power' },
],
},
{
code: 2003, type: 'plc_do', label: 'PLC 数字输出', color: '#4CAF50', icon: 'DO',
width: 180, height: 120,
ports: [
{ id: 'p-do1', name: 'DO1', side: 'right', position: 0.15, type: 'digital' },
{ id: 'p-do2', name: 'DO2', side: 'right', position: 0.35, type: 'digital' },
{ id: 'p-do3', name: 'DO3', side: 'right', position: 0.55, type: 'digital' },
{ id: 'p-do4', name: 'DO4', side: 'right', position: 0.75, type: 'digital' },
{ id: 'p-com', name: 'COM', side: 'left', position: 0.5, type: 'power' },
],
},
{
code: 2004, type: 'plc_ai', label: 'PLC 模拟输入', color: '#E91E63', icon: 'AI',
width: 180, height: 100,
ports: [
{ id: 'p-ai1', name: 'AI1', side: 'left', position: 0.3, type: 'analog' },
{ id: 'p-ai2', name: 'AI2', side: 'left', position: 0.7, type: 'analog' },
{ id: 'p-vcc', name: 'VCC', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-gnd', name: 'GND', side: 'bottom',position: 0.5, type: 'power' },
],
},
{
code: 2005, type: 'plc_ao', label: 'PLC 模拟输出', color: '#FF5722', icon: 'AO',
width: 180, height: 100,
ports: [
{ id: 'p-ao1', name: 'AO1', side: 'right', position: 0.3, type: 'analog' },
{ id: 'p-ao2', name: 'AO2', side: 'right', position: 0.7, type: 'analog' },
{ id: 'p-vcc', name: 'VCC', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-gnd', name: 'GND', side: 'bottom',position: 0.5, type: 'power' },
],
},
],
},
{
category: '水利/液压',
items: [
{
code: 3001, type: 'pump', label: '水泵', color: '#00BCD4', icon: 'P',
width: 160, height: 100,
ports: [
{ id: 'p-in', name: '进水', side: 'left', position: 0.5, type: 'water' },
{ id: 'p-out', name: '出水', side: 'right', position: 0.5, type: 'water' },
{ id: 'p-pwr1', name: 'L', side: 'top', position: 0.3, type: 'power' },
{ id: 'p-pwr2', name: 'N', side: 'top', position: 0.7, type: 'power' },
],
},
{
code: 3002, type: 'valve', label: '阀门', color: '#009688', icon: 'V',
width: 120, height: 80,
ports: [
{ id: 'p-in', name: '进口', side: 'left', position: 0.5, type: 'water' },
{ id: 'p-out', name: '出口', side: 'right', position: 0.5, type: 'water' },
{ id: 'p-ctl', name: '控制', side: 'top', position: 0.5, type: 'digital' },
],
},
{
code: 3003, type: 'flow_meter', label: '流量计', color: '#00ACC1', icon: 'FM',
width: 140, height: 80,
ports: [
{ id: 'p-in', name: '进口', side: 'left', position: 0.5, type: 'water' },
{ id: 'p-out', name: '出口', side: 'right', position: 0.5, type: 'water' },
{ id: 'p-sig', name: '信号', side: 'bottom',position: 0.5, type: 'analog' },
],
},
{
code: 3004, type: 'tank', label: '水箱', color: '#0288D1', icon: 'TK',
width: 180, height: 120,
ports: [
{ id: 'p-in', name: '进水', side: 'top', position: 0.5, type: 'water' },
{ id: 'p-out', name: '出水', side: 'bottom', position: 0.5, type: 'water' },
{ id: 'p-level', name: '液位信号', side: 'right', position: 0.5, type: 'analog' },
],
},
],
},
{
category: '机械/气动',
items: [
{
code: 4001, type: 'cylinder', label: '气缸', color: '#8BC34A', icon: 'CY',
width: 160, height: 80,
ports: [
{ id: 'p-a', name: 'A腔', side: 'left', position: 0.5, type: 'air' },
{ id: 'p-b', name: 'B腔', side: 'right', position: 0.5, type: 'air' },
{ id: 'p-fb',name: '反馈', side: 'bottom',position: 0.5, type: 'digital' },
],
},
{
code: 4002, type: 'solenoid_valve', label: '电磁阀', color: '#689F38', icon: 'SV',
width: 140, height: 80,
ports: [
{ id: 'p-in', name: 'P', side: 'left', position: 0.5, type: 'air' },
{ id: 'p-a', name: 'A', side: 'right', position: 0.3, type: 'air' },
{ id: 'p-b', name: 'B', side: 'right', position: 0.7, type: 'air' },
{ id: 'p-ctl', name: '线圈', side: 'top', position: 0.5, type: 'digital' },
],
},
{
code: 4003, type: 'servo', label: '伺服电机', color: '#FF9800', icon: 'SV',
width: 180, height: 100,
ports: [
{ id: 'p-u', name: 'U', side: 'top', position: 0.2, type: 'power' },
{ id: 'p-v', name: 'V', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-w', name: 'W', side: 'top', position: 0.8, type: 'power' },
{ id: 'p-enc',name: '编码器', side: 'right',position: 0.5, type: 'analog' },
{ id: 'p-pe', name: 'PE', side: 'bottom',position: 0.5, type: 'power' },
],
},
{
code: 4004, type: 'vfd', label: '变频器', color: '#673AB7', icon: 'VFD',
width: 180, height: 120,
ports: [
{ id: 'p-r', name: 'R', side: 'top', position: 0.2, type: 'power' },
{ id: 'p-s', name: 'S', side: 'top', position: 0.5, type: 'power' },
{ id: 'p-t', name: 'T', side: 'top', position: 0.8, type: 'power' },
{ id: 'p-u', name: 'U', side: 'bottom', position: 0.2, type: 'power' },
{ id: 'p-v', name: 'V', side: 'bottom', position: 0.5, type: 'power' },
{ id: 'p-w', name: 'W', side: 'bottom', position: 0.8, type: 'power' },
{ id: 'p-fi', name: 'FWD', side: 'left', position: 0.3, type: 'digital' },
{ id: 'p-ri', name: 'REV', side: 'left', position: 0.7, type: 'digital' },
{ id: 'p-fb', name: 'FB', side: 'right',position: 0.5, type: 'analog' },
],
},
],
},
];
/**
* 扁平化的功能代码映射(向后兼容导入功能)
*/
export const FUNCTION_CODE_MAP = {};
const _allDevices = {};
DEVICE_CATEGORIES.forEach(cat => {
cat.items.forEach(item => {
FUNCTION_CODE_MAP[item.code] = {
type: item.type,
label: item.label,
color: item.color,
icon: item.icon,
};
_allDevices[item.code] = item;
});
});
/** 获取完整的设备定义(含端口模板) */
export function getDeviceDefinition(code) {
return _allDevices[code] || null;
}
/** 获取设备类型信息,未知功能代码返回默认值 */ /** 获取设备类型信息,未知功能代码返回默认值 */
export function getDeviceType(functionCode) { export function getDeviceType(functionCode) {
......
...@@ -133,9 +133,9 @@ export async function parseConnectionSheet(file) { ...@@ -133,9 +133,9 @@ export async function parseConnectionSheet(file) {
edges.push({ edges.push({
id: `edge-${edgeIndex++}`, id: `edge-${edgeIndex++}`,
source: sourceId, source: sourceId,
sourceHandle: source.port ? `s-port-${source.port}` : 's-port-default', sourceHandle: source.port ? `port-${source.port}` : 'port-default',
target: targetId, 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