设备状态界面

This commit is contained in:
mmc
2026-05-11 16:34:44 +08:00
parent 86880ac786
commit daf93a50e2
6 changed files with 435 additions and 48 deletions

View File

@@ -28,51 +28,54 @@ api.interceptors.request.use(
}
);
// 响应拦截器(处理 401 + 网络错误 + 服务挂了)
// 统一登出跳转方法
const handleLogoutAndRedirect = () => {
localStorage.clear();
Cookies.remove('userId');
Cookies.remove('Admin-Token');
// 动态导入避免循环依赖
import('../store').then(store => {
import('../store/userSlice').then(({ logout }) => {
store.default.dispatch(logout());
});
});
message.error('登录已过期,请重新登录');
setTimeout(() => {
window.location.href = '/login';
}, 800);
};
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data;
(response: AxiosResponse) => {
const data = response.data;
// ==============================================
// 👇 重点:如果接口返回 code = 401,直接跳转登录
// ==============================================
if (data?.code == 401) {
handleLogoutAndRedirect();
return Promise.reject(new Error('登录已过期'));
}
return data;
},
async (error: AxiosError) => {
// ==============================================
// 👇 这里处理:服务挂了 / 断网 / 请求失败 / 500
// ==============================================
// 网络异常 / 服务器挂了
if (!error.response) {
message.error('服务异常或网络断开,即将重新登录');
localStorage.clear();
Cookies.remove('userId');
Cookies.remove('Admin-Token');
// 动态加载 store 避免循环引用
const store = await import('../store');
const { logout } = await import('../store/userSlice');
store.default.dispatch(logout());
setTimeout(() => {
window.location.href = '/login';
}, 800);
handleLogoutAndRedirect();
return Promise.reject(error);
}
// ==============================================
// 👇 这里处理:401 未授权 / token 过期
// ==============================================
// HTTP 401
if (error.response.status === 401) {
localStorage.clear();
Cookies.remove('userId');
Cookies.remove('Admin-Token');
const store = await import('../store');
const { logout } = await import('../store/userSlice');
store.default.dispatch(logout());
message.error('登录已过期,请重新登录');
setTimeout(() => {
window.location.href = '/login';
}, 800);
handleLogoutAndRedirect();
return Promise.reject(error);
}
// 其他错误(500等)不跳转,只返回错误
return Promise.reject(error);
}
);

View File

