机器人控制界面和 实时监控界面 加上 急停、避障、低电量自动返航、倾斜保护等 功能面板 加上 方向盘点击 控制机器 功能
This commit is contained in:
157
src/components/devices/DPadControl.tsx
Normal file
157
src/components/devices/DPadControl.tsx
Normal 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
@@ -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>
|
||||
)}
|
||||
|
||||
90
src/components/devices/RobotFeatureButtons.tsx
Normal file
90
src/components/devices/RobotFeatureButtons.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '级别低',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user