机器人控制界面 添加键盘 控件 控制页面方向盘

This commit is contained in:
mmc
2026-10-12 15:36:40 +08:00
parent 5d8b9559d0
commit d30f52af4e
3 changed files with 349 additions and 14 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Circle } from 'lucide-react';
@@ -15,6 +15,22 @@ interface DPadControlProps {
disabled?: boolean;
}
// 键盘按键映射
const KEY_MAP: Record<string, string> = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
w: 'up',
s: 'down',
a: 'left',
d: 'right',
W: 'up',
S: 'down',
A: 'left',
D: 'right',
};
const DPadControl: React.FC<DPadControlProps> = ({ onGamepadUpdate, onSpeedChange, disabled = false }) => {
const { t } = useTranslation();
const [pressed, setPressed] = useState<Record<string, boolean>>({});
@@ -23,6 +39,8 @@ const DPadControl: React.FC<DPadControlProps> = ({ onGamepadUpdate, onSpeedChang
// 用 ref 存储最新状态,避免 setInterval 闭包捕获旧值
const pressedRef = useRef<Record<string, boolean>>({});
const speedRef = useRef(0.5);
// 记录当前按下的键盘按键,避免 keydown 重复触发
const activeKeysRef = useRef<Set<string>>(new Set());
// 构建状态:只传输 xy 坐标(axes),buttons 全部为 false
const buildState = useCallback(
@@ -86,6 +104,53 @@ const DPadControl: React.FC<DPadControlProps> = ({ onGamepadUpdate, onSpeedChang
[disabled, emit],
);
// 键盘控制:方向键 / WASD
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 焦点在输入框时不触发
const target = e.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const key = KEY_MAP[e.key];
if (!key) return;
// 防止 keydown 持续触发(系统自动重复)
if (activeKeysRef.current.has(key)) return;
activeKeysRef.current.add(key);
e.preventDefault();
startPress(key);
};
const handleKeyUp = (e: KeyboardEvent) => {
const key = KEY_MAP[e.key];
if (!key) return;
activeKeysRef.current.delete(key);
e.preventDefault();
endPress(key);
};
// 窗口失焦时清空所有按键状态
const handleBlur = () => {
activeKeysRef.current.forEach((key) => endPress(key));
activeKeysRef.current.clear();
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
// 清理定时器
if (pressTimer.current) {
window.clearInterval(pressTimer.current);
pressTimer.current = null;
}
};
}, [startPress, endPress]);
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)]';

View File

