# 前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警 ## 题目描述 在 Vue 3 + 高德地图的微前端子应用中,实现一个**养老院/社区电子围栏**系统: ``` ┌──────────────────────────────────────────────┐ │ sub-app-header │ ├──────────────────────────────────────────────┤ │ 🚨 电子围栏报警!(越界时全屏覆盖) │ │ ✅ 老人在安全区域内 (状态标签) │ │ │ │ 🗺️ 高德地图 │ │ [绿色多边形围栏] + [👴 可拖拽老人] │ │ │ ├──────────────────────────────────────────────┤ │ 🗺️ 电子围栏 [🔷绘制围栏][⭕圆形围栏][🗑️清除] │ │ 🎯 绘制提示条(顶点计数 + 闭合提示) │ ├──────────────────────────────────────────────┤ │ 👴 老人控制 [➕添加][↑←→↓方向键][🗑️移除] │ ├──────────────────────────────────────────────┤ │ 📋 报警记录(右侧浮层) │ └──────────────────────────────────────────────┘ ``` **核心要求:** 1. 支持**多边形**和**圆形**两种围栏绘制 2. 老人标记可拖拽 + 方向键移动,每次移动**即时检测**越界 3. 越界时触发**声光报警**:围栏变红、全屏浮层、蜂鸣音、报警记录 4. 老人回到围栏内**自动解除**报警 5. 组件卸载时**完整清理**所有地图覆盖物和事件监听 --- ## 架构设计 ### 文件分层 ``` microapp-vue3/src/ ├── composables/ │ ├── useAmap.ts ← 地图实例管理(单例加载 JSAPI、生命周期) │ └── useGeofence.ts ← 电子围栏核心逻辑(围栏、标记、检测、报警) ├── views/ │ └── MapView.vue ← 视图层(布局、按钮、报警 UI) ├── config/amap.ts ← 密钥、版本、插件列表 └── vite-env.d.ts ← AMap 类型声明(Polygon/Circle/Marker/GeometryUtil...) ``` **关键设计决策:逻辑与视图分离。** `useGeofence` composable 封装所有地图操作,`MapView.vue` 只负责 UI 渲染和事件绑定。好处: - 围栏逻辑可在其他页面复用(如多围栏管理页) - 单元测试只需测 composable,无需挂载 Vue 组件 - 视图层更换 UI 框架(如 Element Plus 面板)不影响核心逻辑 --- ## 核心技术点拆解 ### 1. 围栏绘制 — 交互设计与踩坑 #### 多边形绘制流程 ``` 点击「🔷 绘制围栏」 → map.setStatus({ doubleClickZoom: false }) ← 关键!禁用双击缩放 → 注册 map.on('click', onClick) → 光标变 crosshair 每次单击: ├─ 地理距离 < 30m 且 已有点 ≥ 3? → 闭环完成 ✅(点击首顶点) ├─ 否则 → 添加顶点 │ ├─ new AMap.Marker(编号圆点) │ ├─ new AMap.Polyline(虚线预览) │ └─ 点数 ≥ 3?→ 销毁首顶点 → 重建为红色脉冲高亮标记 └─ 右键 → cancelDraw() 闭环(三种方式): ├─ 点击红色脉冲的首顶点(Haversine < 30m) ├─ 点击「✅ 闭合围栏(N点)」按钮 └─ 创建 AMap.Polygon → 清理绘制中间状态 → 恢复 doubleClickZoom ``` #### 圆形绘制流程 ``` 点击「⭕ 圆形围栏」 → 第 1 次点击:确定圆心(红色标记) → map.on('mousemove'):动态预览圆形(Haversine 实时计算半径) → 第 2 次点击:确定半径 → 清理预览 Circle → 创建正式 Circle 围栏 → 右键:取消 ``` #### ⚠️ 踩坑记录(面试中最有区分度) | 踩坑 | 现象 | 根因 | 修复 | |------|------|------|------| | **双击闭合** | 双击后地图缩放 + 多了 2 个废顶点 | AMap 默认 `doubleClickZoom: true` 拦截双击;双击 = click+click+dblclick | `map.setStatus({ doubleClickZoom: false })` + 改用点击首顶点闭合 | | **`lngLatToContainer({lng,lat})`** | 第 4 次点击报 `Pixel(NaN, NaN)` | AMap v2 不接受普通对象,只接受 `[lng,lat]` 或 `LngLat` 实例 | 用 **Haversine 地理距离**替换像素距离判断,不再调用此方法 | | **`marker.setContent()`** | 更新标记内容后内部状态损坏 → NaN | AMap v2 的 `setContent` 触发重新布局,新旧内容尺寸不同时偏移计算异常 | 改为 **destroy + new Marker** 重建,确保内部状态干净 | | **`e.pixel` 不可靠** | 偶发 NaN | 某些事件回调中 `e.pixel` 可能未被填充 | 一律使用 `e.lnglat` + Haversine,不依赖像素坐标 | | **scoped CSS 无效** | 自定义 Marker 样式不生效 | AMap 注入的 DOM 不带 Vue 的 `data-v-xxx` 属性 | 新增**非 scoped** 的 `