This commit is contained in:
mmc
2026-05-20 15:12:51 +08:00
parent 6fb4e4cbfb
commit 36d64043ef
5 changed files with 532 additions and 420 deletions

4
env.js
View File

@@ -15,8 +15,8 @@ const envConfig = {
},
[ENV.PROD]: {
// baseURL: 'http://192.168.2.56:8081',
//baseURL: 'http://1.95.137.212:59015',//http://192.168.2.56:8081
baseURL: 'https://serviceri.satabot.com',
baseURL: 'http://1.95.137.212:59015',//http://192.168.2.56:8081
//baseURL: 'https://serviceri.satabot.com',
WS_URL: 'wss://ri.satabot.com/ws/'
},
};

View File

@@ -447,14 +447,14 @@ export default function UserRolePage() {
: <Tag color="red">停用</Tag>
},
{
title: '是否有设备权限', dataIndex: 'deviceOperatePerm', width: 80,
title: '是否有设备权限', dataIndex: 'deviceOperatePerm', width: 140,
render: (_, s) => s.deviceOperatePerm == true
? <Tag color="green">有权限</Tag>
: <Tag color="red">无权限</Tag>
},
{
title: '操作',
width: 220,
width: 300,
fixed: 'right',
render: (_, r) => (
<Space wrap>
@@ -478,7 +478,6 @@ export default function UserRolePage() {
onChange={(p) => setUserPagination({ ...userPagination, pageNum: p.current!, pageSize: p.pageSize! })}
bordered
size="middle"
// 👇 下面这行是修复不自适应的关键
scroll={{ x: 'max-content' }}
/>
</TabPane>

View File

@@ -1,344 +1,298 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import React, {
useState,
useEffect,
useMemo,
useRef,
useCallback,
} from 'react';
import {
Row, Col, Card, Statistic, Tag, Typography,
Space, Empty, Descriptions, Badge, Progress, Button,
Divider, Select, message, Modal
Row,
Col,
Card,
Tag,
Typography,
Space,
Empty,
Descriptions,
Badge,
Progress,
Button,
Select,
message,
Modal,
} from 'antd';
import {
ThunderboltOutlined, DashboardOutlined, AimOutlined,
EnvironmentOutlined, RocketOutlined, GlobalOutlined,
VideoCameraOutlined, ControlOutlined,
CameraOutlined
DashboardOutlined,
GlobalOutlined,
VideoCameraOutlined,
ControlOutlined,
CameraOutlined,
ThunderboltOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import XboxController from './XboxController';
import DroneLivePlayer from '../VolcRtcPlayer.jsx';
import { getLiveStream, getControlRight, getJurisdiction } from '../../api/device.ts';
import { getLiveStream } from '../../api/device.ts';
import CesiumMap from '../Map.jsx';
import { wsManager } from '../WebSocketManager.ts';
import { useSelector } from 'react-redux';
import { RootState } from '@/src/store/index.ts';
import envConfig from '../../../env';
import { useWebRTCPlayer } from '../useWebRTCStream.tsx';
import VideoPlayer from '../VideoPlayer.tsx';
import DeviceController from './DeviceController';
const { Text } = Typography;
const OBSTACLE_TYPE = {
0: '未知',
1: '人',
2: '车辆',
3: '围栏',
4: '坑洞',
5: '草丛',
};
export default function DeviceStatusPage({ devicesAll, serialNumber, onDeviceChange }) {
const [planeVideo, setPlaneVideo] = useState([]);
const [isControlled, setIsControlled] = useState(false);
const [wsDeviceInfo, setWsDeviceInfo] = useState({});
const [wsStatus, setWsStatus] = useState('disconnected');
const [isRequestControl, setIsRequestControl] = useState(false);
const connectedDeviceSnRef = useRef(null);
const requestDeviceId = useRef('');
const controlRef = useRef(false);
export default function DeviceStatusPage({
devicesAll,
serialNumber,
onDeviceChange,
}) {
const { userInfo } = useSelector((state: RootState) => state.user);
const [messageApi, contextHolder] = message.useMessage();
const controllerRef = useRef<DeviceController | null>(null);
const [planeVideo, setPlaneVideo] = useState([]);
const [isControlled, setIsControlled] = useState(false);
const [hasControlRight, setHasControlRight] = useState(false);
const [connecting, setConnecting] = useState(false);
const [wsStatus, setWsStatus] = useState('disconnected');
const [wsDeviceInfo, setWsDeviceInfo] = useState({});
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState({ title: '', content: '' });
const [modelType, setModelType] = useState('');
const [hasControlRight, setHasContronRight] = useState(false);
const [modalContent, setModalContent] = useState({
title: '',
content: '',
});
// ========== 机器人视角切换(只给机器人用)==========
const [mowerView, setMowerView] = useState<'front' | 'back' | 'left' | 'right' | 'top' | 'all'>('front');
const [mowerView, setMowerView] = useState('front');
// 主视频(机器人用)
const { video: robotVideo, error, ready } = useWebRTCPlayer(
serialNumber || '',
`webrtc://ri.satabot.com/live/livestream/${serialNumber}`
);
// 获取选中的设备详情
const device = useMemo(() => {
const sn = serialNumber || (devicesAll?.[0]?.device_sn || devicesAll?.[0]?.serialNumber);
const sn =
serialNumber ||
devicesAll?.[0]?.device_sn ||
devicesAll?.[0]?.serialNumber;
if (!sn) return null;
return devicesAll?.find(d => (d.device_sn || d.serialNumber) == sn);
return devicesAll?.find(
(d) => (d.device_sn || d.serialNumber) === sn,
);
}, [devicesAll, serialNumber]);
// 判断是否为无人机
const isUAV = useMemo(() => {
return device && (device.device_sn);
return !!device?.device_sn;
}, [device]);
// 选中设备(给视频用)
const selectedDevice = useMemo(() => {
return device;
}, [device]);
// 优先显示 WebSocket 推送的实时数据
const runningStatus = useMemo(() => {
const baseStatus = device?.lastRunningStatus || {};
if (!isUAV && Object.keys(wsDeviceInfo).length > 0) {
return {
...baseStatus,
...wsDeviceInfo
const base = device?.lastRunningStatus || {};
return isUAV
? base
: {
...base,
...wsDeviceInfo,
};
}
return baseStatus;
}, [device, wsDeviceInfo, isUAV]);
const fix2 = (val) => {
if (val === null || val === undefined || isNaN(val)) return '-';
return Number(val).toFixed(2);
};
const { video: robotVideo, error, ready } = useWebRTCPlayer(
serialNumber || '',
`webrtc://ri.satabot.com/live/livestream/${serialNumber}`,
);
const getVal = (val) => val === "no_rain" ? "无" : (val || '-');
// 获取权限
const getControl = useCallback((serialnum) => {
const data = { platform: "web", deviceId: serialnum };
getJurisdiction(data).then(res => {
if (res?.code == 200 && res?.data?.remoteControl == true) {
controlRef.current = true;
setIsControlled(true);
} else {
controlRef.current = false;
setIsControlled(false);
}
}).catch(err => {
controlRef.current = false;
setIsControlled(false);
})
}, []);
const showModal = (content, type) => {
setModalContent(content);
setModelType(type);
setIsModalOpen(true);
};
useEffect(() => {
const controller = new DeviceController(userInfo, messageApi);
const sendPermissionRequest = (data, type) => {
const wsdata = {
type: type,
switchResult: data,
token: userInfo.token,
deviceId: type === "switchPermission" ? connectedDeviceSnRef.current : requestDeviceId.current,
userName: userInfo.username,
controller.onDeviceInfoUpdate = setWsDeviceInfo;
controller.onControlStatusChange = setIsControlled;
controller.onHasControlRightChange = setHasControlRight;
controller.onRequestControlStatusChange = setConnecting;
controller.onWsStatusChange = setWsStatus;
controller.onPermissionRequest = (content) => {
setModalContent(content);
setIsModalOpen(true);
};
wsManager.send(wsdata);
};
const handleOk = () => {
sendPermissionRequest(true, modelType);
if (modelType === "switchPermission") {
setIsRequestControl(true);
messageApi.loading({ key: "control", content: '权限请求中...', duration: 0 });
} else {
if (requestDeviceId.current === connectedDeviceSnRef.current) {
controlRef.current = false;
setIsControlled(false);
}
}
setIsModalOpen(false);
};
controller.init();
const handleCancel = () => {
if (modelType === "switchPermission") {
setIsRequestControl(false);
} else {
sendPermissionRequest(false, modelType);
}
setIsModalOpen(false);
};
controllerRef.current = controller;
// WebSocket 消息处理
const handleWsMessage = useCallback((messageData) => {
switch (messageData.type) {
case 'deviceInfo':
const deviceData = {};
messageData.data.forEach(item => { deviceData[item.name] = item.value; });
setWsDeviceInfo(deviceData);
break;
case 'AUTH':
if (messageData.isAuth === true) {
getControl(connectedDeviceSnRef.current);
}
break;
case 'switchResult':
messageApi.success({ content: '权限获取成功', key: 'control', duration: 2 });
setIsRequestControl(false);
controlRef.current = true;
setIsControlled(true);
break;
case 'switchPermission':
const rawPlatform = messageData.platform;
if (rawPlatform == "null") return;
const platform = (messageData.platform || '').toLowerCase().trim();
if (platform === "web" || !platform) return;
requestDeviceId.current = messageData.deviceId;
showModal({
title: "权限请求",
content: `${messageData.platform || '未知平台'}请求控制${requestDeviceId.current === connectedDeviceSnRef.current ? "" : requestDeviceId.current}权限, 是否同意?`
}, 'switchResult');
break;
case 'detectionReport':
const obsData = messageData.data;
if (obsData && obsData.deviceId === connectedDeviceSnRef.current) {
const type = obsData?.obstacle?.type;
const isSecure = obsData?.isSecure;
const distance = obsData?.obstacle?.distance?.toFixed(2);
if (isSecure) return;
messageApi.warning({
key: 'obstacle-warning',
content: `设备检测到${OBSTACLE_TYPE[type] ?? '未知物'},距离 ${distance}m!`,
duration: 3
});
}
break;
}
}, [getControl, messageApi]);
// 连接设备 WebSocket
const connectDevice = useCallback(async (targetDevice) => {
if (!targetDevice || isUAV) return;
wsManager.close();
connectedDeviceSnRef.current = null;
setWsDeviceInfo({});
const token = userInfo.token;
const userName = userInfo.username;
const wsUrl = envConfig.WS_URL || "wss://ri.satabot.com/ws/";
wsManager.connect(targetDevice.serialNumber || targetDevice.device_sn, wsUrl);
try {
const res = await getControlRight({ userName, sourceType: 1, token });
if (res?.code == 200) {
connectedDeviceSnRef.current = targetDevice.serialNumber || targetDevice.device_sn;
setHasContronRight(true);
await getControl(connectedDeviceSnRef.current);
} else {
setHasContronRight(false);
messageApi.warning("其他端正在控制");
}
} catch (err) {
messageApi.error("权限校验失败");
}
}, [isUAV, getControl, userInfo, messageApi]);
return () => {
controller.destroy();
controllerRef.current = null;
};
}, [userInfo, messageApi]);
useEffect(() => {
if (device) {
connectDevice(device);
}
return () => { wsManager.close(); };
}, [device, connectDevice]);
if (!device || !controllerRef.current) return;
useEffect(() => {
const unsubStatus = wsManager.onStatusChange(setWsStatus);
const unsubMsg = wsManager.onMessage(handleWsMessage);
return () => { unsubStatus(); unsubMsg(); };
}, [handleWsMessage]);
controllerRef.current.connectDevice(device);
}, [device]);
useEffect(() => {
if (!device?.gateway_sn) {
setPlaneVideo([]);
return;
}
const data = {
getway_sn: device?.gateway_sn,
drone_sn: device?.device_sn,
getway_camera_list: device?.gateway_camera_list ?? [],
drone_camera_list: device?.drone_camera_list ?? [],
video_expire: 7200,
quality_type: "adaptive",
}
getLiveStream(data).then(res => {
if (res.code == 200) {
setPlaneVideo(res.data || []);
const loadVideo = async () => {
try {
const data = {
getway_sn: device.gateway_sn,
drone_sn: device.device_sn,
getway_camera_list: device.gateway_camera_list ?? [],
drone_camera_list: device.drone_camera_list ?? [],
video_expire: 7200,
quality_type: 'adaptive',
};
const res = await getLiveStream(data);
if (res.code === 200) {
setPlaneVideo(res.data || []);
}
} catch (err) {
console.error(err);
}
})
};
loadVideo();
}, [device]);
const handleGamepadUpdate = (state) => {
if (!isControlled || !connectedDeviceSnRef.current) return;
const payload = {
type: 'gamepad',
deviceId: connectedDeviceSnRef.current,
data: { axes: state.axes, buttons: state.buttons }
};
wsManager.send(payload);
};
const handleControlModal = (data) => {
if (isRequestControl) return;
if (!hasControlRight) {
messageApi.warning("当前网页端无控制权限");
return;
}
if (data.control == true) {
showModal({ title: "权限请求", content: "是否请求控制权限?" }, 'switchPermission');
} else {
setIsModalOpen(false);
}
}
// 归一化地图显示的设备列表
const mapDevices = useMemo(() => {
if (!device) return [];
const normalizedDevice = {
return {
...device,
lastRunningStatus: {
...device.lastRunningStatus,
longitude: runningStatus.longitude || device.longitude,
latitude: runningStatus.latitude || device.latitude,
longitude:
runningStatus.longitude || device.longitude,
latitude:
runningStatus.latitude || device.latitude,
},
name: device.deviceAlias || device.callsign || device.deviceName || '当前设备',
type: isUAV ? 'drone' : (device.productName?.includes('割草机') ? 'mower' : 'robot')
name:
device.deviceAlias ||
device.callsign ||
device.deviceName ||
'设备',
type: isUAV
? 'drone'
: device.productName?.includes('割草机')
? 'mower'
: 'robot',
};
return [normalizedDevice];
}, [device, isUAV, runningStatus]);
const handleGamepadUpdate = useCallback((state) => {
controllerRef.current?.sendGamepad(state);
}, []);
const handleControlModal = useCallback(() => {
controllerRef.current?.requestControl();
}, []);
const handleOk = () => {
controllerRef.current?.sendPermissionRequest(
true,
'switchResult',
);
setIsModalOpen(false);
};
const handleCancel = () => {
controllerRef.current?.sendPermissionRequest(
false,
'switchResult',
);
setIsModalOpen(false);
};
const fix2 = (val) => {
if (val === null || val === undefined || isNaN(val)) {
return '-';
}
return Number(val).toFixed(2);
};
if (!device) {
return <Empty description="请选择一个设备以查看状态" style={{ marginTop: 100 }} />;
return (
<Empty
description="请选择设备"
style={{ marginTop: 100 }}
/>
);
}
return (
<div style={{ padding: '0px' }}>
<div style={{ padding: 0 }}>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<Text strong>选择设备:</Text>
<Select
style={{ width: 280 }}
placeholder="请选择要查看的设备"
value={device?.device_sn || device?.serialNumber}
onChange={(val) => onDeviceChange && onDeviceChange(val)}
onChange={(val) => onDeviceChange?.(val)}
showSearch
optionFilterProp="label"
options={devicesAll?.map(item => ({
options={devicesAll?.map((item) => ({
value: item.device_sn || item.serialNumber,
label: item.deviceAlias || item.drone_callsign || item.callsign || item.deviceName || '未知设备'
label:
item.deviceAlias ||
item.drone_callsign ||
item.callsign ||
item.deviceName ||
'未知',
}))}
/>
{device && (
<Tag color="blue" style={{ marginLeft: 8 }}>
{isUAV ? '无人机' : '机器人'}
</Tag>
)}
<Tag color="blue">
{isUAV ? '无人机' : '机器人'}
</Tag>
{wsStatus === 'connected' && (
<Badge status="processing" color="#52c41a" text="实时连接中" style={{ color: '#52c41a' }} />
<Badge
status="processing"
color="#52c41a"
text="实时连接中"
/>
)}
</div>
<Row gutter={[16, 16]}>
{/* 左侧:视频和地图 */}
<Col xs={24} lg={16}>
<Card
title={<><VideoCameraOutlined /> 实时视频监控 - {device.deviceAlias || device.callsign || '监控画面'}</>}
title={
<>
<VideoCameraOutlined />
实时视频监控
</>
}
bordered={false}
style={{ borderRadius: 12, marginBottom: 16 }}
style={{ borderRadius: 12 }}
bodyStyle={{ padding: 12 }}
>
{/* ====================== 机器人:视角切换按钮(只在机器人显示)====================== */}
{!isUAV && (
<Space style={{ marginBottom: 12, flexWrap: 'wrap' }}>
<Space style={{ marginBottom: 12 }}>
<Button type={mowerView === 'front' ? 'primary' : 'default'} icon={<CameraOutlined />} onClick={() => setMowerView('front')}>前视</Button>
<Button type={mowerView === 'back' ? 'primary' : 'default'} onClick={() => setMowerView('back')}>后视</Button>
<Button type={mowerView === 'left' ? 'primary' : 'default'} onClick={() => setMowerView('left')}>左视</Button>
@@ -348,75 +302,78 @@ export default function DeviceStatusPage({ devicesAll, serialNumber, onDeviceCha
</Space>
)}
{/* ====================== 视频区域 ====================== */}
<div style={{
display: 'grid',
gap: '8px',
gridTemplateColumns: isUAV
? (planeVideo.length > 1 ? '1fr 1fr' : '1fr')
: (mowerView === 'all' ? '1fr 1fr' : '1fr'),
gridTemplateRows: isUAV
? (planeVideo.length > 2 ? '1fr 1fr' : '1fr')
: (mowerView === 'all' ? '1fr 1fr' : '1fr'),
height: 450,
background: '#000',
borderRadius: 8,
overflow: 'hidden'
}}>
{/* ---------------------- 无人机视频(保持原样)---------------------- */}
<div
style={{
height: 450,
background: '#000',
borderRadius: 8,
overflow: 'hidden',
display: 'flex',
}}
>
{isUAV ? (
planeVideo.length > 0 ? (
planeVideo.map((stream, index) => (
<div key={index} style={{
position: 'relative',
gridColumn: (planeVideo.length === 3 && index === 0) ? 'span 2' : 'span 1'
}}>
<DroneLivePlayer streamData={stream} />
<div style={{
position: 'absolute', top: 8, left: 8,
background: 'rgba(0,0,0,0.5)', color: '#fff',
padding: '2px 8px', borderRadius: 4, fontSize: 12
}}>
{stream?.camera_position === 'indoor' ? '库内监控' : `画面 ${index + 1}`}
</div>
planeVideo.map((s, i) => (
<div
key={i}
style={{
position: 'relative',
flex: 1,
}}
>
<DroneLivePlayer streamData={s} />
</div>
))
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666', gridColumn: 'span 2' }}>
暂无实时画面
<div style={{ color: '#ccc', margin: 'auto' }}>
暂无视频
</div>
)
) : robotVideo ? (
<VideoPlayer
deviceId={serialNumber}
idx={
mowerView === 'front'
? 1
: mowerView === 'back'
? 2
: mowerView === 'left'
? 3
: 4
}
className="w-full h-full object-contain"
video={robotVideo}
error={error}
ready={ready}
/>
) : (
/* ---------------------- 机器人视频(带视角切换)---------------------- */
robotVideo ? (
<VideoPlayer
deviceId={serialNumber}
idx={mowerView === "front" ? 1 : mowerView === "back" ? 2 : mowerView === "left" ? 3 : 4}
className="w-full h-full object-contain"
video={robotVideo}
error={error}
ready={ready}
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ccc' }}>
机器人视频加载中...
</div>
)
<div style={{ color: '#ccc', margin: 'auto' }}>
视频加载中...
</div>
)}
</div>
</Card>
<Card
title={<><GlobalOutlined /> 位置态势图</>}
title={
<>
<GlobalOutlined />
位置态势图
</>
}
bordered={false}
style={{ borderRadius: 12 }}
bodyStyle={{ padding: 12, height: 400 }}
bodyStyle={{
padding: 12,
height: 400,
}}
>
<CesiumMap devices={mapDevices} />
<CesiumMap devices={[mapDevices]} />
</Card>
</Col>
{/* 右侧:状态面板和控制 */}
<Col xs={24} lg={8}>
<Card
title={<><DashboardOutlined /> {isUAV ? '无人机状态' : '机器人状态'}详情</>}
@@ -473,18 +430,27 @@ export default function DeviceStatusPage({ devicesAll, serialNumber, onDeviceCha
</Descriptions>
)}
</Card>
{!isUAV && (
<Card
title={<><ControlOutlined /> 远程控制</>}
title={
<>
<ControlOutlined />
远程控制
</>
}
bordered={false}
style={{ borderRadius: 12, background: '#0f172a' }}
headStyle={{ color: '#fff', borderBottom: '1px solid #1e293b' }}
style={{
borderRadius: 12,
background: '#0f172a',
}}
headStyle={{ color: '#fff' }}
>
<XboxController
onGamepadUpdate={handleGamepadUpdate}
handleControlModal={handleControlModal}
isControlled={isControlled}
hasControlRight={hasControlRight}
isConnecting={connecting}
/>
</Card>
)}
@@ -492,10 +458,10 @@ export default function DeviceStatusPage({ devicesAll, serialNumber, onDeviceCha
</Row>
<Modal
title={modalContent.title}
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
title={modalContent.title}
okText="确定"
cancelText="取消"
>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isConnecting }) => {
const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isConnecting, hasControlRight }) => {
const [gamepadState, setGamepadState] = useState<{
buttons: boolean[];
axes: number[];
@@ -89,7 +89,7 @@ const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isC
const handleDisconnect = (e: GamepadEvent) => {
console.log('❌ Gamepad disconnected');
handleControlModal({ control: false });
//handleControlModal({ control: false });
setGamepadState(prev => ({ ...prev, connected: false }));
};
@@ -103,6 +103,8 @@ const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isC
}, []);
// UI mapping for Xbox Gamepad
//点火熄火 上面俩 AB升降底盘 XY割刀加减速 button急停
// Buttons: 0:A, 1:B, 2:X, 3:Y, 4:LB, 5:RB, 6:LT, 7:RT, 8:Back, 9:Start, 10:LS, 11:RS, 12:Up, 13:Down, 14:Left, 15:Right
const { buttons, axes, connected } = gamepadState;
@@ -113,8 +115,7 @@ const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isC
return (
<div className="relative w-full aspect-[4/3] flex items-center justify-center "
onClick={() => {
if (connected && !isControlled) {
// 这里写点击后要执行的逻辑,比如打开权限请求弹窗
if (connected && !isControlled && hasControlRight) {
console.log("点击了无控制权限,发起权限请求");
// 示例:调用你代码中已有的权限请求弹窗逻辑
handleControlModal({ control: true, type: "switchPermission" });
@@ -123,7 +124,7 @@ const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isC
{/* Gamepad Status Badge */}
<div
className={`absolute top-0 right-0 px-2 py-0.5 rounded text-[8px] font-bold tracking-widest border
${connected
${connected || !hasControlRight
? isConnecting
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
: isControlled
@@ -133,14 +134,17 @@ const XboxController = ({ onGamepadUpdate, handleControlModal, isControlled, isC
}
`}
>
{connected
? isConnecting
? '连接中...'
: isControlled
? '控制中'
: '无控制权限'
: '未连接设备'
}
{!connected ? (
'未连接设备'
) : !hasControlRight ? (
'其他 Web 端已连接'
) : isConnecting ? (
'请求控制中...'
) : isControlled ? (
'控制中'
) : (
'点击获取控制权限'
)}
</div>
<svg viewBox="0 0 400 300" className="w-full h-full drop-shadow-[0_0_20px_rgba(34,211,238,0.15)]">

View File

@@ -1,73 +1,99 @@
// src/components/device/deviceController.js
import { message } from 'antd';
import throttle from 'lodash/throttle';
import { wsManager } from '../WebSocketManager.ts';
import { getControlRight, getJurisdiction } from '../../api/device.ts';
import {
getControlRight,
getJurisdiction,
} from '../../api/device.ts';
import envConfig from '../../../env';
export default class DeviceController {
constructor(userInfo, messageApi) {
this.userInfo = userInfo;
this.messageApi = messageApi;
this.wsUrl = envConfig.WS_URL || 'wss://ri.satabot.com/ws/';
// 状态
this.wsUrl =
envConfig.WS_URL || 'wss://ri.satabot.com/ws/';
this.connectedDeviceSn = null;
this.requestDeviceId = '';
this.isControlled = false;
this.isRequestControl = false;
this.hasControlRight = false;
this.wsDeviceInfo = {};
this.wsStatus = 'disconnected';
// 回调
this.wsStatus = 'disconnected';
this.wsDeviceInfo = {};
this.controlTimeout = null;
this.onDeviceInfoUpdate = null;
this.onControlStatusChange = null;
this.onWsStatusChange = null;
this.onHasControlRightChange = null;
this.onRequestControlStatusChange = null;
this.onPermissionRequest = null;
// 绑定 this
this.handleWsMessage = this.handleWsMessage.bind(this);
this.getControl = this.getControl.bind(this);
this.connectDevice = this.connectDevice.bind(this);
this.sendPermissionRequest = this.sendPermissionRequest.bind(this);
this.handleWsStatus = this.handleWsStatus.bind(this);
this.sendGamepad = throttle(
this.sendGamepad.bind(this),
50,
{
leading: true,
trailing: true,
},
);
}
// 初始化监听
init() {
wsManager.onMessage(this.handleWsMessage);
wsManager.onStatusChange((status) => {
this.wsStatus = status;
if (this.onWsStatusChange) this.onWsStatusChange(status);
});
this.unSubMessage = wsManager.onMessage(
this.handleWsMessage,
);
this.unSubStatus = wsManager.onStatusChange(
this.handleWsStatus,
);
}
// 销毁
destroy() {
this.clearControlTimeout();
this.unSubMessage?.();
this.unSubStatus?.();
wsManager.close();
}
// 获取设备控制权限
async getControl(serialnum) {
try {
const data = { platform: 'web', deviceId: serialnum };
const res = await getJurisdiction(data);
const ok = res?.code === 200 && res?.data?.remoteControl === true;
this.isControlled = ok;
if (this.onControlStatusChange) this.onControlStatusChange(ok);
} catch (err) {
handleWsStatus(status) {
this.wsStatus = status;
this.onWsStatusChange?.(status);
if (status === 'disconnected') {
this.isControlled = false;
if (this.onControlStatusChange) this.onControlStatusChange(false);
this.onControlStatusChange?.(false);
}
}
// 连接设备 WebSocket
async connectDevice(device) {
if (!device) return;
const sn = device.serialNumber || device.device_sn;
const sn =
device.serialNumber || device.device_sn;
this.connectedDeviceSn = null;
this.wsDeviceInfo = {};
this.isControlled = false;
this.onControlStatusChange?.(false);
wsManager.close();
this.connectedDeviceSn = null;
this.wsDeviceInfo = {};
try {
const res = await getControlRight({
@@ -76,23 +102,103 @@ export default class DeviceController {
token: this.userInfo.token,
});
if (res?.code === 200) {
this.hasControlRight = true;
wsManager.connect(sn, this.wsUrl);
this.connectedDeviceSn = sn;
await this.getControl(sn);
} else {
this.hasControlRight = false;
this.hasControlRight = res?.code === 200;
this.onHasControlRightChange?.(
this.hasControlRight,
);
if (!this.hasControlRight) {
this.messageApi.warning('其他端正在控制');
return;
}
wsManager.connect(sn, this.wsUrl);
this.connectedDeviceSn = sn;
await this.getControl(sn);
} catch (err) {
console.error(err);
this.messageApi.error('权限校验失败');
}
}
// 发送权限请求
async getControl(serialnum) {
try {
const res = await getJurisdiction({
platform: 'web',
deviceId: serialnum,
});
this.isControlled =
res?.code === 200 &&
res?.data?.remoteControl === true;
this.onControlStatusChange?.(
this.isControlled,
);
} catch (err) {
console.error(err);
this.isControlled = false;
this.onControlStatusChange?.(false);
}
}
requestControl() {
if (this.isRequestControl) {
return;
}
if (!this.hasControlRight) {
this.messageApi.warning('无控制权限');
return;
}
this.isRequestControl = true;
this.onRequestControlStatusChange?.(true);
this.messageApi.loading({
key: 'control',
content: '请求控制中...',
duration: 0,
});
this.sendPermissionRequest(
true,
'switchPermission',
);
this.clearControlTimeout();
this.controlTimeout = setTimeout(() => {
if (!this.isRequestControl) return;
this.isRequestControl = false;
this.onRequestControlStatusChange?.(false);
this.messageApi.destroy('control');
this.messageApi.error('控制请求超时');
}, 10000);
}
clearControlTimeout() {
if (this.controlTimeout) {
clearTimeout(this.controlTimeout);
this.controlTimeout = null;
}
}
sendPermissionRequest(data, type) {
const wsData = {
wsManager.send({
type,
switchResult: data,
token: this.userInfo.token,
@@ -101,61 +207,86 @@ export default class DeviceController {
? this.connectedDeviceSn
: this.requestDeviceId,
userName: this.userInfo.username,
};
wsManager.send(wsData);
}
// 处理外部请求控制
requestControl() {
if (this.isRequestControl || !this.hasControlRight) {
if (!this.hasControlRight) this.messageApi.warning('当前网页端无控制权限');
return;
}
this.isRequestControl = true;
this.messageApi.loading({
key: 'control',
content: '权限请求中...',
duration: 0,
});
this.sendPermissionRequest(true, 'switchPermission');
}
// 处理 WebSocket 消息
handleWsMessage(msg) {
switch (msg.type) {
case 'deviceInfo':
case 'deviceInfo': {
const data = {};
msg.data.forEach((item) => (data[item.name] = item.value));
this.wsDeviceInfo = data;
if (this.onDeviceInfoUpdate) this.onDeviceInfoUpdate(data);
break;
case 'AUTH':
if (msg.isAuth) this.getControl(this.connectedDeviceSn);
break;
case 'switchResult':
this.messageApi.success({
content: '权限获取成功',
key: 'control',
duration: 2,
msg.data.forEach((item) => {
data[item.name] = item.value;
});
this.isRequestControl = false;
this.isControlled = true;
if (this.onControlStatusChange) this.onControlStatusChange(true);
break;
case 'switchPermission':
const platform = (msg.platform || '').toLowerCase().trim();
if (!msg.platform || platform === 'web') return;
this.requestDeviceId = msg.deviceId;
if (this.onPermissionRequest) {
this.onPermissionRequest({
title: '权限请求',
content: `${msg.platform} 请求控制 ${msg.deviceId}`,
});
}
this.wsDeviceInfo = data;
this.onDeviceInfoUpdate?.(data);
break;
}
case 'AUTH': {
if (msg.isAuth) {
this.getControl(this.connectedDeviceSn);
}
break;
}
case 'switchResult': {
this.clearControlTimeout();
this.isRequestControl = false;
this.onRequestControlStatusChange?.(false);
if (
msg.success === false ||
msg.switchResult === false
) {
this.isControlled = false;
this.onControlStatusChange?.(false);
this.messageApi.error({
key: 'control',
content: msg.message || '控制失败',
});
return;
}
this.isControlled = true;
this.onControlStatusChange?.(true);
this.messageApi.success({
key: 'control',
content: '控制成功',
});
break;
}
case 'switchPermission': {
const platform = (
msg.platform || ''
).toLowerCase();
if (!platform || platform === 'web' || platform == "null") {
return;
}
this.requestDeviceId = msg.deviceId;
this.onPermissionRequest?.({
title: '权限请求',
content: `${msg.platform} 请求控制设备`,
});
break;
}
case 'detectionReport':
if (msg.data?.deviceId !== this.connectedDeviceSn) return;
@@ -170,16 +301,28 @@ export default class DeviceController {
duration: 3,
});
break;
default:
break;
}
}
// 发送游戏手柄指令
sendGamepad(state) {
if (!this.isControlled || !this.connectedDeviceSn) return;
if (
!this.isControlled ||
!this.connectedDeviceSn
) {
return;
}
console.log(state, "state")
wsManager.send({
type: 'gamepad',
type: 'remoteControl',
deviceId: this.connectedDeviceSn,
data: { axes: state.axes, buttons: state.buttons },
data: {
axes: state.axes,
buttons: state.buttons,
},
});
}
}