机器人控制界面和 实时监控界面 加上 急停、避障、低电量自动返航、倾斜保护等 功能面板 加上 方向盘点击 控制机器 功能

This commit is contained in:
mmc
2026-10-12 12:42:16 +08:00
parent 5d468fb0b7
commit 0e41464d94
7 changed files with 559 additions and 2620 deletions

View File

@@ -0,0 +1,157 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Circle } from 'lucide-react';
export interface GamepadState {
buttons: boolean[];
axes: number[];
connected: boolean;
ts?: number;
}
interface DPadControlProps {
onGamepadUpdate?: (state: GamepadState) => void;
onSpeedChange?: (speed: number) => void;
disabled?: boolean;
}
const DPadControl: React.FC<DPadControlProps> = ({ onGamepadUpdate, onSpeedChange, disabled = false }) => {
const { t } = useTranslation();
const [pressed, setPressed] = useState<Record<string, boolean>>({});
const [speed, setSpeed] = useState(0.5);
const pressTimer = useRef<number | null>(null);
// 用 ref 存储最新状态,避免 setInterval 闭包捕获旧值
const pressedRef = useRef<Record<string, boolean>>({});
const speedRef = useRef(0.5);
// 构建状态:只传输 xy 坐标(axes),buttons 全部为 false
const buildState = useCallback(
(overrides: Record<string, boolean> = {}): GamepadState => {
const btnMap = { ...pressedRef.current, ...overrides };
// 方向键映射到左摇杆 axes[0] X, axes[1] Y
let axisX = 0;
let axisY = 0;
const spd = speedRef.current;
if (btnMap.left) axisX -= spd;
if (btnMap.right) axisX += spd;
if (btnMap.up) axisY -= spd;
if (btnMap.down) axisY += spd;
return {
buttons: new Array(17).fill(false),
axes: [axisX, axisY, 0, 0],
connected: true,
ts: Date.now(),
};
},
[],
);
const emit = useCallback(
(overrides?: Record<string, boolean>) => {
if (disabled) return;
const state = overrides ? buildState(overrides) : buildState();
onGamepadUpdate?.(state);
},
[buildState, disabled, onGamepadUpdate],
);
const startPress = useCallback(
(key: string) => {
if (disabled) return;
pressedRef.current = { ...pressedRef.current, [key]: true };
setPressed(pressedRef.current);
emit({ [key]: true });
// 持续发送(模拟手柄按住)
if (pressTimer.current) window.clearInterval(pressTimer.current);
pressTimer.current = window.setInterval(() => {
emit();
}, 100);
},
[disabled, emit],
);
const endPress = useCallback(
(key: string) => {
if (disabled) return;
if (pressTimer.current) {
window.clearInterval(pressTimer.current);
pressTimer.current = null;
}
pressedRef.current = { ...pressedRef.current, [key]: false };
setPressed(pressedRef.current);
emit({ [key]: false });
},
[disabled, emit],
);
const btnBase =
'flex items-center justify-center w-8 h-8 rounded-xl border transition-all duration-150 select-none active:scale-95';
const btnActive = 'border-cyan-400 bg-cyan-500/30 text-cyan-300 shadow-[0_0_16px_rgba(34,211,238,0.4)]';
const btnIdle = 'border-slate-600 bg-slate-800/60 text-slate-300 hover:border-cyan-500/60 hover:text-cyan-300';
const renderBtn = (key: string, icon: React.ReactNode, ariaLabel: string) => (
<button
aria-label={ariaLabel}
disabled={disabled}
className={`${btnBase} ${pressed[key] ? btnActive : btnIdle} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
onMouseDown={() => startPress(key)}
onMouseUp={() => endPress(key)}
onMouseLeave={() => pressed[key] && endPress(key)}
onTouchStart={(e) => { e.preventDefault(); startPress(key); }}
onTouchEnd={(e) => { e.preventDefault(); endPress(key); }}
>
{icon}
</button>
);
return (
<div className="flex flex-col items-center gap-5 w-full">
{/* 十字方向键 */}
<div className="grid grid-cols-3 grid-rows-3 gap-2.5">
<div />
{renderBtn('up', <ChevronUp className="w-6 h-6" />, t('devices.dpad.up'))}
<div />
{renderBtn('left', <ChevronLeft className="w-6 h-6" />, t('devices.dpad.left'))}
{renderBtn('center', <Circle className="w-5 h-5 fill-current" />, t('devices.dpad.center'))}
{renderBtn('right', <ChevronRight className="w-6 h-6" />, t('devices.dpad.right'))}
<div />
{renderBtn('down', <ChevronDown className="w-6 h-6" />, t('devices.dpad.down'))}
<div />
</div>
{/* 速度滑块 */}
<div className="w-full px-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-400 font-medium">{t('devices.dpad.speed')}</span>
<span className="text-sm font-bold text-cyan-300">{Math.round(speed * 100)}%</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={speed}
onChange={(e) => {
const v = parseFloat(e.target.value);
setSpeed(v);
speedRef.current = v;
onSpeedChange?.(v);
}}
className="w-full h-2.5 rounded-full appearance-none cursor-pointer bg-slate-700 accent-cyan-400"
style={{
background: `linear-gradient(to right, #22d3ee 0%, #22d3ee ${speed * 100}%, #334155 ${speed * 100}%, #334155 100%)`,
}}
/>
<div className="flex justify-between mt-1.5 text-[11px] text-slate-500">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
</div>
);
};
export default DPadControl;

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import {
ThunderboltOutlined,
EnvironmentOutlined,
DeleteOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import {
@@ -47,6 +48,8 @@ import {
import { useLocation } from 'react-router-dom';
import XboxController from './XboxController';
import RobotFeatureButtons from './RobotFeatureButtons';
import DPadControl from './DPadControl';
import DroneVideoPlayer from './DroneVideoPlayer';
import CesiumMap from '../Map.jsx';
import { useSelector } from 'react-redux';
@@ -628,6 +631,7 @@ export default function DeviceStatusPage() {
}, [device, uavState, currentDroneData]);
const handleGamepadUpdate = useCallback((state) => {
console.log('Gamepad state updated:', state);
controllerRef.current?.sendGamepad(state);
}, []);
@@ -897,7 +901,11 @@ export default function DeviceStatusPage() {
return (
<div style={{ background: '#f5f7fa', minHeight: '100%', padding: 0 }}>
<div style={{ background: 'linear-gradient(180deg, #f0f4f8 0%, #e8eef5 100%)', minHeight: '100%', padding: 0 }}>
<style>{`
.ds-card { box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; border: 1px solid #e8edf3 !important; }
.ds-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.1) !important; transition: box-shadow 0.3s ease; }
`}</style>
<div style={{ padding: '4px 0px 8px' }}>
<Typography.Title level={4} style={{ margin: 0, fontSize: 'clamp(14px, 1.2vw, 18px)' }}>{t('devices.deviceStatus')}</Typography.Title>
</div>
@@ -1027,7 +1035,7 @@ export default function DeviceStatusPage() {
{/* 左侧:机场视频 + 地图 */}
<Col xs={24} lg={8}>
{/* 无人机场视频 + 机场状态 */}
<Card
<Card className="ds-card"
title={
<>
<VideoCameraOutlined />
@@ -1052,7 +1060,7 @@ export default function DeviceStatusPage() {
</Card>
{/* 地图 */}
<Card
<Card className="ds-card"
title={
<>
<GlobalOutlined />
@@ -1107,7 +1115,7 @@ export default function DeviceStatusPage() {
{/* 右侧:无人机视频 + 无人机状态 */}
<Col xs={24} lg={16}>
{/* 无人机视频 + 无人机状态 */}
<Card
<Card className="ds-card"
title={
<>
<VideoCameraOutlined />
@@ -1136,7 +1144,7 @@ export default function DeviceStatusPage() {
// 机器人原布局
<Row gutter={[16, 16]} align="stretch">
<Col xs={24} lg={16}>
<Card
<Card className="ds-card"
title={
<>
<VideoCameraOutlined />
@@ -1144,8 +1152,8 @@ export default function DeviceStatusPage() {
</>
}
variant="borderless"
style={{ borderRadius: 12, marginBottom: 8 }}
styles={{ body: { padding: 12, height: 'calc(100vh - 220px)', minHeight: 500 } }}
style={{ borderRadius: 12, marginBottom: 8, height: '100%' }}
styles={{ body: { padding: 12, height: 'calc(100vh - 240px)', minHeight: 380, display: 'flex', flexDirection: 'column' } }}
extra={
<Button
@@ -1157,7 +1165,7 @@ export default function DeviceStatusPage() {
</Button>
}
>
<Space style={{ marginBottom: 8, }} align="start" >
<Space style={{ marginBottom: 8, flexShrink: 0 }} align="start" >
<Button type={mowerView === 'front' ? 'primary' : 'default'} onClick={() => setMowerView('front')}>{t('devices.frontView')}</Button>
<Button type={mowerView === 'back' ? 'primary' : 'default'} onClick={() => setMowerView('back')}>{t('devices.backView')}</Button>
<Button type={mowerView === 'left' ? 'primary' : 'default'} onClick={() => setMowerView('left')}>{t('devices.leftView')}</Button>
@@ -1221,200 +1229,253 @@ export default function DeviceStatusPage() {
</> : null}
</Space>
{/* 视频容器:剩余空间 */}
<div style={{ position: 'relative', flex: 1, minHeight: 0, borderRadius: 8, overflow: 'hidden', background: '#0a0e1a' }}>
{/* 视频层:停止时不渲染 / 未播放时为空,不影响状态面板 */}
{isVideoPlaying && (
mowerView === 'all' ? (
(
<div style={{ width: '100%', height: '100%' }}>
<MowerAllView
serialNumber={device?.serialNumber}
robotVideo={robotVideo}
error={error}
ready={ready}
/>
</div>
)
) : mowerView === 'front' ? (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{/*<AgrtcRoomRobot CHANNEL={device?.serialNumber || ''} />*/}
<AgrtcRoomRobot CHANNEL={"41-thream03"} />
{/* 视频层:停止时不渲染 / 未播放时为空,不影响状态面板 */}
{isVideoPlaying && (
mowerView === 'all' ? (
(
<div style={{ width: '100%', height: '100%' }}>
<MowerAllView
serialNumber={device?.serialNumber}
robotVideo={robotVideo}
</div>
) : (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<VideoPlayer
deviceId={device?.serialNumber}
idx={
mowerView === 'back' ? 2
: mowerView === 'left' ? 3
: 4
}
className="w-full h-full object-contain"
video={robotVideo}
error={error}
ready={ready}
/>
</div>
)
) : mowerView === 'front' ? (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{/*<AgrtcRoomRobot CHANNEL={device?.serialNumber || ''} />*/}
<AgrtcRoomRobot CHANNEL={"41-thream03"} />
)}
{/* 视频未加载 / 停止播放的占位 */}
{!isVideoPlaying && (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 12,
color: 'rgba(14, 78, 198, 0.35)',
}}>
<VideoCameraOutlined style={{ fontSize: 56, opacity: 0.6 }} />
<div style={{ fontSize: 15 }}>
{!isVideoPlaying ? t('devices.videoStopped') : t('video.videoLoading')}
</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>
{!isVideoPlaying ? t('devices.clickPlayHint') : ''}
</div>
</div>
) : (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<VideoPlayer
deviceId={device?.serialNumber}
idx={
mowerView === 'back' ? 2
: mowerView === 'left' ? 3
: 4
}
className="w-full h-full object-contain"
video={robotVideo}
error={error}
ready={ready}
/>
)}
{/* 单视频(非全景)模式状态叠加层:始终渲染,和视频层解耦 */}
{mowerView !== 'all' && (
<div style={{
position: 'absolute',
top: 8,
left: 8,
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '10px 12px',
borderRadius: 8,
fontSize: 11,
zIndex: 10,
backdropFilter: 'blur(4px)',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
border: '1px solid rgba(24,144,255,0.2)',
}}>
<div style={{ marginBottom: 6, fontWeight: 600, color: '#1890ff' }}>{t('devices.robotStatus')}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '3px 10px' }}>
<span>{t('devices.rtBattery')}</span>
<span style={{ color: (runningStatus?.battery || 0) < 20 ? '#ff4d4f' : '#52c41a' }}>
{runningStatus?.battery !== undefined ? `${runningStatus?.battery}%` : '-'}
</span>
<span>{t('devices.rtVoltage')}</span>
<span>{runningStatus?.voltage !== undefined ? `${Number(runningStatus?.voltage).toFixed(2)}V` : '-'}</span>
<span>{t('devices.rtLeftMotor')}</span>
<span>{runningStatus?.leftCurrent !== undefined ? `${Number(runningStatus?.leftCurrent).toFixed(2)}A` : '-'}</span>
<span>{t('devices.rtRightMotor')}</span>
<span>{runningStatus?.rightCurrent !== undefined ? `${Number(runningStatus?.rightCurrent).toFixed(2)}A` : '-'}</span>
<span>{t('devices.rtLeftSpeed')}</span>
<span>{runningStatus?.leftMeasureSpeed !== undefined ? `${Number(runningStatus?.leftMeasureSpeed).toFixed(1)}rpm` : '-'}</span>
<span>{t('devices.rtRightSpeed')}</span>
<span>{runningStatus?.rightMeasureSpeed !== undefined ? `${Number(runningStatus?.rightMeasureSpeed).toFixed(1)}rpm` : '-'}</span>
<span>{t('devices.rtLeftTemp')}</span>
<span>{runningStatus?.leftMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.leftMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtRightTemp')}</span>
<span>{runningStatus?.rightMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.rightMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtMainTemp')}</span>
<span>{runningStatus?.chipTemp !== undefined ? formatTemperature(Number(runningStatus?.chipTemp), 1).label : '-'}</span>
<span>{t('devices.rtHeadingAngle')}</span>
<span>{runningStatus?.yaw !== undefined ? `${Number(runningStatus?.yaw).toFixed(1)}°` : '-'}</span>
<span>{t('devices.rtPitchAngle')}</span>
<span>{runningStatus?.pitch !== undefined ? `${Number(runningStatus?.pitch).toFixed(1)}°` : '-'}</span>
<span>{t('devices.rtHeadingStatus')}</span>
<span>{runningStatus?.headingStatus !== undefined ? `${runningStatus?.headingStatus == 1 ? t('devices.rtInitialized') : t('devices.uninitialized')}` : '-'}</span>
<span>{t('devices.rtPositioning')}</span>
<span>
{runningStatus?.fix_status ? fixStatusMap[runningStatus?.fix_status] : t('constants.locationUnknown')}
</span>
<span>{t('devices.rtSatellites')}</span>
<span>{t('devices.satelliteCount', { count: runningStatus?.satelliteCnt || 0 })}</span>
<span>{t('devices.rtMode')}</span>
<span>
{runningStatus?.controlMode == 1 ? t('devices.localMode') :
runningStatus?.controlMode == 3 ? t('devices.remoteMode') : t('constants.locationUnknown')}
</span>
</div>
</div>
)
)}
{/* 视频未加载 / 停止播放的占位 */}
{!isVideoPlaying && (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 12,
color: 'rgba(14, 78, 198, 0.35)',
}}>
<VideoCameraOutlined style={{ fontSize: 56, opacity: 0.6 }} />
<div style={{ fontSize: 15 }}>
{!isVideoPlaying ? t('devices.videoStopped') : t('video.videoLoading')}
</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>
{!isVideoPlaying ? t('devices.clickPlayHint') : ''}
</div>
</div>
)}
{/* 单视频(非全景)模式状态叠加层:始终渲染,和视频层解耦 */}
{mowerView !== 'all' && (
<div style={{
position: 'absolute',
top: 106,
left: 20,
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '10px 12px',
borderRadius: 8,
fontSize: 11,
zIndex: 10,
backdropFilter: 'blur(4px)',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
border: '1px solid rgba(24,144,255,0.2)',
}}>
<div style={{ marginBottom: 6, fontWeight: 600, color: '#1890ff' }}>{t('devices.robotStatus')}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '3px 10px' }}>
<span>{t('devices.rtBattery')}</span>
<span style={{ color: (runningStatus?.battery || 0) < 20 ? '#ff4d4f' : '#52c41a' }}>
{runningStatus?.battery !== undefined ? `${runningStatus?.battery}%` : '-'}
</span>
<span>{t('devices.rtVoltage')}</span>
<span>{runningStatus?.voltage !== undefined ? `${Number(runningStatus?.voltage).toFixed(2)}V` : '-'}</span>
<span>{t('devices.rtLeftMotor')}</span>
<span>{runningStatus?.leftCurrent !== undefined ? `${Number(runningStatus?.leftCurrent).toFixed(2)}A` : '-'}</span>
<span>{t('devices.rtRightMotor')}</span>
<span>{runningStatus?.rightCurrent !== undefined ? `${Number(runningStatus?.rightCurrent).toFixed(2)}A` : '-'}</span>
<span>{t('devices.rtLeftSpeed')}</span>
<span>{runningStatus?.leftMeasureSpeed !== undefined ? `${Number(runningStatus?.leftMeasureSpeed).toFixed(1)}rpm` : '-'}</span>
<span>{t('devices.rtRightSpeed')}</span>
<span>{runningStatus?.rightMeasureSpeed !== undefined ? `${Number(runningStatus?.rightMeasureSpeed).toFixed(1)}rpm` : '-'}</span>
<span>{t('devices.rtLeftTemp')}</span>
<span>{runningStatus?.leftMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.leftMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtRightTemp')}</span>
<span>{runningStatus?.rightMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.rightMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtMainTemp')}</span>
<span>{runningStatus?.chipTemp !== undefined ? formatTemperature(Number(runningStatus?.chipTemp), 1).label : '-'}</span>
<span>{t('devices.rtHeadingAngle')}</span>
<span>{runningStatus?.yaw !== undefined ? `${Number(runningStatus?.yaw).toFixed(1)}°` : '-'}</span>
<span>{t('devices.rtPitchAngle')}</span>
<span>{runningStatus?.pitch !== undefined ? `${Number(runningStatus?.pitch).toFixed(1)}°` : '-'}</span>
<span>{t('devices.rtHeadingStatus')}</span>
<span>{runningStatus?.headingStatus !== undefined ? `${runningStatus?.headingStatus == 1 ? t('devices.rtInitialized') : t('devices.uninitialized')}` : '-'}</span>
<span>{t('devices.rtPositioning')}</span>
<span>
{runningStatus?.fix_status ? fixStatusMap[runningStatus?.fix_status] : t('constants.locationUnknown')}
</span>
<span>{t('devices.rtSatellites')}</span>
<span>{t('devices.satelliteCount', { count: runningStatus?.satelliteCnt || 0 })}</span>
<span>{t('devices.rtMode')}</span>
<span>
{runningStatus?.controlMode == 1 ? t('devices.localMode') :
runningStatus?.controlMode == 3 ? t('devices.remoteMode') : t('constants.locationUnknown')}
</span>
</div>
</div>
)
}
}
</div>
</Card>
</Col>
{/* 右边 - 占1格,上部地图,下部远程控制 */}
<Col xs={24} lg={8} style={{ display: 'flex', flexDirection: 'column', }}>
{/* 地图 */}
<Card
title={
<>
<GlobalOutlined />
{t('devices.situationMap')}
</>
}
extra={
<Space size={4}>
<Button
size="small"
type="primary"
icon={<DeleteOutlined />}
onClick={() => setClearNavLine((v) => v + 1)}
>
{t('devices.clearRealtimeTrack')}
</Button>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => {
setPoints([]);
setClearPlanLine((v) => v + 1);
}}
>
{t('devices.clearPlanRoute')}
</Button>
</Space>
}
variant="borderless"
style={{ borderRadius: 12, marginBottom: 8, flex: 1, minHeight: 280 }}
styles={{ body: { padding: 12, height: 'calc((100vh - 260px) / 2)', minHeight: 280 } }}
>
<CesiumMap
ref={mapRef}
devices={[mapDevices]}
points={points}
markedPoints={markedPoints}
realtimePosition={realtimePosition}
realtimeType={realtimeType}
drawRealTime={true}
finishPoint={arrivedPoint}
ifClearNavLine={clearNavLine}
ifClearPlanLine={clearPlanLine}
from="deviceStatus" onMarkPoint={handleMapMarkPoint} onClearDraw={(fn) => { clearDrawRef.current = fn; }}
/>
</Card>
{/* 右边 - 地图 + 控制手柄方向盘 + 功能按钮 三个面板,高度与左侧视频对齐 */}
<Col xs={24} lg={8}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, height: 'calc(100vh - 240px)', minHeight: 380 }}>
{/* 地图 */}
<Card
title={
<>
<GlobalOutlined />
{t('devices.situationMap')}
</>
}
extra={
<Space size={4}>
<Button
size="small"
type="primary"
icon={<DeleteOutlined />}
onClick={() => setClearNavLine((v) => v + 1)}
>
{t('devices.clearRealtimeTrack')}
</Button>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => {
setPoints([]);
setClearPlanLine((v) => v + 1);
}}
>
{t('devices.clearPlanRoute')}
</Button>
</Space>
}
variant="borderless"
className="ds-card"
style={{ borderRadius: 12, flex: 1, minHeight: 0 }}
styles={{ body: { padding: 12, height: 'calc(100% - 57px)' } }}
>
<CesiumMap
ref={mapRef}
devices={[mapDevices]}
points={points}
markedPoints={markedPoints}
realtimePosition={realtimePosition}
realtimeType={realtimeType}
drawRealTime={true}
finishPoint={arrivedPoint}
ifClearNavLine={clearNavLine}
ifClearPlanLine={clearPlanLine}
from="deviceStatus" onMarkPoint={handleMapMarkPoint} onClearDraw={(fn) => { clearDrawRef.current = fn; }}
/>
</Card>
{/* 远程控制面板 */}
<Card
title={
<>
<ControlOutlined />
{t('devices.remoteControl')}
</>
}
variant="borderless"
style={{ borderRadius: 12, background: '#0f172a', flex: 1, minHeight: 280, }}
styles={{
header: { color: '#fff', },
body: { display: 'flex', justifyContent: 'center', alignItems: 'center', height: 'calc(100% - 57px)' },
}}
>
<XboxController
onGamepadUpdate={handleGamepadUpdate}
handleControlModal={handleControlModal}
isControlled={isControlled}
hasControlRight={hasControlRight}
isConnecting={connecting}
/>
</Card>
{/* 手柄 + 方向盘 一个面板 */}
<Card
title={
<>
<ControlOutlined />
{t('devices.remoteControl')}
</>
}
variant="borderless"
className="ds-card"
style={{ borderRadius: 12, background: '#0f172a', flex: 1, minHeight: 0 }}
styles={{
header: { color: '#fff' },
body: { padding: 12, display: 'flex', flexDirection: 'row', gap: 12, height: 'calc(100% - 57px)', overflow: 'hidden' },
}}
>
{/* Xbox 手柄 - 左侧,缩小显示 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', transform: 'scale(0.82)', transformOrigin: 'left center' }}>
<XboxController
onGamepadUpdate={handleGamepadUpdate}
handleControlModal={handleControlModal}
isControlled={isControlled}
hasControlRight={hasControlRight}
isConnecting={connecting}
/>
</div>
{/* 方向盘 + 滑块 - 右侧,居中不挤压 */}
<div style={{ flex: '1 1 auto', minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
{/*<p style={{ color: '#94a3b8', fontSize: 12, fontWeight: 600, marginBottom: 12, textAlign: 'center' }}>
{t('devices.dpad.title')}
</p>*/}
<DPadControl
onGamepadUpdate={(state) => {
handleGamepadUpdate(state);
//console.log('[DeviceStatusPage] DPad state:', {
// buttons: state.buttons.map((b, i) => ({ idx: i, pressed: b })).filter(b => b.pressed),
// axes: state.axes,
// ts: state.ts,
//});
}}
onSpeedChange={(speed) => {
//console.log(`[DeviceStatusPage] Speed slider: ${Math.round(speed * 100)}%`);
}}
//disabled={!isControlled}
/>
</div>
</Card>
{/* 功能按钮 单独一个面板 */}
<Card
title={
<>
<SafetyOutlined style={{ color: '#fa541c' }} />
{t('devices.features.title')}
</>
}
variant="borderless"
style={{ borderRadius: 12, flex: '0 0 auto' }}
styles={{
header: { color: '#fff' },
body: { padding: 12 },
}}
>
<RobotFeatureButtons
activeFeatures={['response', 'obstacleAvoidance', 'lowBatteryReturn', 'tiltProtection']}
onToggle={(feature, enabled) => {
console.log(`[DeviceStatusPage] Feature toggled: ${feature} = ${enabled}`);
}}
/>
</Card>
</div>
</Col>
</Row>
)}

View File

@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ShieldAlert,
Zap,
Siren,
BatteryWarning,
MoveVertical,
} from 'lucide-react';
export type RobotFeature =
| 'emergencyStop'
| 'response'
| 'obstacleAvoidance'
| 'lowBatteryReturn'
| 'tiltProtection';
export interface FeatureConfig {
key: RobotFeature;
icon: React.ReactNode;
activeColor: string;
inactiveColor: string;
}
const FEATURES: FeatureConfig[] = [
{ key: 'emergencyStop', icon: <ShieldAlert className="w-5 h-5" />, activeColor: '#ef4444', inactiveColor: '#94a3b8' },
{ key: 'obstacleAvoidance', icon: <Siren className="w-5 h-5" />, activeColor: '#3b82f6', inactiveColor: '#94a3b8' },
{ key: 'lowBatteryReturn', icon: <BatteryWarning className="w-5 h-5" />, activeColor: '#f59e0b', inactiveColor: '#94a3b8' },
{ key: 'tiltProtection', icon: <MoveVertical className="w-5 h-5" />, activeColor: '#10b981', inactiveColor: '#94a3b8' },
];
interface RobotFeatureButtonsProps {
activeFeatures?: RobotFeature[];
onToggle?: (feature: RobotFeature, enabled: boolean) => void;
className?: string;
}
const RobotFeatureButtons: React.FC<RobotFeatureButtonsProps> = ({
activeFeatures = [],
onToggle,
className = '',
}) => {
const { t } = useTranslation();
const [active, setActive] = useState<Set<RobotFeature>>(new Set(activeFeatures));
const handleClick = (feature: RobotFeature) => {
const next = !active.has(feature);
const nextSet = new Set(active);
if (next) nextSet.add(feature);
else nextSet.delete(feature);
setActive(nextSet);
onToggle?.(feature, next);
};
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{FEATURES.map((f) => {
const isActive = active.has(f.key);
return (
<button
key={f.key}
onClick={() => handleClick(f.key)}
className={`flex items-center gap-1 px-1 py-1 rounded-lg border transition-all duration-200 select-none
${isActive
? 'border-current shadow-lg'
: 'border-slate-700 hover:border-slate-500'
}`}
style={{
color: isActive ? f.activeColor : f.inactiveColor,
backgroundColor: isActive ? `${f.activeColor}1a` : 'transparent',
boxShadow: isActive ? `0 0 12px ${f.activeColor}33` : 'none',
}}
title={t(`devices.features.${f.key}`)}
>
<span className="flex items-center justify-center">{f.icon}</span>
<span className="text-xs font-medium whitespace-nowrap">
{t(`devices.features.${f.key}`)}
</span>
<span
className="ml-1 w-2 h-2 rounded-full transition-all"
style={{ backgroundColor: isActive ? f.activeColor : '#475569' }}
/>
</button>
);
})}
</div>
);
};
export default RobotFeatureButtons;

View File

@@ -629,6 +629,23 @@
landingDockSnLabel: 'Landing Dock Sn Label',
leftView: 'Left View',
leftWheelDrive: 'Left Wheel Drive',
features: {
title: 'Feature Control',
emergencyStop: 'Emergency Stop',
response: 'Response',
obstacleAvoidance: 'Obstacle Avoidance',
lowBatteryReturn: 'Low Battery Return',
tiltProtection: 'Tilt Protection',
},
dpad: {
title: 'Direction Control',
up: 'Forward',
down: 'Backward',
left: 'Turn Left',
right: 'Turn Right',
center: 'Confirm',
speed: 'Speed',
},
legendTitle: 'Legend Title',
levelHigh: 'Level High',
levelLow: 'Level Low',

View File

@@ -653,6 +653,23 @@ const zh = {
landingDockSnLabel: '降落停靠序列号标签',
leftView: '左视角',
leftWheelDrive: '左轮驱动',
features: {
title: '功能控制',
emergencyStop: '急停',
response: '响应',
obstacleAvoidance: '避障',
lowBatteryReturn: '低电量自动返航',
tiltProtection: '倾斜保护',
},
dpad: {
title: '方向控制',
up: '前进',
down: '后退',
left: '左转',
right: '右转',
center: '确认',
speed: '速度',
},
legendTitle: '图例标题',
levelHigh: '级别高',
levelLow: '级别低',

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import {
Card, Row, Col, Table, Select, Input, Tag,
Typography, Button, Space, Avatar, Pagination,
@@ -17,7 +17,7 @@ import {
PauseCircleOutlined, StopOutlined, InfoCircleOutlined,
ClockCircleOutlined, EnvironmentOutlined, PlusOutlined,
SettingOutlined, ExclamationCircleOutlined, FileTextOutlined,
DeleteOutlined, AimOutlined, SendOutlined, LockOutlined,
DeleteOutlined, AimOutlined, SendOutlined, LockOutlined, SafetyOutlined,
NodeIndexOutlined, SaveOutlined, MaximizeOutlined, ReloadOutlined
} from '@ant-design/icons';
import { Line, Bar, Pie } from '@ant-design/charts';
@@ -28,6 +28,7 @@ import { useSelector } from 'react-redux';
import dayjs from 'dayjs';
import envConfig from '../../env';
import { useMqtt } from '../hooks/useMqtt';
import RobotFeatureButtons from '../components/devices/RobotFeatureButtons';
import DeviceController from '../components/devices/deviceController';
import {
@@ -369,19 +370,19 @@ const RealTimeMonitorPage = () => {
const lat = rt.lat;
//if (!isFinite(lon) || !isFinite(lat) || (lon === 0 && lat === 0)) return d;
return {
...d,
longitude: lon,
latitude: lat,
heading: rt.yaw,
batteryLevel: rt.battery,
feStatus: rt.status,
taskId: rt.taskId,
updateTime: rt.updateTime || d.updateTime,
// 同步写入 locationMessage / lastRunningStatus,确保 Map.jsx 设备渲染 useEffect 能读到实时坐标
locationMessage: { longitude: lon, latitude: lat, heading: rt.yaw, batteryLevel: rt.battery },
lastRunningStatus: { longitude: lon, latitude: lat, heading: rt.yaw, batteryLevel: rt.battery },
raw: { ...(d.raw || d), heading: rt.yaw, batteryLevel: rt.b, longitude: lon, latitude: lat },
};
...d,
longitude: lon,
latitude: lat,
heading: rt.yaw,
batteryLevel: rt.battery,
feStatus: rt.status,
taskId: rt.taskId,
updateTime: rt.updateTime || d.updateTime,
// 同步写入 locationMessage / lastRunningStatus,确保 Map.jsx 设备渲染 useEffect 能读到实时坐标
locationMessage: { longitude: lon, latitude: lat, heading: rt.yaw, batteryLevel: rt.battery },
lastRunningStatus: { longitude: lon, latitude: lat, heading: rt.yaw, batteryLevel: rt.battery },
raw: { ...(d.raw || d), heading: rt.yaw, batteryLevel: rt.b, longitude: lon, latitude: lat },
};
});
}, [devices, realtimeMap]);
@@ -1383,6 +1384,19 @@ const RealTimeMonitorPage = () => {
)}
</Card>
<Card size="small" style={{ borderRadius: 10, marginBottom: 0 }}>
<p style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>
<SafetyOutlined style={{ color: '#fa541c', marginRight: 6 }} />
{t('devices.features.title')}
</p>
<RobotFeatureButtons
activeFeatures={['response', 'obstacleAvoidance', 'lowBatteryReturn', 'tiltProtection']}
onToggle={(feature, enabled) => {
console.log('[RTMonitor Feature]', feature, enabled);
}}
/>
</Card>
{/* ============== 【中部】预设路径列表 ============== */}
<Card
size="small"