@@ -50,6 +50,7 @@ import { useLocation } from 'react-router-dom';
import XboxController from './XboxController';
import RobotFeatureButtons from './RobotFeatureButtons';
import DPadControl from './DPadControl';
import ReverseCameraOverlay from './ReverseCameraOverlay';
import DroneVideoPlayer from './DroneVideoPlayer';
import CesiumMap from '../Map.jsx';
import { useSelector } from 'react-redux';
@@ -118,6 +119,7 @@ export default function DeviceStatusPage() {
});
const [mowerView, setMowerView] = useState('front');
const [steerXY, setSteerXY] = useState({ x: 0, y: 0 });
const [uavState, setUavState] = useState<any>(null);
const [points, setPoints] = useState([]);
const [clearNavLine, setClearNavLine] = useState(0);
@@ -632,6 +634,10 @@ export default function DeviceStatusPage() {
const handleGamepadUpdate = useCallback((state) => {
console.log('Gamepad state updated:', state);
// 记录方向盘 xy 坐标,用于倒车影像叠加层
if (state?.axes) {
setSteerXY({ x: state.axes[0] || 0, y: state.axes[1] || 0 });
}
controllerRef.current?.sendGamepad(state);
}, []);
@@ -1268,6 +1274,11 @@ export default function DeviceStatusPage() {
)
)}
{/* 倒车影像叠加层:根据方向盘 xy 显示转向辅助线 */}
{/*{isVideoPlaying && (*/}
<ReverseCameraOverlay x={steerXY.x} y={steerXY.y} visible={true} />
{/*//)}*/}
{/* 视频未加载 / 停止播放的占位 */}
{!isVideoPlaying && (
<div style={{
@@ -1417,11 +1428,31 @@ export default function DeviceStatusPage() {
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' },
body: {
padding: 12,
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap', // ✅ 新增:空间不足自动换行
gap: 12,
height: 'calc(100% - 57px)',
overflow: 'auto', // ✅ 改成 auto,换行后内容不会被截断
alignItems: 'center', // 垂直居中,换行后美观
justifyContent: 'center',
},
}}
>
{/* Xbox 手柄 - 左侧,缩小显示 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', transform: 'scale(0.82)', transformOrigin: 'left center' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
minWidth: 320, // ✅ 限制最小宽度,小于这个宽度就会换行
transform: 'scale(0.82)',
transformOrigin: 'left center',
}}
>
<XboxController
onGamepadUpdate={handleGamepadUpdate}
handleControlModal={handleControlModal}
@@ -1431,28 +1462,29 @@ export default function DeviceStatusPage() {
/>
</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>*/}
{/* 方向盘 + 滑块 - 右侧 */}
<div
style={{
flex: '1 1 auto',
//minWidth: 300, // ✅ 最小宽度,触发换行阈值,可按需调整
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<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={

View File

@@ -0,0 +1,238 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface ReverseCameraOverlayProps {
x: number;
y: number;
visible?: boolean;
}
/**
* 倒车影像叠加层
* @param x 方向盘 X 轴 (-1 ~ 1),负值左转,正值右转
* @param y 方向盘 Y 轴 (-1 ~ 1),负值前进,正值后退
* @param visible 是否显示
*/
const ReverseCameraOverlay: React.FC<ReverseCameraOverlayProps> = ({ x, y, visible = true }) => {
const { t } = useTranslation();
if (!visible) return null;
// 将 x 限制在 [-1, 1]
const clamp = (v: number) => Math.max(-1, Math.min(1, v));
const steer = clamp(x || 0);
const move = clamp(y || 0);
// 转向角度(用于辅助线弯曲):steer * 35deg
const steerAngle = steer * 35;
// 后退时显示倒车线,前进时减弱
const reverseOpacity = move > 0 ? 1 : 0.35;
return (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 5,
overflow: 'hidden',
}}
>
<svg
width="100%"
height="100%"
viewBox="0 0 400 240"
preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, opacity: reverseOpacity }}
>
<defs>
<linearGradient id="rv-line" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.95" />
<stop offset="100%" stopColor="#22d3ee" stopOpacity="0.1" />
</linearGradient>
</defs>
{/* 倒车网格(透视梯形) */}
<g
style={{
transformOrigin: '200px 240px',
transform: `perspective(200px) rotateX(55deg) rotateZ(${-steerAngle * 0.4}deg)`,
transition: 'transform 0.12s linear',
}}
>
{/* 横向网格线 */}
{[0, 1, 2, 3, 4, 5].map((i) => {
const yPos = 240 - i * 30;
const inset = i * 18;
return (
<line
key={`h-${i}`}
x1={inset}
y1={yPos}
x2={400 - inset}
y2={yPos}
stroke="#22d3ee"
strokeOpacity={0.25 + i * 0.08}
strokeWidth={1}
/>
);
})}
{/* 纵向网格线 */}
{[-3, -2, -1, 0, 1, 2, 3].map((i) => {
const bottomX = 200 + i * 40;
const topX = 200 + i * 8;
return (
<line
key={`v-${i}`}
x1={bottomX}
y1={240}
x2={topX}
y2={60}
stroke="#22d3ee"
strokeOpacity={0.18}
strokeWidth={1}
/>
);
})}
</g>
{/* 两侧倒车轨迹线(随转向弯曲) */}
<g style={{ transition: 'all 0.12s linear' }}>
{/* 左侧轨迹线 */}
<path
d={`M ${160 + steer * 60} 240 Q ${180 + steer * 40} ${160 + steer * 30} ${200 + steer * 30} ${100 + steer * 20}`}
stroke="#22d3ee"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px rgba(34,211,238,0.6))' }}
/>
{/* 右侧轨迹线 */}
<path
d={`M ${240 + steer * 60} 240 Q ${220 + steer * 40} ${160 + steer * 30} ${200 + steer * 30} ${100 + steer * 20}`}
stroke="#22d3ee"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px rgba(34,211,238,0.6))' }}
/>
</g>
{/* 安全区域(中间梯形) */}
<polygon
points={`${150 + steer * 30},240 ${250 + steer * 30},240 ${220 + steer * 15},170 ${180 + steer * 15},170`}
fill="#22c55e"
fillOpacity="0.06"
stroke="#22c55e"
strokeOpacity="0.4"
strokeWidth="1"
style={{ transition: 'all 0.12s linear' }}
/>
</svg>
{/* 顶部方向指示 */}
<div
style={{
position: 'absolute',
top: 8,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 12px',
borderRadius: 12,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
fontSize: 12,
fontWeight: 600,
backdropFilter: 'blur(4px)',
letterSpacing: 1,
}}
>
{move > 0 ? (
<>
<span style={{ color: '#f59e0b' }}>R</span>
<span>{t('devices.reverse')}</span>
</>
) : move < 0 ? (
<>
<span style={{ color: '#22c55e' }}>D</span>
<span>{t('devices.forward')}</span>
</>
) : (
<>
<span style={{ color: '#94a3b8' }}>N</span>
<span>{t('devices.neutral')}</span>
</>
)}
</div>
{/* 左右转向箭头 */}
{steer < -0.1 && (
<div
style={{
position: 'absolute',
left: 12,
top: '50%',
transform: `translateY(-50%) translateX(${steer * 20}px)`,
color: '#22d3ee',
fontSize: 36,
fontWeight: 900,
textShadow: '0 0 12px rgba(34,211,238,0.8)',
opacity: Math.min(1, Math.abs(steer)),
transition: 'all 0.1s linear',
}}
>
◀
</div>
)}
{steer > 0.1 && (
<div
style={{
position: 'absolute',
right: 12,
top: '50%',
transform: `translateY(-50%) translateX(${steer * 20}px)`,
color: '#22d3ee',
fontSize: 36,
fontWeight: 900,
textShadow: '0 0 12px rgba(34,211,238,0.8)',
opacity: Math.min(1, Math.abs(steer)),
transition: 'all 0.1s linear',
}}
>
▶
</div>
)}
{/* 底部距离 / 速度 指示条 */}
<div
style={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '4px 14px',
borderRadius: 12,
background: 'rgba(0,0,0,0.55)',
color: '#e2e8f0',
fontSize: 11,
backdropFilter: 'blur(4px)',
}}
>
<span>
{t('devices.steer')}: <strong style={{ color: '#22d3ee' }}>{(steer * 100).toFixed(0)}%</strong>
</span>
<span style={{ width: 1, height: 10, background: '#475569' }} />
<span>
{t('devices.throttle')}: <strong style={{ color: move >= 0 ? '#f59e0b' : '#22c55e' }}>{(move * 100).toFixed(0)}%</strong>
</span>
</div>
</div>
);
};
export default ReverseCameraOverlay;