需求背景:在安全巡检系统中,为巡检人员配置"电子围栏",当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

1. 组件背景与业务场景
- 业务目标:为巡检系统配置"电子围栏",限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
- 使用人群:业务管理员/调度人员;交互上要求"易绘制、可编辑、易清空、可搜索定位"。
- 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
- 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。
界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:
- 围栏区域名称、异常停留时限(分钟)、启停状态;
- 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

2. 核心功能点与交互流程拆解
- 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
- 工具切换:多边形与矩形两类覆盖物的快速切换。
- 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
- 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
- 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
- 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
- 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。
基本链路如下:
- 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
- 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
- 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
- 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口
3. 技术选型与实现要点
3.1 地图与几何编辑:TMap GeometryEditor
- 地图基座:TMap.Map
- 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
- 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
- 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)
示例代码initMap:
ts
const initMap = () => {
map = new TMap.Map("map-container", {
zoom: 16,
center: new TMap.LatLng(latitude.value, longitude.value),
showControl: false,
});
// 已有几何解析与注入(编辑/查看)
const polygonGeometries: any[] = [];
if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
const geometries = JSON.parse(formData.value.fenceArea);
geometries.forEach((geo) => {
polygonGeometries.push({
id: `polygon_${polygonGeometries.length}`,
paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
});
});
}
// 多边形与矩形覆盖物
polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
rectangle = new TMap.MultiRectangle({ map, geometries: [] });
// 编辑器绑定
editor = new TMap.tools.GeometryEditor({
map,
overlayList: [
{ overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
{ overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
],
actionMode: "", // 由外部模式切换驱动
activeOverlayId: activeType.value,
snappable: !isViewMode.value,
selectable: !isViewMode.value,
});
// 绘制/编辑完成后更新数据
editor.on("draw_complete", updateFenceArea);
editor.on("adjust_complete", updateFenceArea);
};
模式切换实现(绘制/编辑/删除/一键删除):
ts
const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
if (activeMode.value === id && id !== "delete" && id !== "deletes") return;
switch (id) {
case "draw":
editor.stop();
editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
activeMode.value = id;
break;
case "edit":
editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
activeMode.value = id;
break;
case "delete":
editor.delete();
updateFenceArea();
break;
case "deletes":
// 临时切换到编辑模式,批量选择并删除所有几何
const wasInDrawMode = activeMode.value === "draw";
if (wasInDrawMode) {
activeMode.value = "edit";
editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
}
editor.select([]);
const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
updateFenceArea();
if (wasInDrawMode) {
activeMode.value = "draw";
editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
}
break;
}
};
工具切换(多边形/矩形)仅需切换 activeOverlayId:
ts
const handleToolChange = (id: "polygon"|"rectangle") => {
if (activeType.value === id) return;
activeType.value = id;
editor.setActiveOverlay(id);
};
3.2 坐标收集与相交检测
-
目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea
-
相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交
ts
const updateFenceArea = () => {
const geometries: any[] = [];
const allPolygons: any[] = [];
if (polygon?.geometries?.length) {
polygon.geometries.forEach((geo) => {
geometries.push({ type: "polygon", paths: geo.paths });
allPolygons.push(geo.paths);
});
}
if (rectangle?.geometries?.length) {
rectangle.geometries.forEach((geo) => {
geometries.push({ type: "rectangle", paths: geo.paths });
allPolygons.push(geo.paths);
});
}
// 多边形两两相交检测
if (allPolygons.length > 1) {
let hasIntersection = false;
for (let i = 0; i < allPolygons.length - 1; i++) {
for (let j = i + 1; j < allPolygons.length; j++) {
const inter = TMap.geometry.computePolygonIntersection(
allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
);
if (inter && inter.length > 0) { hasIntersection = true; break; }
}
if (hasIntersection) break;
}
if (hasIntersection) {
message.error("围栏区域不能相交或重叠,请调整区域位置!");
return false;
}
}
formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
return true;
};
3.3 缩略图绘制与上传
-
动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本
-
方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边
ts
const drawFenceThumbnail = async () => {
if (!formData.value.fenceArea) return;
const canvas = document.createElement("canvas");
canvas.width = 384; canvas.height = 216;
const ctx = canvas.getContext("2d"); if (!ctx) return;
// 背景图可替换为项目默认底图
const bg = await new Promise<HTMLImageElement>((res, rej) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => res(img);
img.onerror = rej;
img.src = "https://via.placeholder.com/384x216.png?text=BG";
});
ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);
const geometries = JSON.parse(formData.value.fenceArea);
let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
geometries.forEach((g) => g.paths.forEach((p:any) => {
const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
}));
const padding = 10;
const contentW = canvas.width - padding * 2;
const contentH = canvas.height - padding * 2;
const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
const cx = canvas.width / 2; const cy = canvas.height / 2;
ctx.strokeStyle = "rgba(252,193,31,.70)";
ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";
geometries.forEach((g:any) => {
ctx.beginPath();
g.paths.forEach((p:any, idx:number) => {
const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath(); ctx.fill(); ctx.stroke();
});
const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
if (!blob) return;
const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};
(背景图为示例图片) 
3.4 搜索联想与定位
- 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
ts
const getSuggestions = throttle(() => {
if (!address.value) { suggestionList.value = []; return; }
suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
.then((result) => { suggestionList.value = result.data; })
.catch((error) => {
if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
else message.error("搜索失败," + error.message + ",请联系系统管理员");
});
}, 500);
function setSuggestion(item) {
suggestionList.value = [];
infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
address.value = item.title;
const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
infoWindowList.push(w);
map.setCenter(item.location);
}
3.5 打开弹窗、提交与资源清理
- 打开弹窗时设置标题与编辑模式:
ts
const open = async (type: "create"|"update"|"view", id?: number) => {
dialogVisible.value = true; formType.value = type; resetForm();
if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
nextTick(() => {
initMap();
if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
else { dialogTitle.value = "查看围栏区域"; }
});
};
- 提交时停止编辑、校验相交、生成缩略图并调用接口:
ts
const submitForm = async () => {
editor.stop();
const isValid = updateFenceArea();
if (!isValid) return;
await formRef.value.validate();
formLoading.value = true;
await drawFenceThumbnail();
try {
const data = formData.value as unknown as PatrolEfenceVO;
if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
dialogVisible.value = false; emit("success");
} finally { formLoading.value = false; }
};
- 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
ts
const cleanupMap = () => {
if (editor) { editor.destroy(); editor = null; }
if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);
4. 踩坑记录与性能优化经验
-
编辑器状态一致性
- 删除"全部"前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
- 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
-
绘制结束与提交时机
- 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免"拖动中提交"的状态差异。
-
缩略图映射边界
- 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 "安全边距"系数,避免贴边截断。
- y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
-
搜索联想与调用频率
- 使用 lodash-es throttle(500ms)降低接口压力。
- 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
-
只读模式开关
- isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
-
资源释放
- 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。
5. 可复用的最佳实践总结
- 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
- 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
- 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
- 缩略图抽象:将"坐标→画布"的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
- 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
- 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
- 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。