1376 lines
45 KiB
TypeScript
1376 lines
45 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
|
||
|