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 @@
},
"node_modules/uplot": {
"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==",
"license": "MIT"
},
......
......@@ -95,9 +95,8 @@ export default function FlowCanvas() {
clearSelection();
}, [clearSelection]);
/** 空格键旋转选中节点 */
useEffect(() => {
function handleKeyDown(e) {
/** 空格键旋转选中节点 — 仅画布获得焦点时生效 */
const handleCanvasKeyDown = useCallback((e) => {
if (e.code !== 'Space') return;
// 避免在输入框中触发
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
......@@ -122,9 +121,6 @@ export default function FlowCanvas() {
requestAnimationFrame(() => {
updateNodeInternalsRef.current?.(selectedNode.id);
});
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
/** 双击画布空白处添加新节点 */
......@@ -206,7 +202,7 @@ export default function FlowCanvas() {
}, []);
return (
<div className={styles.canvas} ref={reactFlowWrapper}>
<div className={styles.canvas} ref={reactFlowWrapper} tabIndex={-1} onKeyDown={handleCanvasKeyDown} style={{ outline: 'none' }}>
<ReactFlow
nodes={nodes}
edges={edges}
......
......@@ -62,6 +62,7 @@ export default function ProjectPanel() {
const [toastFading, setToastFading] = useState(false);
const [showLiveChart, setShowLiveChart] = useState(false);
const toastTimerRef = useRef(null);
const [confirmState, setConfirmState] = useState(null); // { id, name }
const showToast = useCallback((msg) => {
setToastMsg(msg);
......@@ -73,16 +74,34 @@ export default function ProjectPanel() {
}, 2000);
}, []);
// ── 监听仿真完成 /hil/done ──
// ── HIL 启动后自动打开 LiveChart ──
const prevHilStatusRef2 = useRef(null);
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(() => {
if (!hilRunningProject) return;
const projectId = hilRunningProject.id;
const ws = new WebSocket(`ws://${window.location.hostname}:9090`);
let done = false;
let ws = null;
let retryCount = 0;
let retryTimer = null;
const MAX_RETRIES = 15;
function createWs() {
if (done) return;
ws = new WebSocket(`ws://${window.location.hostname}:9090`);
ws.onopen = () => {
retryCount = 0;
ws.send(JSON.stringify({ op: 'subscribe', topic: '/hil/done', type: 'std_msgs/String' }));
};
ws.onmessage = (event) => {
......@@ -94,9 +113,21 @@ export default function ProjectPanel() {
}
} catch { /* ignore */ }
};
ws.onerror = () => {};
ws.onclose = () => {
ws = null;
if (!done && retryCount < MAX_RETRIES) {
retryCount++;
retryTimer = setTimeout(createWs, 1000);
}
};
}
createWs();
return () => {
done = true;
if (ws.readyState <= 1) ws.close();
if (retryTimer) clearTimeout(retryTimer);
if (ws && ws.readyState <= 1) ws.close();
};
}, [hilRunningProject?.id, hilDone]);
......@@ -167,11 +198,19 @@ export default function ProjectPanel() {
setRenamingId(null);
}, [renamingId, renameValue, renameProject]);
const handleDelete = useCallback((id, name) => {
if (window.confirm(`确认删除「${name}」?此操作不可恢复。`)) {
deleteProject(id);
}
}, [deleteProject]);
const handleDelete = useCallback((id, name, e) => {
const rect = e.currentTarget.getBoundingClientRect();
setConfirmState({ id, name, top: rect.bottom + 4, right: window.innerWidth - rect.right });
}, []);
const confirmDelete = useCallback(() => {
if (confirmState) deleteProject(confirmState.id);
setConfirmState(null);
}, [confirmState, deleteProject]);
const cancelDelete = useCallback(() => {
setConfirmState(null);
}, []);
if (!panelOpen) return null;
......@@ -245,7 +284,7 @@ export default function ProjectPanel() {
>✏️</button>
<button
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="删除"
>🗑</button>
</div>
......@@ -342,7 +381,7 @@ export default function ProjectPanel() {
{p.executeStatus === 'running' ? (
<><span className={styles.spinner}></span> 下发中…</>
) : (
<>下发等效设备执行</>
<>软件仿真</>
)}
</button>
......@@ -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 && (
<LiveChart
......
......@@ -524,3 +524,93 @@
.sectionTitleIcon {
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 })
};
}, []);
const overlayRef = useRef(null);
useEffect(() => { overlayRef.current?.focus(); }, []);
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.header}>
......
......@@ -315,13 +315,16 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
const selectAll = useCallback(() => setSelectedVars(new Set(variables.map(v => v.name))), [variables]);
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;
if (hasData && selectedVars === null) return null;
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.header}>
<div className={styles.headerLeft}>
......
......@@ -8,10 +8,14 @@
* 订阅两个话题:
* /hil/sim_data — 实时仿真数据帧 (JSON)
* /hil/done — 仿真完成信号
*
* 自动重连: 连接失败或断开后自动重试 (最多 MAX_RETRIES 次, 间隔 RETRY_DELAY_MS)
*/
import { useState, useRef, useCallback, useEffect } from 'react';
const MAX_POINTS = 5000;
const MAX_RETRIES = 15;
const RETRY_DELAY_MS = 1000;
export default function useRosBridge({ onDone } = {}) {
const [status, setStatus] = useState('disconnected');
......@@ -25,6 +29,12 @@ export default function useRosBridge({ onDone } = {}) {
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
// 重连控制
const retryCountRef = useRef(0);
const retryTimerRef = useRef(null);
const urlRef = useRef(null);
const manualDisconnectRef = useRef(false);
const flushToState = useCallback(() => {
if (rafPendingRef.current) return;
rafPendingRef.current = true;
......@@ -34,7 +44,7 @@ export default function useRosBridge({ onDone } = {}) {
});
}, []);
const connect = useCallback((url = 'ws://localhost:9090') => {
const connectInternal = useCallback((url) => {
if (wsRef.current) return;
setStatus('connecting');
......@@ -43,6 +53,7 @@ export default function useRosBridge({ onDone } = {}) {
ws.onopen = () => {
setStatus('connected');
retryCountRef.current = 0; // 连接成功, 重置重试计数
// 订阅仿真数据
ws.send(JSON.stringify({
op: 'subscribe',
......@@ -90,14 +101,46 @@ export default function useRosBridge({ onDone } = {}) {
}
};
ws.onerror = () => setStatus('error');
ws.onerror = () => {
// onerror 后通常会触发 onclose, 重连逻辑放在 onclose 里
};
ws.onclose = () => {
setStatus('disconnected');
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]);
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(() => {
manualDisconnectRef.current = true;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
......@@ -108,7 +151,11 @@ export default function useRosBridge({ onDone } = {}) {
}, []);
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 };
......
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