Commit 9b79e64e authored by Developer's avatar Developer

feat: 优化项目面板三段式布局、实物节点样式和HIL结果获取

- ProjectPanel: 重构为模型编译/软件仿真/半实物仿真三段式卡片布局
- ProjectPanel: 编译成功后才显示仿真按钮,项目点击展开/折叠
- ProjectPanel: 合并获取+查看HIL结果为单按钮,HIL时长默认10秒
- ProjectPanel: 半实物仿真按钮重命名,去除冗余状态提示
- CustomDeviceNode: 实物节点虚线框和发光使用卡片主题色
- CustomDeviceNode: 每种颜色独立动画名,避免多节点颜色冲突
- CustomDeviceNode: 实物节点选中时白色虚线边框+白色光晕
- PropertiesPanel: toggle关闭态改为蓝灰色,文字改为实物标记
- useProjectStore: localStorage持久化剔除csvData避免超限
- useProjectStore: fetchHilResults增加错误反馈,成功后清除errorDetail
- useProjectStore: openProject支持null参数实现折叠
parent e722bb2d
...@@ -44,16 +44,27 @@ function CustomDeviceNode({ data, selected }) { ...@@ -44,16 +44,27 @@ function CustomDeviceNode({ data, selected }) {
background: '#1e1e2e', background: '#1e1e2e',
borderRadius: 8, borderRadius: 8,
outline: isHardware outline: isHardware
? '2px dashed #fb923c' ? `${selected ? 3 : 2}px dashed ${selected ? '#fff' : color}`
: `2px solid ${selected ? '#fff' : color}`, : `2px solid ${selected ? '#fff' : color}`,
outlineOffset: -1, outlineOffset: -1,
boxShadow: isHardware boxShadow: isHardware
? '0 0 14px rgba(251,146,60,0.35)' ? (selected ? `0 0 16px rgba(255,255,255,0.4), 0 0 14px ${color}55` : `0 0 14px ${color}55`)
: selected ? `0 0 12px ${color}88` : '0 4px 12px rgba(0,0,0,0.3)', : selected ? `0 0 12px ${color}88` : '0 4px 12px rgba(0,0,0,0.3)',
overflow: 'visible', overflow: 'visible',
transform: rotation ? `rotate(${rotation}deg)` : undefined, transform: rotation ? `rotate(${rotation}deg)` : undefined,
fontFamily: "'Segoe UI', sans-serif", fontFamily: "'Segoe UI', sans-serif",
animation: isHardware ? `hwGlow_${color.replace('#','')} 2s ease-in-out infinite` : undefined,
}}> }}>
{/* 发光动画关键帧 — 按主题色独立命名 */}
{isHardware && (
<style>{`
@keyframes hwGlow_${color.replace('#','')} {
0%, 100% { box-shadow: 0 0 12px ${color}66; }
50% { box-shadow: 0 0 28px ${color}bb, 0 0 56px ${color}44; }
}
`}</style>
)}
{/* 彩色 Header */} {/* 彩色 Header */}
<div style={{ <div style={{
background: color, background: color,
...@@ -72,7 +83,14 @@ function CustomDeviceNode({ data, selected }) { ...@@ -72,7 +83,14 @@ function CustomDeviceNode({ data, selected }) {
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{name}</span> }}>{name}</span>
{isHardware && ( {isHardware && (
<span style={{ fontSize: 12, flexShrink: 0 }} title="实物设备">🔧</span> <span style={{
fontSize: 8, fontWeight: 800, color: '#fff',
background: '#fb923c',
border: '1.5px solid #fff',
padding: '1px 6px', borderRadius: 10,
letterSpacing: 1, lineHeight: '13px',
flexShrink: 0,
}}>HW</span>
)} )}
</div> </div>
......
...@@ -57,7 +57,7 @@ export default function ProjectPanel() { ...@@ -57,7 +57,7 @@ export default function ProjectPanel() {
const [showExecuteLog, setShowExecuteLog] = useState(false); const [showExecuteLog, setShowExecuteLog] = useState(false);
const [showResultsMode, setShowResultsMode] = useState(null); // null | 'sim' | 'hil' const [showResultsMode, setShowResultsMode] = useState(null); // null | 'sim' | 'hil'
const [simDuration, setSimDuration] = useState(1); const [simDuration, setSimDuration] = useState(1);
const [hilDuration, setHilDuration] = useState(1); const [hilDuration, setHilDuration] = useState(10);
const [toastMsg, setToastMsg] = useState(null); const [toastMsg, setToastMsg] = useState(null);
const [toastFading, setToastFading] = useState(false); const [toastFading, setToastFading] = useState(false);
const [showLiveChart, setShowLiveChart] = useState(false); const [showLiveChart, setShowLiveChart] = useState(false);
...@@ -73,6 +73,33 @@ export default function ProjectPanel() { ...@@ -73,6 +73,33 @@ export default function ProjectPanel() {
}, 2000); }, 2000);
}, []); }, []);
// ── 监听仿真完成 /hil/done ──
const hilRunningProject = projects.find(p => p.hilStatus === 'running');
useEffect(() => {
if (!hilRunningProject) return;
const projectId = hilRunningProject.id;
const ws = new WebSocket(`ws://${window.location.hostname}:9090`);
let done = false;
ws.onopen = () => {
ws.send(JSON.stringify({ op: 'subscribe', topic: '/hil/done', type: 'std_msgs/String' }));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.topic === '/hil/done' && !done) {
done = true;
hilDone(projectId);
}
} catch { /* ignore */ }
};
return () => {
done = true;
if (ws.readyState <= 1) ws.close();
};
}, [hilRunningProject?.id, hilDone]);
const activeProject = projects.find(p => p.id === activeProjectId) || null; const activeProject = projects.find(p => p.id === activeProjectId) || null;
// 执行成功后自动弹出结果页面(仅在 running → success 转换时触发) // 执行成功后自动弹出结果页面(仅在 running → success 转换时触发)
...@@ -185,7 +212,7 @@ export default function ProjectPanel() { ...@@ -185,7 +212,7 @@ export default function ProjectPanel() {
return ( return (
<div key={p.id} className={`${styles.projectItem} ${isActive ? styles.active : ''}`}> <div key={p.id} className={`${styles.projectItem} ${isActive ? styles.active : ''}`}>
{/* 项目标题行 */} {/* 项目标题行 */}
<div className={styles.projectHeader} onClick={() => openProject(p.id)}> <div className={styles.projectHeader} onClick={() => isActive ? openProject(null) : openProject(p.id)}>
<span className={styles.projectIcon}> <span className={styles.projectIcon}>
{p.compileStatus === 'success' ? '✅' : p.compileStatus === 'error' ? '❌' : '📄'} {p.compileStatus === 'success' ? '✅' : p.compileStatus === 'error' ? '❌' : '📄'}
</span> </span>
...@@ -227,21 +254,13 @@ export default function ProjectPanel() { ...@@ -227,21 +254,13 @@ export default function ProjectPanel() {
{/* 展开区域:编译 & 执行 */} {/* 展开区域:编译 & 执行 */}
{isActive && ( {isActive && (
<div className={styles.expandedSection}> <div className={styles.expandedSection}>
{/* 状态指示 */}
<div className={styles.statusRow}> {/* ── 模块 1: 模型编译 ── */}
<span style={{ fontSize: 10, color: '#666' }}>编译:</span> <div className={styles.sectionCard}>
<StatusBadge <div className={styles.sectionTitle}>
status={p.compileStatus} <span className={styles.sectionTitleIcon}></span> 模型编译
labels={{ none: '未编译', success: '编译成功', error: '编译失败' }}
/>
<span style={{ fontSize: 10, color: '#666', marginLeft: 8 }}>执行:</span>
<StatusBadge
status={p.executeStatus}
labels={{ none: '未执行', success: '执行完成', error: '执行失败' }}
/>
</div> </div>
{/* 模型名 */}
<div className={styles.modelNameRow}> <div className={styles.modelNameRow}>
<span style={{ fontSize: 11, color: '#666', flexShrink: 0 }}>模型名:</span> <span style={{ fontSize: 11, color: '#666', flexShrink: 0 }}>模型名:</span>
<input <input
...@@ -253,12 +272,11 @@ export default function ProjectPanel() { ...@@ -253,12 +272,11 @@ export default function ProjectPanel() {
/> />
</div> </div>
{/* 编译按钮 */}
<button <button
className={`${styles.simBtn} ${styles.compile}`} className={`${styles.simBtn} ${styles.compile}`}
onClick={(e) => { e.stopPropagation(); compileProject(p.id); }} onClick={(e) => { e.stopPropagation(); compileProject(p.id); }}
disabled={p.compileStatus === 'compiling'} disabled={p.compileStatus === 'compiling'}
style={{ width: '100%', marginBottom: 6 }} style={{ width: '100%', marginBottom: 4 }}
> >
{p.compileStatus === 'compiling' ? ( {p.compileStatus === 'compiling' ? (
<><span className={styles.spinner}></span> 编译中…</> <><span className={styles.spinner}></span> 编译中…</>
...@@ -267,8 +285,37 @@ export default function ProjectPanel() { ...@@ -267,8 +285,37 @@ export default function ProjectPanel() {
)} )}
</button> </button>
{/* 普通仿真: 时长 + 执行按钮 */} <div className={styles.statusRow}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <span style={{ fontSize: 10, color: '#666' }}>状态:</span>
<StatusBadge
status={p.compileStatus}
labels={{ none: '未编译', success: '编译成功', error: '编译失败' }}
/>
</div>
{/* 编译日志 */}
{p.compileResult && (
<div className={styles.logSection}>
<button className={styles.logToggle} onClick={(e) => { e.stopPropagation(); setShowCompileLog(v => !v); }}>
{showCompileLog ? '▼' : '▶'} 编译输出
</button>
{showCompileLog && (
<div className={`${styles.logContent} ${p.compileStatus === 'error' ? styles.logError : styles.logSuccess}`}>
{p.compileResult.errors || p.compileResult.output || '无输出'}
</div>
)}
</div>
)}
</div>
{/* ── 模块 2: 软件仿真(编译成功后显示) ── */}
{p.compileStatus === 'success' && (
<div className={styles.sectionCard}>
<div className={styles.sectionTitle}>
<span className={styles.sectionTitleIcon}></span> 软件仿真
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<label style={{ fontSize: 11, color: '#888', whiteSpace: 'nowrap' }}>仿真时长:</label> <label style={{ fontSize: 11, color: '#888', whiteSpace: 'nowrap' }}>仿真时长:</label>
<input <input
type="number" type="number"
...@@ -277,22 +324,20 @@ export default function ProjectPanel() { ...@@ -277,22 +324,20 @@ export default function ProjectPanel() {
onChange={(e) => setSimDuration(e.target.value === '' ? '' : Number(e.target.value))} onChange={(e) => setSimDuration(e.target.value === '' ? '' : Number(e.target.value))}
onBlur={() => { if (!simDuration || simDuration <= 0) setSimDuration(1); }} onBlur={() => { if (!simDuration || simDuration <= 0) setSimDuration(1); }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
disabled={p.compileStatus !== 'success'}
style={{ style={{
flex: 1, padding: '3px 6px', fontSize: 11, flex: 1, padding: '3px 6px', fontSize: 11,
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 4, color: '#ccc', outline: 'none', borderRadius: 4, color: '#ccc', outline: 'none',
opacity: p.compileStatus !== 'success' ? 0.4 : 1,
}} }}
/> />
<span style={{ fontSize: 11, color: '#666' }}></span> <span style={{ fontSize: 11, color: '#666' }}></span>
</div> </div>
<button <button
className={`${styles.simBtn} ${styles.execute}`} className={`${styles.simBtn} ${styles.execute}`}
onClick={(e) => { e.stopPropagation(); executeProject(p.id, { stopTime: simDuration }); }} onClick={(e) => { e.stopPropagation(); executeProject(p.id, { stopTime: simDuration }); }}
disabled={p.compileStatus !== 'success' || p.executeStatus === 'running'} disabled={p.executeStatus === 'running'}
title={p.compileStatus !== 'success' ? '请先编译成功' : '下发等效设备执行'} style={{ width: '100%', marginBottom: 4 }}
style={{ width: '100%', marginBottom: 6 }}
> >
{p.executeStatus === 'running' ? ( {p.executeStatus === 'running' ? (
<><span className={styles.spinner}></span> 下发中…</> <><span className={styles.spinner}></span> 下发中…</>
...@@ -301,14 +346,57 @@ export default function ProjectPanel() { ...@@ -301,14 +346,57 @@ export default function ProjectPanel() {
)} )}
</button> </button>
{/* 半实物仿真: 时长 + HIL 按钮 */} {p.executeStatus && p.executeStatus !== 'none' && (
{(() => { <div className={styles.statusRow} style={{ marginBottom: 4 }}>
<span style={{ fontSize: 10, color: '#666' }}>状态:</span>
<StatusBadge
status={p.executeStatus}
labels={{ none: '未执行', success: '执行完成', error: '执行失败' }}
/>
</div>
)}
{/* 查看软件仿真结果 */}
{p.executeStatus === 'success' && p.executeResult?.csvData && (
<button
className={`${styles.simBtn} ${styles.viewResults}`}
onClick={(e) => { e.stopPropagation(); setShowResultsMode('sim'); }}
style={{ width: '100%', marginBottom: 4 }}
>📊 查看仿真结果</button>
)}
{/* 执行日志 */}
{p.executeResult && (
<div className={styles.logSection}>
<button className={styles.logToggle} onClick={(e) => { e.stopPropagation(); setShowExecuteLog(v => !v); }}>
{showExecuteLog ? '▼' : '▶'} 执行结果
</button>
{showExecuteLog && (
<div className={`${styles.logContent} ${p.executeStatus === 'error' ? styles.logError : ''}`}>
{p.executeResult.logs || p.executeResult.csvData || '无输出'}
</div>
)}
</div>
)}
</div>
)}
{/* ── 模块 3: 半实物仿真(编译成功后显示) ── */}
{p.compileStatus === 'success' && (() => {
const hwCount = (p.nodes || []).filter(n => n.data?.isHardware).length; const hwCount = (p.nodes || []).filter(n => n.data?.isHardware).length;
const hilDisabled = hwCount === 0 || p.compileStatus !== 'success' || p.hilStatus === 'starting' || p.hilStatus === 'running'; const hilDisabled = hwCount === 0 || p.hilStatus === 'starting' || p.hilStatus === 'running';
return ( return (
<div className={styles.sectionCardHil}>
<div className={styles.sectionTitle} style={{ color: '#fb923c' }}>
<span className={styles.sectionTitleIcon}>🔧</span> 半实物仿真
{hwCount > 0 && <span style={{ fontSize: 10, opacity: 0.7 }}>({hwCount} 个实物节点)</span>}
</div>
{hwCount === 0 ? (
<span style={{ fontSize: 10, color: '#666' }}>请先在属性面板中将节点标记为实物</span>
) : (
<> <>
{hwCount > 0 && ( <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<label style={{ fontSize: 11, color: '#888', whiteSpace: 'nowrap' }}>HIL时长:</label> <label style={{ fontSize: 11, color: '#888', whiteSpace: 'nowrap' }}>HIL时长:</label>
<input <input
type="number" type="number"
...@@ -326,131 +414,75 @@ export default function ProjectPanel() { ...@@ -326,131 +414,75 @@ export default function ProjectPanel() {
/> />
<span style={{ fontSize: 11, color: '#666' }}></span> <span style={{ fontSize: 11, color: '#666' }}></span>
</div> </div>
)}
<button <button
className={`${styles.simBtn} ${styles.execute}`} className={`${styles.simBtn} ${styles.execute}`}
style={{ style={{
width: '100%', marginBottom: 4, width: '100%', marginBottom: 4,
background: hwCount > 0 ? 'rgba(251,146,60,0.08)' : undefined, background: 'rgba(251,146,60,0.08)',
borderColor: hwCount > 0 ? 'rgba(251,146,60,0.3)' : undefined, borderColor: 'rgba(251,146,60,0.3)',
color: hwCount > 0 ? '#fb923c' : undefined, color: '#fb923c',
}} }}
disabled={hilDisabled} disabled={hilDisabled}
title={p.compileStatus !== 'success' ? '请先编译成功' : hwCount === 0 ? '请先在属性面板中将节点标记为实物' : `${hwCount} 个实物节点`}
onClick={(e) => { e.stopPropagation(); startHil(p.id, { duration: hilDuration }); }} onClick={(e) => { e.stopPropagation(); startHil(p.id, { duration: hilDuration }); }}
> >
{p.hilStatus === 'starting' ? ( {p.hilStatus === 'starting' ? (
<><span className={styles.spinner}></span> 启动中…</> <><span className={styles.spinner}></span> 启动中…</>
) : ( ) : (
<>🔧 半实物仿真 {hwCount > 0 && <span style={{ opacity: 0.7 }}>({hwCount})</span>}</> <>🔧 半实物仿真</>
)} )}
</button> </button>
</>
);
})()}
{/* HIL 状态显示 */} {/* HIL 状态 + 运行中按钮 */}
{p.hilStatus && p.hilStatus !== 'none' && ( {p.hilStatus && p.hilStatus !== 'none' && (
<div className={styles.statusRow} style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}> <>
<span style={{ fontSize: 11, color: '#888' }}>半实物仿真:</span> <div className={styles.statusRow} style={{ marginBottom: 4 }}>
<span style={{ fontSize: 10, color: '#666' }}>状态:</span>
<StatusBadge status={p.hilStatus === 'starting' ? 'compiling' : p.hilStatus === 'running' ? 'running' : p.hilStatus} <StatusBadge status={p.hilStatus === 'starting' ? 'compiling' : p.hilStatus === 'running' ? 'running' : p.hilStatus}
labels={{ none: '未启动', success: '就绪', error: '失败' }} /> labels={{ none: '未启动', success: '就绪', error: '失败', done: '已完成', stopped: '已停止' }} />
{p.hilResult?.message && ( </div>
<span style={{ fontSize: 10, color: p.hilStatus === 'error' ? '#ef4444' : '#aaa' }}>
{p.hilResult.message}
</span>
)}
{p.hilResult?.fmuPath && (
<span style={{ fontSize: 9, color: '#666', wordBreak: 'break-all' }}>
FMU: {p.hilResult.fmuPath}
</span>
)}
{p.hilResult?.errorDetail && ( {p.hilResult?.errorDetail && (
<span style={{ fontSize: 9, color: '#ef4444', wordBreak: 'break-all' }}> <span style={{ fontSize: 9, color: '#ef4444', wordBreak: 'break-all', display: 'block', marginBottom: 4 }}>
{p.hilResult.errorDetail} {p.hilResult.errorDetail}
</span> </span>
)} )}
{/* 停止按钮 */} </>
)}
{/* 运行中: 实时数据 + 停止 */}
{p.hilStatus === 'running' && ( {p.hilStatus === 'running' && (
<div style={{ display: 'flex', gap: 4, width: '100%', marginTop: 4 }}> <div style={{ display: 'flex', gap: 4, width: '100%', marginBottom: 4 }}>
<button <button
className={`${styles.simBtn}`} className={`${styles.simBtn}`}
style={{ style={{ flex: 1, background: 'rgba(79,138,255,0.08)', borderColor: 'rgba(79,138,255,0.3)', color: '#4f8aff' }}
flex: 1,
background: 'rgba(79,138,255,0.08)',
borderColor: 'rgba(79,138,255,0.3)',
color: '#4f8aff',
}}
onClick={(e) => { e.stopPropagation(); setShowLiveChart(true); }} onClick={(e) => { e.stopPropagation(); setShowLiveChart(true); }}
>📈 实时数据</button> >📈 实时数据</button>
<button <button
className={`${styles.simBtn}`} className={`${styles.simBtn}`}
style={{ style={{ flex: 1, background: 'rgba(239,68,68,0.08)', borderColor: 'rgba(239,68,68,0.3)', color: '#ef4444' }}
flex: 1,
background: 'rgba(239,68,68,0.08)',
borderColor: 'rgba(239,68,68,0.3)',
color: '#ef4444',
}}
onClick={(e) => { e.stopPropagation(); stopHil(p.id); }} onClick={(e) => { e.stopPropagation(); stopHil(p.id); }}
>⏹ 停止仿真</button> >⏹ 停止</button>
</div>
)}
</div> </div>
)} )}
{/* 查看 HIL 结果 */} {/* 查看 HIL 结果(自动获取+显示) */}
{(p.hilStatus === 'done' || p.hilStatus === 'stopped') && p.hilResult?.csvData && ( {(p.hilStatus === 'done' || p.hilStatus === 'stopped' || p.hilStatus === 'running') && (
<button <button
className={`${styles.simBtn} ${styles.viewResults}`} className={`${styles.simBtn} ${styles.viewResults}`}
onClick={(e) => { e.stopPropagation(); setShowResultsMode('hil'); }} onClick={async (e) => {
e.stopPropagation();
if (!p.hilResult?.csvData) await fetchHilResults(p.id);
setShowResultsMode('hil');
}}
style={{ width: '100%', marginBottom: 4 }} style={{ width: '100%', marginBottom: 4 }}
>📊 查看 HIL 结果</button> >📊 查看 HIL 结果</button>
)} )}
{/* 获取结果按钮(运行中/停止后可尝试获取) */} </>
{(p.hilStatus === 'running' || p.hilStatus === 'stopped') && !p.hilResult?.csvData && (
<button
className={`${styles.simBtn}`}
style={{ width: '100%', marginBottom: 4, color: '#60a5fa' }}
onClick={(e) => { e.stopPropagation(); fetchHilResults(p.id); }}
>📲 获取 HIL 结果</button>
)}
{/* 查看普通仿真结果 */}
{p.executeStatus === 'success' && p.executeResult?.csvData && (
<button
className={`${styles.simBtn} ${styles.viewResults}`}
onClick={(e) => { e.stopPropagation(); setShowResultsMode('sim'); }}
style={{ width: '100%', marginBottom: 4 }}
>📊 查看仿真结果</button>
)}
{/* 编译日志 */}
{p.compileResult && (
<div className={styles.logSection}>
<button className={styles.logToggle} onClick={(e) => { e.stopPropagation(); setShowCompileLog(v => !v); }}>
{showCompileLog ? '▼' : '▶'} 编译输出
</button>
{showCompileLog && (
<div className={`${styles.logContent} ${p.compileStatus === 'error' ? styles.logError : styles.logSuccess}`}>
{p.compileResult.errors || p.compileResult.output || '无输出'}
</div>
)} )}
</div> </div>
)} );
})()}
{/* 执行日志 */}
{p.executeResult && (
<div className={styles.logSection}>
<button className={styles.logToggle} onClick={(e) => { e.stopPropagation(); setShowExecuteLog(v => !v); }}>
{showExecuteLog ? '▼' : '▶'} 执行结果
</button>
{showExecuteLog && (
<div className={`${styles.logContent} ${p.executeStatus === 'error' ? styles.logError : ''}`}>
{p.executeResult.logs || p.executeResult.csvData || '无输出'}
</div>
)}
</div>
)}
</div> </div>
)} )}
</div> </div>
...@@ -477,6 +509,7 @@ export default function ProjectPanel() { ...@@ -477,6 +509,7 @@ export default function ProjectPanel() {
{showLiveChart && ( {showLiveChart && (
<LiveChart <LiveChart
rosBridgeUrl={`ws://${window.location.hostname}:9090`} rosBridgeUrl={`ws://${window.location.hostname}:9090`}
sessionId={activeProject?.hilResult?.sessionId}
onClose={() => setShowLiveChart(false)} onClose={() => setShowLiveChart(false)}
onDone={() => { onDone={() => {
const ap = activeProject; const ap = activeProject;
......
...@@ -492,3 +492,35 @@ ...@@ -492,3 +492,35 @@
background: rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.15);
color: #86efac; color: #86efac;
} }
/* ===== 三段式模块卡片 ===== */
.sectionCard {
background: rgba(255,255,255,0.02);
border: 1px solid #2a2a3a;
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
}
.sectionCardHil {
border: 1px solid rgba(251,146,60,0.18);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
background: rgba(251,146,60,0.04);
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: #999;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 5px;
letter-spacing: 0.5px;
}
.sectionTitleIcon {
font-size: 12px;
}
...@@ -104,10 +104,10 @@ export default function PropertiesPanel() { ...@@ -104,10 +104,10 @@ export default function PropertiesPanel() {
))} ))}
</div> </div>
{/* 半实物仿真: 实物设备标记(流程节点不显示) */} {/* 实物标记(流程节点不显示) */}
{!getModelMapping(selectedNode.data?.templateData?.type)?.isFlowNode && ( {!getModelMapping(selectedNode.data?.templateData?.type)?.isFlowNode && (
<> <>
<label className={styles.label}>半实物仿真</label> <label className={styles.label}>实物标记</label>
<div <div
style={{ style={{
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
...@@ -120,7 +120,7 @@ export default function PropertiesPanel() { ...@@ -120,7 +120,7 @@ export default function PropertiesPanel() {
> >
<div style={{ <div style={{
width: 36, height: 20, borderRadius: 10, position: 'relative', width: 36, height: 20, borderRadius: 10, position: 'relative',
background: selectedNode.data?.isHardware ? '#fb923c' : '#333', background: selectedNode.data?.isHardware ? '#fb923c' : '#3b4a5c',
transition: 'background 0.2s', transition: 'background 0.2s',
}}> }}>
<div style={{ <div style={{
...@@ -132,7 +132,7 @@ export default function PropertiesPanel() { ...@@ -132,7 +132,7 @@ export default function PropertiesPanel() {
</div> </div>
<span style={{ <span style={{
fontSize: 12, fontWeight: 600, fontSize: 12, fontWeight: 600,
color: selectedNode.data?.isHardware ? '#fb923c' : '#666', color: selectedNode.data?.isHardware ? '#fb923c' : '#8eafc5',
}}> }}>
{selectedNode.data?.isHardware ? '🔧 实物设备' : '软件仿真'} {selectedNode.data?.isHardware ? '🔧 实物设备' : '软件仿真'}
</span> </span>
......
...@@ -19,9 +19,26 @@ function loadProjects() { ...@@ -19,9 +19,26 @@ function loadProjects() {
} }
} }
/** 保存项目列表到 localStorage */ /** 保存项目列表到 localStorage(剔除大体积数据避免超限) */
function persistProjects(projects) { function persistProjects(projects) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)); const lite = projects.map(p => {
const clean = { ...p };
// csvData 体积大,仅保留在内存不持久化
if (clean.executeResult) {
clean.executeResult = { ...clean.executeResult };
delete clean.executeResult.csvData;
}
if (clean.hilResult) {
clean.hilResult = { ...clean.hilResult };
delete clean.hilResult.csvData;
}
return clean;
});
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(lite));
} catch (e) {
console.warn('[persistProjects] localStorage 写入失败:', e.message);
}
} }
function generateId() { function generateId() {
...@@ -119,6 +136,7 @@ const useProjectStore = create((set, get) => ({ ...@@ -119,6 +136,7 @@ const useProjectStore = create((set, get) => ({
/** 打开项目,加载数据到画布 */ /** 打开项目,加载数据到画布 */
openProject: (id) => { openProject: (id) => {
if (!id) { set({ activeProjectId: null }); return; }
const project = get().projects.find(p => p.id === id); const project = get().projects.find(p => p.id === id);
if (!project) return; if (!project) return;
const flowStore = useFlowStore.getState(); const flowStore = useFlowStore.getState();
...@@ -297,10 +315,12 @@ const useProjectStore = create((set, get) => ({ ...@@ -297,10 +315,12 @@ const useProjectStore = create((set, get) => ({
return; return;
} }
// 调用后端启动 HIL // 调用后端启动 HIL(复用已有 FMU 跳过编译)
const cachedFmu = project.hilResult?.fmuPath || '';
const resp = await startHilSession({ const resp = await startHilSession({
moCode: exported.code, moCode: exported.code,
modelName, modelName,
fmuPath: cachedFmu,
hardwarePorts: exported.hardwarePorts, hardwarePorts: exported.hardwarePorts,
duration, duration,
stepSize, stepSize,
...@@ -335,6 +355,20 @@ const useProjectStore = create((set, get) => ({ ...@@ -335,6 +355,20 @@ const useProjectStore = create((set, get) => ({
const project = projects.find(p => p.id === id); const project = projects.find(p => p.id === id);
if (!project) return; if (!project) return;
// 立即更新 UI 状态为完成
const setStatus = (updates) => {
const updated = get().projects.map(p =>
p.id === id ? { ...p, ...updates } : p
);
persistProjects(updated);
set({ projects: updated });
};
setStatus({
hilStatus: 'done',
hilResult: { ...project.hilResult, message: '仿真已完成' },
});
// 延迟 1s 等待 CSV 写入完成,然后获取结果 // 延迟 1s 等待 CSV 写入完成,然后获取结果
setTimeout(() => get().fetchHilResults(id), 1000); setTimeout(() => get().fetchHilResults(id), 1000);
}, },
...@@ -371,7 +405,7 @@ const useProjectStore = create((set, get) => ({ ...@@ -371,7 +405,7 @@ const useProjectStore = create((set, get) => ({
fetchHilResults: async (id) => { fetchHilResults: async (id) => {
const { projects } = get(); const { projects } = get();
const project = projects.find(p => p.id === id); const project = projects.find(p => p.id === id);
if (!project || !project.hilResult?.sessionId) return; if (!project) return;
const setStatus = (updates) => { const setStatus = (updates) => {
const updated = get().projects.map(p => const updated = get().projects.map(p =>
...@@ -381,8 +415,18 @@ const useProjectStore = create((set, get) => ({ ...@@ -381,8 +415,18 @@ const useProjectStore = create((set, get) => ({
set({ projects: updated }); set({ projects: updated });
}; };
const sid = project.hilResult?.sessionId;
if (!sid) {
console.warn('[fetchHilResults] sessionId 缺失', project.hilResult);
setStatus({
hilResult: { ...project.hilResult, errorDetail: '无法获取结果:缺少 sessionId' },
});
return;
}
try { try {
const resp = await getHilResults(project.hilResult.sessionId); const resp = await getHilResults(sid);
console.log('[fetchHilResults] resp:', resp);
if (resp.code === 0 && resp.data?.csv_data) { if (resp.code === 0 && resp.data?.csv_data) {
setStatus({ setStatus({
hilStatus: 'done', hilStatus: 'done',
...@@ -390,11 +434,19 @@ const useProjectStore = create((set, get) => ({ ...@@ -390,11 +434,19 @@ const useProjectStore = create((set, get) => ({
...project.hilResult, ...project.hilResult,
message: '仿真完成', message: '仿真完成',
csvData: resp.data.csv_data, csvData: resp.data.csv_data,
errorDetail: null,
}, },
}); });
} else {
setStatus({
hilResult: { ...project.hilResult, errorDetail: resp.message || '结果为空,CSV 可能尚未写入' },
});
} }
} catch (err) { } catch (err) {
// 结果未就绪,静默失败 console.error('[fetchHilResults] error:', err);
setStatus({
hilResult: { ...project.hilResult, errorDetail: `获取失败: ${err.message}` },
});
} }
}, },
......
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