@@ -54,7 +54,7 @@ export default function DroneLivePlayer({ streamData, videoMode = "NORMAL" }) {
timerRef.current = null;
}
leaveRoom(); // 先离开旧房间
await new Promise((resolve) => setTimeout(resolve, 100)); // 等待 SDK 内部清理完成
await new Promise((resolve) => setTimeout(resolve, 100));
// 3. 组件已卸载,直接放弃
if (!mountedRef.current) {

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import {
Layout, Card, Row, Col, Statistic, Tag, Button, Table,
Typography, Space, Progress, Badge, Timeline, message,
Select
Select, Modal
} from 'antd';
import {
EnvironmentOutlined, RobotOutlined, BranchesOutlined,
@@ -11,7 +11,7 @@ import {
CloudOutlined, ClockCircleOutlined, ReloadOutlined,
AimOutlined, ApiOutlined, SettingOutlined,
PlusOutlined, ArrowRightOutlined,
EditOutlined
EditOutlined, FullscreenOutlined, InfoCircleOutlined
} from '@ant-design/icons';
import { useSelector } from 'react-redux';
@@ -44,9 +44,10 @@ const logs = [
];
// 设备总览组件
export default function DeviceOverviewPage({ devices, planedevices, devicesAll }) {
export default function DeviceOverviewPage({ devices, planedevices, devicesAll, onViewStatus }) {
const [selectedDevice, setSelectedDevice] = useState(null);
const [planeVideo, setPlaneVideo] = useState('');
const [planeVideo, setPlaneVideo] = useState([]); // 初始为空数组
const [zoomedVideo, setZoomedVideo] = useState(null); // 放大查看的视频数据
// 动态计算顶部卡片数据
const dynamicTopCards = React.useMemo(() => {
@@ -205,8 +206,12 @@ export default function DeviceOverviewPage({ devices, planedevices, devicesAll }
</Col>
<Col xs={24} lg={10}>
<Card title={<> 设备控制面板</>} bordered={false} style={{ borderRadius: 12, marginBottom: 16 }} bodyStyle={{ padding: 16, paddingBottom: 0 }}>
<Select
<Card
title={<><SettingOutlined style={{ marginRight: 6, }} /> 设备控制面板</>}
bordered={false}
style={{ borderRadius: 12, marginBottom: 16 }}
bodyStyle={{ padding: 16, paddingBottom: 0 }}
> <Select
style={{ width: 220, marginBottom: 10 }}
placeholder="请选择设备"
value={selectedDevice?.device_sn || selectedDevice?.serialNumber}
@@ -233,6 +238,16 @@ export default function DeviceOverviewPage({ devices, planedevices, devicesAll }
<Tag color={selectedDevice.onlineStatus === 1 ? "success" : "default"} size="small">
{selectedDevice.onlineStatus == 1 ? "在线" : "离线"}
</Tag>
<Button
type="primary"
ghost
size="small"
icon={<InfoCircleOutlined />}
style={{ borderRadius: 4, fontSize: 12, marginLeft: 'auto' }}
onClick={() => onViewStatus && onViewStatus(selectedDevice.device_sn || selectedDevice.serialNumber)}
>
详情
</Button>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
@@ -412,6 +427,19 @@ export default function DeviceOverviewPage({ devices, planedevices, devicesAll }
</Card>
</Col>
</Row>
<Modal
title="视频预览"
open={!!zoomedVideo}
onCancel={() => setZoomedVideo(null)}
footer={null}
width={1000}
centered
bodyStyle={{ padding: 0, height: 600, background: '#000' }}
destroyOnClose
>
{zoomedVideo && <DroneLivePlayer streamData={zoomedVideo} videoMode="LARGE" />}
</Modal>
</Content>
);
}

View File

@@ -0,0 +1,342 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Row, Col, Card, Statistic, Tag, Typography,
Space, Empty, Descriptions, Badge, Progress, Button,
Divider, Select
} from 'antd';
import {
ThunderboltOutlined, DashboardOutlined, AimOutlined,
EnvironmentOutlined, RocketOutlined, GlobalOutlined,
VideoCameraOutlined, ControlOutlined, FullscreenOutlined
} from '@ant-design/icons';
import XboxController from './XboxController';
import DroneLivePlayer from '../VolcRtcPlayer.jsx';
import { getLiveStream } from '../../api/device.ts';
const { Title, Text } = Typography;
// 模拟地图组件
const MockMap = ({ longitude, latitude }) => (
<div style={{
width: '100%',
height: '100%',
background: '#e6f7ff url(https://picsum.photos/seed/map/1200/800) center/cover',
borderRadius: 8,
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
position: 'absolute',
top: 16,
left: 16,
background: 'rgba(255,255,255,0.9)',
padding: '8px 12px',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
zIndex: 10
}}>
<div style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 4 }}>实时坐标</div>
<div style={{ fontSize: 12 }}>经度: {longitude || '118.78'}</div>
<div style={{ fontSize: 12 }}>纬度: {latitude || '32.06'}</div>
</div>
<div style={{
width: 24,
height: 24,
background: '#1890ff',
borderRadius: '50%',
border: '3px solid #fff',
boxShadow: '0 0 10px rgba(24,144,255,0.8)',
position: 'relative'
}}>
<div style={{
position: 'absolute',
top: -30,
left: '50%',
transform: 'translateX(-50%)',
background: '#1890ff',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 10,
whiteSpace: 'nowrap'
}}>当前设备</div>
</div>
</div>
);
export default function DeviceStatusPage({ devicesAll, serialNumber, onDeviceChange }) {
const [planeVideo, setPlaneVideo] = useState([]);
const [isControlled, setIsControlled] = useState(false);
// 获取选中的设备详情
const device = useMemo(() => {
const sn = serialNumber || (devicesAll?.[0]?.device_sn || devicesAll?.[0]?.serialNumber);
if (!sn) return null;
return devicesAll?.find(d => (d.device_sn || d.serialNumber) === sn);
}, [devicesAll, serialNumber]);
// 判断是否为无人机
const isUAV = useMemo(() => {
return device && (device.device_sn || device.productName?.includes('无人机'));
}, [device]);
const runningStatus = device?.lastRunningStatus || {};
// 辅助函数:数字格式化
const fix2 = (val) => {
if (val === null || val === undefined || isNaN(val)) return '-';
return Number(val).toFixed(2);
};
// 辅助函数:安全取值
const getVal = (val) => val === "no_rain" ? "无" : (val || '-');
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 || []);
}
})
}, [device]);
const handleGamepadUpdate = (state) => {
// console.log('Gamepad Update:', state);
};
const handleControlModal = ({ control }) => {
setIsControlled(control);
};
if (!device) {
return <Empty description="请选择一个设备以查看状态" style={{ marginTop: 100 }} />;
}
return (
<div style={{ padding: '0px' }}>
<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)}
showSearch
optionFilterProp="label"
options={devicesAll?.map(item => ({
value: item.device_sn || item.serialNumber,
label: item.deviceAlias || item.drone_callsign || item.callsign || item.deviceName || '未知设备'
}))}
/>
{device && (
<Tag color="blue" style={{ marginLeft: 8 }}>
{isUAV ? '无人机' : '机器人'}
</Tag>
)}
</div>
<Row gutter={[16, 16]}>
{/* 左侧:视频和地图 */}
<Col xs={24} lg={16}>
<Card
title={<><VideoCameraOutlined /> 实时视频监控 - {device.deviceAlias || device.callsign || '监控画面'}</>}
bordered={false}
style={{ borderRadius: 12, marginBottom: 16 }}
bodyStyle={{ padding: 12 }}
>
<div style={{
display: 'grid',
gap: '8px',
gridTemplateColumns: planeVideo.length > 1 ? '1fr 1fr' : '1fr',
gridTemplateRows: planeVideo.length > 2 ? '1fr 1fr' : '1fr',
height: 450,
background: '#000',
borderRadius: 8,
overflow: 'hidden'
}}>
{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>
</div>
))
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666', gridColumn: 'span 2' }}>
暂无实时画面
</div>
)}
</div>
</Card>
<Card
title={<><GlobalOutlined /> 位置态势图</>}
bordered={false}
style={{ borderRadius: 12 }}
bodyStyle={{ padding: 12, height: 400 }}
>
<MockMap longitude={device.longitude} latitude={device.latitude} />
</Card>
</Col>
{/* 右侧:状态面板和控制 */}
<Col xs={24} lg={8}>
<Card
title={<><DashboardOutlined /> {isUAV ? '无人机状态' : '机器人状态'}详情</>}
bordered={false}
style={{ borderRadius: 12, marginBottom: 16 }}
extra={<Tag color={device.onlineStatus === 1 ? 'success' : 'default'}>{device.onlineStatus === 1 ? '在线' : '离线'}</Tag>}
>
{isUAV ? (
/* 无人机特有面板 */
<Descriptions column={2} size="small" layout="vertical">
<Descriptions.Item label={<><ThunderboltOutlined /> 电池电量</>}>
<Progress
percent={device.capacity_percent || 0}
size="small"
status={(device.capacity_percent || 0) < 20 ? 'exception' : 'active'}
/>
</Descriptions.Item>
<Descriptions.Item label={<><EnvironmentOutlined /> 飞行高度</>}>
<Text strong style={{ fontSize: 16 }}>{fix2(device.height)} m</Text>
</Descriptions.Item>
<Descriptions.Item label="环境风速">
<Text>{fix2(device.wind_speed)} m/s</Text>
</Descriptions.Item>
<Descriptions.Item label="环境温度">
<Text>{fix2(device.environment_temperature)} ℃</Text>
</Descriptions.Item>
<Descriptions.Item label="降雨状态">
<Tag color={device.rainfall === 'no_rain' ? 'green' : 'red'}>{getVal(device.rainfall)}</Tag>
</Descriptions.Item>
<Descriptions.Item label="返航距离">
<Text>{fix2(device.home_distance)} m</Text>
</Descriptions.Item>
<Descriptions.Item label="GPS 卫星数">
<Badge count={device.position_state?.gps_number || 0} style={{ backgroundColor: '#1890ff' }} />
</Descriptions.Item>
<Descriptions.Item label="RTK 卫星数">
<Badge count={device.position_state?.rtk_number || 0} style={{ backgroundColor: '#52c41a' }} />
</Descriptions.Item>
<Descriptions.Item label="RTK 状态" span={2}>
<Tag color={device.position_state?.is_fixed === 'fixing_successful' ? 'success' : 'warning'}>
{device.position_state?.is_fixed === 'fixing_successful' ? 'RTK 已固定' : '未固定'}
</Tag>
</Descriptions.Item>
</Descriptions>
) : (
/* 机器人特有面板 */
<Descriptions column={2} size="small" layout="vertical">
<Descriptions.Item label={<><ThunderboltOutlined /> 电池电量</>}>
<Progress
percent={runningStatus.battery || 0}
size="small"
status={(runningStatus.battery || 0) < 20 ? 'exception' : 'active'}
/>
</Descriptions.Item>
<Descriptions.Item label={<><DashboardOutlined /> 系统电压</>}>
<Text strong style={{ fontSize: 16 }}>{runningStatus.voltage || '-'} V</Text>
</Descriptions.Item>
<Descriptions.Item label="左电机电流">
<Text>{runningStatus?.leftCurrent || '-'} A</Text>
</Descriptions.Item>
<Descriptions.Item label="右电机电流">
<Text>{runningStatus?.rightCurrent || '-'} A</Text>
</Descriptions.Item>
<Descriptions.Item label="左轮速度">
<Text>{runningStatus?.leftMeasureSpeed || '-'} A</Text>
</Descriptions.Item>
<Descriptions.Item label="右轮速度">
<Text>{runningStatus?.rightMeasureSpeed || '-'} A</Text>
</Descriptions.Item>
<Descriptions.Item label="左电机温度">
<Text>{runningStatus?.leftMotorTemp || '-'} ℃</Text>
</Descriptions.Item>
<Descriptions.Item label="右电机温度">
<Text>{runningStatus?.rightMotorTemp || '-'} ℃</Text>
</Descriptions.Item>
<Descriptions.Item label="主控温度">
<Text>{runningStatus?.chipTemp || '-'} ℃</Text>
</Descriptions.Item>
<Descriptions.Item label="卫星数量">
<Badge showZero={true}
count={runningStatus?.satelliteCnt || 0} style={{ backgroundColor: '#52c41a' }} />
</Descriptions.Item>
<Descriptions.Item label="偏航角 (Yaw)">
<Text>{runningStatus?.yaw || '-'}°</Text>
</Descriptions.Item>
<Descriptions.Item label="俯仰角 (Pitch)">
<Text>{runningStatus?.pitch || '-'}°</Text>
</Descriptions.Item>
</Descriptions>
)}
</Card>
{isUAV && (
<Card
title={<><ControlOutlined /> 远程控制</>}
bordered={false}
style={{ borderRadius: 12, background: '#0f172a' }}
headStyle={{ color: '#fff', borderBottom: '1px solid #1e293b' }}
>
<XboxController
onGamepadUpdate={handleGamepadUpdate}
handleControlModal={handleControlModal}
isControlled={isControlled}
/>
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Space>
<Button type="primary" danger ghost={!isControlled} onClick={() => setIsControlled(!isControlled)}>
{isControlled ? '释放控制权限' : '请求控制权限'}
</Button>
</Space>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 8 }}>
请连接 Xbox 控制器并按下任意按键以激活
</div>
</div>
</Card>
)}
</Col>
</Row>
</div>
);
}

