机器人控制界面 添加键盘 控件 控制页面方向盘
This commit is contained in:
@@ -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)]';
|
||||
|
||||
@@ -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={
|
||||
|
||||
238
src/components/devices/ReverseCameraOverlay.tsx
Normal file
238
src/components/devices/ReverseCameraOverlay.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user