Files
web-Iot/src/components/devices/WayLinePage.tsx

1292 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } 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
} 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 } from '@/api/device';
import dayjs from 'dayjs';
import utils from '@/lib/utils';
import CesiumMap from '../Map';
const { Text, Title } = Typography;
const { RangePicker } = DatePicker;
export default function WayLinePage({ planedevices }) {
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 [listDateRange, setListDateRange] = useState(null);
const [taskList, setTaskList] = useState([]);
// 工作看板 独立筛选状态
const [boardDrone, setBoardDrone] = useState(null);
const [boardDateRange, setBoardDateRange] = useState(null);
const [boardTaskList, setBoardTaskList] = useState([]);
const [points, setPoints] = 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 [waylineUuid, setWaylineUuid] = useState('');
const [waylineList, setWaylineList] = useState([]); // 航线列表
const [taskForm, setTaskForm] = useState({
name: '',
sn: '',
landing_dock_sn: '',
rth_altitude: 100,
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 || []); // 取list数组
}
} catch (err) {
message.error('获取航线列表失败');
}
};
// 关闭弹窗重置
const resetModals = () => {
setCreateTaskModal(false);
setWaylineUuid('');
setTaskForm({
name: '',
sn: listDrone?.gateway_sn || '',
landing_dock_sn: '',
rth_altitude: 100,
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,
//task_type: 'recurring',
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(); // 刷新列表
} else {
utils.message.error(res.msg || '创建失败');
}
} catch (err) {
utils.message.error('创建异常');
} finally {
setLoading(false);
}
};
// 选中无人机时自动填充机场SN
useEffect(() => {
if (listDrone) {
setTaskForm(prev => ({ ...prev, sn: listDrone.gateway_sn || '' }));
}
}, [listDrone]);
// 顶部统计
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) {
// 处理嵌套数据结构:data: { [sn]: { list: [] } }
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]); // 默认显示第一个无人机
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: '任务UUID', dataIndex: 'uuid', width: 260, ellipsis: true },
{ 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 handleRowClick = async (record) => {
setSelectedTask(record); // 记录当前选中的任务
try {
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([]);
}
} catch (err) {
message.error('请求失败');
console.error(err);
}
};
// 看板数据分组处理
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: '条',
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 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: 16 }}>
{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 points={points} 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, height: "410px" }}
>
<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}>
<Card
title={<span style={{ fontSize: fontTitle, fontWeight: 700 }}>任务编排与 AI 建议</span>}
bordered={false}
bodyStyle={{ padding: '12px 16px' }}
style={{ ...cardStyle, marginBottom: 16 }}
>
<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: '10px 14px', borderRadius: 8, marginTop: 14, 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: 16 }}>
<Col span={8}><Button type="primary" block style={{ background: '#165DFF', height: 36, borderRadius: 18, fontWeight: 600 }}>一键排程</Button></Col>
<Col span={8}><Button type="primary" block style={{ background: '#165DFF', height: 36, borderRadius: 18, fontWeight: 600 }}>智能分配</Button></Col>
<Col span={8}><Button type="primary" block style={{ background: '#165DFF', height: 36, borderRadius: 18, fontWeight: 600 }}>生成工单</Button></Col>
</Row>
</Card>
<Card
title={<span style={{ fontSize: fontTitle, fontWeight: 700 }}>航线参数 / 作业参数</span>}
extra={<Text type="secondary" style={{ fontSize: 12 }}>当前航线:FL-03</Text>}
bordered={false}
bodyStyle={{ padding: '16px' }}
style={{ ...cardStyle, marginBottom: 16 }}
>
<Row gutter={[24, 16]}>
{[
{ label: '飞行高度', value: '35', unit: 'm' },
{ label: '速度', value: '6', unit: 'm/s' },
{ label: '航向重叠', value: '75', unit: '%' },
{ label: '旁向重叠', value: '65', unit: '%' },
{ label: '安全返航电量', value: '25', unit: '%' },
{ label: '清洗喷洒量', value: '120', unit: 'ml/㎡' },
{ label: '除草宽幅', value: '1.2', unit: 'm' },
{ label: '巡检采样频率', value: '2', unit: 'Hz' },
].map((item, idx) => (
<Col span={6} key={idx}>
<div style={{ fontSize: 11, color: '#86909C', marginBottom: 6 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Input size="small" defaultValue={item.value} style={{ width: '100%', height: 26, borderRadius: 4, textAlign: 'center', fontWeight: 600 }} />
<span style={{ fontSize: 11, color: '#4E5969' }}>{item.unit}</span>
</div>
</Col>
))}
</Row>
<Divider style={{ margin: '16px 0' }} />
<div style={{ display: 'flex', gap: 32 }}>
<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>
<Divider style={{ margin: '16px 0' }} />
<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}
// 统一样式:高度36 + 圆角18 + 字重600 + 图标文字居中
style={{ height: 36, 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}
// 统一样式:主色#165DFF + 高度36 + 圆角18 + 字重600
style={{ background: '#165DFF', height: 36, 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: 36, 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: 36, 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}
>
<Row gutter={16} align="middle">
<Col span={6}>
<div style={{ textAlign: 'center', background: '#F6FFED', padding: '12px 8px', borderRadius: 10, border: '1px solid #B7EB8F' }}>
<div style={{ fontSize: 11, color: '#52C41A', fontWeight: 700, marginBottom: 8 }}>适宜执行</div>
<CheckCircle2 size={24} color="#52C41A" style={{ margin: '0 auto 8px' }} />
<div style={{ fontSize: 10, color: '#86909C' }}>适宜航行</div>
</div>
</Col>
<Col span={18}>
<Row gutter={[16, 16]}>
{[
{
label: '风速',
value: `${listDrone?.wind_speed ?? '-'} m/s`,
icon: <Wind size={16} />,
color: '#165DFF',
judge: (val) => {
const speed = parseFloat(val);
if (isNaN(speed)) return { desc: '未知', color: '#86909C' };
if (speed <= 5) return { desc: '适宜', color: '#52C41A' };
if (speed <= 8) return { desc: '谨慎', color: '#FF7D00' };
return { desc: '不适宜', color: '#F53F3F' };
}
},
{
label: '温度',
value: `${listDrone?.environment_temperature ?? '-'}℃`,
icon: <Thermometer size={16} />,
color: '#FF7D00',
judge: (val) => {
const temp = parseFloat(val);
if (isNaN(temp)) return { desc: '未知', color: '#86909C' };
if (temp >= 0 && temp <= 40) return { desc: '适宜', color: '#52C41A' };
return { desc: '不适宜', color: '#F53F3F' };
}
},
{
label: '降雨',
value: `${listDrone?.rainfall ?? '-'} `,
icon: <ThunderboltOutlined size={16} />,
color: '#FF7D00',
judge: (val) => {
if (val?.trim() === "no_rain") {
return { desc: '适宜', color: '#52C41A' };
} else {
return { desc: '不适宜', color: '#F53F3F' };
}
}
},
{
label: '信号质量',
value: (() => {
const q = listDrone?.network_state?.quality;
if (q === 'good') return '优';
if (q === 'better') return '良';
if (q === 'moderate') return '中';
if (q === 'poor') return '差';
if (q === 'too_pool') return '极差';
return '正常';
})(),
icon: <Satellite size={16} />,
color: '#00B42A',
judge: (val) => {
if (val === '优' || val === '良' || val === '正常') return { desc: '适宜', color: '#52C41A' };
if (val === '中') return { desc: '谨慎', color: '#FF7D00' };
return { desc: '不适宜', color: '#F53F3F' };
}
},
].map((item, idx) => {
const status = item.judge(item.value);
return (
<Col span={12} key={idx}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32, height: 32, borderRadius: 8,
background: '#F7F8FA', display: 'flex',
justifyContent: 'center', alignItems: 'center',
color: item.color
}}
>
{item.icon}
</div>
<div>
<div style={{ fontSize: 10, color: '#86909C' }}>{item.label}</div>
<div style={{ fontSize: fontNormal, fontWeight: 700, color: '#1D2129' }}>
{item.value}
</div>
<div style={{ fontSize: 10, color: status.color, fontWeight: 500 }}>
{status.desc}
</div>
</div>
</div>
</Col>
);
})}
</Row>
</Col>
</Row>
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: '#86909C', borderTop: '1px solid #F1F2F5', paddingTop: 12 }}>
<Space size={6}><AlertCircle size={14} color="#FF7D00" /> <span>16:30 后风速升高,建议提前完成任务</span></Space>
<Space size={6}><Clock size={14} color="#165DFF" /> <span>预计 14:00-16:00 为最佳执行窗口</span></Space>
</div>
</Card>
</Col>
</Row>
{/* 底部内容区 */}
<Row gutter={16}>
<Col xs={24} xl={14}>
<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={10}>
<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>
{/*<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button type="link" size="small" style={{ fontSize: 12, color: '#86909C' }}>查看全部 <ArrowRightOutlined style={{ fontSize: 10 }} /></Button>
</div>*/}
</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' },
//{ label: '单次定时', value: 'timed' },
//{ label: '重复', value: 'recurring' },
//{ label: '连续', value: 'continuous' },
]}
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>
</div>
);
}