Commit cf0265ab authored by fenghen777's avatar fenghen777

feat: HIL实时数据优化 + 键盘事件修复 + 自定义删除确认框

- useRosBridge: 添加WebSocket自动重连机制(最多15次,间隔1s)
- ProjectPanel: HIL启动后自动打开LiveChart实时数据窗口
- ProjectPanel: /hil/done监听增加重连逻辑
- FlowCanvas: 空格键旋转改为焦点作用域(不再穿透弹窗)
- LiveChart/SimResultsModal: 弹窗打开时自动接管焦点
- ProjectPanel: 原生confirm替换为暗色主题popover确认框
- 按钮文案: 下发等效设备执行 → 软件仿真
parent b80fdf7e
...@@ -3136,7 +3136,7 @@ ...@@ -3136,7 +3136,7 @@
}, },
"node_modules/uplot": { "node_modules/uplot": {
"version": "1.6.32", "version": "1.6.32",
"resolved": "https://registry.npmmirror.com/uplot/-/uplot-1.6.32.tgz", "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT" "license": "MIT"
}, },
......
...@@ -95,36 +95,32 @@ export default function FlowCanvas() { ...@@ -95,36 +95,32 @@ export default function FlowCanvas() {
clearSelection(); clearSelection();
}, [clearSelection]); }, [clearSelection]);
/** 空格键旋转选中节点 */ /** 空格键旋转选中节点 — 仅画布获得焦点时生效 */
useEffect(() => { const handleCanvasKeyDown = useCallback((e) => {
function handleKeyDown(e) { if (e.code !== 'Space') return;
if (e.code !== 'Space') return; // 避免在输入框中触发
// 避免在输入框中触发 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const { selectedNode, nodes } = useFlowStore.getState(); const { selectedNode, nodes } = useFlowStore.getState();
if (!selectedNode) return; if (!selectedNode) return;
e.preventDefault(); e.preventDefault();
const currentRotation = selectedNode.data?.rotation || 0; const currentRotation = selectedNode.data?.rotation || 0;
const newRotation = (currentRotation + 90) % 360; const newRotation = (currentRotation + 90) % 360;
useFlowStore.setState({ useFlowStore.setState({
nodes: nodes.map(n => nodes: nodes.map(n =>
n.id === selectedNode.id n.id === selectedNode.id
? { ...n, data: { ...n.data, rotation: newRotation } } ? { ...n, data: { ...n.data, rotation: newRotation } }
: n : n
), ),
selectedNode: { ...selectedNode, data: { ...selectedNode.data, rotation: newRotation } }, selectedNode: { ...selectedNode, data: { ...selectedNode.data, rotation: newRotation } },
}); });
// 强制 React Flow 重新计算 Handle 位置,解决旋转后连线脱离 // 强制 React Flow 重新计算 Handle 位置,解决旋转后连线脱离
requestAnimationFrame(() => { requestAnimationFrame(() => {
updateNodeInternalsRef.current?.(selectedNode.id); updateNodeInternalsRef.current?.(selectedNode.id);
}); });
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
/** 双击画布空白处添加新节点 */ /** 双击画布空白处添加新节点 */
...@@ -206,7 +202,7 @@ export default function FlowCanvas() { ...@@ -206,7 +202,7 @@ export default function FlowCanvas() {
}, []); }, []);
return ( return (
<div className={styles.canvas} ref={reactFlowWrapper}> <div className={styles.canvas} ref={reactFlowWrapper} tabIndex={-1} onKeyDown={handleCanvasKeyDown} style={{ outline: 'none' }}>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
......
...@@ -62,6 +62,7 @@ export default function ProjectPanel() { ...@@ -62,6 +62,7 @@ export default function ProjectPanel() {
const [toastFading, setToastFading] = useState(false); const [toastFading, setToastFading] = useState(false);
const [showLiveChart, setShowLiveChart] = useState(false); const [showLiveChart, setShowLiveChart] = useState(false);
const toastTimerRef = useRef(null); const toastTimerRef = useRef(null);
const [confirmState, setConfirmState] = useState(null); // { id, name }
const showToast = useCallback((msg) => { const showToast = useCallback((msg) => {
setToastMsg(msg); setToastMsg(msg);
...@@ -73,30 +74,60 @@ export default function ProjectPanel() { ...@@ -73,30 +74,60 @@ export default function ProjectPanel() {
}, 2000); }, 2000);
}, []); }, []);
// ── 监听仿真完成 /hil/done ── // ── HIL 启动后自动打开 LiveChart ──
const prevHilStatusRef2 = useRef(null);
const hilRunningProject = projects.find(p => p.hilStatus === 'running'); const hilRunningProject = projects.find(p => p.hilStatus === 'running');
useEffect(() => {
const cur = hilRunningProject?.hilStatus;
const prev = prevHilStatusRef2.current;
prevHilStatusRef2.current = cur;
if (prev !== 'running' && cur === 'running') {
setShowLiveChart(true);
}
}, [hilRunningProject?.hilStatus]);
// ── 监听仿真完成 /hil/done(带重连) ──
useEffect(() => { useEffect(() => {
if (!hilRunningProject) return; if (!hilRunningProject) return;
const projectId = hilRunningProject.id; const projectId = hilRunningProject.id;
const ws = new WebSocket(`ws://${window.location.hostname}:9090`);
let done = false; let done = false;
let ws = null;
ws.onopen = () => { let retryCount = 0;
ws.send(JSON.stringify({ op: 'subscribe', topic: '/hil/done', type: 'std_msgs/String' })); let retryTimer = null;
}; const MAX_RETRIES = 15;
ws.onmessage = (event) => {
try { function createWs() {
const msg = JSON.parse(event.data); if (done) return;
if (msg.topic === '/hil/done' && !done) { ws = new WebSocket(`ws://${window.location.hostname}:9090`);
done = true; ws.onopen = () => {
hilDone(projectId); retryCount = 0;
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 */ }
};
ws.onerror = () => {};
ws.onclose = () => {
ws = null;
if (!done && retryCount < MAX_RETRIES) {
retryCount++;
retryTimer = setTimeout(createWs, 1000);
} }
} catch { /* ignore */ } };
}; }
createWs();
return () => { return () => {
done = true; done = true;
if (ws.readyState <= 1) ws.close(); if (retryTimer) clearTimeout(retryTimer);
if (ws && ws.readyState <= 1) ws.close();
}; };
}, [hilRunningProject?.id, hilDone]); }, [hilRunningProject?.id, hilDone]);
...@@ -167,11 +198,19 @@ export default function ProjectPanel() { ...@@ -167,11 +198,19 @@ export default function ProjectPanel() {
setRenamingId(null); setRenamingId(null);
}, [renamingId, renameValue, renameProject]); }, [renamingId, renameValue, renameProject]);
const handleDelete = useCallback((id, name) => { const handleDelete = useCallback((id, name, e) => {
if (window.confirm(`确认删除「${name}」?此操作不可恢复。`)) { const rect = e.currentTarget.getBoundingClientRect();
deleteProject(id); setConfirmState({ id, name, top: rect.bottom + 4, right: window.innerWidth - rect.right });
} }, []);
}, [deleteProject]);
const confirmDelete = useCallback(() => {
if (confirmState) deleteProject(confirmState.id);
setConfirmState(null);
}, [confirmState, deleteProject]);
const cancelDelete = useCallback(() => {
setConfirmState(null);
}, []);
if (!panelOpen) return null; if (!panelOpen) return null;
...@@ -245,7 +284,7 @@ export default function ProjectPanel() { ...@@ -245,7 +284,7 @@ export default function ProjectPanel() {
>✏️</button> >✏️</button>
<button <button
className={`${styles.iconBtn} ${styles.danger}`} className={`${styles.iconBtn} ${styles.danger}`}
onClick={(e) => { e.stopPropagation(); handleDelete(p.id, p.name); }} onClick={(e) => { e.stopPropagation(); handleDelete(p.id, p.name, e); }}
title="删除" title="删除"
>🗑</button> >🗑</button>
</div> </div>
...@@ -342,7 +381,7 @@ export default function ProjectPanel() { ...@@ -342,7 +381,7 @@ export default function ProjectPanel() {
{p.executeStatus === 'running' ? ( {p.executeStatus === 'running' ? (
<><span className={styles.spinner}></span> 下发中…</> <><span className={styles.spinner}></span> 下发中…</>
) : ( ) : (
<>下发等效设备执行</> <>软件仿真</>
)} )}
</button> </button>
...@@ -505,6 +544,25 @@ export default function ProjectPanel() { ...@@ -505,6 +544,25 @@ export default function ProjectPanel() {
/> />
)} )}
{/* 删除确认弹出框 */}
{confirmState && (
<>
<div className={styles.confirmBackdrop} onClick={cancelDelete} />
<div
className={styles.confirmPopover}
style={{ top: confirmState.top, right: confirmState.right }}
>
<div className={styles.confirmText}>
确认删除「<strong>{confirmState.name}</strong>」?
</div>
<div className={styles.confirmActions}>
<button className={styles.confirmBtnCancel} onClick={cancelDelete}>取消</button>
<button className={styles.confirmBtnOk} onClick={confirmDelete}>删除</button>
</div>
</div>
</>
)}
{/* 实时图表弹窗 */} {/* 实时图表弹窗 */}
{showLiveChart && ( {showLiveChart && (
<LiveChart <LiveChart
......
...@@ -524,3 +524,93 @@ ...@@ -524,3 +524,93 @@
.sectionTitleIcon { .sectionTitleIcon {
font-size: 12px; font-size: 12px;
} }
/* ===== 删除确认弹出框 ===== */
.confirmBackdrop {
position: fixed;
inset: 0;
z-index: 999;
}
.confirmPopover {
position: fixed;
z-index: 1000;
background: #1e1e2e;
border: 1px solid #2a2a3a;
border-radius: 8px;
padding: 12px 14px;
width: 220px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
animation: confirmSlideIn 0.12s ease-out;
}
.confirmPopover::before {
content: '';
position: absolute;
top: -6px;
right: 8px;
width: 10px;
height: 10px;
background: #1e1e2e;
border-top: 1px solid #2a2a3a;
border-left: 1px solid #2a2a3a;
transform: rotate(45deg);
}
@keyframes confirmSlideIn {
from { transform: translateY(-4px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.confirmText {
font-size: 12px;
color: #999;
line-height: 1.5;
margin-bottom: 12px;
}
.confirmText strong {
color: #ccc;
}
.confirmActions {
display: flex;
gap: 8px;
}
.confirmBtnCancel {
flex: 1;
padding: 6px 0;
border: 1px solid #333;
border-radius: 5px;
background: #16161e;
color: #999;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.12s;
}
.confirmBtnCancel:hover {
background: #2a2a3e;
color: #ccc;
border-color: #555;
}
.confirmBtnOk {
flex: 1;
padding: 6px 0;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 5px;
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.12s;
}
.confirmBtnOk:hover {
background: rgba(239, 68, 68, 0.22);
border-color: #ef4444;
}
...@@ -262,8 +262,11 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) ...@@ -262,8 +262,11 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
}; };
}, []); }, []);
const overlayRef = useRef(null);
useEffect(() => { overlayRef.current?.focus(); }, []);
return ( return (
<div className={styles.overlay} onClick={onClose}> <div ref={overlayRef} className={styles.overlay} data-modal-overlay tabIndex={-1} onClick={onClose} style={{ outline: 'none' }}>
<div className={styles.modal} onClick={e => e.stopPropagation()}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* 头部 */} {/* 头部 */}
<div className={styles.header}> <div className={styles.header}>
......
...@@ -315,13 +315,16 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -315,13 +315,16 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
const selectAll = useCallback(() => setSelectedVars(new Set(variables.map(v => v.name))), [variables]); const selectAll = useCallback(() => setSelectedVars(new Set(variables.map(v => v.name))), [variables]);
const selectNone = useCallback(() => setSelectedVars(new Set()), []); const selectNone = useCallback(() => setSelectedVars(new Set()), []);
const overlayRef = useRef(null);
useEffect(() => { overlayRef.current?.focus(); }, []);
// ===== 条件渲染 ===== // ===== 条件渲染 =====
const hasData = columns.length > 0 && columns[0]?.length > 0 && variables.length > 0; const hasData = columns.length > 0 && columns[0]?.length > 0 && variables.length > 0;
if (hasData && selectedVars === null) return null; if (hasData && selectedVars === null) return null;
return ( return (
<div className={styles.overlay} onClick={onClose}> <div ref={overlayRef} className={styles.overlay} data-modal-overlay tabIndex={-1} onClick={onClose} style={{ outline: 'none' }}>
<div className={styles.modal} onClick={e => e.stopPropagation()}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
......
...@@ -8,10 +8,14 @@ ...@@ -8,10 +8,14 @@
* 订阅两个话题: * 订阅两个话题:
* /hil/sim_data — 实时仿真数据帧 (JSON) * /hil/sim_data — 实时仿真数据帧 (JSON)
* /hil/done — 仿真完成信号 * /hil/done — 仿真完成信号
*
* 自动重连: 连接失败或断开后自动重试 (最多 MAX_RETRIES 次, 间隔 RETRY_DELAY_MS)
*/ */
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
const MAX_POINTS = 5000; const MAX_POINTS = 5000;
const MAX_RETRIES = 15;
const RETRY_DELAY_MS = 1000;
export default function useRosBridge({ onDone } = {}) { export default function useRosBridge({ onDone } = {}) {
const [status, setStatus] = useState('disconnected'); const [status, setStatus] = useState('disconnected');
...@@ -25,6 +29,12 @@ export default function useRosBridge({ onDone } = {}) { ...@@ -25,6 +29,12 @@ export default function useRosBridge({ onDone } = {}) {
const onDoneRef = useRef(onDone); const onDoneRef = useRef(onDone);
onDoneRef.current = onDone; onDoneRef.current = onDone;
// 重连控制
const retryCountRef = useRef(0);
const retryTimerRef = useRef(null);
const urlRef = useRef(null);
const manualDisconnectRef = useRef(false);
const flushToState = useCallback(() => { const flushToState = useCallback(() => {
if (rafPendingRef.current) return; if (rafPendingRef.current) return;
rafPendingRef.current = true; rafPendingRef.current = true;
...@@ -34,7 +44,7 @@ export default function useRosBridge({ onDone } = {}) { ...@@ -34,7 +44,7 @@ export default function useRosBridge({ onDone } = {}) {
}); });
}, []); }, []);
const connect = useCallback((url = 'ws://localhost:9090') => { const connectInternal = useCallback((url) => {
if (wsRef.current) return; if (wsRef.current) return;
setStatus('connecting'); setStatus('connecting');
...@@ -43,6 +53,7 @@ export default function useRosBridge({ onDone } = {}) { ...@@ -43,6 +53,7 @@ export default function useRosBridge({ onDone } = {}) {
ws.onopen = () => { ws.onopen = () => {
setStatus('connected'); setStatus('connected');
retryCountRef.current = 0; // 连接成功, 重置重试计数
// 订阅仿真数据 // 订阅仿真数据
ws.send(JSON.stringify({ ws.send(JSON.stringify({
op: 'subscribe', op: 'subscribe',
...@@ -90,14 +101,46 @@ export default function useRosBridge({ onDone } = {}) { ...@@ -90,14 +101,46 @@ export default function useRosBridge({ onDone } = {}) {
} }
}; };
ws.onerror = () => setStatus('error'); ws.onerror = () => {
// onerror 后通常会触发 onclose, 重连逻辑放在 onclose 里
};
ws.onclose = () => { ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null; wsRef.current = null;
// 如果是手动断开, 不重连
if (manualDisconnectRef.current) {
setStatus('disconnected');
return;
}
// 自动重连
if (retryCountRef.current < MAX_RETRIES && urlRef.current) {
retryCountRef.current++;
setStatus('connecting');
retryTimerRef.current = setTimeout(() => {
connectInternal(urlRef.current);
}, RETRY_DELAY_MS);
} else {
setStatus('error');
}
}; };
}, [flushToState]); }, [flushToState]);
const connect = useCallback((url = 'ws://localhost:9090') => {
if (wsRef.current) return;
urlRef.current = url;
manualDisconnectRef.current = false;
retryCountRef.current = 0;
connectInternal(url);
}, [connectInternal]);
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
manualDisconnectRef.current = true;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
...@@ -108,7 +151,11 @@ export default function useRosBridge({ onDone } = {}) { ...@@ -108,7 +151,11 @@ export default function useRosBridge({ onDone } = {}) {
}, []); }, []);
useEffect(() => { useEffect(() => {
return () => { if (wsRef.current) wsRef.current.close(); }; return () => {
manualDisconnectRef.current = true;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (wsRef.current) wsRef.current.close();
};
}, []); }, []);
return { status, headers, data, connect, disconnect }; return { status, headers, data, connect, disconnect };
......
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