Commit 3e05f33d authored by Developer's avatar Developer

feat: 优化项目面板布局和实物节点样式

parent 9b79e64e
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
* App - 主入口组件 * App - 主入口组件
* 支持两个视图:流程编辑器 / 符号编辑器 * 支持两个视图:流程编辑器 / 符号编辑器
* 右侧集成项目管理面板 * 右侧集成项目管理面板
* 独立路由: /hil-panel — 虚拟硬件操控面板
*/ */
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ReactFlowProvider } from '@xyflow/react'; import { ReactFlowProvider } from '@xyflow/react';
import Toolbar from './components/Toolbar/Toolbar'; import Toolbar from './components/Toolbar/Toolbar';
import Sidebar from './components/Sidebar/Sidebar'; import Sidebar from './components/Sidebar/Sidebar';
...@@ -10,10 +12,11 @@ import FlowCanvas from './components/Canvas/FlowCanvas'; ...@@ -10,10 +12,11 @@ import FlowCanvas from './components/Canvas/FlowCanvas';
import PropertiesPanel from './components/PropertiesPanel/PropertiesPanel'; import PropertiesPanel from './components/PropertiesPanel/PropertiesPanel';
import ComponentEditor from './components/ComponentEditor/ComponentEditor'; import ComponentEditor from './components/ComponentEditor/ComponentEditor';
import ProjectPanel from './components/ProjectPanel/ProjectPanel'; import ProjectPanel from './components/ProjectPanel/ProjectPanel';
import HardwarePanel from './pages/HardwarePanel';
import useComponentLibrary from './hooks/useComponentLibrary'; import useComponentLibrary from './hooks/useComponentLibrary';
import './App.css'; import './App.css';
export default function App() { function MainApp() {
const currentView = useComponentLibrary(s => s.currentView); const currentView = useComponentLibrary(s => s.currentView);
return ( return (
...@@ -34,3 +37,14 @@ export default function App() { ...@@ -34,3 +37,14 @@ export default function App() {
</ReactFlowProvider> </ReactFlowProvider>
); );
} }
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/hil-panel" element={<HardwarePanel />} />
<Route path="*" element={<MainApp />} />
</Routes>
</BrowserRouter>
);
}
...@@ -219,6 +219,15 @@ export default function FlowCanvas() { ...@@ -219,6 +219,15 @@ export default function FlowCanvas() {
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
onNodesDelete={(deleted) => {
// 键盘删除后清理关联边 + 清空选中
const ids = new Set(deleted.map(n => n.id));
const { edges, selectedNode } = useFlowStore.getState();
useFlowStore.setState({
edges: edges.filter(e => !ids.has(e.source) && !ids.has(e.target)),
selectedNode: selectedNode && ids.has(selectedNode.id) ? null : selectedNode,
});
}}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions} defaultEdgeOptions={defaultEdgeOptions}
......
/** /**
* LiveChart — 实时仿真数据图表 * LiveChart — 实时仿真数据图表 + 硬件操控面板
* *
* 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧, * 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧,
* 动态追加数据点并实时绘制。 * 动态追加数据点并实时绘制。右侧嵌入硬件操控面板。
* *
* Props: * Props:
* rosBridgeUrl — rosbridge WebSocket 地址 (默认 ws://localhost:9090) * rosBridgeUrl — rosbridge WebSocket 地址
* sessionId — HIL 会话 ID(用于获取硬件端口列表)
* onClose — 关闭回调 * onClose — 关闭回调
* onDone — 仿真完成回调
*/ */
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import {
LineChart, Line, XAxis, YAxis, CartesianGrid, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Tooltip, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import useRosBridge from '../../hooks/useRosBridge'; import useRosBridge from '../../hooks/useRosBridge';
import { getHilPorts } from '../../utils/api';
import styles from './LiveChart.module.css'; import styles from './LiveChart.module.css';
const COLORS = [ const COLORS = [
...@@ -21,7 +24,6 @@ const COLORS = [ ...@@ -21,7 +24,6 @@ const COLORS = [
'#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b', '#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b',
]; ];
// 简易中文映射 (复用 SimResultsModal 的逻辑)
const COMP_CN = { const COMP_CN = {
capacitor: '电容', resistor: '电阻', inductor: '电感', capacitor: '电容', resistor: '电阻', inductor: '电感',
voltagesource: '电压源', currentsource: '电流源', ground: '接地', voltagesource: '电压源', currentsource: '电流源', ground: '接地',
...@@ -42,18 +44,8 @@ function toChinese(name) { ...@@ -42,18 +44,8 @@ function toChinese(name) {
} }
function StatusDot({ status }) { function StatusDot({ status }) {
const colorMap = { const colorMap = { disconnected: '#666', connecting: '#fbbf24', connected: '#22c55e', error: '#ef4444' };
disconnected: '#666', const labelMap = { disconnected: '未连接', connecting: '连接中…', connected: '实时接收中', error: '连接失败' };
connecting: '#fbbf24',
connected: '#22c55e',
error: '#ef4444',
};
const labelMap = {
disconnected: '未连接',
connecting: '连接中…',
connected: '实时接收中',
error: '连接失败',
};
return ( return (
<span className={styles.statusDot}> <span className={styles.statusDot}>
<span className={styles.dot} style={{ background: colorMap[status] || '#666' }} /> <span className={styles.dot} style={{ background: colorMap[status] || '#666' }} />
...@@ -62,24 +54,108 @@ function StatusDot({ status }) { ...@@ -62,24 +54,108 @@ function StatusDot({ status }) {
); );
} }
export default function LiveChart({ rosBridgeUrl, onClose, onDone }) { // ── 硬件控件模式配置 ──
const { status, headers, data, connect, disconnect } = useRosBridge({ onDone }); const MODE_CONFIG = {
switch: { label: '开关', icon: '🔘', unit: '', min: 0, max: 1, step: 1, isSwitch: true },
resistor: { label: '电阻', icon: '🎛', unit: 'Ω', min: 1, max: 100000, step: 1, isSwitch: false },
capacitor: { label: '电容', icon: '🔋', unit: 'μF', min: 0.1, max: 10000, step: 0.1, isSwitch: false },
inductor: { label: '电感', icon: '🧲', unit: 'mH', min: 0.1, max: 10000, step: 0.1, isSwitch: false },
voltage_src: { label: '电压源', icon: '⚡', unit: 'V', min: 0, max: 1000, step: 0.1, isSwitch: false },
};
export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) {
const [simDone, setSimDone] = useState(false);
const wrappedOnDone = useCallback(() => {
setSimDone(true);
onDone?.();
}, [onDone]);
const { status, headers, data, connect, disconnect } = useRosBridge({ onDone: wrappedOnDone });
const [selectedVars, setSelectedVars] = useState(null); const [selectedVars, setSelectedVars] = useState(null);
// 自动连接 // ── 硬件面板状态 ──
const [hwPorts, setHwPorts] = useState([]);
const [hwValues, setHwValues] = useState({});
const hwWsRef = useRef(null);
// 自动连接 rosbridge(数据接收)
useEffect(() => { useEffect(() => {
connect(rosBridgeUrl || 'ws://localhost:9090'); connect(rosBridgeUrl || 'ws://localhost:9090');
return () => disconnect(); return () => disconnect();
}, [rosBridgeUrl]); // eslint-disable-line react-hooks/exhaustive-deps }, [rosBridgeUrl]); // eslint-disable-line react-hooks/exhaustive-deps
// 识别变量列 (排除 time) // ── 获取硬件端口列表 ──
useEffect(() => {
if (!sessionId) return;
getHilPorts(sessionId).then(resp => {
if (resp.code === 0 && resp.data?.ports) {
const p = resp.data.ports;
setHwPorts(p);
const init = {};
p.forEach(port => {
const name = port.instance_name || port.name;
init[name] = port.mode === 'switch' ? 0 : port.gain;
});
setHwValues(init);
}
}).catch(() => {});
}, [sessionId]);
// ── 硬件面板 WebSocket(发布 override) ──
useEffect(() => {
if (!hwPorts.length) return;
const ws = new WebSocket(`ws://${window.location.hostname}:9090`);
hwWsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({
op: 'advertise',
topic: '/hil/user_override',
type: 'std_msgs/String',
}));
};
ws.onclose = () => { if (hwWsRef.current === ws) hwWsRef.current = null; };
ws.onerror = () => {};
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ op: 'unadvertise', topic: '/hil/user_override' }));
}
ws.close();
};
}, [hwPorts.length]);
const publishOverride = useCallback((component, value) => {
const ws = hwWsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
op: 'publish',
topic: '/hil/user_override',
msg: { data: JSON.stringify({ component, value }) },
}));
}, []);
const handleHwChange = useCallback((component, value) => {
const numVal = Number(value);
setHwValues(prev => ({ ...prev, [component]: numVal }));
publishOverride(component, numVal);
}, [publishOverride]);
const handleToggle = useCallback((component) => {
setHwValues(prev => {
const newVal = prev[component] > 0.5 ? 0 : 1;
publishOverride(component, newVal);
return { ...prev, [component]: newVal };
});
}, [publishOverride]);
// ── 图表逻辑 ──
const xKey = useMemo( const xKey = useMemo(
() => headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time', () => headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time',
[headers] [headers]
); );
const variables = useMemo(() => headers.filter(h => h !== xKey), [headers, xKey]); const variables = useMemo(() => headers.filter(h => h !== xKey), [headers, xKey]);
// 自动选择前 5 个变量
useEffect(() => { useEffect(() => {
if (variables.length > 0 && selectedVars === null) { if (variables.length > 0 && selectedVars === null) {
setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length)))); setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length))));
...@@ -93,7 +169,6 @@ export default function LiveChart({ rosBridgeUrl, onClose, onDone }) { ...@@ -93,7 +169,6 @@ export default function LiveChart({ rosBridgeUrl, onClose, onDone }) {
const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s; const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s;
}), []); }), []);
// 使用最近 500 个点渲染 (避免 SVG 性能问题)
const displayData = useMemo(() => { const displayData = useMemo(() => {
if (data.length <= 500) return data; if (data.length <= 500) return data;
return data.slice(-500); return data.slice(-500);
...@@ -172,6 +247,62 @@ export default function LiveChart({ rosBridgeUrl, onClose, onDone }) { ...@@ -172,6 +247,62 @@ export default function LiveChart({ rosBridgeUrl, onClose, onDone }) {
</ResponsiveContainer> </ResponsiveContainer>
)} )}
</div> </div>
{/* 硬件操控面板(右侧) */}
{hwPorts.length > 0 && !simDone && (
<div className={styles.hwPanel}>
<div className={styles.hwTitle}>🎛 硬件操控</div>
<div className={styles.hwList}>
{hwPorts.map(port => {
const name = port.instance_name || port.name;
const cfg = MODE_CONFIG[port.mode] || MODE_CONFIG.resistor;
const value = hwValues[name] ?? port.gain;
return (
<div key={name} className={styles.hwCard}>
<div className={styles.hwCardHead}>
<span>{cfg.icon}</span>
<span className={styles.hwCardName}>{port.hw_label || name}</span>
<span className={styles.hwCardMode}>{cfg.label}</span>
</div>
{cfg.isSwitch ? (
<div className={styles.hwSwitch}>
<button
className={`${styles.hwSwitchBtn} ${value > 0.5 ? styles.hwOn : styles.hwOff}`}
onClick={() => handleToggle(name)}
>
<span className={styles.hwKnob} />
</button>
<span className={value > 0.5 ? styles.hwLabelOn : styles.hwLabelOff}>
{value > 0.5 ? 'ON' : 'OFF'}
</span>
</div>
) : (
<div className={styles.hwSlider}>
<input
type="range" min={cfg.min} max={cfg.max} step={cfg.step}
value={value}
onChange={e => handleHwChange(name, e.target.value)}
className={styles.hwRange}
/>
<div className={styles.hwValRow}>
<input
type="number" min={cfg.min} max={cfg.max} step={cfg.step}
value={value}
onChange={e => handleHwChange(name, e.target.value)}
className={styles.hwNumInput}
/>
<span className={styles.hwUnit}>{cfg.unit}</span>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
.modal { .modal {
width: 90vw; width: 90vw;
height: 80vh; height: 80vh;
max-width: 1200px; max-width: 1440px;
background: #0f0f1a; background: #0f0f1a;
border: 1px solid rgba(79, 138, 255, 0.15); border: 1px solid rgba(79, 138, 255, 0.15);
border-radius: 16px; border-radius: 16px;
...@@ -199,3 +199,155 @@ ...@@ -199,3 +199,155 @@
color: #555; color: #555;
font-size: 13px; font-size: 13px;
} }
/* ===== 硬件操控面板(右侧) ===== */
.hwPanel {
width: 220px;
border-left: 1px solid rgba(79, 138, 255, 0.08);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.hwTitle {
padding: 10px 12px;
font-size: 12px;
font-weight: 700;
color: #22c55e;
border-bottom: 1px solid rgba(79, 138, 255, 0.08);
letter-spacing: 0.5px;
}
.hwList {
flex: 1;
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
display: flex;
flex-direction: column;
gap: 8px;
}
.hwCard {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(79, 138, 255, 0.1);
border-radius: 10px;
padding: 10px;
}
.hwCardHead {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 11px;
}
.hwCardName {
font-weight: 600;
color: #ccc;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hwCardMode {
font-size: 10px;
color: #64748b;
background: rgba(100, 116, 139, 0.15);
padding: 1px 6px;
border-radius: 3px;
}
/* 开关 */
.hwSwitch {
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
}
.hwSwitchBtn {
width: 56px;
height: 28px;
border-radius: 28px;
border: none;
cursor: pointer;
position: relative;
transition: background 0.3s;
padding: 0;
}
.hwOff { background: #374151; }
.hwOn { background: #22c55e; box-shadow: 0 0 8px #22c55e44; }
.hwKnob {
position: absolute;
top: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
transition: left 0.3s;
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.hwOff .hwKnob { left: 3px; }
.hwOn .hwKnob { left: 31px; }
.hwLabelOn { font-size: 13px; font-weight: 700; color: #22c55e; }
.hwLabelOff { font-size: 13px; font-weight: 700; color: #64748b; }
/* 滑条 */
.hwSlider {
display: flex;
flex-direction: column;
gap: 6px;
}
.hwRange {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #1e293b;
border-radius: 2px;
outline: none;
}
.hwRange::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4f8aff;
cursor: pointer;
box-shadow: 0 0 4px rgba(79, 138, 255, 0.4);
}
.hwValRow {
display: flex;
align-items: center;
gap: 4px;
justify-content: center;
}
.hwNumInput {
width: 80px;
padding: 4px 6px;
background: #1e293b;
border: 1px solid rgba(79, 138, 255, 0.2);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
font-weight: 600;
text-align: center;
outline: none;
}
.hwNumInput:focus {
border-color: #4f8aff;
}
.hwUnit {
font-size: 11px;
color: #64748b;
}
...@@ -7,7 +7,10 @@ import { parseConnectionSheet, toReactFlowData } from '../utils/xlsxParser'; ...@@ -7,7 +7,10 @@ import { parseConnectionSheet, toReactFlowData } from '../utils/xlsxParser';
import { getLayoutedNodes } from '../utils/layoutEngine'; import { getLayoutedNodes } from '../utils/layoutEngine';
import { getDeviceType, getDeviceDefinition, LAYOUT_DIRECTION, PORT_TYPES, getPortTypeByFunctionCode } from '../utils/constants'; import { getDeviceType, getDeviceDefinition, LAYOUT_DIRECTION, PORT_TYPES, getPortTypeByFunctionCode } from '../utils/constants';
let nodeCounter = 0; /** 生成唯一节点/端口 ID(时间戳 + 随机后缀,不依赖计数器) */
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
const useFlowStore = create((set, get) => ({ const useFlowStore = create((set, get) => ({
nodes: [], nodes: [],
...@@ -76,26 +79,26 @@ const useFlowStore = create((set, get) => ({ ...@@ -76,26 +79,26 @@ 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 tag = uid();
const deviceType = getDeviceType(functionCode); const deviceType = getDeviceType(functionCode);
const deviceDef = getDeviceDefinition(Number(functionCode)); const deviceDef = getDeviceDefinition(Number(functionCode));
const id = `new-device-${counter}`; const id = `dev-${tag}`;
// 使用设备定义的端口和尺寸,或回退到默认值 // 使用设备定义的端口和尺寸,或回退到默认值
const defW = deviceDef?.width || 180; const defW = deviceDef?.width || 180;
const defH = deviceDef?.height || 80; const defH = deviceDef?.height || 80;
const defPorts = deviceDef?.ports const defPorts = deviceDef?.ports
? deviceDef.ports.map(p => ({ ? deviceDef.ports.map(p => ({
...p, ...p,
id: `${p.id}-${counter}`, // 确保端口 id 全局唯一 id: `${p.id}-${tag}`, // 确保端口 id 全局唯一
portId: p.id, portId: p.id,
description: '', description: '',
connector: p.connector || p.name, connector: p.connector || p.name,
})) }))
: [ : [
{ id: `p-in-1-${counter}`, name: '输入1', portId: 1, description: '', type: 'generic', side: 'left', position: 0.5, connector: 'in1' }, { id: `p-in-1-${tag}`, name: '输入1', portId: 1, description: '', type: 'generic', side: 'left', position: 0.5, connector: 'in1' },
{ id: `p-out-1-${counter}`, name: '输出1', portId: 2, description: '', type: 'generic', side: 'right', position: 0.5, connector: 'out1' }, { id: `p-out-1-${tag}`, name: '输出1', portId: 2, description: '', type: 'generic', side: 'right', position: 0.5, connector: 'out1' },
]; ];
const templateData = { const templateData = {
name: deviceType.label, name: deviceType.label,
...@@ -130,8 +133,8 @@ const useFlowStore = create((set, get) => ({ ...@@ -130,8 +133,8 @@ const useFlowStore = create((set, get) => ({
/** 添加自定义模板节点 */ /** 添加自定义模板节点 */
addCustomNode: (position = { x: 100, y: 100 }, template) => { addCustomNode: (position = { x: 100, y: 100 }, template) => {
const counter = ++nodeCounter; const tag = uid();
const id = `custom-${counter}`; const id = `cust-${tag}`;
const defaultPortType = template.ports?.[0]?.type || 'generic'; const defaultPortType = template.ports?.[0]?.type || 'generic';
const newNode = { const newNode = {
id, id,
...@@ -243,8 +246,13 @@ const useFlowStore = create((set, get) => ({ ...@@ -243,8 +246,13 @@ const useFlowStore = create((set, get) => ({
importFromJSON: (jsonString) => { importFromJSON: (jsonString) => {
try { try {
const { nodes, edges } = JSON.parse(jsonString); const parsed = JSON.parse(jsonString);
set({ nodes: nodes || [], edges: edges || [], error: null }); // 清除 React Flow 内部属性,强制重新计算 handle 位置
const nodes = (parsed.nodes || []).map(n => {
const { measured, width, height, internals, ...clean } = n;
return clean;
});
set({ nodes, edges: parsed.edges || [], error: null });
} catch (err) { } catch (err) {
set({ error: '无效的 JSON 文件' }); set({ error: '无效的 JSON 文件' });
} }
......
/**
* HardwarePanel — 虚拟硬件操控面板 (独立页面)
*
* 路由: /hil-panel?session_id=xxx
* 功能: 从 API 获取当前 HIL 会话的硬件端口列表,
* 为每个实物元器件渲染对应的控件(开关/滑条),
* 操作时通过 rosbridge WebSocket 发布到 /hil/user_override。
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { getHilPorts } from '../utils/api';
import styles from './HardwarePanel.module.css';
// 模式配置
const MODE_CONFIG = {
switch: { label: '开关', icon: '🔘', unit: '', min: 0, max: 1, step: 1, isSwitch: true },
resistor: { label: '电阻', icon: '🎛', unit: 'Ω', min: 1, max: 100000, step: 1, isSwitch: false },
capacitor: { label: '电容', icon: '🔋', unit: 'μF', min: 0.1, max: 10000, step: 0.1, isSwitch: false },
inductor: { label: '电感', icon: '🧲', unit: 'mH', min: 0.1, max: 10000, step: 0.1, isSwitch: false },
voltage_src: { label: '电压源', icon: '⚡', unit: 'V', min: 0, max: 1000, step: 0.1, isSwitch: false },
};
function formatValue(value, mode) {
const cfg = MODE_CONFIG[mode];
if (!cfg) return `${value}`;
if (cfg.isSwitch) return value > 0.5 ? 'ON' : 'OFF';
return `${Number(value).toFixed(2)} ${cfg.unit}`;
}
export default function HardwarePanel() {
const [ports, setPorts] = useState([]);
const [values, setValues] = useState({});
const [wsStatus, setWsStatus] = useState('disconnected');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const wsRef = useRef(null);
// 从 URL 获取 session_id
const sessionId = new URLSearchParams(window.location.search).get('session_id');
// 获取硬件端口列表
useEffect(() => {
if (!sessionId) {
setError('缺少 session_id 参数');
setLoading(false);
return;
}
getHilPorts(sessionId).then(resp => {
if (resp.code === 0 && resp.data?.ports) {
const p = resp.data.ports;
setPorts(p);
// 初始值 = gain
const initVals = {};
p.forEach(port => {
const name = port.instance_name || port.name;
initVals[name] = port.mode === 'switch' ? 0 : port.gain;
});
setValues(initVals);
} else {
setError(resp.message || '获取端口列表失败');
}
setLoading(false);
}).catch(err => {
setError('网络错误: ' + err.message);
setLoading(false);
});
}, [sessionId]);
// 连接 rosbridge WebSocket
useEffect(() => {
const url = `ws://${window.location.hostname}:9090`;
console.log('[HardwarePanel] 连接 WebSocket:', url);
setWsStatus('connecting');
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
console.log('[HardwarePanel] WebSocket 已连接');
setWsStatus('connected');
// 必须先 advertise 话题,rosbridge 才能转发 publish
ws.send(JSON.stringify({
op: 'advertise',
topic: '/hil/user_override',
type: 'std_msgs/String',
}));
console.log('[HardwarePanel] 已 advertise /hil/user_override');
};
ws.onerror = (e) => { console.error('[HardwarePanel] WebSocket 错误:', e); setWsStatus('error'); };
ws.onclose = (e) => {
console.warn('[HardwarePanel] WebSocket 关闭:', e.code, e.reason);
// 仅清理自己的引用,防止 StrictMode 双挂载覆盖新连接
if (wsRef.current === ws) {
setWsStatus('disconnected');
wsRef.current = null;
}
};
return () => {
// 断开前 unadvertise
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ op: 'unadvertise', topic: '/hil/user_override' }));
}
ws.close();
};
}, []);
// 发布用户操控值
const publishOverride = useCallback((component, value) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('[HardwarePanel] WebSocket 未连接,无法发布', ws?.readyState);
return;
}
const payload = JSON.stringify({ component, value });
console.log('[HardwarePanel] 发布 override:', payload);
// rosbridge publish 协议
ws.send(JSON.stringify({
op: 'publish',
topic: '/hil/user_override',
msg: { data: payload },
}));
}, []);
// 更新值并发布
const handleChange = useCallback((component, value, mode) => {
const numVal = Number(value);
setValues(prev => ({ ...prev, [component]: numVal }));
publishOverride(component, numVal);
}, [publishOverride]);
// 开关切换
const handleToggle = useCallback((component) => {
setValues(prev => {
const newVal = prev[component] > 0.5 ? 0 : 1;
publishOverride(component, newVal);
return { ...prev, [component]: newVal };
});
}, [publishOverride]);
if (loading) {
return (
<div className={styles.page}>
<div className={styles.loading}>加载中...</div>
</div>
);
}
if (error) {
return (
<div className={styles.page}>
<div className={styles.error}>{error}</div>
</div>
);
}
return (
<div className={styles.page}>
<header className={styles.header}>
<h1 className={styles.title}>🎛 虚拟硬件面板</h1>
<div className={styles.headerInfo}>
<span className={styles.sessionBadge}>会话: {sessionId?.slice(0, 12)}...</span>
<span className={`${styles.wsDot} ${styles[wsStatus]}`} />
<span className={styles.wsLabel}>
{wsStatus === 'connected' ? '已连接' : wsStatus === 'connecting' ? '连接中...' : '未连接'}
</span>
</div>
</header>
<div className={styles.grid}>
{ports.map(port => {
const name = port.instance_name || port.name;
const cfg = MODE_CONFIG[port.mode] || MODE_CONFIG.resistor;
const value = values[name] ?? port.gain;
return (
<div key={name} className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardIcon}>{cfg.icon}</span>
<span className={styles.cardName}>{port.hw_label || name}</span>
<span className={styles.cardMode}>{cfg.label}</span>
</div>
<div className={styles.cardBody}>
{cfg.isSwitch ? (
/* 开关控件 */
<div className={styles.switchWrapper}>
<button
className={`${styles.switchBtn} ${value > 0.5 ? styles.switchOn : styles.switchOff}`}
onClick={() => handleToggle(name)}
>
<span className={styles.switchKnob} />
</button>
<span className={`${styles.switchLabel} ${value > 0.5 ? styles.on : styles.off}`}>
{value > 0.5 ? 'ON' : 'OFF'}
</span>
</div>
) : (
/* 滑条 + 数字输入 */
<div className={styles.sliderWrapper}>
<input
type="range"
className={styles.slider}
min={cfg.min}
max={cfg.max}
step={cfg.step}
value={value}
onChange={e => handleChange(name, e.target.value, port.mode)}
/>
<div className={styles.valueRow}>
<input
type="number"
className={styles.numberInput}
min={cfg.min}
max={cfg.max}
step={cfg.step}
value={value}
onChange={e => handleChange(name, e.target.value, port.mode)}
/>
<span className={styles.unit}>{cfg.unit}</span>
</div>
</div>
)}
</div>
<div className={styles.cardFooter}>
<span className={styles.currentValue}>
当前: {formatValue(value, port.mode)}
</span>
</div>
</div>
);
})}
</div>
{ports.length === 0 && (
<div className={styles.empty}>
当前会话没有标记为实物的元器件
</div>
)}
</div>
);
}
/* HardwarePanel — 虚拟硬件面板暗色主题样式 */
.page {
min-height: 100vh;
background: linear-gradient(135deg, #0a0e1a 0%, #111827 50%, #0f172a 100%);
color: #e2e8f0;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
padding: 24px;
}
.loading, .error, .empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
font-size: 18px;
color: #94a3b8;
}
.error { color: #f87171; }
/* ─── 头部 ─── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(79, 138, 255, 0.15);
}
.title {
font-size: 24px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #4f8aff, #22c55e);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.headerInfo {
display: flex;
align-items: center;
gap: 10px;
}
.sessionBadge {
background: rgba(79, 138, 255, 0.1);
border: 1px solid rgba(79, 138, 255, 0.2);
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
color: #94a3b8;
font-family: monospace;
}
.wsDot {
width: 8px; height: 8px;
border-radius: 50%;
background: #666;
}
.wsDot.connected { background: #22c55e; box-shadow: 0 0 6px #22c55e88; }
.wsDot.connecting { background: #fbbf24; }
.wsDot.error { background: #ef4444; }
.wsLabel { font-size: 12px; color: #94a3b8; }
/* ─── 网格 ─── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
/* ─── 卡片 ─── */
.card {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(79, 138, 255, 0.15);
border-radius: 16px;
padding: 20px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.card:hover {
border-color: rgba(79, 138, 255, 0.35);
box-shadow: 0 4px 20px rgba(79, 138, 255, 0.08);
}
.cardHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.cardIcon { font-size: 24px; }
.cardName {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.cardMode {
font-size: 11px;
color: #64748b;
background: rgba(100, 116, 139, 0.15);
padding: 2px 8px;
border-radius: 4px;
}
.cardBody {
margin: 16px 0;
}
.cardFooter {
border-top: 1px solid rgba(79, 138, 255, 0.08);
padding-top: 12px;
}
.currentValue {
font-size: 12px;
color: #64748b;
}
/* ─── 开关 ─── */
.switchWrapper {
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
padding: 12px 0;
}
.switchBtn {
width: 80px;
height: 40px;
border-radius: 40px;
border: none;
cursor: pointer;
position: relative;
transition: background 0.3s;
padding: 0;
}
.switchOff { background: #374151; }
.switchOn { background: #22c55e; box-shadow: 0 0 12px #22c55e44; }
.switchKnob {
position: absolute;
top: 4px;
width: 32px;
height: 32px;
border-radius: 50%;
background: white;
transition: left 0.3s;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.switchOff .switchKnob { left: 4px; }
.switchOn .switchKnob { left: 44px; }
.switchLabel {
font-size: 18px;
font-weight: 700;
width: 40px;
}
.switchLabel.on { color: #22c55e; }
.switchLabel.off { color: #64748b; }
/* ─── 滑条 ─── */
.sliderWrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
.slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #1e293b;
border-radius: 3px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #4f8aff;
cursor: pointer;
box-shadow: 0 0 8px rgba(79, 138, 255, 0.4);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #4f8aff;
cursor: pointer;
border: none;
}
.valueRow {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
}
.numberInput {
width: 120px;
padding: 8px 12px;
background: #1e293b;
border: 1px solid rgba(79, 138, 255, 0.2);
border-radius: 8px;
color: #e2e8f0;
font-size: 16px;
font-weight: 600;
text-align: center;
outline: none;
}
.numberInput:focus {
border-color: #4f8aff;
box-shadow: 0 0 0 2px rgba(79, 138, 255, 0.15);
}
.unit {
font-size: 14px;
color: #64748b;
min-width: 30px;
}
...@@ -67,12 +67,13 @@ export async function checkHealth() { ...@@ -67,12 +67,13 @@ export async function checkHealth() {
* @param {number} params.duration - 仿真时长 (秒) * @param {number} params.duration - 仿真时长 (秒)
* @returns {Promise<{code, message, data: {session_id, status, fmu_path, ports}}>} * @returns {Promise<{code, message, data: {session_id, status, fmu_path, ports}}>}
*/ */
export async function startHilSession({ moCode, modelName, hardwarePorts, stepSize = 0.001, duration = 10.0 }) { export async function startHilSession({ moCode, modelName, fmuPath = '', hardwarePorts, stepSize = 0.001, duration = 10.0 }) {
return request('/hil/start', { return request('/hil/start', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
mo_code: moCode, mo_code: moCode,
model_name: modelName, model_name: modelName,
fmu_path: fmuPath,
hardware_ports: hardwarePorts.map(p => ({ hardware_ports: hardwarePorts.map(p => ({
name: p.name, name: p.name,
instance_name: p.instanceName, instance_name: p.instanceName,
...@@ -84,6 +85,7 @@ export async function startHilSession({ moCode, modelName, hardwarePorts, stepSi ...@@ -84,6 +85,7 @@ export async function startHilSession({ moCode, modelName, hardwarePorts, stepSi
topic: p.topic || '', topic: p.topic || '',
hw_label: p.hwNodeLabel || '', hw_label: p.hwNodeLabel || '',
hw_node_id: p.hwNodeId || '', hw_node_id: p.hwNodeId || '',
gain: p.gain ?? 1.0,
})), })),
step_size: stepSize, step_size: stepSize,
duration, duration,
...@@ -122,3 +124,13 @@ export async function getHilResults(sessionId) { ...@@ -122,3 +124,13 @@ export async function getHilResults(sessionId) {
const resp = await fetch(`${API_BASE}/hil/results?session_id=${encodeURIComponent(sessionId)}`); const resp = await fetch(`${API_BASE}/hil/results?session_id=${encodeURIComponent(sessionId)}`);
return resp.json(); return resp.json();
} }
/**
* 获取半实物仿真硬件端口列表
* @param {string} sessionId
* @returns {Promise<{code, message, data: {session_id, ports: [{name, instance_name, mode, gain, hw_label}]}}>}
*/
export async function getHilPorts(sessionId) {
const resp = await fetch(`${API_BASE}/hil/ports?session_id=${encodeURIComponent(sessionId)}`);
return resp.json();
}
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