1547 lines
58 KiB
TypeScript
1547 lines
58 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
Row, Col, Card, Button, Space, Table, Tag, Typography, Progress, Badge,
|
||
Tabs, Input, Switch, Divider, Tooltip, Timeline, Select, message, DatePicker,
|
||
Modal, InputNumber
|
||
} from 'antd';
|
||
import {
|
||
PlusOutlined, ImportOutlined, NodeIndexOutlined,
|
||
SaveOutlined, FullscreenOutlined, EnvironmentOutlined,
|
||
BranchesOutlined, CloudOutlined, ThunderboltOutlined, CheckCircleOutlined,
|
||
InfoCircleOutlined, ClockCircleOutlined,
|
||
SettingOutlined, WarningOutlined, ArrowRightOutlined,
|
||
SearchOutlined, MoreOutlined, AimOutlined, PlayCircleOutlined
|
||
} from '@ant-design/icons';
|
||
import {
|
||
Plane, Navigation, Wind, Thermometer, Droplets, Satellite,
|
||
LayoutGrid, Clock, CheckCircle2, AlertCircle, Zap, Waves,
|
||
Calendar, User, TrendingUp, Maximize2, Play, Square, X, MapPin, RotateCw
|
||
} from 'lucide-react';
|
||
import {
|
||
getsinglePlan, getsinglePlanTrack, updateFlightTaskStatus, postFlightCommmand, getWayLine, createWaylineTask, getFlightTaskDetail
|
||
} from '@/api/device';
|
||
import dayjs from 'dayjs';
|
||
import utils from '@/lib/utils';
|
||
import CesiumMap from '../Map';
|
||
import { getUAVState } from '@/api/device';
|
||
import { fix2, getVal } from '@/lib/utils';
|
||
|
||
const { Text, Title } = Typography;
|
||
const { RangePicker } = DatePicker;
|
||
|
||
export default function WayLinePage({ planedevices, onRefresh }) {
|
||
const fontSmall = 'clamp(10px, 0.7vw, 11px)';
|
||
const fontNormal = 'clamp(12px, 0.75vw, 13px)';
|
||
const fontTitle = 'clamp(14px, 0.85vw, 16px)';
|
||
const fontLarge = 'clamp(18px, 1.1vw, 24px)';
|
||
|
||
const cardStyle = { borderRadius: 12, border: 'none', boxShadow: '0 2px 12px rgba(0,0,0,0.04)' };
|
||
|
||
// 任务列表 独立筛选状态
|
||
const [listDrone, setListDrone] = useState(null);
|
||
const [listDroneRealtime, setListDroneRealtime] = useState(null);
|
||
const [listDateRange, setListDateRange] = useState(null);
|
||
const [taskList, setTaskList] = useState([]);
|
||
|
||
const listDroneTimerRef = useRef<any>(null);
|
||
|
||
// 定时获取任务列表所选无人机的实时状态
|
||
useEffect(() => {
|
||
const fetchRealtime = async () => {
|
||
if (listDrone?.gateway_sn && listDrone?.device_sn) {
|
||
try {
|
||
const res = await getUAVState({
|
||
sn: listDrone.gateway_sn,
|
||
droneSn: listDrone.device_sn
|
||
});
|
||
if (res.code === 200) {
|
||
setListDroneRealtime(res.data);
|
||
}
|
||
} catch (err) {
|
||
console.error('获取列表无人机实时状态失败', err);
|
||
}
|
||
} else {
|
||
setListDroneRealtime(null);
|
||
}
|
||
};
|
||
|
||
fetchRealtime();
|
||
listDroneTimerRef.current = setInterval(fetchRealtime, 3000);
|
||
|
||
return () => {
|
||
if (listDroneTimerRef.current) clearInterval(listDroneTimerRef.current);
|
||
};
|
||
}, [listDrone]);
|
||
|
||
// 工作看板 独立筛选状态
|
||
const [boardDrone, setBoardDrone] = useState(null);
|
||
const [boardDateRange, setBoardDateRange] = useState(null);
|
||
const [boardTaskList, setBoardTaskList] = useState([]);
|
||
|
||
// ✅ 航线列表 独立无人机筛选(新增)
|
||
const [waylineDrone, setWaylineDrone] = useState(null);
|
||
const [focusLocation, setFocusLocation] = useState(null);
|
||
|
||
const [points, setPoints] = useState([]);
|
||
|
||
const fetchAndFocusDrone = async (drone) => {
|
||
if (!drone?.gateway_sn || !drone?.device_sn) return;
|
||
try {
|
||
const res = await getUAVState({
|
||
sn: drone.gateway_sn,
|
||
droneSn: drone.device_sn
|
||
});
|
||
if (res.code === 200 && res.data) {
|
||
setFocusLocation({
|
||
lon: res.data.longitude,
|
||
lat: res.data.latitude,
|
||
type: drone.type || 'plane',
|
||
name: drone.drone_callsign || drone.name || drone.device_sn
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error('获取无人机位置失败', err);
|
||
}
|
||
};
|
||
|
||
// 航线列表相关状态
|
||
const [selectedWayline, setSelectedWayline] = useState(null);
|
||
const [rthModalVisible, setRthModalVisible] = useState(false);
|
||
const [tempRthAltitude, setTempRthAltitude] = useState(20);
|
||
const [waylineSearch, setWaylineSearch] = useState('');
|
||
|
||
// 任务控制相关状态
|
||
const [selectedTask, setSelectedTask] = useState(null);
|
||
const [taskstatus, setTaskStatus] = useState('suspended');
|
||
const [isReturningHome, setIsReturningHome] = useState(false);
|
||
const [isTaskPaused, setIsTaskPaused] = useState(false);
|
||
const [flightCommandLoading, setFlightCommandLoading] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// ==============================================
|
||
// ✅ 创建任务弹窗相关状态(已完整整合)
|
||
// ==============================================
|
||
const [createTaskModal, setCreateTaskModal] = useState(false);
|
||
const [taskDetail, setTaskDetail] = useState(null);
|
||
const [waylineUuid, setWaylineUuid] = useState('');
|
||
const [waylineList, setWaylineList] = useState([]); // 航线列表
|
||
const [taskForm, setTaskForm] = useState({
|
||
name: '',
|
||
sn: '',
|
||
landing_dock_sn: '',
|
||
rth_altitude: 10,
|
||
rth_mode: 'optimal',
|
||
wayline_precision_type: 'gps',
|
||
resumable_status: 'auto',
|
||
min_battery_capacity: 50,
|
||
task_type: 'immediate',
|
||
repeat_type: 'nonrepeating',
|
||
repeat_option: {
|
||
interval: 1,
|
||
days_of_week: [],
|
||
days_of_month: [],
|
||
week_of_month: 1,
|
||
},
|
||
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||
|
||
recurring_task_start_time_list: [],
|
||
});
|
||
// 根据 repeat_type 自动构建接口需要的 repeat_option
|
||
const buildRepeatOption = () => {
|
||
const { repeat_type, repeat_option } = taskForm;
|
||
const { interval, days_of_week, days_of_month, week_of_month } = repeat_option;
|
||
|
||
if (repeat_type === 'nonrepeating') return undefined;
|
||
|
||
switch (repeat_type) {
|
||
case 'daily':
|
||
return { interval: Math.max(1, interval) };
|
||
|
||
case 'weekly':
|
||
return { interval, days_of_week };
|
||
|
||
case 'absolute_monthly':
|
||
return { interval, days_of_month };
|
||
|
||
case 'relative_monthly':
|
||
return { interval, week_of_month, days_of_week };
|
||
|
||
default:
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
const fetchWaylineList = async () => {
|
||
try {
|
||
const res = await getWayLine();
|
||
if (res?.code == 200 || res?.code == 0) {
|
||
const _list = res?.data?.list.map(item => {
|
||
return {
|
||
...item,
|
||
label: item.name || `航线_${item.id || '未知'}`,
|
||
value: item.id
|
||
}
|
||
})
|
||
setWaylineList(_list || []);
|
||
}
|
||
} catch (err) {
|
||
message.error('获取航线列表失败');
|
||
}
|
||
};
|
||
|
||
|
||
// 关闭弹窗重置
|
||
const resetModals = () => {
|
||
setCreateTaskModal(false);
|
||
setWaylineUuid('');
|
||
setTaskForm({
|
||
name: '',
|
||
sn: waylineDrone?.gateway_sn || '',
|
||
landing_dock_sn: '',
|
||
rth_altitude: 10,
|
||
rth_mode: 'optimal',
|
||
wayline_precision_type: 'gps',
|
||
resumable_status: 'auto',
|
||
min_battery_capacity: 50,
|
||
task_type: 'immediate',
|
||
repeat_type: 'nonrepeating',
|
||
repeat_option: { interval: 1 },
|
||
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||
begin_at: null,
|
||
end_at: null,
|
||
recurring_task_start_time_list: [],
|
||
});
|
||
};
|
||
|
||
// 创建任务提交
|
||
const createFlightTask = async () => {
|
||
if (!taskForm.name || !waylineUuid || !taskForm.sn) {
|
||
utils.message.warning('请完善必填项');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const payload = {
|
||
...taskForm,
|
||
wayline_uuid: waylineUuid,
|
||
repeat_type: taskForm.repeat_type,
|
||
repeat_option: buildRepeatOption(),
|
||
recurring_task_start_time_list: taskForm.recurring_task_start_time_list.map(t => dayjs(t).unix()),
|
||
};
|
||
|
||
const res: any = await createWaylineTask(payload);
|
||
if (res.code === 200 || res.code === 0) {
|
||
utils.message.success('创建任务成功');
|
||
resetModals();
|
||
getListTask();
|
||
if (onRefresh) onRefresh();
|
||
} else {
|
||
utils.message.error(res.msg || '创建失败');
|
||
}
|
||
} catch (err) {
|
||
utils.message.error('创建异常');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 航线列表无人机变更时同步到表单
|
||
useEffect(() => {
|
||
if (waylineDrone) {
|
||
setTaskForm(prev => ({
|
||
...prev,
|
||
sn: waylineDrone.gateway_sn || ''
|
||
}));
|
||
}
|
||
}, [waylineDrone]);
|
||
|
||
// 顶部统计
|
||
const [todayCount, setTodayCount] = useState(0);
|
||
const [yesterdayCount, setYesterdayCount] = useState(0);
|
||
const [trend, setTrend] = useState('');
|
||
const [trendColor, setTrendColor] = useState('');
|
||
|
||
// 计算今日/昨日任务数量
|
||
const calcTodayTaskCount = () => {
|
||
if (!taskList?.length) return;
|
||
|
||
const todayStart = dayjs().startOf('day').valueOf();
|
||
const todayEnd = dayjs().endOf('day').valueOf();
|
||
const yesterdayStart = dayjs().subtract(1, 'day').startOf('day').valueOf();
|
||
const yesterdayEnd = dayjs().subtract(1, 'day').endOf('day').valueOf();
|
||
|
||
const todayTotal = taskList.filter(t => {
|
||
const time = dayjs(t.begin_at).valueOf();
|
||
return time >= todayStart && time <= todayEnd;
|
||
}).length;
|
||
|
||
const yesterdayTotal = taskList.filter(t => {
|
||
const time = dayjs(t.begin_at).valueOf();
|
||
return time >= yesterdayStart && time <= yesterdayEnd;
|
||
}).length;
|
||
|
||
const diff = todayTotal - yesterdayTotal;
|
||
let trendText = '';
|
||
let color = '#00B42A';
|
||
|
||
if (diff > 0) {
|
||
trendText = `较昨日 +${diff}`;
|
||
color = '#00B42A';
|
||
} else if (diff < 0) {
|
||
trendText = `较昨日 ${diff}`;
|
||
color = '#F53F3F';
|
||
} else {
|
||
trendText = '较昨日 持平';
|
||
color = '#86909C';
|
||
}
|
||
|
||
setTodayCount(todayTotal);
|
||
setYesterdayCount(yesterdayTotal);
|
||
setTrend(trendText);
|
||
setTrendColor(color);
|
||
};
|
||
|
||
useEffect(() => {
|
||
calcTodayTaskCount();
|
||
}, [taskList]);
|
||
|
||
|
||
// 获取任务列表数据
|
||
const getListTask = async () => {
|
||
if (!planedevices?.length) return;
|
||
try {
|
||
const defaultStart = dayjs().startOf('day').unix();
|
||
const defaultEnd = dayjs().endOf('day').unix();
|
||
|
||
const [beginAt, endAt] = listDateRange?.length === 2
|
||
? [dayjs(listDateRange[0]).unix(), dayjs(listDateRange[1]).unix()]
|
||
: [defaultStart, defaultEnd];
|
||
|
||
const sns = listDrone ? [listDrone.gateway_sn] : planedevices.map(item => item.gateway_sn);
|
||
const res = await getsinglePlan({ sns, beginAt, endAt });
|
||
if (res?.code === 200) {
|
||
let combinedList = [];
|
||
if (res.data) {
|
||
Object.values(res.data).forEach((item: any) => {
|
||
if (item.list) combinedList = combinedList.concat(item.list);
|
||
});
|
||
}
|
||
setTaskList(combinedList);
|
||
}
|
||
} catch (err) {
|
||
message.error('任务列表数据获取失败');
|
||
}
|
||
};
|
||
|
||
// 获取看板任务数据
|
||
const getBoardTask = async () => {
|
||
if (!planedevices?.length) return;
|
||
try {
|
||
const defaultStart = dayjs().startOf('day').unix();
|
||
const defaultEnd = dayjs().endOf('day').unix();
|
||
|
||
const [beginAt, endAt] = boardDateRange?.length === 2
|
||
? [dayjs(boardDateRange[0]).unix(), dayjs(boardDateRange[1]).unix()]
|
||
: [defaultStart, defaultEnd];
|
||
|
||
const sns = boardDrone ? [boardDrone.gateway_sn] : planedevices.map(item => item.gateway_sn);
|
||
const res = await getsinglePlan({ sns, beginAt, endAt });
|
||
if (res?.code === 200) {
|
||
let combinedList = [];
|
||
if (res.data) {
|
||
Object.values(res.data).forEach((item: any) => {
|
||
if (item.list) combinedList = combinedList.concat(item.list);
|
||
});
|
||
}
|
||
setBoardTaskList(combinedList);
|
||
}
|
||
} catch (err) {
|
||
message.error('任务看板数据获取失败');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchWaylineList();
|
||
}, [])
|
||
|
||
// 初始化默认设备
|
||
useEffect(() => {
|
||
if (planedevices?.length) {
|
||
setListDrone(planedevices[0]);
|
||
setWaylineDrone(planedevices[0]); // 航线列表默认选中第一个
|
||
setBoardDrone(null);
|
||
}
|
||
}, [planedevices]);
|
||
|
||
useEffect(() => {
|
||
getListTask();
|
||
}, [listDrone, listDateRange]);
|
||
|
||
useEffect(() => {
|
||
getBoardTask();
|
||
}, [boardDrone, boardDateRange]);
|
||
|
||
// 任务状态标签
|
||
const getStatusTag = (status) => {
|
||
const statusMap = {
|
||
success: { color: 'success', text: '执行成功' },
|
||
preparing: { color: 'blue', text: '准备中' },
|
||
running: { color: 'processing', text: '执行中' },
|
||
failed: { color: 'error', text: '执行失败' },
|
||
waiting: { color: 'default', text: '待开始' },
|
||
starting_failure: { color: 'error', text: '启动失败' },
|
||
executing: { color: 'processing', text: '执行中' },
|
||
suspended: { color: 'warning', text: '已挂起' },
|
||
restored: { color: 'processing', text: '恢复执行' },
|
||
terminated: { color: 'default', text: '已终止' },
|
||
timeout: { color: 'error', text: '执行超时' },
|
||
};
|
||
const item = statusMap[status] || { color: 'default', text: status };
|
||
return <Tag color={item.color}>{item.text}</Tag>;
|
||
};
|
||
|
||
// 列表表格列
|
||
const taskColumns = [
|
||
{ title: '任务名称', dataIndex: 'name', width: 260, ellipsis: true, fixed: "left" },
|
||
{ title: '设备SN', dataIndex: 'sn', width: 140 },
|
||
{ title: '状态', dataIndex: 'status', width: 80, render: getStatusTag },
|
||
{
|
||
title: '开始时间', dataIndex: 'begin_at', width: 170,
|
||
render: t => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||
},
|
||
{
|
||
title: '结束时间', dataIndex: 'end_at', width: 170,
|
||
render: t => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||
},
|
||
];
|
||
const getTrackFn = async (record) => {
|
||
const res = await getsinglePlanTrack(record);
|
||
if (res.code === 0 && res.data.track?.points?.length) {
|
||
const points = res.data.track.points.map(item => ({
|
||
lon: item.longitude || item.lon,
|
||
lat: item.latitude || item.lat
|
||
}));
|
||
setPoints(points);
|
||
} else {
|
||
utils.message.warning("暂无航线数据");
|
||
setPoints([]);
|
||
}
|
||
}
|
||
|
||
// 表格行点击查看轨迹
|
||
const handleRowClick = async (record) => {
|
||
setSelectedTask(record);
|
||
try {
|
||
getTaskDetail(record.uuid);
|
||
await getTrackFn(record);
|
||
|
||
} catch (err) {
|
||
message.error('请求失败');
|
||
}
|
||
};
|
||
|
||
// 航线列表点击处理
|
||
const handleWaylineClick = (wayline) => {
|
||
setSelectedWayline(wayline);
|
||
if (wayline.points && wayline.points.length > 0) {
|
||
const mapPoints = wayline.points.map(p => ({
|
||
lon: p.longitude || p.lon,
|
||
lat: p.latitude || p.lat
|
||
}));
|
||
setPoints(mapPoints);
|
||
} else if (wayline.track?.points?.length > 0) {
|
||
const mapPoints = wayline.track.points.map(p => ({
|
||
lon: p.longitude || p.lon,
|
||
lat: p.latitude || p.lat
|
||
}));
|
||
setPoints(mapPoints);
|
||
}
|
||
};
|
||
|
||
// 执行航线按钮点击(改为图标)
|
||
const handleExecuteClick = (e, wayline) => {
|
||
e.stopPropagation();
|
||
// ✅ 使用航线列表自己的无人机
|
||
if (!waylineDrone?.gateway_sn) {
|
||
utils.message.warning('请先在航线列表上方选择执行无人机');
|
||
return;
|
||
}
|
||
setSelectedWayline(wayline);
|
||
setTempRthAltitude(20);
|
||
setRthModalVisible(true);
|
||
};
|
||
useEffect(() => {
|
||
fetchAndFocusDrone(waylineDrone);
|
||
}, [waylineDrone])
|
||
|
||
// 确认执行航线任务
|
||
const confirmExecuteWayline = async () => {
|
||
if (!selectedWayline || !waylineDrone?.gateway_sn) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
const payload = {
|
||
name: `${selectedWayline.name}_${dayjs().format('MMDDHHmm')}`,
|
||
sn: waylineDrone.gateway_sn, // ✅ 使用航线列表无人机
|
||
wayline_uuid: selectedWayline.id || selectedWayline.uuid,
|
||
rth_altitude: tempRthAltitude,
|
||
task_type: 'immediate',
|
||
rth_mode: 'optimal',
|
||
wayline_precision_type: 'gps',
|
||
resumable_status: 'auto',
|
||
min_battery_capacity: 50,
|
||
repeat_type: 'nonrepeating',
|
||
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||
};
|
||
|
||
const res: any = await createWaylineTask(payload);
|
||
if (res.code === 200 || res.code === 0) {
|
||
utils.message.success('航线执行任务创建成功');
|
||
setRthModalVisible(false);
|
||
getListTask();
|
||
//if (onRefresh) {
|
||
// setTimeout(() => {
|
||
// onRefresh();
|
||
// }, 1500)
|
||
//}
|
||
|
||
//await getTrackFn({
|
||
// uuid: res.data.task_uuid
|
||
|
||
//});
|
||
} else {
|
||
utils.message.error(res.msg || '执行失败');
|
||
}
|
||
} catch (err) {
|
||
utils.message.error('创建任务异常');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 看板数据分组处理
|
||
const groupBoardData = () => {
|
||
const waitList = boardTaskList.filter(item =>
|
||
['preparing', 'waiting', 'suspended', 'terminated'].includes(item.status)
|
||
);
|
||
const runList = boardTaskList.filter(item =>
|
||
['running', 'executing', 'restored'].includes(item.status)
|
||
);
|
||
const finishList = boardTaskList.filter(item =>
|
||
['success'].includes(item.status)
|
||
);
|
||
return { waitList, runList, finishList };
|
||
};
|
||
const { waitList, runList, finishList } = groupBoardData();
|
||
|
||
const topCards = [
|
||
{
|
||
title: '今日航线计划', value: todayCount, unit: '条', desc: '进行中', trend: trend, trendColor: trendColor,
|
||
icon: <LayoutGrid size={22} />, color: '#165DFF'
|
||
},
|
||
{ title: '执行中任务', value: runList.length, unit: '个', desc: '进行中', icon: <Plane size={22} />, color: '#165DFF' },
|
||
{ title: '待审批任务', value: '4', unit: '个', trend: '较昨日 -1', trendColor: '#F53F3F', icon: <Calendar size={22} />, color: '#FF7D00' },
|
||
{ title: '任务完成率', value: finishList.length ? Math.round((finishList.length / taskList.length) * 100) : 0, unit: '%', trend: '较昨日 +6%', trendColor: '#00B42A', icon: <CheckCircle2 size={22} />, color: '#00B42A' },
|
||
{ title: 'AI 优化收益', value: '+7.8', unit: '%', trend: '较昨日 +1.2%', trendColor: '#00B42A', icon: <TrendingUp size={22} />, color: '#722ED1' },
|
||
{ title: '覆盖装备', value: '4 类 / 9 台', unit: '', desc: '全部在线', icon: <Satellite size={22} />, color: '#165DFF' },
|
||
];
|
||
|
||
// 指令下发逻辑
|
||
const sendFlightCommand = async (command: 'return_home' | 'return_home_cancel' | 'flighttask_pause' | 'flighttask_recovery') => {
|
||
if (!listDrone?.device_sn) {
|
||
utils.message.warning('未选中无人机或无人机SN不存在');
|
||
return;
|
||
}
|
||
|
||
setFlightCommandLoading(true);
|
||
const data = {
|
||
command,
|
||
deviceSn: listDrone.device_sn,
|
||
}
|
||
try {
|
||
const res: any = await postFlightCommmand(data);
|
||
if (res.code === 200 || res.code === 0) {
|
||
utils.message.success('指令下发成功');
|
||
if (command === 'return_home') setIsReturningHome(true);
|
||
if (command === 'return_home_cancel') setIsReturningHome(false);
|
||
if (command === 'flighttask_pause') setIsTaskPaused(true);
|
||
if (command === 'flighttask_recovery') setIsTaskPaused(false);
|
||
} else {
|
||
utils.message.error(res.msg || '指令下发失败');
|
||
}
|
||
} catch (err) {
|
||
utils.message.error('接口请求异常');
|
||
} finally {
|
||
setFlightCommandLoading(false);
|
||
}
|
||
};
|
||
|
||
const getTaskDetail = async (taskId) => {
|
||
if (!taskId) return;
|
||
setTaskDetail(null);
|
||
setLoading(true);
|
||
try {
|
||
const res = await getFlightTaskDetail(taskId);
|
||
if (res.code === 0) {
|
||
setTaskDetail(res.data);
|
||
} else {
|
||
setTaskDetail(null);
|
||
utils.message.error('暂无详情');
|
||
}
|
||
} catch (err) {
|
||
setTaskDetail(null);
|
||
utils.message.error('获取详情接口异常');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 任务状态更新逻辑
|
||
const updateTaskStatus = async (taskId: string, status: string) => {
|
||
if (!taskId) {
|
||
utils.message.warning('请先选择航线任务');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const res: any = await updateFlightTaskStatus(taskId, status);
|
||
if (res.code === 200 || res.code === 0) {
|
||
const statusText = {
|
||
executing: '任务执行成功',
|
||
suspended: '任务已挂起',
|
||
restored: '任务已继续',
|
||
return_home: '任务已返航'
|
||
}[status] || '状态更新成功';
|
||
|
||
if (status === "suspended") {
|
||
setTaskStatus('suspended');
|
||
} else {
|
||
setTaskStatus('restored');
|
||
}
|
||
utils.message.success(statusText);
|
||
getListTask();
|
||
} else {
|
||
utils.message.error(res.msg || '操作失败');
|
||
}
|
||
} catch (err) {
|
||
utils.message.error('操作接口异常');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="normal-spacing" style={{ background: '#f5f7fa', minHeight: '100vh', padding: '0px', paddingTop: 0 }}>
|
||
<Row gutter={[16, 16]} style={{ marginBottom: 8 }}>
|
||
{topCards.map((item, index) => (
|
||
<Col xs={12} sm={8} md={4} key={index}>
|
||
<Card bordered={false} bodyStyle={{ padding: '12px 14px' }} style={cardStyle}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ width: 36, height: 36, background: item.bg, borderRadius: 10, display: 'flex', justifyContent: 'center', alignItems: 'center', color: item.color }}>
|
||
{React.cloneElement(item.icon, { size: 18 })}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 13, color: '#86909C' }}>{item.title}</div>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||
<span style={{ fontSize: 'clamp(16px, 1vw, 20px)', fontWeight: 700, color: '#1D2129' }}>{item.value}</span>
|
||
<span style={{ fontSize: 9, color: '#4E5969' }}>{item.unit}</span>
|
||
</div>
|
||
<div style={{ fontSize: 9, color: item.trendColor || '#86909C', fontWeight: 500 }}>
|
||
{item.trend || item.desc}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
|
||
<Row gutter={10} style={{ marginBottom: 10 }}>
|
||
<Col xs={24} xl={14} style={{ display: "flex", flexDirection: "column" }}>
|
||
{/* 地图卡片 */}
|
||
<Card
|
||
title={<span style={{ fontSize: fontNormal, fontWeight: 700 }}>航线编辑与路径规划</span>}
|
||
size="small"
|
||
extra={
|
||
<Space size={4}>
|
||
<Button size="small" icon={<PlusOutlined />} style={{ borderRadius: 4 }}>新增</Button>
|
||
<Button size="small" icon={<ImportOutlined />} style={{ borderRadius: 4 }}>导入</Button>
|
||
<Button size="small" icon={<NodeIndexOutlined />} style={{ borderRadius: 4 }}>优化</Button>
|
||
<Button size="small" icon={<SaveOutlined />} style={{ borderRadius: 4 }}>保存</Button>
|
||
<Button size="small" icon={<Maximize2 size={12} />} />
|
||
</Space>
|
||
}
|
||
bordered={false}
|
||
bodyStyle={{ padding: 0, position: 'relative' }}
|
||
style={{ ...cardStyle, marginBottom: 10 }}
|
||
>
|
||
<div style={{ width: '100%', height: 420, background: '#e8f3ff url(https://picsum.photos/seed/solar_map_v2/1600/1000) center/cover', position: 'relative' }}>
|
||
<CesiumMap devices={planedevices} points={points} focusLocation={focusLocation} from="device-control" />
|
||
<div style={{ position: 'absolute', top: 16, left: 16, background: 'rgba(255,255,255,0.92)', backdropFilter: 'blur(8px)', padding: '14px 16px', borderRadius: 10, width: 150, boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}>
|
||
<div style={{ fontSize: fontSmall, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span style={{ width: 14, height: 2, background: '#165DFF' }}></span> 无人机航线</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span style={{ width: 14, height: 2, background: '#FF7D00' }}></span> 巡检路线</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span style={{ width: 14, height: 2, background: '#00B42A' }}></span> 除草路线</div>
|
||
<Divider style={{ margin: '6px 0' }} />
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}><div style={{ width: 18, height: 18, background: '#165DFF', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', color: '#fff', fontSize: 10 }}>H</div> <span>无人机机场</span></div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}><Zap size={14} color="#00B42A" /> <span>充电点</span></div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}><AlertCircle size={14} color="#F53F3F" /> <span>禁飞区</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 独立筛选 - 设备任务列表 */}
|
||
<Card
|
||
title={
|
||
<div className="flex justify-between w-full items-center">
|
||
<span style={{ fontSize: 14, fontWeight: 700 }}>设备任务列表</span>
|
||
<Space size={12}>
|
||
<RangePicker
|
||
size="small"
|
||
showTime
|
||
format="YYYY-MM-DD HH:mm:ss"
|
||
value={listDateRange}
|
||
onChange={(val) => setListDateRange(val)}
|
||
placeholder={['开始时间', '结束时间']}
|
||
/>
|
||
<Select
|
||
style={{ width: 180 }}
|
||
size="small"
|
||
placeholder="全部无人机"
|
||
showSearch
|
||
allowClear
|
||
options={planedevices?.map(item => ({
|
||
value: item.device_sn,
|
||
label: item.drone_callsign || item.device_sn || item.callsign
|
||
}))}
|
||
value={listDrone?.device_sn}
|
||
onChange={(val) => {
|
||
const find = planedevices.find(i => i.device_sn === val);
|
||
setListDrone(find || null);
|
||
|
||
}}
|
||
/>
|
||
</Space>
|
||
</div>
|
||
}
|
||
size="small"
|
||
extra={
|
||
<Tooltip title="创建任务">
|
||
<PlusOutlined
|
||
style={{ cursor: 'pointer', color: '#165DFF', fontSize: 12, marginLeft: 10 }}
|
||
onClick={() => {
|
||
resetModals();
|
||
setCreateTaskModal(true);
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
}
|
||
bordered={false}
|
||
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column', height: "calc(100% - 40px)", overflow: "auto" }}
|
||
style={{ ...cardStyle, maxHeight: "250px", flex: 1, marginBottom: 0 }}
|
||
>
|
||
<Table
|
||
size="small"
|
||
rowKey="uuid"
|
||
columns={taskColumns}
|
||
dataSource={taskList}
|
||
pagination={false}
|
||
style={{ height: "calc(100% - 40px)" }}
|
||
rowClassName={(record) =>
|
||
selectedTask?.uuid === record.uuid ? 'table-row-highlight' : ''
|
||
}
|
||
onRow={(record) => ({
|
||
onClick: () => handleRowClick(record),
|
||
style: { cursor: 'pointer' }
|
||
})}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col xs={24} xl={10} style={{ display: "flex", flexDirection: "column" }} >
|
||
<Card
|
||
title={<span style={{ fontSize: fontTitle, fontWeight: 700 }}>任务编排与 AI 建议</span>}
|
||
bordered={false}
|
||
bodyStyle={{ padding: '12px 16px' }}
|
||
style={{ ...cardStyle, marginBottom: 8 }}
|
||
>
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={[
|
||
{ key: '1', id: 'FL-03', device: 'UAV-A01', type: '无人机热斑巡检', level: '高', time: '10:00 - 11:20', duration: '1h20m', status: '建议执行' },
|
||
{ key: '2', id: 'RB-02', device: 'RBT-01', type: '围栏巡检', level: '高', time: '11:30 - 12:30', duration: '1h00m', status: '建议执行' },
|
||
{ key: '3', id: 'CL-05', device: 'UAV-C01', type: '组件清洗', level: '中', time: '14:00 - 16:00', duration: '2h00m', status: '等待执行' },
|
||
{ key: '4', id: 'GR-04', device: 'GR-01', type: '阵列除草', level: '中', time: '09:30 - 11:30', duration: '2h00m', status: '建议执行' },
|
||
{ key: '5', id: 'FL-07', device: 'UAV-A01', type: 'AI 复检航线', level: '低', time: '16:30 - 17:10', duration: '0h40m', status: '待审批' },
|
||
]}
|
||
columns={[
|
||
{ title: '编号', dataIndex: 'id', width: 70, render: (t) => <Text style={{ color: '#165DFF', fontWeight: 600 }}>{t}</Text> },
|
||
{ title: '装备', dataIndex: 'device', width: 80 },
|
||
{ title: '类型', dataIndex: 'type', ellipsis: true },
|
||
{ title: '优先级', dataIndex: 'level', width: 80, render: (l) => <span style={{ color: l === '高' ? '#F53F3F' : l === '中' ? '#FF7D00' : '#86909C', fontSize: 11, fontWeight: 600 }}>▲ {l}</span> },
|
||
{ title: '建议时间', dataIndex: 'time', width: 110 },
|
||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Text style={{ color: s.includes('建议') ? '#165DFF' : '#86909C', fontSize: 11, fontWeight: 500 }}>{s}</Text> },
|
||
]}
|
||
/>
|
||
<div style={{ background: '#F8F9FB', padding: '2px 14px', borderRadius: 8, marginTop: 8, border: '1px solid #F1F2F5' }}>
|
||
<div style={{ display: 'flex', gap: 12, fontSize: fontSmall, alignItems: 'center' }}>
|
||
<span style={{ fontWeight: 700, color: '#1D2129' }}>AI 建议:</span>
|
||
<Space split={<Divider type="vertical" style={{ margin: 0 }} />}>
|
||
<Space size={4}><CheckCircle2 size={13} color="#165DFF" /> <span>推荐优先执行 FL-03 航线</span></Space>
|
||
<Space size={4}><Clock size={13} color="#165DFF" /> <span>14:00-16:00 风速最佳</span></Space>
|
||
<Space size={4}><AlertCircle size={13} color="#FF7D00" /> <span>区块 B-12 存在高温风险</span></Space>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
<Row gutter={12} style={{ marginTop: 8 }}>
|
||
<Col span={8}><Button type="primary" block style={{ background: '#165DFF', height: 28, borderRadius: 18, fontWeight: 600 }}>一键排程</Button></Col>
|
||
<Col span={8}><Button type="primary" block style={{ background: '#165DFF', height: 28, borderRadius: 18, fontWeight: 600 }}>智能分配</Button></Col>
|
||
<Col span={8}><Button type="primary" block style={{ background: '#165DFF', height: 28, borderRadius: 18, fontWeight: 600 }}>生成工单</Button></Col>
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card
|
||
title={<span style={{ fontSize: fontTitle, fontWeight: 700 }}>航线参数 / 作业参数</span>}
|
||
extra={<Text type="secondary" style={{ fontSize: 12 }}>当前航线:{selectedTask?.name || '-'}</Text>}
|
||
bordered={false}
|
||
bodyStyle={{ padding: '16px' }}
|
||
style={{ ...cardStyle, marginBottom: 8 }}
|
||
>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', marginBottom: 8 }}>
|
||
{[
|
||
{ label: '飞行高度', value: taskDetail?.rth_altitude ?? '-', unit: 'm' },
|
||
{ label: '返航模式', value: taskDetail?.rth_mode === 'preset' ? '预设' : taskDetail?.rth_mode === 'optimal' ? '智能' : '-', unit: '' },
|
||
{ label: '航线精度', value: taskDetail?.wayline_precision_type === 'gps' ? 'GPS' : taskDetail?.wayline_precision_type === 'rtk' ? 'RTK' : '-', unit: '' },
|
||
{ label: '断点续飞', value: taskDetail?.resumable_status === 'auto' ? '自动' : taskDetail?.resumable_status === 'manual' ? '手动' : '-', unit: '' },
|
||
{ label: '安全返航电量', value: taskDetail?.min_battery_capacity ?? '-', unit: '%' },
|
||
{
|
||
label: '任务类型', value:
|
||
taskDetail?.task_type === 'immediate' ? '立即任务' :
|
||
taskDetail?.task_type === 'timed' ? '定时任务' :
|
||
taskDetail?.task_type === 'recurring' ? '重复任务' :
|
||
taskDetail?.task_type === 'continuous' ? '连续任务' : '-',
|
||
unit: ''
|
||
},
|
||
{
|
||
label: '重复类型', value:
|
||
taskDetail?.repeat_type === 'nonrepeating' ? '不重复' :
|
||
taskDetail?.repeat_type === 'daily' ? '每天' :
|
||
taskDetail?.repeat_type === 'weekly' ? '每周' :
|
||
taskDetail?.repeat_type === 'absolute_monthly' ? '按月(日期)' :
|
||
taskDetail?.repeat_type === 'relative_monthly' ? '按月(星期)' : '-',
|
||
unit: ''
|
||
},
|
||
{ label: '失控动作', value: taskDetail?.out_of_control_action_in_flight === 'return_home' ? '返航' : '-', unit: '' },
|
||
].map((item, idx) => (
|
||
<div
|
||
key={idx}
|
||
style={{
|
||
background: '#F7F8FA',
|
||
borderRadius: 8,
|
||
padding: '4px 10px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between'
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 12, color: '#4E5969' }}>{item.label}</span>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: '#1D2129' }}>{item.value}</span>
|
||
<span style={{ fontSize: 11, color: '#86909C' }}>{item.unit}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 32, marginBottom: 8 }}>
|
||
<Space size={8}>
|
||
<span style={{ fontSize: fontNormal, color: '#4E5969' }}>避障开关</span>
|
||
<Switch size="small" defaultChecked />
|
||
</Space>
|
||
<Space size={8}>
|
||
<span style={{ fontSize: fontNormal, color: '#4E5969' }}>夜航限制</span>
|
||
<Switch size="small" />
|
||
</Space>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
|
||
{selectedTask?.status === 'executing' || selectedTask?.status === 'running' || selectedTask?.status === 'restored' ? (
|
||
<Button
|
||
type="primary"
|
||
danger
|
||
icon={<Square size={16} />}
|
||
onClick={() => updateTaskStatus(selectedTask?.uuid, 'suspended')}
|
||
loading={loading}
|
||
style={{ height: 28, borderRadius: 18, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
>
|
||
挂起任务
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="primary"
|
||
icon={<Play size={16} />}
|
||
onClick={() => {
|
||
if (selectedTask?.status === 'suspended') {
|
||
updateTaskStatus(selectedTask?.uuid, 'restored');
|
||
} else {
|
||
updateTaskStatus(selectedTask?.uuid, 'executing');
|
||
}
|
||
}}
|
||
loading={loading}
|
||
style={{ background: '#165DFF', height: 28, borderRadius: 18, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
>
|
||
{selectedTask?.status === 'suspended' ? '继续任务' : '执行任务'}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
type="primary"
|
||
danger={isReturningHome}
|
||
icon={isReturningHome ? <X size={16} /> : <MapPin size={16} />}
|
||
onClick={() => sendFlightCommand(isReturningHome ? 'return_home_cancel' : 'return_home')}
|
||
loading={flightCommandLoading}
|
||
style={{ background: '#165DFF', height: 28, borderRadius: 18, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
>
|
||
{isReturningHome ? '取消返航' : '返航'}
|
||
</Button>
|
||
|
||
<Button
|
||
type="default"
|
||
icon={isTaskPaused ? <RotateCw size={16} /> : <Square size={16} />}
|
||
onClick={() => sendFlightCommand(isTaskPaused ? 'flighttask_recovery' : 'flighttask_pause')}
|
||
loading={flightCommandLoading}
|
||
style={{ height: 28, borderRadius: 18, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
>
|
||
{isTaskPaused ? '恢复任务' : '暂停任务'}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card
|
||
title={<span style={{ fontSize: fontTitle, fontWeight: 700 }}>天气窗口与执行约束</span>}
|
||
bordered={false}
|
||
bodyStyle={{ padding: '16px' }}
|
||
style={{ ...cardStyle, flex: 1, }}
|
||
>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '8px', marginBottom: 16 }}>
|
||
<div style={{ background: '#F7F8FA', borderRadius: 10, padding: '12px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(22, 93, 255, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<CloudOutlined size={20} style={{ color: '#165DFF' }} />
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: '#86909C' }}>适宜性</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#00B42A' }}>适宜执行</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ background: '#F7F8FA', borderRadius: 10, padding: '12px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(22, 93, 255, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Wind size={20} style={{ color: '#165DFF' }} />
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: '#86909C' }}>风速</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#1D2129' }}>{fix2((listDroneRealtime || listDrone)?.wind_speed)} m/s</div>
|
||
<div style={{ fontSize: 12, color: '#00B42A' }}>适宜</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ background: '#F7F8FA', borderRadius: 10, padding: '12px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(255, 125, 0, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Thermometer size={20} style={{ color: '#FF7D00' }} />
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: '#86909C' }}>温度</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#1D2129' }}>{fix2((listDroneRealtime || listDrone)?.environment_temperature)}℃</div>
|
||
<div style={{ fontSize: 12, color: '#00B42A' }}>适宜</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ background: '#F7F8FA', borderRadius: 10, padding: '12px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(255, 125, 0, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Droplets size={20} style={{ color: '#FF7D00' }} />
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: '#86909C' }}>降水状态</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#1D2129' }}>{getVal((listDroneRealtime || listDrone)?.rainfall)}</div>
|
||
<div style={{ fontSize: 12, color: (listDroneRealtime || listDrone)?.rainfall === 'no_rain' ? '#00B42A' : '#F53F3F' }}>
|
||
{(listDroneRealtime || listDrone)?.rainfall === 'no_rain' ? '低风险' : '有雨'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ background: '#F7F8FA', borderRadius: 10, padding: '12px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(0, 180, 42, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Satellite size={20} style={{ color: '#00B42A' }} />
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: '#86909C' }}>GNSS质量</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#1D2129' }}>
|
||
{(listDroneRealtime || listDrone)?.position_state?.rtk_number ?? 0} RTK
|
||
</div>
|
||
<div style={{ fontSize: 12, color: (listDroneRealtime || listDrone)?.position_state?.is_fixed === 'fixing_successful' ? '#00B42A' : '#FF7D00' }}>
|
||
{(listDroneRealtime || listDrone)?.position_state?.is_fixed === 'fixing_successful' ? '已固定' : '未固定'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
<div style={{ flex: 1, background: 'rgba(255, 125, 0, 0.1)', borderRadius: 8, padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<AlertCircle size={16} style={{ color: '#FF7D00' }} />
|
||
<span style={{ fontSize: 13, color: '#FF7D00', fontWeight: 500 }}>16:30 后风速升高,建议提前完成任务</span>
|
||
</div>
|
||
<div style={{ flex: 1, background: 'rgba(22, 93, 255, 0.1)', borderRadius: 8, padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<InfoCircleOutlined size={16} style={{ color: '#165DFF' }} />
|
||
<span style={{ fontSize: 13, color: '#165DFF', fontWeight: 500 }}>预计 14:00-16:00 为最佳执行窗口</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 底部内容区 */}
|
||
<Row gutter={16}>
|
||
<Col xs={24} xl={4}>
|
||
<Card
|
||
title={
|
||
<div className="flex justify-between items-center w-full">
|
||
<span style={{ fontSize: fontTitle, fontWeight: 700 }}>航线列表</span>
|
||
{/* ✅ 航线列表独立无人机筛选 */}
|
||
<Select
|
||
size="small"
|
||
style={{ width: 120 }}
|
||
placeholder="选择无人机"
|
||
showSearch
|
||
allowClear
|
||
options={planedevices?.map(item => ({
|
||
value: item.device_sn,
|
||
label: item.drone_callsign || item.device_sn || item.callsign
|
||
}))}
|
||
value={waylineDrone?.device_sn}
|
||
onChange={(val) => {
|
||
const find = planedevices.find(i => i.device_sn === val);
|
||
setWaylineDrone(find || null);
|
||
|
||
}}
|
||
/>
|
||
</div>
|
||
}
|
||
bordered={false}
|
||
bodyStyle={{ padding: '12px', display: 'flex', flexDirection: 'column', height: 480 }}
|
||
style={{ ...cardStyle, height: '100%' }}
|
||
>
|
||
<Input
|
||
placeholder="搜索航线..."
|
||
prefix={<SearchOutlined style={{ color: '#86909C' }} />}
|
||
style={{ marginBottom: 12, borderRadius: 6 }}
|
||
size="small"
|
||
value={waylineSearch}
|
||
onChange={e => setWaylineSearch(e.target.value)}
|
||
/>
|
||
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 4 }} className="custom-scrollbar">
|
||
{waylineList
|
||
.filter(item => item.name?.toLowerCase().includes(waylineSearch.toLowerCase()))
|
||
.map((item) => (
|
||
<div
|
||
key={item.id || item.uuid}
|
||
onClick={() => handleWaylineClick(item)}
|
||
style={{
|
||
padding: '10px 12px',
|
||
borderRadius: 8,
|
||
marginBottom: 8,
|
||
cursor: 'pointer',
|
||
background: selectedWayline?.id === item.id ? '#E8F3FF' : '#F7F8FA',
|
||
border: selectedWayline?.id === item.id ? '1px solid #165DFF' : '1px solid transparent',
|
||
transition: 'all 0.2s',
|
||
position: 'relative'
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: '#1D2129', marginBottom: 4, paddingRight: 40 }}>
|
||
{item.name}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: '#86909C', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Clock size={12} /> {item.update_time ? dayjs(item.update_time).format('MM-DD HH:mm') : '未知时间'}
|
||
</div>
|
||
{/* ✅ 执行按钮改为图标 */}
|
||
<Tooltip title="执行航线">
|
||
<Button
|
||
type="text"
|
||
icon={<PlayCircleOutlined style={{ color: '#165DFF', fontSize: 18 }} />}
|
||
style={{
|
||
position: 'absolute',
|
||
right: 8,
|
||
top: '50%',
|
||
transform: 'translateY(-50%)',
|
||
}}
|
||
onClick={(e) => handleExecuteClick(e, item)}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
))}
|
||
{waylineList.length === 0 && (
|
||
<div style={{ textAlign: 'center', padding: '20px 0', color: '#86909C', fontSize: 12 }}>
|
||
暂无航线数据
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col xs={24} xl={12}>
|
||
<Card
|
||
title={<span style={{ fontSize: fontTitle, fontWeight: 700 }}>任务甘特视图 / 排班视图 <Text type="secondary" style={{ fontSize: 11, fontWeight: 400, marginLeft: 12 }}>今日 (2025-05-24)</Text></span>}
|
||
extra={<MoreOutlined style={{ cursor: 'pointer', fontSize: 18 }} />}
|
||
bordered={false}
|
||
bodyStyle={{ padding: 16 }}
|
||
style={{ ...cardStyle, height: '100%' }}
|
||
>
|
||
<div style={{ position: 'relative', border: '1px solid #F2F3F5', borderRadius: 8, padding: '16px 12px' }}>
|
||
<div style={{ display: 'flex', fontSize: 10, color: '#86909C', borderBottom: '1px solid #F2F3F5', paddingBottom: 10, textAlign: 'center' }}>
|
||
<div style={{ width: 140 }}></div>
|
||
<div style={{ flex: 1, display: 'flex', justifyContent: 'space-around' }}>
|
||
<span>08:00</span><span>10:00</span><span>12:00</span><span>14:00</span><span>16:00</span><span>18:00</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
||
{[
|
||
{ label: '无人机机场 A01', sub: '(UAV-A01)', tasks: [{ start: '25%', width: '18%', title: 'FL-03 热斑巡检', time: '10:00-11:20', color: '#165DFF' }, { start: '75%', width: '12%', title: 'FL-07 复检', time: '16:30-17:10', color: '#165DFF' }] },
|
||
{ label: '巡检机器人 RBT-01', sub: '', tasks: [{ start: '45%', width: '15%', title: 'RB-02 围栏巡检', time: '11:30-12:30', color: '#FF7D00' }] },
|
||
{ label: '除草机器人 GR-01', sub: '', tasks: [{ start: '10%', width: '22%', title: 'GR-04 阵列除草', time: '09:30-11:30', color: '#00B42A' }] },
|
||
{ label: '清洗无人机 UAV-C01', sub: '', tasks: [{ start: '65%', width: '20%', title: 'CL-05 组件清洗', time: '14:00-16:00', color: '#13C2C2' }] },
|
||
].map((row, idx) => (
|
||
<div key={idx} style={{ display: 'flex', alignItems: 'center' }}>
|
||
<div style={{ width: 140, paddingRight: 12 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, color: '#1D2129' }}>{row.label}</div>
|
||
<div style={{ fontSize: 10, color: '#86909C' }}>{row.sub}</div>
|
||
</div>
|
||
<div style={{ flex: 1, height: 40, background: '#F7F8FA', borderRadius: 6, position: 'relative' }}>
|
||
{row.tasks.map((task, tidx) => (
|
||
<div key={tidx} style={{
|
||
position: 'absolute', left: task.start, width: task.width, height: '100%',
|
||
background: task.color, borderRadius: 6, color: '#fff',
|
||
padding: '4px 8px', display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||
boxShadow: '0 2px 6px rgba(0,0,0,0.1)', zIndex: 2
|
||
}}>
|
||
<div style={{ fontSize: 10, fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{task.title}</div>
|
||
<div style={{ fontSize: 9, opacity: 0.9 }}>{task.time}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ position: 'absolute', left: '38%', top: 0, bottom: 0, width: 2, background: '#F53F3F', zIndex: 1, opacity: 0.6 }}>
|
||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#F53F3F', position: 'absolute', top: -3, left: -2 }} />
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col xs={24} xl={8}>
|
||
<Card
|
||
title={
|
||
<div className="flex justify-between items-center w-full">
|
||
<span style={{ fontSize: fontTitle, fontWeight: 700 }}>工作任务看板</span>
|
||
<Space size={8}>
|
||
<RangePicker
|
||
size="small"
|
||
showTime
|
||
format="YYYY-MM-DD HH:mm:ss"
|
||
value={boardDateRange}
|
||
onChange={(val) => setBoardDateRange(val)}
|
||
placeholder={['开始', '结束']}
|
||
/>
|
||
<Select
|
||
style={{ width: 140 }}
|
||
size="small"
|
||
placeholder="全部无人机"
|
||
showSearch
|
||
allowClear
|
||
options={planedevices?.map(item => ({
|
||
value: item.device_sn,
|
||
label: item.drone_callsign || item.device_sn
|
||
}))}
|
||
value={boardDrone?.device_sn}
|
||
onChange={(val) => {
|
||
const find = planedevices.find(i => i.device_sn === val);
|
||
setBoardDrone(find || null);
|
||
|
||
}}
|
||
/>
|
||
</Space>
|
||
</div>
|
||
}
|
||
bordered={false}
|
||
bodyStyle={{ padding: 16 }}
|
||
style={{ ...cardStyle, height: '100%', maxHeight: 480, overflow: "auto" }}
|
||
>
|
||
<Row gutter={10}>
|
||
{[
|
||
{
|
||
title: '待执行', count: waitList.length, color: '#4E5969', bg: '#F7F8FA',
|
||
list: waitList.map(item => ({
|
||
id: item.sn?.slice(-5) || '',
|
||
name: item.name,
|
||
device: item.sn,
|
||
begin_at: ''
|
||
}))
|
||
},
|
||
{
|
||
title: '执行中', count: runList.length, color: '#165DFF', bg: '#E8F3FF',
|
||
list: runList.map(item => ({
|
||
id: item.sn?.slice(-5) || '',
|
||
name: item.name,
|
||
device: item.sn,
|
||
begin_at: ''
|
||
}))
|
||
},
|
||
{
|
||
title: '已完成', count: finishList.length, color: '#00B42A', bg: '#E8FFEA',
|
||
list: finishList.map(item => ({
|
||
id: item.sn?.slice(-5) || '',
|
||
name: item.name,
|
||
device: item.sn,
|
||
begin_at: item.begin_at,
|
||
completed_at: item.completed_at
|
||
}))
|
||
},
|
||
].map((col, idx) => (
|
||
<Col span={8} key={idx}>
|
||
<div style={{ background: col.bg, borderRadius: 10, padding: 10, height: '100%', minHeight: 280 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 12, display: 'flex', justifyContent: 'space-between', color: col.color }}>
|
||
<span>{col.title} ({col.count})</span>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{col.list.map((task, tidx) => (
|
||
<div key={tidx} style={{ background: '#fff', borderRadius: 8, padding: 10, boxShadow: '0 2px 4px rgba(0,0,0,0.04)', border: '1px solid #F1F2F5' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 700, color: '#165DFF', marginBottom: 6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{task.name}
|
||
</div> <div style={{ fontSize: 10, color: '#86909C' }}>
|
||
<div style={{ marginBottom: 2 }}>装备: {task.device || 'UAV-A01'}</div>
|
||
{task.begin_at && <div style={{ marginTop: 2 }}>开始时间: {task.begin_at}</div>}
|
||
{task.completed_at && <div style={{ marginTop: 2 }}>完成时间: {task.completed_at}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 创建任务弹窗 */}
|
||
<Modal
|
||
open={createTaskModal}
|
||
title="创建航线任务"
|
||
onCancel={resetModals}
|
||
footer={null}
|
||
maskClosable={false}
|
||
width={650}
|
||
>
|
||
<div className="py-4 space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">航线名称 <span className="text-red-500">*</span></label>
|
||
<Input
|
||
value={taskForm.name}
|
||
onChange={(e) => setTaskForm({ ...taskForm, name: e.target.value })}
|
||
placeholder="请输入"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">选择航线 <span className="text-red-500">*</span></label>
|
||
<Select
|
||
showSearch
|
||
value={waylineUuid}
|
||
onChange={setWaylineUuid}
|
||
options={waylineList}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">机场SN <span className="text-red-500">*</span></label>
|
||
<Input value={taskForm.sn} disabled />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">降落地场SN</label>
|
||
<Input
|
||
value={taskForm.landing_dock_sn}
|
||
onChange={(e) => setTaskForm({ ...taskForm, landing_dock_sn: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">返航高度(m)</label>
|
||
<InputNumber
|
||
min={0}
|
||
value={taskForm.rth_altitude}
|
||
onChange={(v) => setTaskForm({ ...taskForm, rth_altitude: v })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">返航模式</label>
|
||
<Select
|
||
value={taskForm.rth_mode}
|
||
onChange={(v) => setTaskForm({ ...taskForm, rth_mode: v })}
|
||
options={[{ label: '智能', value: 'optimal' }, { label: '预设', value: 'preset' }]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">航线精度</label>
|
||
<Select
|
||
value={taskForm.wayline_precision_type}
|
||
onChange={(v) => setTaskForm({ ...taskForm, wayline_precision_type: v })}
|
||
options={[{ label: 'GPS', value: 'gps' }, { label: 'RTK', value: 'rtk' }]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">断点续飞</label>
|
||
<Select
|
||
value={taskForm.resumable_status}
|
||
onChange={(v) => setTaskForm({ ...taskForm, resumable_status: v })}
|
||
options={[{ label: '自动', value: 'auto' }, { label: '手动', value: 'manual' }]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">最低电量(%)</label>
|
||
<InputNumber
|
||
min={50}
|
||
max={100}
|
||
value={taskForm.min_battery_capacity}
|
||
onChange={(v) => setTaskForm({ ...taskForm, min_battery_capacity: v })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">任务类型</label>
|
||
<Select
|
||
value={taskForm.task_type}
|
||
onChange={(v) => setTaskForm({ ...taskForm, task_type: v })}
|
||
options={[
|
||
{ label: '立即', value: 'immediate' },
|
||
]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 时间选择 */}
|
||
{(taskForm.task_type === 'timed' || taskForm.task_type === 'recurring' || taskForm.task_type === 'continuous') && (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">开始时间</label>
|
||
<DatePicker
|
||
showTime
|
||
value={taskForm.begin_at}
|
||
onChange={(v) => setTaskForm({ ...taskForm, begin_at: v })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
{(taskForm.task_type === 'recurring' || taskForm.task_type === 'continuous') && (
|
||
<div>
|
||
<label className="text-sm block mb-1">结束时间</label>
|
||
<DatePicker
|
||
showTime
|
||
value={taskForm.end_at}
|
||
onChange={(v) => setTaskForm({ ...taskForm, end_at: v })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 重复任务配置 repeat_option 对象绑定 */}
|
||
{taskForm.task_type === 'recurring' && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">重复模式</label>
|
||
<Select
|
||
value={taskForm.repeat_type}
|
||
onChange={(v) => setTaskForm({ ...taskForm, repeat_type: v })}
|
||
options={[
|
||
{ label: '不重复', value: 'nonrepeating' },
|
||
{ label: '每天', value: 'daily' },
|
||
{ label: '每周', value: 'weekly' },
|
||
{ label: '每月(日期)', value: 'absolute_monthly' },
|
||
{ label: '每月(星期)', value: 'relative_monthly' },
|
||
]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">重复间隔</label>
|
||
<InputNumber
|
||
min={1}
|
||
value={taskForm.repeat_option.interval}
|
||
onChange={(v) => setTaskForm({
|
||
...taskForm,
|
||
repeat_option: { ...taskForm.repeat_option, interval: v }
|
||
})}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 每周 */}
|
||
{taskForm.repeat_type === 'weekly' && (
|
||
<div>
|
||
<label className="text-sm block mb-1">周几执行</label>
|
||
<Select
|
||
mode="multiple"
|
||
value={taskForm.repeat_option.days_of_week}
|
||
onChange={(v) => setTaskForm({
|
||
...taskForm,
|
||
repeat_option: { ...taskForm.repeat_option, days_of_week: v }
|
||
})}
|
||
options={[
|
||
{ label: '周日', value: 0 },
|
||
{ label: '周一', value: 1 },
|
||
{ label: '周二', value: 2 },
|
||
{ label: '周三', value: 3 },
|
||
{ label: '周四', value: 4 },
|
||
{ label: '周五', value: 5 },
|
||
{ label: '周六', value: 6 }
|
||
]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 按月日期 */}
|
||
{taskForm.repeat_type === 'absolute_monthly' && (
|
||
<div>
|
||
<label className="text-sm block mb-1">每月几日</label>
|
||
<Select
|
||
mode="multiple"
|
||
value={taskForm.repeat_option.days_of_month}
|
||
onChange={(v) => setTaskForm({
|
||
...taskForm,
|
||
repeat_option: { ...taskForm.repeat_option, days_of_month: v }
|
||
})}
|
||
options={Array.from({ length: 31 }, (_, i) => ({ label: i + 1, value: i + 1 }))}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 按月星期 */}
|
||
{taskForm.repeat_type === 'relative_monthly' && (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm block mb-1">第几周</label>
|
||
<Select
|
||
value={taskForm.repeat_option.week_of_month}
|
||
onChange={(v) => setTaskForm({
|
||
...taskForm,
|
||
repeat_option: { ...taskForm.repeat_option, week_of_month: v }
|
||
})}
|
||
options={[1, 2, 3, 4].map(w => ({ label: `第${w}周`, value: w }))}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm block mb-1">周几</label>
|
||
<Select
|
||
mode="multiple"
|
||
value={taskForm.repeat_option.days_of_week}
|
||
onChange={(v) => setTaskForm({
|
||
...taskForm,
|
||
repeat_option: { ...taskForm.repeat_option, days_of_week: v }
|
||
})}
|
||
options={[
|
||
{ label: '周日', value: 0 },
|
||
{ label: '周一', value: 1 },
|
||
{ label: '周二', value: 2 },
|
||
{ label: '周三', value: 3 },
|
||
{ label: '周四', value: 4 },
|
||
{ label: '周五', value: 5 },
|
||
{ label: '周六', value: 6 }
|
||
]}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-sm block mb-1">执行时间点</label>
|
||
<Select
|
||
mode="tags"
|
||
value={taskForm.recurring_task_start_time_list}
|
||
onChange={(v) => setTaskForm({
|
||
...taskForm,
|
||
recurring_task_start_time_list: v
|
||
})}
|
||
placeholder="输入时间戳"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 按钮 */}
|
||
<div className="flex justify-end gap-2 mt-4">
|
||
<Button onClick={resetModals}>取消</Button>
|
||
<Button type="primary" loading={loading} onClick={createFlightTask}>确认创建</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 执行航线设置返航高度 Modal */}
|
||
<Modal
|
||
title="执行航线任务"
|
||
open={rthModalVisible}
|
||
onOk={confirmExecuteWayline}
|
||
onCancel={() => setRthModalVisible(false)}
|
||
confirmLoading={loading}
|
||
okText="立即执行"
|
||
cancelText="取消"
|
||
centered
|
||
width={400}
|
||
>
|
||
<div style={{ padding: '10px 0' }}>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Text strong>所选航线:</Text>
|
||
<Text>{selectedWayline?.name}</Text>
|
||
</div>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Text strong>执行设备:</Text>
|
||
<Text>{waylineDrone?.drone_callsign || waylineDrone?.device_sn || '未选择设备'}</Text>
|
||
</div>
|
||
<div>
|
||
<div style={{ marginBottom: 8 }}><Text strong>返航高度 (m):</Text></div>
|
||
<InputNumber
|
||
min={10}
|
||
max={500}
|
||
value={tempRthAltitude}
|
||
onChange={val => setTempRthAltitude(val || 20)}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|