View File

@@ -29,7 +29,7 @@ export default function WayLinePage() {
];
return (
<div style={{ background: '#f5f7fa', minHeight: '100vh', padding: '16px 24px' }}>
<div style={{ background: '#f5f7fa', minHeight: '100vh', padding: '0px 24px' }}>
{/* 顶部统计卡片 */}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Layout, Typography, Menu } from 'antd';
import { AimOutlined, AppstoreOutlined, SettingOutlined } from '@ant-design/icons';
import DeviceOverviewPage from '../components/devices/DeviceOverviewPage';
import DeviceStatusPage from '../components/devices/DeviceStatusPage';
import { getDeviceListApi, getPlaneDeviceList } from '../api/device';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
@@ -10,14 +11,17 @@ import WayLinePage from '../components/devices/WayLinePage';
const { Header, Content } = Layout;
const { Text } = Typography;
const DeviceStatusSettingPage = () => (
const DeviceStatusSettingPage = ({ serialNumber }) => (
<div style={{ padding: 20 }}>
<h2>设置状态页面(/devices/status)</h2>
<h2>设置状态页面</h2>
<p>当前查看设备序列号: <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{serialNumber || '未选择设备'}</span></p>
{/* 这里后续可以根据 serialNumber 加载具体的设备状态详情 */}
</div>
);
export default function DeviceIndexPage() {
const [activeTab, setActiveTab] = useState('overview');
const [selectedSn, setSelectedSn] = useState(null);
const { userInfo } = useSelector((state: RootState) => state.user);
const [devices, setDevices] = useState([]);
const [planedevices, setPlaneDevices] = useState([]);
@@ -25,7 +29,7 @@ export default function DeviceIndexPage() {
const tabs = [
{ key: 'overview', label: '装备总览', icon: <AppstoreOutlined /> },
{ key: 'status', label: '设置状态', icon: <SettingOutlined /> },
{ key: 'status', label: '设备状态', icon: <SettingOutlined /> },
{ key: 'wayLineTask', label: '航线任务', icon: <AimOutlined /> },
];
@@ -44,11 +48,9 @@ export default function DeviceIndexPage() {
const devData = deviceRes?.code === 200 ? (deviceRes.data || []) : [];
const planeData = planeRes?.code === 200 ? (planeRes.data?.detail || []) : [];
// 3. 分别保存
setDevices(devData);
setPlaneDevices(planeData);
// 4. 最终合并(只执行一次!)
setDevicesAll([...devData, ...planeData]);
} catch (err) {
@@ -60,15 +62,27 @@ export default function DeviceIndexPage() {
fetchAllData();
}, [userInfo]);
const handleViewStatus = (sn) => {
setSelectedSn(sn);
setActiveTab('status');
};
const renderContent = () => {
if (activeTab === 'overview') {
return <DeviceOverviewPage
devices={devices}
planedevices={planedevices}
devicesAll={devicesAll}
onViewStatus={handleViewStatus}
/>;
}
if (activeTab === 'status') {
return <DeviceStatusPage
devicesAll={devicesAll}
serialNumber={selectedSn}
onDeviceChange={(sn) => setSelectedSn(sn)}
/>;
}
if (activeTab === 'status') return <DeviceStatusSettingPage />;
if (activeTab === 'wayLineTask') return <WayLinePage />;
return null;
};