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

1376 lines
45 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, useRef, useMemo, useCallback } from 'react';
import { Button, Select, Input, Empty, Tooltip, Space, Card, Radio, Typography, Switch, message, Modal, Form, Tag, App } from 'antd';
import {
DeleteOutlined, UndoOutlined, ClearOutlined,
EnvironmentOutlined, SaveOutlined, EditOutlined, PlusOutlined
} from '@ant-design/icons';
import {
Navigation, MapPin, MapPinned, Route, Maximize2
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { RootState } from '@/src/store';
import CesiumMap from '../Map';
import utils from '../../lib/utils';
import { dataUrlToFile } from '@/lib/utils';
import { getSiteDevice, addRoute, getRobotRouteList, deleteRoute, getSingleRouteList } from '@/api/device';
import { saveSiteMapInfo } from '@/api/mapEdit';
import { getSiteMapInfo, getStationRegions, saveRegion, deleteRegion } from '@/api/stationManage';
import { wsManager } from '@/components/WebSocketManager.ts';
import envConfig from '../../../env';
type EditorType = 'returnPoint' | 'restArea' | 'routeGen';
type RouteType = 'MOWING_TASK' | 'INSPECTION_TASK' | 'OTHER';
interface MarkedPoint {
id: string;
lon: number;
lat: number;
name: string;
deviceId?: string;
}
interface ReturnTaskForm {
name: string;
routeType: RouteType;
remark: string;
}
const genId = () => Math.random().toString(36).slice(2, 10);
export default function MapEditorPage() {
const { t } = useTranslation();
const { stationId, currentStation } = useSelector((state: RootState) => state.station);
const userInfo = useSelector((state: RootState) => state.user.userInfo);
const { modal } = App.useApp();
// 当前编辑类型
const [activeType, setActiveType] = useState<EditorType>('returnPoint');
// 地图工具模式(点选/框选,路径生成只支持点选)
const [toolMode, setToolMode] = useState<'point' | 'box'>('point');
// 返航点、分区、路径点数据
const [returnPoints, setReturnPoints] = useState<MarkedPoint[]>([]);
const [restAreas, setRestAreas] = useState<MarkedPoint[]>([]);
const [routePoints, setRoutePoints] = useState<MarkedPoint[]>([]);
const [selectedRobotId, setSelectedRobotId] = useState<string | undefined>(undefined);
// 已选机器人的实时经纬度(来自 WebSocket)
const [robotRealtimePos, setRobotRealtimePos] = useState<{ lng?: number; lat?: number } | null>(null);
const [robotPosConnected, setRobotPosConnected] = useState(false);
// 路径表单
const [routeForm, setRouteForm] = useState<ReturnTaskForm>({
name: '',
routeType: 'MOWING_TASK',
remark: '',
});
// 路径生成:工作模式(覆盖=true / 单点=false)、保存状态
const [isCoverage, setIscoverage] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// 预设路径点击预览(覆盖=多边形框,单点=折线)
const [previewPolyPoints, setPreviewPolyPoints] = useState<any[]>([]);
const [previewRouteMode, setPreviewRouteMode] = useState('');
// 地图上返航点/分区叠加层的刷新标识
const [overlayRefreshKey, setOverlayRefreshKey] = useState(0);
// 点击已配置列表后的地图聚焦/高亮
const [focusOverlay, setFocusOverlay] = useState<any>(null);
const [messageApi, contextHolder] = message.useMessage();
const mapRef = useRef<any>(null);
const [points, setPoints] = useState<{ lng: number; lat: number }[]>([]);
// 机器人列表
const [robots, setRobots] = useState<any[]>([]);
// 已配置的区域列表(从 /system/region/selectBySiteId 拉取)
const [regionList, setRegionList] = useState<any[]>([]);
// 已配置的返航点(从 /system/siteMapInfo/selectBySiteId 拉取的 restPoints)
const [returnPointConfigList, setReturnPointConfigList] = useState<any[]>([]);
// 站点地图信息主键 id,编辑/保存时需要带上
const [siteMapInfoId, setSiteMapInfoId] = useState<number | null>(null);
// 预设路径(供分区的 bindRoutes / defaultRoute 选择)
const [presetRoutes, setPresetRoutes] = useState<any[]>([]);
const [selectedRouteId, setSelectedRouteId] = useState<string | number | null>(null);
// 分区保存弹窗
const [regionModalOpen, setRegionModalOpen] = useState(false);
const [editingRegion, setEditingRegion] = useState<any>(null);
const [regionForm] = Form.useForm();
const isRouteGen = activeType === 'routeGen';
// 当前展示的点位列表
const currentList = useMemo(() => {
if (activeType === 'returnPoint') return returnPoints;
if (activeType === 'restArea') return restAreas;
if (activeType === 'routeGen') return routePoints;
return [];
}, [activeType, returnPoints, restAreas, routePoints]);
// 传给Cesium地图渲染的点位
const drawingPoints = useMemo(() => {
return currentList.map(p => ({ id: p.id, lon: p.lon, lat: p.lat }));
}, [currentList]);
// 切换到非路径生成类型时清除预设路径预览
useEffect(() => {
if (activeType !== 'routeGen') {
setPreviewPolyPoints([]);
setPreviewRouteMode('');
}
}, [activeType]);
// 加载机器人设备列表
useEffect(() => {
const loadDevices = async () => {
try {
const res = await getSiteDevice({ siteId: stationId });
if (res?.code === 200) setRobots(res.rows || []);
} catch (e) {
console.warn('load devices failed', e);
}
};
loadDevices();
}, [stationId]);
// 加载已配置的区域列表(分区)和站点地图信息(返航点),以及预设路径
useEffect(() => {
if (!stationId) return;
// 拉分区列表(数组)
getStationRegions({ siteId: stationId }).then((res: any) => {
if (res?.code === 200) {
const list = res.data || res.rows || [];
setRegionList(Array.isArray(list) ? list : []);
} else {
setRegionList([]);
}
}).catch(() => setRegionList([]));
// 拉站点地图信息(返航点),返回的是一个对象,restPoints 在 data.restPoints 里
getSiteMapInfo({ siteId: stationId }).then((res: any) => {
if (res?.code === 200 && res.data) {
setSiteMapInfoId(res.data.id ?? null);
setReturnPointConfigList(res?.data?.restPoints || []);
} else {
setSiteMapInfoId(null);
setReturnPointConfigList([]);
}
}).catch(() => {
setSiteMapInfoId(null);
setReturnPointConfigList([]);
});
// 拉预设路径
getRobotRouteList({ siteId: stationId }).then((res: any) => {
if (res?.code === 200 && res.data) {
setPresetRoutes(res.data);
} else {
setPresetRoutes([]);
}
}).catch(() => setPresetRoutes([]));
}, [stationId]);
// 选择机器人后连接 WebSocket,实时接收经纬度
useEffect(() => {
// 切换机器人时先清空上一个机器人的实时位置,避免误用旧坐标
setRobotRealtimePos(null);
setRobotPosConnected(false);
if (!selectedRobotId) return;
const wsUrl = envConfig.WS_URL || 'wss://ri.satabot.com/ws/';
try {
wsManager.connect(selectedRobotId, wsUrl);
} catch (e) {
console.warn('[MapEditor] connect ws failed', e);
}
const unsubscribe = wsManager.onMessage((msg) => {
if (msg?.type !== 'locationInfo') return;
const raw = msg.data || {};
let lon, lat;
if (Array.isArray(raw)) {
raw.forEach((it) => {
if (it?.name === 'longitude') lon = it.value;
if (it?.name === 'latitude') lat = it.value;
});
} else {
lon = raw?.longitude;
lat = raw?.latitude;
}
if (lon !== undefined && lat !== undefined) {
setRobotRealtimePos({ lng: Number(lon), lat: Number(lat) });
setRobotPosConnected(true);
}
});
return () => {
unsubscribe();
setRobotPosConnected(false);
};
}, [selectedRobotId]);
// 页面卸载时断开 WebSocket
useEffect(() => {
return () => {
wsManager.close();
};
}, []);
// ========== 地图打点回调(点选/框选完成后都会进入这里,打印坐标) ==========
const handleMarkPoint = useCallback(
({ lon, lat }: { lon: number; lat: number }) => {
const point = {
id: genId(),
lon: Number(lon),
lat: Number(lat),
};
if (activeType === 'returnPoint') {
setReturnPoints(prev => [
...prev,
{
...point,
name: `返航点-${String(prev.length + 1).padStart(2, '0')}`,
deviceId: selectedRobotId,
},
]);
} else if (activeType === 'restArea') {
setRestAreas(prev => [
...prev,
{
...point,
name: `分区点-${String(prev.length + 1).padStart(2, '0')}`,
},
]);
} else if (activeType === 'routeGen') {
setRoutePoints(prev => [
...prev,
{
...point,
name: `路径点-${prev.length + 1}`,
},
]);
}
},
[activeType, selectedRobotId]
);
// ========== 点选按钮:记录选中机器人的实时经纬度 ==========
const handlePointSelectClick = () => {
if (!selectedRobotId) {
messageApi.warning(t('devices.mapEditor.selectRobotFirst'));
return;
}
if (!robotRealtimePos?.lng || !robotRealtimePos?.lat) {
messageApi.warning(t('devices.mapEditor.robotPosNotReady'));
return;
}
handleMarkPoint({ lon: robotRealtimePos.lng, lat: robotRealtimePos.lat });
};
// ========== 撤销 ==========
const handleUndo = () => {
if (activeType === 'returnPoint') setReturnPoints(prev => prev.slice(0, -1));
else if (activeType === 'restArea') setRestAreas(prev => prev.slice(0, -1));
else if (activeType === 'routeGen') setRoutePoints(prev => prev.slice(0, -1));
};
// ========== 清空当前类型全部点位 ==========
const handleClearAll = useCallback(() => {
// 空列表直接 return,避免空数组 setState 触发 effect→setState 循环
//if (currentList.length === 0 && points.length === 0 && previewPolyPoints.length === 0) return;
if (activeType === 'returnPoint') {
setReturnPoints([]);
} else if (activeType === 'restArea') {
setRestAreas([]);
} else if (activeType === 'routeGen') {
setRoutePoints([]);
}
setPoints([]);
setPreviewPolyPoints([]);
setPreviewRouteMode('');
}, [activeType, currentList.length, points.length, previewPolyPoints.length]);
// ========== 删除单个点位 ==========
const removePoint = (id: string) => {
if (activeType === 'returnPoint') setReturnPoints(prev => prev.filter(p => p.id !== id));
else if (activeType === 'restArea') setRestAreas(prev => prev.filter(p => p.id !== id));
else if (activeType === 'routeGen') setRoutePoints(prev => prev.filter(p => p.id !== id));
};
// ========== 修改点位名称 ==========
const renamePoint = (id: string, name: string) => {
if (activeType === 'returnPoint') setReturnPoints(prev => prev.map(p => p.id === id ? { ...p, name } : p));
else if (activeType === 'restArea') setRestAreas(prev => prev.map(p => p.id === id ? { ...p, name } : p));
else if (activeType === 'routeGen') setRoutePoints(prev => prev.map(p => p.id === id ? { ...p, name } : p));
};
// ========== 绑定机器人 ==========
const bindRobot = (id: string, deviceId?: string) => {
if (activeType === 'returnPoint') setReturnPoints(prev => prev.map(p => p.id === id ? { ...p, deviceId } : p));
else if (activeType === 'restArea') setRestAreas(prev => prev.map(p => p.id === id ? { ...p, deviceId } : p));
};
// ========== 保存返航点 / 分区 ==========
const handleSaveReturnOrRest = () => {
if (activeType === 'returnPoint') {
// 返航点:走 saveSiteMapInfo
console.log('[MapEditor] 保存返航点 currentList:', currentList);
const restPoints = currentList.map(p => ({
position: { lat: p.lat, lng: p.lon },
bindDeviceId: p.deviceId,
alias: p.name,
}));
const idArr = restPoints.map(item => item.bindDeviceId);
const repeatIds: string[] = [];
const seen = new Set();
for (const id of idArr) {
if (seen.has(id)) {
repeatIds.push(id);
} else {
seen.add(id);
}
}
if (repeatIds.length > 0) {
// 去重展示重复id
const dupSet = [...new Set(repeatIds)];
messageApi.warning(t('devices.mapEditor.duplicateBindDevice'));
return;
}
const payload: any = {
restPoints,
siteId: stationId,
orgId: currentStation?.orgId,
};
// 编辑时带上站点地图信息主键 id
if (siteMapInfoId != null) payload.id = siteMapInfoId;
console.log(payload, "保存返航点payload");
saveSiteMapInfo(payload).then((res: any) => {
if (res?.code == 200) {
utils.message.success(t('devices.mapEditor.saveSuccess'));
setOverlayRefreshKey(k => k + 1);
// 保存后刷新返航点列表
getSiteMapInfo({ siteId: stationId }).then((r: any) => {
if (r?.code == 200 && r.data) {
setSiteMapInfoId(r.data.id ?? null);
setReturnPointConfigList(r.data.restPoints || []);
console.log(r.data.restPoints, "已配置返航点列表");
}
});
} else {
utils.message.error(res?.msg || t('common.saveFail'));
}
});
} else {
// 分区:弹出 Modal 让用户填写 regionName / bindRoutes 等,再调 saveRegion
if (currentList.length === 0) {
messageApi.warning(t('devices.mapEditor.needAtLeast2Points'));
return;
}
setEditingRegion(null);
regionForm.resetFields();
setRegionModalOpen(true);
}
};
// ========== 分区 Modal 确认保存 ==========
const handleSaveRegionConfirm = async () => {
try {
const values = await regionForm.validateFields();
// 编辑时若地图上没有重新框选,则沿用原有区域范围
const scope: any[] = currentList.length > 0
? currentList.map(p => ({ lat: p.lat, lng: p.lon }))
: (editingRegion?.scope || []);
const payload = {
id: editingRegion?.id, // 编辑时带上
regionName: values.regionName,
regionCode: values.regionCode,
siteId: stationId,
type: 'region',
remark: values.remark,
scope,
status: 1,
defaultRoute: values.defaultRoute ?? null,
bindRoutes: values.bindRoutes || [],
};
console.log('[MapEditor] saveRegion payload:', payload);
const res: any = await saveRegion(payload);
if (res?.code === 200) {
messageApi.success(t('devices.mapEditor.saveSuccess'));
setOverlayRefreshKey(k => k + 1);
setRegionModalOpen(false);
setEditingRegion(null);
regionForm.resetFields();
// 保存成功后清空当前编辑的分区点位
setRestAreas([]);
setPoints([]);
// 刷新分区列表
getStationRegions({ siteId: stationId }).then((r: any) => {
if (r?.code === 200) {
const list = r.data || r.rows || [];
setRegionList(Array.isArray(list) ? list : []);
}
});
} else {
messageApi.error(res?.msg || t('common.saveFail'));
}
} catch (err) {
console.error('[MapEditor] saveRegion failed:', err);
}
};
// ========== 编辑已配置的分区 ==========
const handleEditRegion = (item: any) => {
setActiveType('restArea');
setEditingRegion(item);
// 把分区范围回填到地图点位
const scope = Array.isArray(item.scope) ? item.scope : [];
regionForm.setFieldsValue({
regionName: item.regionName,
regionCode: item.regionCode,
remark: item.remark,
defaultRoute: item.defaultRoute != null ? Number(item.defaultRoute) : undefined,
bindRoutes: (item.bindRoutes || []).map((r: any) => Number(r)),
});
setRegionModalOpen(true);
};
// ========== 删除已配置的区域 / 返航点 ==========
const handleDeleteConfig = async (item: any) => {
modal.confirm({
title: t('common.confirmDelete'),
content: `${item.regionName || item.name || ''}`,
onOk: async () => {
try {
const res: any = await deleteRegion([item.id]);
if (res?.code === 200) {
messageApi.success(t('common.deleteSuccess'));
setOverlayRefreshKey(k => k + 1);
getStationRegions({ siteId: stationId }).then((r: any) => {
if (r?.code === 200) {
const list = r.data || r.rows || [];
setRegionList(Array.isArray(list) ? list : []);
}
});
} else {
messageApi.error(res?.msg || t('common.saveFail'));
}
} catch (e) {
console.error(e);
}
},
});
};
// ========== 修改已配置返航点的绑定设备 ==========
const handleUpdateReturnPointBind = (index: number, bindDeviceId: string | undefined) => {
const updated = returnPointConfigList.map((p: any, i: number) =>
i === index ? { ...p, bindDeviceId: bindDeviceId || null } : p
);
setReturnPointConfigList(updated);
const restPoints = updated.map((p: any) => ({
alias: p.alias,
position: p.position,
bindDeviceId: p.bindDeviceId || null,
}));
const idArr = restPoints.map(item => item.bindDeviceId);
const repeatIds: string[] = [];
const seen = new Set();
for (const id of idArr) {
if (seen.has(id)) {
repeatIds.push(id);
} else {
seen.add(id);
}
}
if (repeatIds.length > 0) {
// 去重展示重复id
const dupSet = [...new Set(repeatIds)];
messageApi.warning(t('devices.mapEditor.duplicateBindDevice'));
return;
}
const payload: any = {
restPoints,
siteId: stationId,
orgId: currentStation?.orgId,
};
if (siteMapInfoId != null) payload.id = siteMapInfoId;
saveSiteMapInfo(payload).then((res: any) => {
if (res?.code === 200) {
messageApi.success(t('devices.mapEditor.bindUpdateSuccess'));
setOverlayRefreshKey(k => k + 1);
// 刷新以获取最新 id
getSiteMapInfo({ siteId: stationId }).then((r: any) => {
if (r?.code === 200 && r.data) {
setSiteMapInfoId(r.data.id ?? null);
setReturnPointConfigList(r.data.restPoints || []);
}
});
} else {
messageApi.error(res?.msg || t('common.saveFail'));
}
}).catch((e: any) => {
console.error(e);
messageApi.error(t('common.saveFail'));
});
};
// ========== 点击预设路径在地图上预览(覆盖=框,单点=线) ==========
const handlePreviewRoute = async (route: any) => {
setSelectedRouteId(route.id);
if (!route?.id) return;
try {
const res: any = await getSingleRouteList({ id: route.id });
const mode = res?.data?.mode || route.mode || '';
const pathPoints = res?.data?.jsonData?.path || [];
if (!pathPoints.length) {
messageApi.warning(t('devices.route2'));
return;
}
// 预览前清空正在编辑的路径点
setRoutePoints([]);
setPoints([]);
setPreviewRouteMode(mode);
setPreviewPolyPoints(pathPoints);
} catch (e) {
console.error('[MapEditor] preview route failed:', e);
}
};
// ========== 删除预设路径 ==========
const handleDeletePresetRoute = (route: any) => {
modal.confirm({
title: t('devices.mapEditor.confirmDeleteRoute'),
content: `${route.workName || route.name || ''}`,
okButtonProps: { danger: true },
onOk: async () => {
try {
const res: any = await deleteRoute(route.workName || route.name);
if (res?.code === 200) {
messageApi.success(t('common.deleteSuccess'));
// 刷新预设路径列表
const list = await getRobotRouteList({ siteId: stationId });
if (list?.code === 200 && list.data) setPresetRoutes(list.data);
} else {
messageApi.error(res?.msg || t('common.saveFail'));
}
} catch (e) {
console.error(e);
}
},
});
};
// ========== 保存路径生成 ==========
const handleSaveRoute = async () => {
// 1. 基本校验
if (!routeForm.name?.trim()) {
messageApi.warning(t('devices.mapEditor.fillName'));
return;
}
if (isCoverage && routePoints.length < 3) {
messageApi.warning(t('devices.minThreePoints'));
return;
}
if (!isCoverage && routePoints.length < 1) {
messageApi.warning(t('devices.mapEditor.needAtLeast2Points'));
return;
}
setIsSaving(true);
try {
// 2. 构造路径数据,对齐 DeviceStatusPage.handleComplete 返回格式
let positionPoints = routePoints.map(p => ({ lat: p.lat, lon: p.lon }));
// 覆盖模式:保证闭环,首尾点一致
if (isCoverage) {
const firstPt = positionPoints[0];
const lastPt = positionPoints[positionPoints.length - 1];
if (!(firstPt.lat === lastPt.lat && firstPt.lon === lastPt.lon)) {
positionPoints.push({ ...firstPt });
}
}
const path = positionPoints.map(p => ({ lat: p.lat, lng: p.lon }));
const resultObj = {
path: path,
outer: path.map(p => ({ lng: p.lng, lat: p.lat })),
};
// 3. 地图截图(可选,失败不影响保存)
let screenshotDataUrl = null;
if (mapRef.current?.captureScreenshot) {
try { screenshotDataUrl = await mapRef.current.captureScreenshot(); } catch (e) { /* ignore */ }
}
// 4. 组装 FormData
const formData = new FormData();
if (screenshotDataUrl) {
const file = dataUrlToFile(screenshotDataUrl, 'screenshot.jpg');
if (file instanceof File) formData.append('file', file);
}
const workRecord = {
workName: routeForm.name.trim(),
siteId: stationId,
orgId: currentStation?.orgId,
jsonData: resultObj,
mode: isCoverage ? 'coverage' : 'single_point',
routeType: routeForm.routeType,
};
formData.append('workRecord', new Blob([JSON.stringify(workRecord)], { type: 'application/json' }));
console.log('[MapEditor] 保存路径 workRecord:', workRecord);
// 5. 调用保存接口
const res = await addRoute(formData);
if (res?.code === 200) {
messageApi.success(t('common.saveSuccess'));
// 保存成功后清空名称和路径点
setRoutePoints([]);
setRouteForm({ ...routeForm, name: '' });
// 刷新预设路径列表
getRobotRouteList({ siteId: stationId }).then((r: any) => {
if (r?.code === 200 && r.data) setPresetRoutes(r.data);
});
} else {
messageApi.error(res?.msg || t('common.saveFail'));
}
} catch (err) {
console.error('[MapEditor] save route failed:', err);
messageApi.error(t('common.saveFail'));
} finally {
setIsSaving(false);
}
};
return (
<>
{contextHolder}
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}>
<div style={{ padding: '4px 0px 8px' }}>
<Typography.Title level={4} style={{ margin: 0, fontSize: 'clamp(14px, 1.2vw, 18px)' }}>{t('devices.mapEditor.mapEditorTitle')}</Typography.Title>
</div>
<div style={{ display: 'flex', flex: 1, gap: 12, minHeight: 0 }}>
{/* ============== 左侧地图区域 ============== */}
<Card
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' } }}
variant="borderless"
>
{/* 顶部工具栏,对齐截图按钮:返航点 | 分区 | 路径生成、选择、点选、框选、撤销、清空 */}
<div
style={{
padding: '8px 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
borderBottom: '1px solid #f0f0f0',
background: '#fafafa',
flexWrap: 'wrap',
}}
>
<Space size={4}>
<Button
type={activeType === 'returnPoint' ? 'primary' : 'default'}
size="small"
onClick={() => setActiveType('returnPoint')}
icon={<Navigation size={14} />}
>
返航点
</Button>
<Button
type={activeType === 'restArea' ? 'primary' : 'default'}
size="small"
onClick={() => setActiveType('restArea')}
icon={<MapPinned size={14} />}
>
分区
</Button>
<Button
type={activeType === 'routeGen' ? 'primary' : 'default'}
size="small"
onClick={() => setActiveType('routeGen')}
icon={<Route size={14} />}
>
路径生成
</Button>
</Space>
<div style={{ width: 1, height: 20, background: '#d9d9d9', margin: '0 4px' }} />
{!isRouteGen ? (
<Space size={6}>
<Button
size="small"
type="primary"
onClick={handlePointSelectClick}
disabled={!selectedRobotId || !robotRealtimePos?.lng}
>
<Space size={4}><MapPin size={12} />{t('devices.mapEditor.pointSelect')}</Space>
</Button>
<Select
size="small"
style={{ width: 170 }}
placeholder={t('devices.mapEditor.selectRobot')}
value={selectedRobotId}
onChange={(v) => setSelectedRobotId(v)}
allowClear
options={robots.map(r => ({
value: r.serialNumber,
label: `${r.deviceAlias || r.serialNumber}`,
}))}
/>
</Space>
) : (
<span style={{ color: '#999', fontSize: 12 }}>路径模式:点击地图依次添加路径点</span>
)}
<div style={{ flex: 1 }} />
<Space size={6}>
<Tooltip title="撤销">
<Button size="small" icon={<UndoOutlined />} onClick={handleUndo} disabled={currentList.length === 0 && points.length === 0} />
</Tooltip>
<Tooltip title="清空">
<Button size="small" icon={<ClearOutlined />} onClick={handleClearAll} />
</Tooltip>
</Space>
</div>
{/* 地图容器 */}
<div style={{ flex: 1, minHeight: 360, position: 'relative' }}>
<CesiumMap
from="mapEditor"
devices={robots}
markedPoints={drawingPoints}
mapLayer={currentStation?.siteMapName}
onMarkPoint={handleMarkPoint}
onClearDraw={handleClearAll}
ref={mapRef}
points={points}
activeType={activeType}
polyPoints={previewPolyPoints}
routeMode={previewRouteMode}
overlayRefreshKey={overlayRefreshKey}
focusOverlay={focusOverlay}
/>
{/* 左上角提示 */}
<div
style={{
position: 'absolute', top: 12, left: 12,
background: 'rgba(255,255,255,0.92)',
padding: '6px 12px', borderRadius: 6,
fontSize: 12, color: '#333',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
zIndex: 10,
}}
>
{isRouteGen ? '点击地图添加路径点,按顺序生成路线' : '点击地图或者框选区域添加点位'}
</div>
{/* 左下角计数 */}
<div
style={{
position: 'absolute', bottom: 12, left: 12,
background: 'rgba(255,255,255,0.92)',
padding: '4px 10px', borderRadius: 6,
fontSize: 12, color: '#165DFF', fontWeight: 600,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
zIndex: 10,
}}
>
已选点位:{currentList.length}
</div>
</div>
</Card>
{/* ============== 右侧配置面板(对齐截图样式) ============== */}
<div
style={{
width: 380,
display: 'flex',
flexDirection: 'column',
gap: 8,
flexShrink: 0,
overflow: 'hidden',
}}
>
{/* 【选择类型】卡片:始终渲染,左右双向联动修复 */}
<Card
size="small"
title="选择类型"
variant="borderless"
styles={{ body: { padding: 12 } }}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
<div
onClick={() => setActiveType('returnPoint')}
style={{
cursor: 'pointer',
padding: '10px 16px',
border: activeType === 'returnPoint' ? '1px solid #165DFF' : '1px solid #e5e6eb',
borderRadius: 8,
background: activeType === 'returnPoint' ? '#e8f3ff' : '#fff',
textAlign: 'center',
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Navigation size={18} color="#165DFF" style={{ alignSelf: 'center' }} />
<div style={{ fontSize: 13, marginTop: 4 }}>返航点</div>
<div style={{ fontSize: 11, color: '#86909C' }}>机器人任务结束后返回的点位</div>
</div>
<div
onClick={() => setActiveType('restArea')}
style={{
cursor: 'pointer',
padding: '10px 16px',
border: activeType === 'restArea' ? '1px solid #165DFF' : '1px solid #e5e6eb',
borderRadius: 8,
background: activeType === 'restArea' ? '#e8f3ff' : '#fff',
textAlign: 'center',
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<MapPinned size={18} color="#13C2C2" style={{ alignSelf: 'center' }} />
<div style={{ fontSize: 13, marginTop: 4 }}>分区</div>
<div style={{ fontSize: 11, color: '#86909C' }}>场站中发分区,绑定规划路线</div>
</div>
<div
onClick={() => setActiveType('routeGen')}
style={{
cursor: 'pointer',
padding: '10px 16px',
border: activeType === 'routeGen' ? '1px solid #165DFF' : '1px solid #e5e6eb',
borderRadius: 8,
background: activeType === 'routeGen' ? '#e8f3ff' : '#fff',
textAlign: 'center',
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Route size={18} color="#00B42A" style={{ alignSelf: 'center' }} />
<div style={{ fontSize: 13, marginTop: 4 }}>路径生成</div>
<div style={{ fontSize: 11, color: '#86909C' }}>生成规划路径</div>
</div>
</div>
</Card>
{/* 路径生成:表单 + 路径点合并为一个卡片 */}
{isRouteGen && (
<Card
size="small"
title={<span style={{ fontSize: 14, fontWeight: 600 }}>{t('devices.mapEditor.routeGenTitle')}</span>}
extra={
<Space size={6}>
<Button size="small" onClick={handleClearAll} disabled={currentList.length === 0 && points.length === 0}>
{t('common.cancel')}
</Button>
<Button
size="small"
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveRoute}
disabled={currentList.length === 0 || isSaving}
loading={isSaving}
>
{t('common.save')}
</Button>
</Space>
}
variant="borderless"
styles={{ body: { padding: 12, maxHeight: 460, overflowY: 'auto' } }}
style={{ flex: 1 }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, color: '#333' }}>
{t('devices.mapEditor.routeName')}
</label>
<Input
value={routeForm.name}
onChange={(e) => setRouteForm({ ...routeForm, name: e.target.value })}
placeholder={t('devices.mapEditor.routeNamePlaceholder')}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, color: '#333' }}>
{t('devices.mapEditor.routeType')}
</label>
<Select
className="w-full"
value={routeForm.routeType}
onChange={(v) => setRouteForm({ ...routeForm, routeType: v })}
options={[
{ value: 'MOWING_TASK', label: t('devices.mapEditor.typeMowing') },
{ value: 'INSPECTION_TASK', label: t('devices.mapEditor.typeInspection') },
{ value: 'OTHER', label: t('devices.mapEditor.typeOther') },
]}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, color: '#333' }}>
{t('devices.workMode')}
</label>
<Select
className="w-full"
value={isCoverage}
onChange={(v) => setIscoverage(v)}
options={[
{ value: true, label: t('devices.coverageMode') },
{ value: false, label: t('devices.singleMode') },
]}
/>
</div>
{/* 路径点列表 */}
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
{t('devices.mapEditor.routePoints')}
<span style={{ marginLeft: 8, fontSize: 12, color: '#999', fontWeight: 400 }}>
已选 {currentList.length} 个
</span>
</div>
{currentList.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('devices.mapEditor.routeEmptyHint')}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{currentList.map((p, idx) => (
<div
key={p.id}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #f0f0f0',
background: '#fafbff',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span style={{
width: 22, height: 22, borderRadius: '50%',
background: '#165DFF', color: '#fff', fontSize: 12,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>{idx + 1}</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 500 }}>{p.name}</span>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => removePoint(p.id)}
/>
</div>
))}
</div>
)}
</div>
</div>
</Card>
)}
{/* 点位列表卡片(返航点 / 分区) */}
{!isRouteGen && (
<Card
size="small"
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>
{activeType === 'returnPoint' ? '返航点列表' : '分区列表'}
<span style={{ marginLeft: 8, fontSize: 12, color: '#999', fontWeight: 400 }}>
已选 {currentList.length} 个
</span>
</span>
}
extra={
<Space size={6}>
<Button size="small" onClick={handleClearAll} disabled={currentList.length === 0 && points.length === 0}>
{t('common.cancel')}
</Button>
<Button
size="small"
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveReturnOrRest}
disabled={currentList.length === 0}
>
{t('common.save')}
</Button>
</Space>
}
variant="borderless"
styles={{ body: { padding: 12, maxHeight: 420, overflowY: 'auto' } }}
style={{ flex: 1 }}
>
{currentList.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请在地图上选择或添加点位"
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 360, overflowY: 'auto' }}>
{currentList.map((p) => (
<div
key={p.id}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #f0f0f0',
background: '#fafbff',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{activeType === 'returnPoint'
? <Navigation size={16} color="#165DFF" />
: <MapPinned size={16} color="#13C2C2" />}
<div style={{ flex: 1, minWidth: 0 }}>
<Input
size="small"
value={p.name}
onChange={(e) => renamePoint(p.id, e.target.value)}
style={{ fontSize: 13 }}
/>
{activeType === 'returnPoint' && (
<Select
size="small"
value={p.deviceId}
allowClear
placeholder="未绑定"
onChange={(v) => bindRobot(p.id, v)}
style={{ width: '100%', marginTop: 4 }}
options={robots.map(r => ({
value: r.serialNumber,
label: `${r.deviceAlias || r.deviceName}(${r.serialNumber})`,
}))}
/>
)}
</div>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => removePoint(p.id)}
/>
</div>
))}
</div>
)}
</Card>
)}
{isRouteGen && (
<Card
size="small"
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>
{t('devices.presetRoutes')}
<span style={{ marginLeft: 8, fontSize: 12, color: '#999', fontWeight: 400 }}>
共 {presetRoutes.length} 条
</span>
</span>
}
variant="borderless"
styles={{ body: { padding: 12, maxHeight: 100, overflowY: 'auto' } }}
style={{ flex: '0 0 auto' }}
>
{presetRoutes.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('devices.mapEditor.noPresetRoutes')}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{presetRoutes.map((route: any) => (
<div
key={route.id}
onClick={() => handlePreviewRoute(route)}
style={{
cursor: 'pointer',
padding: '8px 10px',
borderRadius: 6,
border: selectedRouteId === route.id ? '1px solid #1677ff' : '1px solid #f0f0f0',
background: selectedRouteId === route.id ? '#e8f3ff' : '#fafbff',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s', // 加个过渡,顺滑一点
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{route.workName || route.name || t('devices.mapEditor.unnamed')}
</div>
<div style={{ fontSize: 11, color: '#86909C', marginTop: 2 }}>
{t('devices.mapEditor.mode')}: {route.mode === 'coverage' || route.mode === 'coverage_area'
? t('devices.coverageMode')
: t('devices.singleMode')}
{route.routeType && (
<span style={{ marginLeft: 8 }}>
· {route.routeType === 'MOWING_TASK' ? t('devices.mapEditor.typeMowing')
: route.routeType === 'INSPECTION_TASK' ? t('devices.mapEditor.typeInspection')
: t('devices.mapEditor.typeOther')}
</span>
)}
</div>
</div>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
handleDeletePresetRoute(route);
}}
/>
</div>
))}
</div>
)}
</Card>
)}
{/* 已配置列表(仅返航点 / 分区 tab 显示,数据来自 /system/region/selectBySiteId) */}
{!isRouteGen && (
<Card
size="small"
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>
{activeType === 'returnPoint'
? t('devices.mapEditor.configuredReturnPoints')
: t('devices.mapEditor.configuredRegions')}
<span style={{ marginLeft: 8, fontSize: 12, color: '#999', fontWeight: 400 }}>
{activeType === 'returnPoint' ? returnPointConfigList.length : regionList.length}
</span>
</span>
}
variant="borderless"
styles={{ body: { padding: 12, maxHeight: 280, overflowY: 'auto' } }}
>
{(() => {
const list = activeType === 'returnPoint' ? returnPointConfigList : regionList;
if (!list || list.length === 0) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={activeType === 'returnPoint'
? t('devices.mapEditor.noConfiguredReturnPoints')
: t('devices.mapEditor.noConfiguredRegions')}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((item: any, idx: number) => {
const isReturnPoint = activeType === 'returnPoint';
const isActive = focusOverlay
? isReturnPoint
? focusOverlay.type === 'returnPoint' && focusOverlay.index === idx
: focusOverlay.type === 'region' && String(focusOverlay.id) === String(item.id)
: false;
return (
<div
key={item.id ?? idx}
onClick={() => setFocusOverlay({
type: isReturnPoint ? 'returnPoint' : 'region',
index: idx,
id: item.id,
nonce: Date.now(),
})}
style={{
cursor: 'pointer',
padding: '8px 10px',
borderRadius: 6,
border: isActive ? '1px solid #1677ff' : '1px solid #f0f0f0',
background: isActive ? '#e8f3ff' : '#fafafa',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{activeType === 'returnPoint'
? <Navigation size={16} color="#165DFF" />
: <MapPinned size={16} color="#13C2C2" />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{item.alias || item.regionName || t('devices.mapEditor.unnamed')}
</div>
{activeType === 'returnPoint' ? (
<span onClick={(e) => e.stopPropagation()}>
<Select
size="small"
value={item.bindDeviceId || undefined}
allowClear
placeholder={t('devices.mapEditor.unbind')}
onChange={(v) => handleUpdateReturnPointBind(idx, v)}
style={{ width: '100%', marginTop: 4 }}
options={robots.map(r => ({
value: r.serialNumber,
label: `${r.deviceAlias || r.deviceName}(${r.serialNumber})`,
}))}
/>
</span>
) : (
<>
<div style={{ fontSize: 11, color: '#86909C', marginTop: 2 }}>
{item.scope?.length ? `${t('devices.mapEditor.scopeCount')}: ${item.scope.length}` : ''}
</div>
{item.bindRoutes?.length > 0 && (
<div style={{ marginTop: 4 }}>
{item.bindRoutes.map((rid: number) => {
const r = presetRoutes.find((x: any) => Number(x.id) === Number(rid));
return r ? (
<Tag key={rid} color="blue" style={{ marginBottom: 2 }}>{r.workName || r.name}</Tag>
) : (
<Tag key={rid}>{rid}</Tag>
);
})}
</div>
)}
</>
)}
</div>
{activeType === 'restArea' && (
<>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
handleEditRegion(item);
}}
/>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
handleDeleteConfig(item);
}}
/>
</>
)}
</div>
);
})}
</div>
);
})()}
</Card>
)}
</div>
</div>
</div>
{/* 分区保存 Modal */}
<Modal
title={editingRegion ? t('devices.mapEditor.editRegion') : t('devices.mapEditor.saveRegion')}
open={regionModalOpen}
onOk={handleSaveRegionConfirm}
onCancel={() => {
setRegionModalOpen(false);
setEditingRegion(null);
}}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
destroyOnClose
>
<Form form={regionForm} layout="vertical" preserve={false}>
<Form.Item
label={t('devices.mapEditor.regionName')}
name="regionName"
rules={[{ required: true, message: t('devices.mapEditor.regionNameRequired') }]}
>
<Input placeholder={t('devices.mapEditor.regionNamePlaceholder')} />
</Form.Item>
<Form.Item
label={t('devices.mapEditor.regionCode')}
name="regionCode"
>
<Input placeholder={t('devices.mapEditor.regionCodePlaceholder')} />
</Form.Item>
<Form.Item
label={t('devices.mapEditor.defaultRoute')}
name="defaultRoute"
>
<Select
allowClear
placeholder={t('devices.mapEditor.selectRoutePlaceholder')}
options={presetRoutes.map((r: any) => ({
value: Number(r.id),
label: r.workName || r.name,
}))}
/>
</Form.Item>
<Form.Item
label={t('devices.mapEditor.bindRoutes')}
name="bindRoutes"
>
<Select
mode="multiple"
allowClear
placeholder={t('devices.mapEditor.selectRoutesPlaceholder')}
options={presetRoutes.map((r: any) => ({
value: Number(r.id),
label: r.workName || r.name,
}))}
/>
</Form.Item>
<Form.Item
label={t('devices.mapEditor.remark')}
name="remark"
>
<Input.TextArea rows={2} placeholder={t('devices.mapEditor.remarkPlaceholder')} />
</Form.Item>
<div style={{ fontSize: 12, color: '#86909C', marginTop: -4 }}>
{t('devices.mapEditor.currentScopePoints')}: {currentList.length}
</div>
</Form>
</Modal>
</>
);
}