1236 lines
35 KiB
TypeScript
1236 lines
35 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useCallback,
|
||
} from 'react';
|
||
|
||
import {
|
||
Row,
|
||
Col,
|
||
Card,
|
||
Tag,
|
||
Typography,
|
||
Space,
|
||
Empty,
|
||
Descriptions,
|
||
Badge,
|
||
Progress,
|
||
Button,
|
||
Select,
|
||
message,
|
||
Modal,
|
||
Switch,
|
||
} from 'antd';
|
||
|
||
import {
|
||
DashboardOutlined,
|
||
GlobalOutlined,
|
||
VideoCameraOutlined,
|
||
ControlOutlined,
|
||
CameraOutlined,
|
||
ThunderboltOutlined,
|
||
EnvironmentOutlined,
|
||
DeleteOutlined,
|
||
} from '@ant-design/icons';
|
||
|
||
import {
|
||
getsinglePlan,
|
||
getsinglePlanTrack,
|
||
getFlightTaskDetail,
|
||
getUAVState,
|
||
getSiteDevice,
|
||
getSiteUAVList,
|
||
addRoute,
|
||
} from '../../api/device.ts';
|
||
|
||
import XboxController from './XboxController';
|
||
import DroneVideoPlayer from './DroneVideoPlayer';
|
||
import CesiumMap from '../Map.jsx';
|
||
import { useSelector } from 'react-redux';
|
||
import { RootState } from '@/src/store/index.ts';
|
||
import { useWebRTCPlayer } from '../useWebRTCStream.tsx';
|
||
import VideoPlayer from '../VideoPlayer.tsx';
|
||
import DeviceController from './DeviceController';
|
||
import { DatePicker } from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import { MowerAllView } from './MowerAllView';
|
||
import { useMqtt } from '@/hooks/useMqtt';
|
||
import envConfig from '../../../env';
|
||
import { dataUrlToFile } from '@/lib/utils.ts';
|
||
const { RangePicker } = DatePicker;
|
||
|
||
const { Text } = Typography;
|
||
|
||
export default function DeviceStatusPage({
|
||
serialNumber,
|
||
onDeviceChange,
|
||
}) {
|
||
const { userInfo } = useSelector((state: RootState) => state.user);
|
||
const { stationId } = useSelector((state: RootState) => state.station);
|
||
const [persistentDroneData, setPersistentDroneData] = useState(null); // 持久化无人机数据
|
||
const [persistentHostData, setPersistentHostData] = useState(null); // 持久化机场数据
|
||
const [devices, setDevices] = useState([]);
|
||
const [planedevices, setPlaneDevices] = useState([]);
|
||
const [devicesAll, setDevicesAll] = useState([]);
|
||
const [arrivedPoint, setArrivedPoint] = useState(null);//完成点
|
||
const [isLoopMode, setIsLoopMode] = useState(true);
|
||
|
||
// 开关切换回调
|
||
const handleChange = (checked: boolean) => {
|
||
setIsLoopMode(checked);
|
||
if (checked) {
|
||
console.log('当前作业方式:回字');
|
||
} else {
|
||
console.log('当前作业方式:单点');
|
||
}
|
||
};
|
||
|
||
|
||
// 获取数据
|
||
const fetchAllData = async () => {
|
||
if (!stationId) return;
|
||
|
||
try {
|
||
// 1. 同时发请求
|
||
const [deviceRes, planeRes] = await Promise.all([
|
||
getSiteDevice({ siteId: stationId, pageSize: 9999, pageNum: 1, orgId: userInfo.orgId }),
|
||
getSiteUAVList(stationId),
|
||
]);
|
||
|
||
// 2. 处理普通设备
|
||
const devData = deviceRes?.code === 200 ? (deviceRes.rows || []) : [];
|
||
|
||
// 3. 处理无人机 + 自动补全地图需要的字段(关键改动)
|
||
let planeData = planeRes?.code === 200 ? (planeRes?.rows || []) : [];
|
||
|
||
planeData = planeData.map(item => ({
|
||
...item,
|
||
type: 'plane',
|
||
lastRunningStatus: {
|
||
longitude: item.longitude || 0,
|
||
latitude: item.latitude || 0,
|
||
},
|
||
deviceAlias: item.drone_callsign || item.device_sn
|
||
|| '无人机',
|
||
|
||
}));
|
||
|
||
setDevices(devData);
|
||
setPlaneDevices(planeData);
|
||
|
||
// 合并所有设备
|
||
const all = [...devData, ...planeData];
|
||
setDevicesAll(all);
|
||
|
||
} catch (err) {
|
||
console.error('数据请求失败', err);
|
||
}
|
||
};
|
||
|
||
const handleFinishEdPoint = (point) => {
|
||
setArrivedPoint(point);
|
||
}
|
||
|
||
|
||
useEffect(() => {
|
||
fetchAllData();
|
||
}, [userInfo, stationId]);
|
||
|
||
// 获取当前选中的无人机设备
|
||
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_sn;
|
||
}, [device]);
|
||
|
||
// 当MQTT数据更新时,更新持久化数据(只在有新数据时更新)
|
||
const handleMqttMessage = (topic, message) => {
|
||
|
||
try {
|
||
const parsedMessage = JSON.parse(message);
|
||
|
||
if (parsedMessage.data && parsedMessage.data.host) {
|
||
const { host } = parsedMessage.data;
|
||
|
||
// 判断是无人机数据还是机场数据:无人机数据有 battery 字段
|
||
const isDroneData = host.battery !== undefined;
|
||
|
||
if (isDroneData) {
|
||
// 处理无人机数据
|
||
setPersistentDroneData(prevData => {
|
||
const newData = { ...prevData };
|
||
|
||
|
||
// 无人机特有字段
|
||
if (host.battery?.capacity_percent !== undefined) {
|
||
newData.capacity_percent = host.battery.capacity_percent;
|
||
}
|
||
if (host.attitude_head !== undefined) {
|
||
newData.heading = host.attitude_head;
|
||
}
|
||
if (host.horizontal_speed !== undefined) {
|
||
newData.horizontal_speed = host.horizontal_speed;
|
||
}
|
||
if (host.vertical_speed !== undefined) {
|
||
newData.vertical_speed = host.vertical_speed;
|
||
}
|
||
if (host.total_flight_time !== undefined) {
|
||
newData.total_flight_time = host.total_flight_time;
|
||
}
|
||
if (host.total_flight_sorties !== undefined) {
|
||
newData.total_flight_sorties = host.total_flight_sorties;
|
||
}
|
||
if (host.mode_code !== undefined) {
|
||
newData.mode_code = host.mode_code;
|
||
}
|
||
|
||
// 通用字段
|
||
if (host.height !== undefined) {
|
||
newData.height = host.height;
|
||
}
|
||
if (host.wind_speed !== undefined) {
|
||
newData.wind_speed = host.wind_speed;
|
||
}
|
||
if (host.position_state !== undefined) {
|
||
newData.position_state = host.position_state;
|
||
}
|
||
if (host.longitude !== undefined) {
|
||
newData.longitude = host.longitude;
|
||
}
|
||
if (host.latitude !== undefined) {
|
||
newData.latitude = host.latitude;
|
||
}
|
||
|
||
return newData;
|
||
});
|
||
} else {
|
||
// 处理机场数据
|
||
setPersistentHostData(prevData => {
|
||
const newData = { ...prevData };
|
||
|
||
// 机场特有字段
|
||
if (host.drone_charge_state?.capacity_percent !== undefined) {
|
||
newData.capacity_percent = host.drone_charge_state.capacity_percent;
|
||
}
|
||
if (host.air_conditioner?.air_conditioner_state !== undefined) {
|
||
newData.air_conditioner_state = host.air_conditioner.air_conditioner_state;
|
||
}
|
||
if (host.air_conditioner_state !== undefined) {
|
||
newData.air_conditioner_state = host.air_conditioner_state;
|
||
}
|
||
|
||
// 通用字段
|
||
if (host.environment_temperature !== undefined) {
|
||
newData.environment_temperature = host.environment_temperature;
|
||
}
|
||
if (host.wind_speed !== undefined) {
|
||
newData.wind_speed = host.wind_speed;
|
||
}
|
||
if (host.rainfall !== undefined) {
|
||
newData.rainfall = host.rainfall === 0 ? 'no_rain' : 'rain';
|
||
}
|
||
if (host.humidity !== undefined) {
|
||
newData.humidity = host.humidity;
|
||
}
|
||
if (host.drone_in_dock !== undefined) {
|
||
newData.drone_in_dock = host.drone_in_dock;
|
||
}
|
||
if (host.cover_state !== undefined) {
|
||
newData.cover_state = host.cover_state;
|
||
}
|
||
if (host.temperature !== undefined) {
|
||
newData.temperature = host.temperature;
|
||
}
|
||
if (host.home_distance !== undefined) {
|
||
newData.home_distance = host.home_distance;
|
||
}
|
||
|
||
return newData;
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('DeviceStatusPage MQTT 消息解析失败:', err);
|
||
}
|
||
};
|
||
|
||
// 根据选中设备动态构建MQTT主题
|
||
const mqttTopics = useMemo(() => {
|
||
if (!isUAV || !device) {
|
||
return [];
|
||
}
|
||
const topics = [];
|
||
if (device.device_sn) {
|
||
topics.push(`thing/product/${device.device_sn}/osd`);
|
||
}
|
||
if (device.gateway_sn && device.gateway_sn !== device.device_sn) {
|
||
topics.push(`thing/product/${device.gateway_sn}/osd`);
|
||
}
|
||
return topics;
|
||
}, [isUAV, device]);
|
||
|
||
// 订阅MQTT消息
|
||
useMqtt({
|
||
url: envConfig.mqtt_url,
|
||
topic: mqttTopics,
|
||
onMessage: handleMqttMessage,
|
||
});
|
||
|
||
// 合并无人机和机场数据
|
||
const currentDroneData = React.useMemo(() => {
|
||
return { ...persistentHostData, ...persistentDroneData };
|
||
}, [persistentDroneData, persistentHostData]);
|
||
|
||
// 实时位置数据(用于地图)
|
||
|
||
|
||
const [messageApi, contextHolder] = message.useMessage();
|
||
|
||
const controllerRef = useRef<DeviceController | null>(null);
|
||
|
||
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 [selectedDrone, setSelectedDrone] = useState(null);
|
||
const [taskList, setTaskList] = useState([]);
|
||
|
||
// 👇 加上这两个
|
||
const [queryTimeRange, setQueryTimeRange] = useState([]); // 时间范围
|
||
const [startTimestamp, setStartTimestamp] = useState(''); // 开始时间戳
|
||
const [endTimestamp, setEndTimestamp] = useState(''); // 结束时间戳
|
||
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [modalContent, setModalContent] = useState({
|
||
title: '',
|
||
content: '',
|
||
});
|
||
|
||
const [mowerView, setMowerView] = useState('front');
|
||
const [uavState, setUavState] = useState<any>(null);
|
||
const [points, setPoints] = useState([]);
|
||
const [clearNavLine, setClearNavLine] = useState(0);
|
||
const [clearPlanLine, setClearPlanLine] = useState(0);
|
||
const timerRef = useRef<any>(null);
|
||
// 打点相关状态
|
||
const [markedPoints, setMarkedPoints] = useState([]); // 标记的点
|
||
const [completeFlag, setCompleteFlag] = useState(false); // 是否完成路径规划
|
||
const [savePath, setSavePath] = useState(null); // 保存的路径数据
|
||
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); // 保存模态框可见性
|
||
const [plotName, setPlotName] = useState(''); // 地块名称
|
||
const [isSaving, setIsSaving] = useState(false); // 是否正在保存
|
||
const mapRef = useRef(null); // 地图 ref
|
||
|
||
const runningStatus = useMemo(() => {
|
||
// 判断对象是否为空
|
||
if ((!wsDeviceInfo || Object.keys(wsDeviceInfo).length === 0) && !isUAV) {
|
||
return {};
|
||
}
|
||
if (isUAV) {
|
||
return {
|
||
...wsDeviceInfo,
|
||
};
|
||
}
|
||
const base = device?.lastRunningStatus || {};
|
||
|
||
return isUAV
|
||
? base
|
||
: {
|
||
...base,
|
||
...wsDeviceInfo,
|
||
};
|
||
}, [device, wsDeviceInfo, isUAV]);
|
||
|
||
const { video: robotVideo, error, ready } = useWebRTCPlayer(
|
||
!isUAV ? serialNumber : '',
|
||
`webrtc://1.95.137.212/live/livestream/${serialNumber}`,
|
||
);
|
||
const getVal = (val) => val === "no_rain" ? "无" : (val || '-');
|
||
|
||
const realtimePosition = React.useMemo(() => {
|
||
// 1. 无人机:优先用 MQTT 实时数据
|
||
if (isUAV) {
|
||
if (currentDroneData?.longitude && currentDroneData?.latitude) {
|
||
return {
|
||
lon: currentDroneData.longitude,
|
||
lat: currentDroneData.latitude,
|
||
heading: currentDroneData.heading || 0,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 2. 机器人:从 wsDeviceInfo / runningStatus 取实时经纬度
|
||
const robotLon = runningStatus.longitude || wsDeviceInfo.longitude;
|
||
const robotLat = runningStatus.latitude || wsDeviceInfo.latitude;
|
||
|
||
if (robotLon && robotLat) {
|
||
return {
|
||
lon: robotLon,
|
||
lat: robotLat,
|
||
heading: runningStatus.pitch || wsDeviceInfo.pitch || 0,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}, [isUAV, currentDroneData, runningStatus, wsDeviceInfo]);
|
||
|
||
|
||
useEffect(() => {
|
||
const controller = new DeviceController(userInfo, messageApi);
|
||
|
||
controller.onDeviceInfoUpdate = setWsDeviceInfo;
|
||
controller.onControlStatusChange = setIsControlled;
|
||
controller.onHasControlRightChange = setHasControlRight;
|
||
controller.onRequestControlStatusChange = setConnecting;
|
||
controller.onWsStatusChange = setWsStatus;
|
||
controller.handleArrivedPoint = handleFinishEdPoint;
|
||
|
||
|
||
controller.onPermissionRequest = (content) => {
|
||
setModalContent(content);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
controller.init();
|
||
|
||
controllerRef.current = controller;
|
||
|
||
return () => {
|
||
controller?.destroy();
|
||
controllerRef.current?.destroy();
|
||
|
||
controllerRef.current = null;
|
||
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!device || !controllerRef.current || !device?.serialNumber || isUAV) return;
|
||
setWsDeviceInfo({});
|
||
|
||
controllerRef.current.connectDevice(device);
|
||
|
||
}, [device?.serialNumber]);
|
||
|
||
// 定时获取无人机状态
|
||
useEffect(() => {
|
||
const fetchUavState = async () => {
|
||
if (isUAV && device?.gateway_sn && device?.device_sn) {
|
||
try {
|
||
const res = await getUAVState({
|
||
sn: device.gateway_sn,
|
||
droneSn: device.device_sn
|
||
});
|
||
if (res.code === 200) {
|
||
setUavState(res.data);
|
||
}
|
||
} catch (err) {
|
||
console.error('获取无人机状态失败', err);
|
||
}
|
||
} else {
|
||
setUavState(null);
|
||
}
|
||
};
|
||
|
||
fetchUavState();
|
||
}, [isUAV, device]);
|
||
|
||
// 获取执行中任务的轨迹
|
||
useEffect(() => {
|
||
const fetchExecutingTaskTrack = async () => {
|
||
if (isUAV && device?.gateway_sn) {
|
||
try {
|
||
const todayStart = dayjs().startOf('day').unix();
|
||
const todayEnd = dayjs().endOf('day').unix();
|
||
const sns = [device.gateway_sn];
|
||
|
||
const res = await getsinglePlan({ sns, beginAt: todayStart, endAt: todayEnd });
|
||
if (res?.code === 200 && res.data) {
|
||
let combinedList = [];
|
||
Object.values(res.data).forEach((item: any) => {
|
||
if (item.list) combinedList = combinedList.concat(item.list);
|
||
});
|
||
|
||
// 筛选出执行中的任务
|
||
const executingTask = combinedList.find(t =>
|
||
['executing', 'running', 'restored'].includes(t.status)
|
||
);
|
||
|
||
if (executingTask) {
|
||
const trackRes = await getsinglePlanTrack(executingTask);
|
||
if (trackRes.code === 0 && trackRes.data.track?.points?.length) {
|
||
const trackPoints = trackRes.data.track.points.map(item => ({
|
||
lon: item.longitude || item.lon,
|
||
lat: item.latitude || item.lat
|
||
}));
|
||
setPoints(trackPoints);
|
||
} else {
|
||
setPoints([]);
|
||
}
|
||
} else {
|
||
setPoints([]);
|
||
}
|
||
} else {
|
||
setPoints([]);
|
||
}
|
||
} catch (err) {
|
||
console.error('获取执行中任务轨迹失败', err);
|
||
setPoints([]);
|
||
}
|
||
} else {
|
||
setPoints([]);
|
||
}
|
||
};
|
||
|
||
fetchExecutingTaskTrack();
|
||
}, [isUAV, device]);
|
||
|
||
const mapDevices = useMemo(() => {
|
||
if (!device) return null;
|
||
|
||
// 优先使用持久化MQTT数据,其次使用uavState
|
||
const lng = currentDroneData?.longitude ?? uavState?.longitude ?? runningStatus.longitude ?? device.longitude;
|
||
const lat = currentDroneData?.latitude ?? uavState?.latitude ?? runningStatus.latitude ?? device.latitude;
|
||
|
||
return {
|
||
...device,
|
||
lastRunningStatus: {
|
||
...device.lastRunningStatus,
|
||
longitude: lng,
|
||
latitude: lat,
|
||
},
|
||
// 让地图取最新经纬度
|
||
longitude: lng,
|
||
latitude: lat,
|
||
name: device.deviceAlias || device.callsign || device.deviceName || '设备',
|
||
type: isUAV
|
||
? 'drone'
|
||
: device.productName?.includes('割草机')
|
||
? 'mower'
|
||
: 'robot',
|
||
};
|
||
}, [device, isUAV, runningStatus, uavState, currentDroneData]);
|
||
const realtimeDevice = useMemo(() => {
|
||
if (!device) return null;
|
||
return {
|
||
...device,
|
||
...uavState,
|
||
...currentDroneData, // 持久化数据最后覆盖,确保优先显示
|
||
};
|
||
}, [device, uavState, currentDroneData]);
|
||
|
||
const handleGamepadUpdate = useCallback((state) => {
|
||
controllerRef.current?.sendGamepad(state);
|
||
}, []);
|
||
|
||
const handleControlModal = useCallback(() => {
|
||
controllerRef.current?.requestControl();
|
||
}, []);
|
||
|
||
const handleOk = () => {
|
||
controllerRef.current?.sendPermissionRequest(
|
||
true,
|
||
'switchResult',
|
||
);
|
||
setIsControlled(false);
|
||
|
||
setIsModalOpen(false);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
|
||
controllerRef.current?.sendPermissionRequest(
|
||
false,
|
||
'switchResult',
|
||
);
|
||
setIsControlled(true);
|
||
|
||
setIsModalOpen(false);
|
||
};
|
||
|
||
const fix2 = (val) => {
|
||
if (val === null || val === undefined || isNaN(val)) {
|
||
return '-';
|
||
}
|
||
|
||
return Number(val).toFixed(2);
|
||
};
|
||
|
||
// 打点功能
|
||
const handleMarkPoint = () => {
|
||
if (!realtimePosition) {
|
||
message.warning('无法获取实时位置,请检查设备连接');
|
||
return;
|
||
}
|
||
const newPoint = {
|
||
lon: Number(realtimePosition.lon),
|
||
lat: Number(realtimePosition.lat),
|
||
};
|
||
setMarkedPoints([...markedPoints, newPoint]);
|
||
messageApi.success(`已标记第 ${markedPoints.length + 1} 个点`);
|
||
};
|
||
|
||
// 清空标记点
|
||
const handleClearPoints = () => {
|
||
setMarkedPoints([]);
|
||
setCompleteFlag(false);
|
||
setSavePath(null);
|
||
};
|
||
|
||
// 完成功能 - 调用路径规划接口
|
||
const handleComplete = async () => {
|
||
//如果时弓子模式
|
||
if (isLoopMode) {
|
||
if (markedPoints.length < 3) {
|
||
messageApi.warning('请至少标记 3 个点');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 准备路径规划请求参数
|
||
const positionPoints = [...markedPoints];
|
||
|
||
|
||
const requestParams = {
|
||
reference: {
|
||
lat: positionPoints[0].lat,
|
||
lon: positionPoints[0].lon,
|
||
},
|
||
heading: 0,
|
||
outer: {
|
||
position: positionPoints.map(point => ({
|
||
lat: point.lat,
|
||
lon: point.lon,
|
||
})),
|
||
sideWidth: 0.0,
|
||
},
|
||
holes: [],
|
||
workType: 0,
|
||
};
|
||
|
||
const response = await fetch('https://servicepathplan.satabot.com/api/path', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(requestParams),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result) {
|
||
const path = result?.data?.path?.map(p => {
|
||
return {
|
||
lng: p.lon,
|
||
lat: p.lat
|
||
}
|
||
})
|
||
setPoints(path);
|
||
setSavePath({
|
||
path: path, // 存储返回的路径结果
|
||
outer: requestParams?.outer?.position?.map(p => {
|
||
return {
|
||
lng: p.lon,
|
||
lat: p.lat
|
||
}
|
||
}
|
||
),
|
||
planModel: 0,
|
||
});
|
||
setCompleteFlag(true);
|
||
messageApi.success('路径规划完成');
|
||
}
|
||
} catch (error) {
|
||
console.error('路径规划失败:', error);
|
||
messageApi.error('路径规划失败,请重试');
|
||
}
|
||
} else {
|
||
const positionPoints = [...markedPoints].map(item => {
|
||
return {
|
||
lng: item.lon,
|
||
lat: item.lat
|
||
|
||
}
|
||
});
|
||
|
||
setPoints(positionPoints);
|
||
setSavePath({
|
||
path: positionPoints, // 存储返回的路径结果
|
||
outer: [],
|
||
planModel: 2,
|
||
});
|
||
setCompleteFlag(true);
|
||
}
|
||
|
||
};
|
||
|
||
// 打开保存模态框
|
||
const handleOpenSaveModal = () => {
|
||
if (!completeFlag) {
|
||
messageApi.warning('请先点击完成生成路径');
|
||
return;
|
||
}
|
||
setPlotName('');
|
||
setIsSaveModalVisible(true);
|
||
};
|
||
|
||
// 保存功能
|
||
const handleSave = async () => {
|
||
if (!plotName.trim()) {
|
||
messageApi.warning('请输入地块名称');
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
try {
|
||
// 1. 获取地图截图
|
||
let screenshotDataUrl = null;
|
||
if (mapRef.current) {
|
||
screenshotDataUrl = await mapRef.current.captureScreenshot();
|
||
}
|
||
|
||
// 2. 创建 FormData
|
||
const formData = new FormData();
|
||
|
||
// 3. 添加文件
|
||
if (screenshotDataUrl) {
|
||
// 将 dataUrl 转换为 Blob
|
||
const file = dataUrlToFile(screenshotDataUrl, "screenshot.jpg");
|
||
console.log("blob", file)
|
||
|
||
|
||
if (file instanceof File) {
|
||
formData.append('file', file);
|
||
}
|
||
}
|
||
|
||
// 4. 添加 workRecord
|
||
const workRecord = {
|
||
workName: plotName,
|
||
siteId: stationId,
|
||
jsonData: savePath,
|
||
};
|
||
formData.append(
|
||
"workRecord",
|
||
new Blob([JSON.stringify(workRecord)], {
|
||
type: "application/json"
|
||
})
|
||
);
|
||
|
||
//formData.append('workRecord', JSON.stringify(workRecord));
|
||
|
||
// 5. 调用保存接口
|
||
const res = await addRoute(formData);
|
||
|
||
if (res.code === 200) {
|
||
messageApi.success('保存成功');
|
||
setIsSaveModalVisible(false);
|
||
// 重置状态
|
||
setMarkedPoints([]);
|
||
setCompleteFlag(false);
|
||
setSavePath(null);
|
||
} else {
|
||
messageApi.error(res.msg || '保存失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('保存失败:', error);
|
||
messageApi.error('保存失败');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const realtimeType = useMemo(() => {
|
||
return isUAV ? 'plane' : 'car';
|
||
}, [isUAV]);
|
||
|
||
|
||
if (!device) {
|
||
return (
|
||
<Empty
|
||
description="请选择设备"
|
||
style={{ marginTop: 100 }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
|
||
return (
|
||
<div style={{ padding: 0 }}>
|
||
{contextHolder}
|
||
|
||
{/* 页面顶部按钮区域 */}
|
||
{!isUAV && (
|
||
<div
|
||
style={{
|
||
marginBottom: 8,
|
||
padding: '4px 0px',
|
||
background: '#f5f7fa',
|
||
borderRadius: 8,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<Text strong>路径规划:</Text>
|
||
<Text type="secondary">已标记 {markedPoints.length} 个点</Text>
|
||
</div>
|
||
<Space size="middle">
|
||
<Space>
|
||
<span>作业方式</span>
|
||
<Switch
|
||
size="small"
|
||
checked={isLoopMode}
|
||
onChange={handleChange}
|
||
/>
|
||
<span>{isLoopMode ? '弓字' : 'AB点'}</span>
|
||
</Space>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleMarkPoint}
|
||
disabled={!realtimePosition}
|
||
>
|
||
打点
|
||
</Button>
|
||
<Button
|
||
type="default"
|
||
onClick={handleClearPoints}
|
||
>
|
||
清空标记
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
ghost
|
||
onClick={handleComplete}
|
||
disabled={markedPoints.length < 2}
|
||
>
|
||
完成
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleOpenSaveModal}
|
||
disabled={!completeFlag}
|
||
>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
marginBottom: 8,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<Text strong>选择设备:</Text>
|
||
|
||
<Select
|
||
style={{ width: 380 }}
|
||
value={device?.device_sn || device?.serialNumber}
|
||
onChange={(val) => onDeviceChange?.(val)}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={[
|
||
{
|
||
label: <span>机器人</span>,
|
||
title: 'manager',
|
||
options: devicesAll.filter(item => item.serialNumber).map(item => ({
|
||
value: item.device_sn || item.serialNumber,
|
||
label: item.deviceAlias || item.deviceName || '未知设备'
|
||
})),
|
||
},
|
||
{
|
||
label: <span>无人机</span>,
|
||
title: 'engineer',
|
||
options: devicesAll.filter(item => item.gateway_sn).map(item => ({
|
||
value: item.device_sn,
|
||
label: item.drone_callsign || item.callsign || '未知设备'
|
||
})),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Tag color="blue">
|
||
{isUAV ? '无人机' : '机器人'}
|
||
</Tag>
|
||
|
||
|
||
</div>
|
||
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={24} lg={16}>
|
||
<Card
|
||
title={
|
||
<>
|
||
<VideoCameraOutlined />
|
||
实时视频监控
|
||
</>
|
||
}
|
||
//extra={
|
||
// <Button
|
||
// size="small"
|
||
// onClick={() => {
|
||
// // 取当前选中设备编号,执行设备切换/刷新逻辑
|
||
// const currentSn = device?.device_sn || device?.serialNumber;
|
||
// console.log('currentSn', currentSn);
|
||
// onDeviceChange?.(currentSn);
|
||
// }}
|
||
// >
|
||
// 刷新
|
||
// </Button>
|
||
//}
|
||
bordered={false}
|
||
style={{ borderRadius: 12, marginBottom: 8 }}
|
||
bodyStyle={{ padding: 12 }}
|
||
>
|
||
{!isUAV && (
|
||
<Space style={{ marginBottom: 8 }}>
|
||
<Button type={mowerView === 'front' ? 'primary' : 'default'} onClick={() => setMowerView('front')}>前视</Button>
|
||
<Button type={mowerView === 'back' ? 'primary' : 'default'} onClick={() => setMowerView('back')}>后视</Button>
|
||
<Button type={mowerView === 'left' ? 'primary' : 'default'} onClick={() => setMowerView('left')}>左视</Button>
|
||
<Button type={mowerView === 'right' ? 'primary' : 'default'} onClick={() => setMowerView('right')}>右视</Button>
|
||
{/*<Button type={mowerView === 'top' ? 'primary' : 'default'} onClick={() => setMowerView('top')}>顶视</Button>*/}
|
||
<Button type={mowerView === 'all' ? 'primary' : 'default'} onClick={() => setMowerView('all')}>全景</Button>
|
||
</Space>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
// flexWrap: 'wrap', // 左右排布不换行
|
||
}}
|
||
>
|
||
{isUAV ? (
|
||
<DroneVideoPlayer
|
||
device={realtimeDevice}
|
||
|
||
height={450}
|
||
|
||
dualVideo={true}
|
||
showSourceSelect={false}
|
||
defaultSource="drone"
|
||
/>
|
||
) : robotVideo ? (
|
||
// 全景模式 = T 型排布
|
||
mowerView === 'all' ? (
|
||
<MowerAllView
|
||
serialNumber={serialNumber}
|
||
robotVideo={robotVideo}
|
||
error={error}
|
||
ready={ready}
|
||
/>
|
||
) : (
|
||
// 单画面模式
|
||
<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={{ color: '#ccc', margin: 'auto' }}>
|
||
视频加载中...
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
<Card
|
||
title={
|
||
<>
|
||
<GlobalOutlined />
|
||
位置态势图
|
||
</>
|
||
}
|
||
extra={
|
||
<Space size={4}>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
icon={<DeleteOutlined />}
|
||
onClick={() => {
|
||
setClearNavLine((v) => v + 1);
|
||
}}
|
||
>
|
||
清空实时轨迹
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
onClick={() => {
|
||
setPoints([]);
|
||
setClearPlanLine((v) => v + 1);
|
||
}}
|
||
>
|
||
清空规划航线
|
||
</Button>
|
||
</Space>
|
||
}
|
||
bordered={false}
|
||
style={{ borderRadius: 12 }}
|
||
bodyStyle={{
|
||
padding: 12,
|
||
height: 400,
|
||
}}
|
||
>
|
||
<CesiumMap
|
||
ref={mapRef}
|
||
devices={[mapDevices]}
|
||
points={points}
|
||
markedPoints={markedPoints}
|
||
realtimePosition={realtimePosition}
|
||
realtimeType={realtimeType}
|
||
drawRealTime={true}
|
||
finishPoint={arrivedPoint}
|
||
ifClearNavLine={clearNavLine}
|
||
ifClearPlanLine={clearPlanLine}
|
||
from="deviceStatus"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col xs={24} lg={8}>
|
||
<Card
|
||
title={<><DashboardOutlined /> {isUAV ? '无人机状态' : '机器人状态'}详情</>}
|
||
bordered={false}
|
||
style={{ borderRadius: 12, marginBottom: 8 }}
|
||
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={(currentDroneData || uavState || device).capacity_percent || 0} size="small" status={((currentDroneData || uavState || device).capacity_percent || 0) < 20 ? 'exception' : 'active'} />
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label={<><EnvironmentOutlined /> 飞行高度</>}>
|
||
<Text strong style={{ fontSize: 16 }}>{fix2((currentDroneData || uavState || device).height)} m</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="水平速度">
|
||
<Text>{fix2(currentDroneData?.horizontal_speed) || '-'} m/s</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="垂直速度">
|
||
<Text>{fix2(currentDroneData?.vertical_speed) || '-'} m/s</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="环境风速">
|
||
<Text>{fix2((currentDroneData || uavState || device).wind_speed)} m/s</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="环境温度">
|
||
<Text>{fix2((currentDroneData || uavState || device).environment_temperature)} ℃</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="环境湿度">
|
||
<Text>{(currentDroneData?.humidity) || '-'}{currentDroneData?.humidity ? '%' : ''}</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="机场温度">
|
||
<Text>{fix2(currentDroneData?.temperature)} ℃</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="降雨状态">
|
||
<Tag color={(currentDroneData || uavState || device).rainfall === 'no_rain' ? 'green' : 'red'}>{getVal((currentDroneData || uavState || device).rainfall)}</Tag>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="航向角">
|
||
<Text>{fix2(currentDroneData?.heading)}°</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="飞行模式">
|
||
<Tag color="blue">
|
||
{currentDroneData?.mode_code !== undefined ? `模式 ${currentDroneData.mode_code}` : '-'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="无人机位置">
|
||
<Tag color="blue">
|
||
{currentDroneData?.drone_in_dock === 1 ? '在库' : '外出'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="机库状态">
|
||
<Tag color={currentDroneData?.cover_state === 1 ? 'processing' : 'default'}>
|
||
{currentDroneData?.cover_state === 1 ? '舱门打开' : '舱门关闭'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="机场空调">
|
||
<Tag color={currentDroneData?.air_conditioner_state === 1 ? 'success' : 'default'}>
|
||
{currentDroneData?.air_conditioner_state === 1 ? '开启' : '关闭'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="GPS 卫星数">
|
||
<Badge showZero count={(currentDroneData || uavState || device).position_state?.gps_number || 0} style={{ backgroundColor: '#1890ff' }} />
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="RTK 卫星数">
|
||
<Badge showZero count={(currentDroneData || uavState || device).position_state?.rtk_number || 0} style={{ backgroundColor: '#52c41a' }} />
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="RTK 状态">
|
||
<Tag color={(currentDroneData || uavState || device).position_state?.is_fixed === 2 || (currentDroneData || uavState || device).position_state?.is_fixed === 'fixing_successful' ? 'success' : 'warning'}>
|
||
{(currentDroneData || uavState || device).position_state?.is_fixed === 2 || (currentDroneData || uavState || device).position_state?.is_fixed === 'fixing_successful' ? 'RTK 已固定' : '未固定'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="实时经纬度" span={2}>
|
||
<Text style={{ fontSize: '12px' }}>
|
||
{currentDroneData?.latitude && currentDroneData?.longitude ?
|
||
`${currentDroneData.latitude.toFixed(6)}, ${currentDroneData.longitude.toFixed(6)}` : '-'}
|
||
</Text>
|
||
</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 }}>{fix2(runningStatus.voltage)} V</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="左电机电流"><Text>{fix2(runningStatus?.leftCurrent)} A</Text></Descriptions.Item>
|
||
<Descriptions.Item label="右电机电流"><Text>{fix2(runningStatus?.rightCurrent)} A</Text></Descriptions.Item>
|
||
<Descriptions.Item label="左轮速度"><Text>{fix2(runningStatus?.leftMeasureSpeed)} rpm</Text></Descriptions.Item>
|
||
<Descriptions.Item label="右轮速度"><Text>{fix2(runningStatus?.rightMeasureSpeed)} rpm</Text></Descriptions.Item>
|
||
<Descriptions.Item label="左电机温度"><Text>{fix2(runningStatus?.leftMotorTemp)} ℃</Text></Descriptions.Item>
|
||
<Descriptions.Item label="右电机温度"><Text>{fix2(runningStatus?.rightMotorTemp)} ℃</Text></Descriptions.Item>
|
||
<Descriptions.Item label="主控温度"><Text>{fix2(runningStatus?.chipTemp)} ℃</Text></Descriptions.Item>
|
||
<Descriptions.Item label="航向角状态"><Text>{(runningStatus?.headingStatus == 1 ? "已初始化" : "未初始化")}</Text></Descriptions.Item>
|
||
<Descriptions.Item label="控制模式"><Text>{(runningStatus?.controlMode == 1 ? "本地模式" : runningStatus?.controlMode == 3 ? "远程模式" : "控制模式")}</Text></Descriptions.Item>
|
||
|
||
|
||
<Descriptions.Item label="定位质量">
|
||
<Text>
|
||
{runningStatus?.qual == 0
|
||
? '无效'
|
||
: runningStatus?.qual == 1
|
||
? 'GPS 单点定位'
|
||
: runningStatus?.qual == 2
|
||
? 'DGPS 伪距差分或 SBAS'
|
||
: runningStatus?.qual == 4
|
||
? 'RTK 固定解'
|
||
: runningStatus?.qual == 5
|
||
? 'RTK 浮点解'
|
||
: '未知'}
|
||
</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="卫星数量">
|
||
<Badge showZero count={runningStatus?.satelliteCnt || 0} style={{ backgroundColor: '#52c41a' }} />
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="偏航角 (Yaw)"><Text>{fix2(runningStatus?.yaw)}°</Text></Descriptions.Item>
|
||
<Descriptions.Item label="俯仰角 (Pitch)"><Text>{fix2(runningStatus?.pitch)}°</Text></Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
</Card>
|
||
{!isUAV && (
|
||
<Card
|
||
title={
|
||
<>
|
||
<ControlOutlined />
|
||
远程控制
|
||
</>
|
||
}
|
||
bordered={false}
|
||
style={{
|
||
borderRadius: 12,
|
||
background: '#0f172a',
|
||
}}
|
||
headStyle={{ color: '#fff' }}
|
||
bodyStyle={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}
|
||
>
|
||
<XboxController
|
||
onGamepadUpdate={handleGamepadUpdate}
|
||
handleControlModal={handleControlModal}
|
||
isControlled={isControlled}
|
||
hasControlRight={hasControlRight}
|
||
isConnecting={connecting}
|
||
/>
|
||
</Card>
|
||
)}
|
||
</Col>
|
||
</Row>
|
||
|
||
<Modal
|
||
open={isModalOpen}
|
||
onOk={handleOk}
|
||
onCancel={handleCancel}
|
||
title={modalContent.title}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<p>{modalContent.content}</p>
|
||
</Modal>
|
||
|
||
{/* 保存路径模态框 */}
|
||
<Modal
|
||
open={isSaveModalVisible}
|
||
title="保存路径"
|
||
onOk={handleSave}
|
||
onCancel={() => setIsSaveModalVisible(false)}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
confirmLoading={isSaving}
|
||
>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<label style={{ display: 'block', marginBottom: 8 }}>地块名称</label>
|
||
<input
|
||
type="text"
|
||
value={plotName}
|
||
onChange={(e) => setPlotName(e.target.value)}
|
||
placeholder="请输入地块名称"
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px 12px',
|
||
border: '1px solid #d9d9d9',
|
||
borderRadius: 4,
|
||
fontSize: 14,
|
||
}}
|
||
/>
|
||
</div>
|
||
{isSaving && (
|
||
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||
<div style={{
|
||
display: 'inline-block',
|
||
width: 30,
|
||
height: 30,
|
||
border: '3px solid #1890ff',
|
||
borderTopColor: 'transparent',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}} />
|
||
<div style={{ marginTop: 8, color: '#666' }}>保存中...</div>
|
||
</div>
|
||
)}
|
||
<style>
|
||
{`
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
`}
|
||
</style>
|
||
</Modal>
|
||
</div >
|
||
);
|
||
}
|