高德地图添加轨迹规划和重放功能

This commit is contained in:
2026-06-25 10:30:45 +08:00
parent 0089e5c107
commit 91afc9d81c
7 changed files with 2740 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
# 前端面试题:高德地图 JSAPI 加载机制与安全密钥
## 题目描述
在 Vue 3 + Vite 项目中,使用 `@amap/amap-jsapi-loader` 动态加载高德地图 JSAPI 2.0。要求:
1. **全局只加载一次** — 多个组件同时调用不会重复下载 ~600KB 脚本
2. **加载失败可重试** — 不缓存失败的 Promise
3. **安全密钥按需配置** — 2021/12/02 后申请的 Key 必须配置 `securityJsCode`
4. **安全密钥必须在脚本加载之前设置** — 否则 Driving/Geocoder 等服务报 `INVALID_USER_SCODE`
---
## 完整源码与解析
### 1. 配置文件
```ts
// config/amap.ts
export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
export const AMAP_WEB_KEY = import.meta.env.VITE_AMAP_WEB_KEY as string
export const AMAP_SECURITY_CODE = import.meta.env.VITE_AMAP_SECURITY_CODE as string
export const AMAP_VERSION = '2.0'
export const AMAP_PLUGINS = [
'AMap.Geocoder', // 地理编码
'AMap.AutoComplete', // 输入提示
'AMap.PlaceSearch', // 搜索
'AMap.Geolocation', // 定位
'AMap.MarkerClusterer', // 点聚合
'AMap.Driving', // 驾车路线规划
] as const
```
### 2. 加载器 — 单例模式
```ts
// composables/useAmap.ts
import AMapLoader from '@amap/amap-jsapi-loader'
import { AMAP_JSAPI_KEY, AMAP_SECURITY_CODE, AMAP_VERSION, AMAP_PLUGINS } from '@/config/amap'
/** 全局 Promise 缓存 — 多次调用共享同一个下载 */
let amapPromise: Promise<typeof AMap> | null = null
/** 全局对象缓存 — 成功后直接返回,避免再次 await */
let AMapGlobal: typeof AMap | null = null
export async function loadAMap(): Promise<typeof AMap> {
// ① 已加载过 → 直接返回
if (AMapGlobal) return AMapGlobal
if (!amapPromise) {
// ② ⚠️ 必须在 JSAPI 脚本加载之前设置安全密钥
if (AMAP_SECURITY_CODE && !(window as any)._AMapSecurityConfig) {
;(window as any)._AMapSecurityConfig = {
securityJsCode: AMAP_SECURITY_CODE,
}
}
// ③ 发起加载
amapPromise = AMapLoader.load({
key: AMAP_JSAPI_KEY,
version: AMAP_VERSION,
plugins: [...AMAP_PLUGINS],
})
.then((amap) => {
AMapGlobal = amap // ④ 成功后缓存,后续调用瞬间返回
return amap
})
.catch((err) => {
amapPromise = null // ⑤ 失败后清空 → 允许重试
throw new Error(`高德地图 JSAPI 加载失败: ${err.message}`)
})
}
return amapPromise
}
```
---
## 核心知识点
### 知识点 ①:单例模式 — 避免重复加载
```
组件 A 组件 B 组件 C
│ │ │
├─ loadAMap() ├─ loadAMap() ├─ loadAMap()
│ amapPromise │ amapPromise │ amapPromise
│ = load() │ !== null │ !== null
│ (开始下载) │ (等待同一Promise)│ (等待同一Promise)
│ ↓ │ ↓ │ ↓
└─── await ──────┴─── await ──────┴─── await
AMapGlobal = amap (缓存)
后续任何调用if (AMapGlobal) return AMapGlobal → 瞬间返回
```
**两层缓存的作用:**
| 缓存层 | 类型 | 作用 |
|--------|------|------|
| `amapPromise` | `Promise<AMap> \| null` | 并发调用去重 — A、B、C 同时调用时,共享同一个下载 Promise |
| `AMapGlobal` | `typeof AMap \| null` | 跨时间缓存 — 下载完成后,任意时刻再调用直接返回对象 |
### 知识点 ②:失败重试
```ts
.catch((err) => {
amapPromise = null // ← 清空 Promise下次调用重新下载
throw new Error(...)
})
```
如果不置 `null`,失败的 Promise 会被缓存,后续调用拿到的是同一个 rejected Promise永远无法恢复。
### 知识点 ③:安全密钥的时序约束
```
✅ 正确顺序:
window._AMapSecurityConfig = { securityJsCode: 'xxx' }
<script src="https://webapi.amap.com/maps?v=2.0&key=..."></script>
AMap.Driving 等服务可用
❌ 错误顺序(写在 AMapLoader.load 之后):
<script src="..."></script> ← JSAPI 已加载,安全配置窗口关闭
window._AMapSecurityConfig = { ... } ← 无效Driving 等服务报 INVALID_USER_SCODE
```
**为什么必须在此之前?** 高德 JSAPI 脚本加载时读取 `_AMapSecurityConfig` 并完成内部初始化。脚本加载后该配置窗口关闭,之后再设置不会生效。
### 知识点 ④:`as const` 插件列表
```ts
export const AMAP_PLUGINS = [
'AMap.Geocoder',
// ...
] as const
```
`as const` 将数组类型收窄为 `readonly` 字面量元组,提供:
- 类型安全:`[...AMAP_PLUGINS]` 展开时保留精确字面量类型
- 防止意外修改:插件列表不应在运行时被 push/pop
- Tree-shaking 友好Vite 可以更好地优化
### 知识点 ⑤:环境变量前缀 `VITE_`
```
.env 文件中:
VITE_AMAP_JSAPI_KEY=xxx ← ✅ Vite 会暴露给客户端
AMAP_JSAPI_KEY=xxx ← ❌ 客户端 import.meta.env 读不到
```
Vite 只暴露 `VITE_` 前缀的环境变量,防止敏感信息泄漏。这是 Vite 的安全设计。
---
## INVALID_USER_SCODE 排查流程
```
[护理员] ❌ Driving 返回错误: 未知错误 INVALID_USER_SCODE
```
**排查清单:**
| 检查项 | 怎么做 |
|--------|--------|
| ① Key 申请时间 | 2021/12/02 之后申请的 Key 必须配置安全密钥 |
| ② `_AMapSecurityConfig` 时机 | 必须在 `AMapLoader.load()` **之前**设置 |
| ③ JSAPI Key vs 安全密钥 | 是两个不同的值,都在 AMap 控制台查看 |
| ④ 密钥拼写 | `securityJsCode`(注意大小写)不能有空格 |
| ⑤ 环境变量 | `.env``VITE_AMAP_SECURITY_CODE=xxx`,重启 dev server |
| ⑥ 条件加载 | 如果没有安全密钥,不设置 config老 Key 不需要) |
---
## 三种 AMap 密钥的区别
| | JSAPI Key | Web Service Key | 安全密钥 |
|---|---|---|---|
| 环境变量 | `VITE_AMAP_JSAPI_KEY` | `VITE_AMAP_WEB_KEY` | `VITE_AMAP_SECURITY_CODE` |
| 用途 | 地图展示、标记、折线 | 服务端 API地理编码等 | 前端安全验证 |
| 使用方式 | `AMapLoader.load({ key })` | 后端 fetch 请求 | `window._AMapSecurityConfig` |
| 暴露风险 | 前端可见(无法避免) | **应仅在后端使用** | 前端可见(建议代理) |
| 影响范围 | 无此 Key 地图无法加载 | 无此 Key 后端 API 不可用 | 2021/12 后新 Key 不配则 Driving/Geocoder 报错 |
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **设计模式** | 单例模式 + 两层缓存Promise + 对象) | ⭐⭐⭐ |
| **错误处理** | 失败清空 Promise 允许重试 | ⭐⭐ |
| **时序约束** | `_AMapSecurityConfig` 必须在脚本加载之前 | ⭐⭐⭐⭐ |
| **Vite** | `VITE_` 前缀的环境变量暴露机制 | ⭐⭐ |
| **TypeScript** | `as const` 字面量类型收窄 | ⭐⭐ |
| **调试** | INVALID_USER_SCODE 的系统化排查 | ⭐⭐⭐⭐ |
## 追问方向
### 追问 1为什么不直接在 `index.html` 中加 `<script>` 标签加载 AMap
**期望回答:**
- 按需加载:仅在访问地图页面时才下载 600KB 脚本,首页不需要
- 插件管理:通过 AMapLoader 统一管理插件版本
- 微前端兼容:子应用独立控制加载时机,避免主应用 `<script>` 影响
- 安全密钥时序:`AMapLoader.load()` 之前可以执行任意 JS 设置 `_AMapSecurityConfig`
### 追问 2如果多个子应用都需要 AMap`AMapGlobal` 会冲突吗?
**期望回答:** 如果多个子应用共用同一个域名,`AMapGlobal` 是模块级变量,在同一 JS 上下文中共享。如果子应用用 iframe 沙箱隔离(本项目的 `vue3-app` 配置了 `iframe: true`),则每个 iframe 有独立的 JS 上下文,各自加载自己的 AMap不会冲突。
### 追问 3`window._AMapSecurityConfig` 中还有一个 `serviceHost` 字段是干什么的?
**期望回答:** 用于代理模式。生产环境中不应把安全密钥写在前端代码里,可以通过 Nginx 反向代理转发:
```nginx
location /_AMapService/ {
set $args "$args&jscode=真实密钥";
proxy_pass https://restapi.amap.com/;
}
```
前端只需配置代理地址而不暴露密钥:
```ts
window._AMapSecurityConfig = { serviceHost: '/_AMapService' }
```

View File

@@ -0,0 +1,396 @@
# 前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警
## 题目描述
在 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** 的 `<style>` 块,用全局 class 选择器 |
---
### 2. 老人标记管理
```ts
// 创建HTML content + draggable + dragend 自动检测
const marker = new AMap.Marker({
position,
content: '<div class="geofence-person-marker">👴</div>', // ← CSS class非 inline
offset: new AMap.Pixel(-17, -17), // 34×34 居中
draggable: true, // 允许拖拽
zIndex: 200, // 高于围栏(100)和顶点(120)
})
marker.on('dragend', () => {
// 每次拖拽结束 → 更新位置 → 立即检测越界
const pos = marker.getPosition()
personPosition.value = [pos.lng, pos.lat]
checkBoundary()
})
```
**两种移动方式:**
| 方式 | 实现 | 步长 | 适用场景 |
|------|------|------|----------|
| 拖拽 | `draggable: true` + `dragend` 事件 | 任意 | 手动测试、演示 |
| 方向键 | `movePerson(direction)` ± 0.0005° | ~30-50m | 精确步进测试 |
**为什么步长用 0.0005° 而非固定米数?** 经纬度步长简单可靠,不依赖地图投影。对于养老场景的社区围栏(通常 100-500m 范围),像素级精度足够。
---
### 3. 越界检测 — 双算法兜底
```ts
function checkBoundary(): void {
const pp = personPosition.value
if (!pp || !fenceOverlay.value) return
let inside: boolean
if (fenceType.value === 'circle') {
// 圆形AMap 内置 contains() 方法
inside = (fenceOverlay.value as AMap.Circle).contains(pp)
} else {
// 多边形:优先 AMap.GeometryUtil兜底射线法
try {
inside = AMap.GeometryUtil.isPointInRing(
{ lng: pp[0], lat: pp[1] },
fenceVertices.value
)
} catch {
inside = isPointInPolygon(pp, fenceVertices.value) // 纯 JS 兜底
}
}
// 状态变迁检测:只在"进入→出去"时报警,"出去→进入"时解除
const wasInside = isInside.value
isInside.value = inside
if (!inside && wasInside) triggerAlarm(pp) // 刚刚越界
if (inside && !wasInside) resetAlarm() // 刚刚回归
}
```
**为什么需要兜底?** `AMap.GeometryUtil` 在某些加载顺序下可能未就绪JSAPI 异步加载)。射线法作为纯数学算法始终可用:
```
射线法Ray Casting从目标点向右发射一条水平线
→ 统计与多边形各边的交点数
→ 奇数 = 内部,偶数 = 外部
```
**状态变迁检测的设计意图:** 不直接用 `if (!inside) alert()`,而是追踪 `wasInside → isInside` 的变化。这样:
- 持续在围栏外 → 不重复报警
- 出去再进来再出去 → 每次跨越边界都报警(累计次数 + 历史记录)
---
### 4. 报警系统 — 五通道并行的设计模式
越界时**同时触发 5 个通道**,每个通道独立可关闭:
```
triggerAlarm()
├─ 通道 1视觉-围栏
│ updateFenceStyle(true)
│ 绿色实线 → 红色虚线fillColor/strokeColor 切换)
├─ 通道 2视觉-全屏浮层
│ alarmActive = true → v-if 渲染
│ 全屏红色脉冲背景 + 震动弹窗 + 脉冲图标
│ 点击任意处 → dismissAlarm()
├─ 通道 3视觉-状态标签
│ fence-status--safe → fence-status--alarm
│ "✅ 安全" → "🚨 已越界!"
├─ 通道 4听觉-蜂鸣
│ startAlarmSound()
│ Web Audio API → OscillatorNode 方波 880Hz
│ 每 800ms 蜂鸣一次setInterval
│ 0.3 秒 duration + exponentialRampToValueAtTime 淡出
└─ 通道 5记录-历史
alarmHistory.push({ time, position })
右侧浮层实时展示
```
**`updateFenceStyle` 的兼容处理:**
```ts
// AMap 不同版本可能没有 setOptions兜底用 Object.assign
poly.setOptions?.(style) || Object.assign(poly as any, style)
```
---
### 5. 响应式设计 — `shallowRef` 的正确用法
```ts
// ✅ 正确shallowRef — 只在引用替换时触发更新
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
const personMarker = shallowRef<AMap.Marker | null>(null)
// ❌ 错误ref — 会深度追踪 AMap 实例的数千个内部属性
// const fenceOverlay = ref<AMap.Polygon | null>(null)
// → setCenter/setZoom 等操作触发深度 diff → 性能灾难
```
| 存储方式 | 适用场景 | 代价 |
|----------|----------|------|
| `shallowRef` | 地图实例、覆盖物(内部状态由 AMap 自行管理) | 无性能损耗 |
| `ref` | 简单值(坐标数组、布尔、计数) | 需要响应式追踪 |
| `computed` | 派生状态(`hasFence``hasPerson` | 自动缓存 |
---
### 6. 生命周期清理 — 避免内存泄漏的完整链条
```ts
onUnmounted(() => {
destroyGeofence()
stopAlarmSound() // 清理 setInterval + Oscillator
clearFence() // map.remove(polygon/circle) + 重置状态
removePerson() // map.remove(marker) + 停止报警
cancelDraw() // 清理预览线/顶点/事件监听 + 恢复 doubleClickZoom
})
```
**事件监听的清理机制:**
```ts
// 绑定:将 handler 引用存储到 map 实例的自定义属性上
;(map as any)._geofenceClick = onClick
map.on('click', onClick)
// 清理:通过引用精确 off避免误删其他监听器
const m = map as any
if (m._geofenceClick) {
map.off('click', m._geofenceClick)
delete m._geofenceClick
}
```
---
## 完整调用链路图
```
用户操作 → 电子围栏响应
═══════════════════════════════════════════
┌─ 绘制 ─────────────────────────────────────┐
│ [🔷绘制围栏] → startDrawPolygon() │
│ → map.setStatus({ doubleClickZoom:false }) │
│ → 逐一点击 → onClick(e) │
│ → 每点new Marker + new Polyline │
│ → ≥3点首顶点变红脉冲 │
│ → 点击首顶点 / [✅闭合] → finishDraw() │
│ → finishPolygonDraw() │
│ → new AMap.Polygon(path, FENCE_STYLE) │
│ → cleanupDrawing(map) │
│ → map.setStatus({ doubleClickZoom:true})│
└────────────────────────────────────────────┘
┌─ 老人 ─────────────────────────────────────┐
│ [➕添加老人] → addPerson() │
│ → new AMap.Marker({draggable:true}) │
│ → marker.on('dragend', checkBoundary) │
│ → checkBoundary() // 初始检测 │
│ │
│ [↑↓←→] / 拖拽 → movePerson() │
│ → marker.setPosition([lng, lat]) │
│ → checkBoundary() // 每次移动立即检测 │
└────────────────────────────────────────────┘
┌─ 检测 ─────────────────────────────────────┐
│ checkBoundary() │
│ ├─ 无围栏 → isInside = true (skip) │
│ ├─ 圆形 → Circle.contains(point) │
│ └─ 多边形 → GeometryUtil.isPointInRing() │
│ └─ 失败 → isPointInPolygon() │
│ └─ wasInside && !inside → triggerAlarm() │
│ └─ !wasInside && inside → resetAlarm() │
└────────────────────────────────────────────┘
┌─ 报警 ─────────────────────────────────────┐
│ triggerAlarm(position) │
│ ├─ alarmCount++ │
│ ├─ alarmHistory.push({time, position}) │
│ ├─ updateFenceStyle(true) // 围栏变红虚线 │
│ ├─ alarmActive = true // 浮层出现 │
│ └─ startAlarmSound() // 蜂鸣开始 │
│ │
│ dismissAlarm() / resetAlarm() │
│ ├─ alarmActive = false │
│ ├─ updateFenceStyle(false) // 恢复绿色 │
│ └─ stopAlarmSound() // 蜂鸣停止 │
└────────────────────────────────────────────┘
```
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **架构设计** | Composable 模式:逻辑/视图分离、单一职责 | ⭐⭐⭐ |
| **响应式** | `shallowRef` vs `ref` vs `computed` 的选择理由 | ⭐⭐ |
| **几何算法** | 射线法Ray Casting原理 + Haversine 公式 | ⭐⭐⭐⭐ |
| **地图 SDK 踩坑** | `doubleClickZoom``lngLatToContainer` 参数格式、`setContent` 副作用 | ⭐⭐⭐⭐⭐ |
| **交互设计** | 三种闭环方式(点击首顶点/按钮/右键取消)的 UX 考量 | ⭐⭐⭐ |
| **事件管理** | 动态事件绑定/解绑、闭包捕获、内存泄漏防范 | ⭐⭐⭐ |
| **Web Audio API** | OscillatorNode + GainNode + exponentialRampToValueAtTime | ⭐⭐⭐ |
| **生命周期** | `onUnmounted` 中 4 步清理链(音效→围栏→标记→绘制态) | ⭐⭐ |
| **CSS 隔离** | scoped vs 全局样式在 AMap 自定义标记中的应用 | ⭐⭐⭐ |
| **状态机** | wasInside/inside 变迁检测避免重复报警 | ⭐⭐ |
---
## 加分项
- **能解释为什么不用 `AMap.MouseTool` 而手动处理 click 事件** — 更细粒度的交互控制(首顶点高亮、实时顶点计数、三通道闭环)
- **能指出 `setContent` 的替代方案** — destroy + new Marker 重建,或使用 `setOptions`(如果 SDK 支持)
- **能说明 `lngLatToContainer` 的参数格式陷阱** — AMap v2 只接受 `[lng, lat]` 数组或 `LngLat` 实例,不接受 `{lng, lat}` 普通对象
- **能提出改进方向**
- 支持多个围栏 + 多个老人(一对多/多对多关系)
- 使用 `watchPosition` 替代 `getCurrentPosition` 实现实时 GPS 追踪
- 报警信息通过 WebSocket 推送到护工端
- 围栏数据持久化localStorage/后端)+ 编辑已有围栏的顶点
- 使用 AMap 的 `PolygonEditor` 插件支持围栏二次编辑
- **能分析 `movePerson` 的步长选择** — 0.0005° 约 30-50 米的经纬度换算
---
## 追问方向
### 追问 1如果围栏有 100 个顶点,`isPointInPolygon` 的性能如何?
**期望回答:** 射线法时间复杂度 O(n)n 为顶点数。100 个顶点约 0.01ms。但养老场景围栏通常 4-20 个顶点,性能完全不是瓶颈。如果真有数千顶点,可以先用 bounding box 做粗筛。
### 追问 2圆形围栏的 `contains()` 精度取决于什么?
**期望回答:** `AMap.Circle.contains()` 基于球面几何计算,精度受限于:
1. 地球半径常量AMap 使用 6378137m
2. 高纬度地区 Mercator 投影变形
3. 极小的圆(<1m可能受浮点精度影响
对于养老场景(通常 100-1000m 半径),精度完全够用。
### 追问 3`alarmActive` 为什么用 `ref` 而不用 `shallowRef`
**期望回答:** `alarmActive` 是布尔值,不是对象。`ref(true)``shallowRef(true)` 对原始值行为完全相同——Vue 对原始类型不会深度追踪。但用 `ref` 语义更清晰("这是一个需要响应式的值")。
### 追问 4如果老人恰好在围栏边界线上`isPointInRing` 返回什么?
**期望回答:** 取决于浮点精度和算法实现。射线法在边界线上行为不确定(可能 true 也可能 false。生产环境应该在边界 ±2m 加一个模糊区间hysteresis避免在边界反复触发报警/解除。
### 追问 5`startAlarmSound` 中为什么要 try-catch
**期望回答:**
1. 部分浏览器(如 iOS Safari要求 AudioContext 必须在用户手势中创建
2. 某些企业环境可能禁用 Web Audio API
3. setInterval 可能被浏览器节流(后台标签页降至 1s
4. 静默失败比页面崩溃更优雅——视觉报警仍然工作
### 追问 6为什么不在 `drag` 事件中检测,而是 `dragend`
**期望回答:** `drag` 事件每像素触发一次,拖拽 100px 就触发 100 次 `checkBoundary`,其中 99 次是浪费。`dragend` 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。

View File

@@ -0,0 +1,242 @@
# 前端面试题全屏三区布局header + 地图 + 操作栏)且无滚动条
## 题目描述
你需要实现一个**微前端子应用**的地图页面,布局要求如下:
```
┌────────────────────────────────┐
│ sub-app-header │ ← 高 48px始终可见
│ 🟢 Vue3 子应用 [首页][关于][地图] │
├────────────────────────────────┤
│ │
│ │
│ 🗺️ 高德地图 │ ← 占满剩余空间
│ │
│ │
├────────────────────────────────┤
│ 🗺️ 高德地图 [📍回到默认] [🎯我的位置] │ ← 高 52px始终可见
└────────────────────────────────┘
```
**核心要求:**
1. 页面**不能出现滚动条**,三个区域必须同时完整显示在视口内
2. 地图区域需正确渲染(高德地图 JSAPI 要求容器有明确的像素尺寸)
3. 该应用既要能在**微前端环境**中嵌入运行,也要能**独立运行**
---
## 初始代码(有问题)
```vue
<!-- App.vue -->
<template>
<div id="microapp-vue3-container">
<header class="sub-app-header">
<h1>🟢 Vue3 子应用</h1>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/map">地图</router-link>
</nav>
</header>
<main class="sub-app-main">
<router-view />
</main>
</div>
</template>
<style>
body {
margin: 0;
}
#microapp-vue3-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #fafafa;
}
.sub-app-header {
height: 48px;
flex-shrink: 0;
/* ...其他样式省略 */
}
.sub-app-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
</style>
```
```vue
<!-- MapView.vue 地图页面 -->
<template>
<div class="map-page">
<div v-if="loading" class="map-status"> 地图加载中...</div>
<div v-else-if="error" class="map-status map-error"> {{ error }}</div>
<div ref="containerRef" class="map-container" />
<div class="map-controls">
<span>🗺 高德地图</span>
<div>
<button @click="resetView">📍 回到默认位置</button>
<button @click="getCurrentPosition">🎯 我的位置</button>
</div>
</div>
</div>
</template>
<style scoped>
.map-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.map-container {
flex: 1;
min-height: 0;
}
.map-controls {
flex-shrink: 0;
/* ... */
}
</style>
```
**问题:** 页面出现了垂直滚动条,地图也没有正确渲染。
---
## 考察知识点
| 层级 | 知识点 | 具体体现 |
|------|--------|----------|
| **CSS 盒模型** | `height: 100%` 的继承链 | 百分比高度必须逐级传递,任意一环缺失都会断裂 |
| **Flexbox** | `flex: 1``min-height: 0` | flex 子项默认 `min-height: auto`,可能导致溢出 |
| **溢出控制** | `overflow` 的层级管理 | 在哪一层阻止溢出、哪一层允许滚动需要精心设计 |
| **第三方库适配** | 地图 SDK 的容器约束 | AMap 要求容器有明确像素高度flex 分配的隐式高度可能不生效 |
| **微前端** | 子应用的挂载点与视口 | `100vh` vs `100%` 在不同环境下的差异 |
---
## 期望答案要点
### 1. 修复百分比高度继承链(关键)
```
初始html → body → #root无高度→ #container100vh ❌ 链断裂
修复html → body → #root → #container全部 100% ✅ 完整链路
```
```css
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* 根级截断,杜绝页面级滚动条 */
}
#microapp-vue3-root {
height: 100%; /* 关键:挂载点也需撑满 */
}
#microapp-vue3-container {
height: 100%; /* 用 % 而非 vh适配微前端环境 */
overflow: hidden;
}
```
**为什么 `100vh` 不够好?**
- 移动端浏览器地址栏收起/展开会改变 `100vh` 的实际值
- 微前端环境中,子应用容器不一定等于视口高度,`100%` 跟随父容器更稳健
### 2. flex 布局的溢出收缩
```css
.sub-app-main {
flex: 1;
min-height: 0; /* 允许收缩到内容以下 */
display: flex;
flex-direction: column;
}
.map-page {
flex: 1;
min-height: 0; /* 同上 */
overflow: hidden; /* 阻止地图页面自身溢出 */
}
```
**关键原理:** flex item 默认 `min-height: auto`,不允许收缩到内容高度以下;设为 `0` 后才真正服从 flex 分配。
### 3. AMap 容器必须有明确的尺寸
```css
.map-container {
flex: 1;
width: 100%;
min-height: 0;
}
/* 避免display: none 导致容器尺寸为 0 */
.map-hidden {
visibility: hidden; /* ✅ 保留占位,地图可正常初始化 */
/* display: none; */ /* ❌ 尺寸归零,地图无法渲染 */
}
```
配合 JS 端延迟 `resize`
```js
onMounted(async () => {
const map = await initMap()
if (map) {
nextTick(() => map.resize()) // 修正 flex 布局下的初始尺寸
}
})
```
### 4. 不同页面的滚动策略分离
地图页不需要滚动,但首页/关于页可能需要:
```css
.sub-app-main {
overflow-y: auto; /* 允许滚动 */
}
.map-page {
overflow: hidden; /* 地图页自己阻止溢出 */
/* flex: 1 意味着它恰好填满父容器,不会触发父级的 overflow-y */
}
```
---
## 加分项
- 能说出 `display: none` vs `visibility: hidden` 对第三方库 DOM 测量的影响
- 能解释为什么在 `onMounted` 中调用 `map.resize()`,而不是 `onBeforeMount`
- 能分析 `resizeEnable: true` 的 AMap 配置与手动 `resize()` 的配合关系
- 能指出微前端场景下 `body { margin: 0 }` 不生效的边界情况(宿主页面的 body 不由子应用控制)
---
## 追问方向
1. **如果不用 flex还有什么方案** → grid、absolute + calc、vh 运算
2. **如何保证在其他页面(首页/关于页)的滚动正常?** → 仅在父级 `.sub-app-main``overflow-y: auto`,子页面不设 `overflow: hidden`
3. **`min-height: 0` 为什么是必须的?** → flex item 的隐含 `min-height: auto` 阻止收缩
4. **如果地图加载失败,当前布局会崩吗?** → 状态提示用绝对定位浮层,不占 flex 空间,不影响布局

View File

@@ -0,0 +1,469 @@
# 前端面试题:高德地图"我的位置"功能完整实现
## 题目描述
在一个 Vue 3 + 高德地图的微前端子应用中,实现**"我的位置"**功能:用户点击按钮后,地图自动定位到当前设备所在地理位置,并将缩放级别调到 15街道级
```
┌────────────────────────────────┐
│ sub-app-header │
│ 🟢 Vue3 子应用 [首页][关于][地图] │
├────────────────────────────────┤
│ │
│ 🗺️ 高德地图 │
│ (当前在北京天安门) │
│ │
├────────────────────────────────┤
│ 🗺️ 高德地图 [📍回到默认] [🎯 我的位置] │ ← 点击这个
└────────────────────────────────┘
点击 🎯 我的位置 →
① 检测浏览器是否支持定位
② 调用浏览器 Geolocation API
③ 提取经纬度
④ 移动地图中心 → 用户所在位置zoom = 15
```
**要求:**
1. 在地图实例创建完成后,按钮才可点击(未就绪时 `disabled`
2. 必须处理:浏览器不支持定位、用户拒绝授权、定位超时等异常
3. 地图实例通过 composable 管理,不能直接在组件中 `new AMap.Map()`
4. 地图需要支持微前端环境iframe 沙箱),注意权限策略差异
---
## 完整源码解读
### 1. 项目文件结构
```
microapp-vue3/src/
├── config/
│ └── amap.ts ← JSAPI 密钥、版本、插件列表
├── composables/
│ └── useAmap.ts ← 地图实例管理(单例加载、生命周期)
├── views/
│ └── MapView.vue ← 地图页面 + "我的位置" 功能
├── vite-env.d.ts ← AMap 全局类型声明
├── App.vue ← 子应用根组件header + router-view
├── main.ts ← 微前端生命周期mount/unmount
└── router/index.ts ← 路由定义
```
---
### 2. 配置文件 — 密钥与插件
```ts
// src/config/amap.ts
export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
export const AMAP_VERSION = '2.0'
export const AMAP_PLUGINS = [
'AMap.Geocoder', // 地理编码 / 逆地理编码
'AMap.AutoComplete', // 输入提示
'AMap.PlaceSearch', // 搜索服务
'AMap.Geolocation', // ← 定位插件(已加载但当前未使用)
'AMap.MarkerClusterer', // 点聚合
] as const
```
**关键细节:** `AMap.Geolocation` 是高德自己的定位插件(基于 IP + 基站),精度低于浏览器原生 GPS。**当前实现选择的是浏览器原生 `navigator.geolocation`,而非高德插件**——这是一个常见的架构决策点(见后文追问)。
---
### 3. 地图 Composable — 实例管理与单例加载
```ts
// src/composables/useAmap.ts
// ① 全局单例 — 避免多次加载 JSAPI 脚本(~600KB
let amapPromise: Promise<typeof AMap> | null = null
let AMapGlobal: typeof AMap | null = null
export async function loadAMap(): Promise<typeof AMap> {
if (AMapGlobal) return AMapGlobal // 缓存命中
if (!amapPromise) {
amapPromise = AMapLoader.load({
key: AMAP_JSAPI_KEY,
version: AMAP_VERSION,
plugins: [...AMAP_PLUGINS],
})
.then((amap) => {
AMapGlobal = amap
return amap
})
.catch((err) => {
amapPromise = null // ② 失败后可重试
throw new Error(`高德地图 JSAPI 加载失败: ${err.message}`)
})
}
return amapPromise
}
export function useAmap(options: AMap.MapOptions = {}) {
const containerRef = ref<HTMLDivElement | null>(null)
const mapInstance = shallowRef<AMap.Map | null>(null) // ③ 浅响应式
const loading = ref(false)
const error = ref<string | null>(null)
async function initMap(): Promise<AMap.Map | null> {
if (!containerRef.value) {
error.value = '地图容器不存在'
return null
}
loading.value = true
error.value = null
try {
const AMap = await loadAMap()
if (mapInstance.value) {
mapInstance.value.destroy() // ④ 先销毁旧实例
}
const defaultOptions: AMap.MapOptions = {
center: [116.397428, 39.90923], // 默认:北京天安门
zoom: 11,
viewMode: '3D',
resizeEnable: true, // ⑤ 窗口 resize 时自动重绘
}
mapInstance.value = new AMap.Map(containerRef.value, {
...defaultOptions,
...options, // ⑥ 调用方可覆盖默认值
})
return mapInstance.value
} catch (err: any) {
error.value = err.message || '地图初始化失败'
return null
} finally {
loading.value = false
}
}
function destroyMap(): void {
if (mapInstance.value) {
mapInstance.value.destroy()
mapInstance.value = null
}
}
onUnmounted(() => { destroyMap() }) // ⑦ 组件卸载自动清理
return { containerRef, mapInstance, loading, error, initMap, destroyMap }
}
```
**设计要点:**
| 编号 | 技术点 | 说明 |
|------|--------|------|
| ① | **模块级单例** | `loadAMap()` 的 Promise 缓存在模块作用域,多次调用 `useAmap()` 不会重复下载 JSAPI |
| ② | **失败重试** | catch 中将 `amapPromise``null`,下一次调用会重新加载 |
| ③ | **`shallowRef`** | 地图实例是复杂第三方对象,不需要深度响应式追踪,`shallowRef` 只在引用变化时触发更新,节省性能 |
| ④ | **销毁旧实例** | 如果多次调用 `initMap()`,先 `destroy()``new`,避免内存泄漏 |
| ⑤ | **`resizeEnable`** | 容器尺寸变化时自动调用 `resize()`,在 flex 布局中尤其重要 |
| ⑥ | **默认值合并** | 调用方传入的 options 覆盖默认值,支持自定义初始位置和缩放 |
| ⑦ | **生命周期绑定** | `onUnmounted` 确保组件销毁时释放地图资源 |
---
### 4. "我的位置" — 核心实现
```vue
<!-- src/views/MapView.vue -->
<template>
<div class="map-page">
<div ref="containerRef" class="map-container"
:class="{ 'map-hidden': loading || error }" />
<div class="map-controls">
<span class="map-title">🗺 高德地图</span>
<div class="map-actions">
<button @click="resetView" :disabled="!mapInstance">📍 回到默认位置</button>
<button @click="getCurrentPosition" :disabled="!mapInstance">🎯 我的位置</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, nextTick } from 'vue'
import { useAmap } from '@/composables/useAmap'
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
center: [116.397428, 39.90923],
zoom: 12,
pitch: 30,
})
// ─────────────────────────────────────────
// 地图初始化
// ─────────────────────────────────────────
async function bootstrapMap() {
const map = await initMap()
if (map) {
nextTick(() => { map.resize() }) // flex 布局下修正地图尺寸
}
}
onMounted(() => { bootstrapMap() })
// ─────────────────────────────────────────
// 📍 回到默认视图
// ─────────────────────────────────────────
function resetView(): void {
const map = mapInstance.value
if (!map) return
map.setCenter([116.397428, 39.90923])
map.setZoom(12)
}
// ─────────────────────────────────────────
// 🎯 我的位置 — 核心功能
// ─────────────────────────────────────────
function getCurrentPosition(): void {
// ① 能力检测 — 浏览器是否支持 Geolocation API
if (navigator.geolocation) {
// ② 调用原生 API
navigator.geolocation.getCurrentPosition(
// ③ 成功回调 — 提取经纬度并移动地图
(pos) => {
const { longitude, latitude } = pos.coords
mapInstance.value?.setCenter([longitude, latitude])
mapInstance.value?.setZoom(15)
},
// ④ 失败回调 — 用户拒绝 / 超时 / 无法获取
(err) => {
alert(`定位失败: ${err.message}`)
}
)
} else {
// ⑤ 完全不支持 — HTTP 环境或老旧浏览器
alert('浏览器不支持定位')
}
}
</script>
```
---
## 核心知识点拆解
### 知识点 ①:`navigator.geolocation` vs `AMap.Geolocation`
| 维度 | `navigator.geolocation`(浏览器原生) | `AMap.Geolocation`(高德插件) |
|------|--------------------------------------|-------------------------------|
| **定位来源** | GPS + Wi-Fi + 基站 + IP | IP 定位 + 基站(无 GPS |
| **精度** | 高(室外可达 5-10 米) | 低(通常 100-500 米) |
| **授权弹窗** | 浏览器原生弹窗(信任度高) | 无弹窗(静默获取) |
| **HTTPS 要求** | **必须 HTTPS** 或 localhost | 无要求 |
| **加载方式** | 浏览器内置,无需加载 | 需加载高德 JSAPI + Geolocation 插件 |
| **使用场景** | "我的位置"精确导航 | 城市级粗略定位、IP 统计 |
**当前代码的架构决策:选择浏览器原生 API**。因为按钮文案是"我的位置",用户预期是精确的 GPS 定位,而非粗粒度的 IP 定位。
### 知识点 ②:`getCurrentPosition` 的参数与安全策略
```js
navigator.geolocation.getCurrentPosition(success, error, options)
```
**可选的第三个参数 `options`(当前未使用):**
```js
navigator.geolocation.getCurrentPosition(success, error, {
timeout: 10000, // 超时时间ms默认 Infinity
maximumAge: 60000, // 缓存有效期ms0 = 强制重新获取
enableHighAccuracy: true, // 高精度模式(启用 GPS默认 false
})
```
**如果面试者能指出缺失的 `options`,说明有实际落地经验:**
- 不设 `timeout` → 用户在室内可能永远等不到回调
- 不设 `enableHighAccuracy: true` → 浏览器可能只返回 IP 定位,精度很差
- 不设 `maximumAge` → 每次点击都重新定位,没有利用缓存
### 知识点 ③:微前端环境下的 Geolocation 权限
```
主应用 (microapp-main)
└─ <micro-app name="vue3-app" iframe>
└─ iframe → 子应用 (microapp-vue3)
```
当子应用运行在 **iframe 沙箱**中时(`subApps.ts``iframe: true``navigator.geolocation` 的行为:
- **Chrome / Edge**iframe 中的 `getCurrentPosition` 需要 iframe 的 `allow="geolocation"` 属性,否则直接触发错误回调
- **Firefox**:会向上询问用户授权,相对宽松
- **Safari**iframe 中基本无法获取位置
**主应用需要额外配置 iframe 的 Permissions Policy**
```html
<!-- 主应用中嵌入子应用的 iframe 需要声明 -->
<iframe allow="geolocation" src="..."></iframe>
<!-- 或通过 HTTP 响应头 -->
Permissions-Policy: geolocation=(self "http://localhost:3001")
```
**当前代码缺少这个配置**——这是一个真实的技术债,也是面试中考察微前端经验的好切入点。
### 知识点 ④:`shallowRef` 的地图实例存储
```ts
const mapInstance = shallowRef<AMap.Map | null>(null)
```
| 对比 | `ref()` | `shallowRef()` |
|------|---------|-----------------|
| 深度追踪 | ✅ 递归追踪内部属性 | ❌ 仅追踪 `.value` 的引用替换 |
| `map.setCenter()` 后 | 触发不必要的重渲染 | **不触发**(引用没变) |
| `map.setZoom()` 后 | 触发不必要的重渲染 | **不触发** |
| 性能 | 差(每次地图操作都 diff | 好(只有销毁/重建才触发) |
**关键原理:** 地图实例内部状态的变更(平移、缩放、标记点增删)都不应该触发 Vue 的响应式更新——地图自己管理自己的 DOM。`shallowRef` 正是为此场景设计。
### 知识点 ⑤:按钮的 `disabled` 守卫
```html
<button @click="getCurrentPosition" :disabled="!mapInstance">🎯 我的位置</button>
```
**防护链:**
```
mapInstance 为 null地图未就绪
→ button disabled → 无法点击 → getCurrentPosition 不会执行
→ 同时函数内部仍有 mapInstance.value?.setCenter() 的 ?. 守卫
```
这是**双重防护**
1. **UI 层守卫**`disabled` 阻止用户操作
2. **逻辑层守卫**`?.` 可选链防止异步竞态(例如在定位回调返回前地图被销毁)
### 知识点 ⑥:异步初始化窗口 — 竞态条件
```
用户操作时间线:
t=0 页面加载onMounted → initMap() 开始
t=50 initMap 还在加载 JSAPI...
t=100 用户疯狂点击 "我的位置" → 按钮 disabledmapInstance 为 null ✅ 被拦截
t=500 initMap 完成mapInstance 赋值
t=600 用户点击 "我的位置" → getCurrentPosition 执行 ✅ 正常工作
```
**如果没有 `:disabled="!mapInstance"`**
```
t=100 getCurrentPosition() 中的 mapInstance.value?.setCenter()
→ undefined?.setCenter() 不报错但什么也不做
→ 地图不动,用户困惑
```
---
## 完整调用链路图
```
用户点击 🎯 我的位置
├─ button :disabled="!mapInstance"
│ └─ mapInstance.value 是否为 null
│ ├─ null → 按钮灰色,事件不触发 【终止】
│ └─ 有值 → 继续
├─ getCurrentPosition()
│ │
│ ├─ ① navigator.geolocation 是否存在?
│ │ ├─ 不存在 → alert('浏览器不支持定位') 【终止】
│ │ └─ 存在 → 继续
│ │
│ ├─ ② navigator.geolocation.getCurrentPosition(success, error)
│ │ │
│ │ ├─ 浏览器弹出授权弹窗:"localhost 想要获取您的位置"
│ │ │ ├─ 用户拒绝 → error({ code: 1, message: "User denied" })
│ │ │ │ └─ alert('定位失败: User denied Geolocation') 【终止】
│ │ │ └─ 用户允许 → 浏览器开始获取位置
│ │ │ ├─ 超时/信号弱 → error({ code: 3, message: "Timeout" })
│ │ │ │ └─ alert('定位失败: Timeout expired') 【终止】
│ │ │ └─ 成功 → success(pos)
│ │ │ │
│ │ │ ├─ ③ const { longitude, latitude } = pos.coords
│ │ │ │ └─ pos.coords 还包含:
│ │ │ │ · accuracy精度
│ │ │ │ · altitude海拔
│ │ │ │ · heading方向角
│ │ │ │ · speed速度m/s
│ │ │ │
│ │ │ ├─ ④ mapInstance.value?.setCenter([lng, lat])
│ │ │ │ └─ 地图中心平移到用户位置
│ │ │ │
│ │ │ └─ ⑤ mapInstance.value?.setZoom(15)
│ │ │ └─ 缩放到街道级(默认 zoom=12 是城市级)
│ │ │
│ │ └─ 注意:当前实现没有传 options 参数!
│ │ 建议补充 { timeout, enableHighAccuracy, maximumAge }
```
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **API 选型** | `navigator.geolocation` vs `AMap.Geolocation` 的取舍 | ⭐⭐⭐ |
| **响应式** | 为何用 `shallowRef` 而非 `ref` 存储地图实例 | ⭐⭐ |
| **异步安全** | `disabled` + `?.` 双重守卫防止竞态 | ⭐⭐ |
| **单例模式** | JSAPI 加载的模块级缓存与失败重试 | ⭐⭐⭐ |
| **微前端** | iframe 沙箱中 geolocation 的 Permissions Policy | ⭐⭐⭐⭐ |
| **安全策略** | HTTPS 要求、授权弹窗、用户拒绝处理 | ⭐⭐ |
| **生命周期** | `onUnmounted` 销毁地图、`onMounted` 初始化 | ⭐ |
| **错误处理** | 三层 fallback浏览器不支持 → 授权拒绝 → 超时 | ⭐⭐ |
| **用户体验** | 缺少 loading 状态、缺少定位失败后的降级 UI | ⭐⭐⭐ |
---
## 加分项
- **能说出 `getCurrentPosition``options` 三个参数及其合理默认值**
- **能指出 iframe 沙箱下 geolocation 需要主应用配合配置 `Permissions-Policy`**
- **能分析为何 `AMap.Geolocation` 已加载却未使用** — 精度不够,不适合"我的位置"场景
- **能提出改进方案**
- 优先用 `navigator.geolocation`高精度fallback 到 `AMap.Geolocation`(低精度但可用)
- 定位过程中按钮显示 loading 态(如 spinner + "定位中..."
- 定位成功后在地图上添加用户位置标记点Marker
-`watchPosition` 替代 `getCurrentPosition` 实现实时追踪
- **能指出 `pos.coords.accuracy` 可用于在地图上绘制精度圈**
---
## 追问方向
### 追问 1如果定位失败如何优雅降级
**期望回答:**
```
navigator.geolocationGPS高精度
↓ 失败/不支持
AMap.GeolocationIP 定位,低精度,但一定能拿到城市级位置)
↓ 也失败
默认位置(北京天安门) + Toast 提示
```
### 追问 2为什么当前代码没用 `AMap.Geolocation` 却加载了它的插件?
**期望回答:** 这是预留能力。插件列表是静态配置,加载后该功能即可用。如果未来需要定位功能(如 POI 搜索中的"附近"功能),不需要改配置重新加载 JSAPI。代价是首屏多加载 ~15KB 的插件代码——一个有意为之的权衡。
### 追问 3如果在 Vue 3 的 `watchEffect` 中调用 `map.setCenter()`,会发生什么?
**期望回答:** 如果 `mapInstance``ref()` 存储,`setCenter` 会改变地图内部状态Vue 的响应式系统会追踪到这些变化并触发 `watchEffect` 重新执行,形成死循环。`shallowRef` 避免了这个问题。
### 追问 4微前端 iframe 沙箱中 `navigator.geolocation` 失效的根本原因?
**期望回答:** 浏览器的 Permissions Policy原 Feature Policy要求跨域 iframe 必须显式声明 `allow="geolocation"` 属性。`@micro-zoe/micro-app` 的 iframe 沙箱创建的是同源 iframe但浏览器的权限模型仍然将其视为独立上下文需要主应用在 `<micro-app>` 标签或框架配置中透传该 permission。

View File

@@ -0,0 +1,200 @@
# 前端面试题:地图路线规划 + 轨迹动画系统
## 题目描述
在 Vue 3 + 高德地图的养老/医疗微前端应用中,实现一个**护理员轨迹系统**
```
操作流程:
[📍标记起点] → 点击地图放 📍
[🏁标记终点] → 点击地图放 🏁
[➕添加护理员] → 在起点创建 👩‍⚕️
[🚗规划路线] → AMap.Driving 计算驾车路线 → 蓝色折线
[▶出发] → 护理员沿路线动画移动
不管路径 500km 还是 2000km动画始终 ~10 秒丝滑播完
```
**核心要求:**
1. 起点和终点通过**点击地图手动标记**(独立 `AMap.Marker`
2. 驾车路线使用 `AMap.Driving` 规划(真实道路路径,非直线)
3. 护理员标记沿路线**动画移动**,无论路径多长都流畅无卡顿
4. 支持暂停、回到起点、全部清除
5. 护理员不存在时点击"出发"要**自动创建**(容错 UX
---
## 架构设计
```
useRouteTrack(mapInstance)
├─ enterSetMode('origin'|'dest') ← 点击地图放标记
├─ addCaregiver() ← 在起点创建护理员
├─ planRoute() ← AMap.Driving 路线规划
│ └─ downsamplePath(full, 250) ← ★ 路径下采样
├─ startAnimation() ← setInterval 帧动画
└─ clearAll() ← 完整清理
```
---
## 核心技术点
### 1. 路径下采样 — 丝滑动画的秘密
```
AMap.Driving 返回原始路径5000+ 个点(每几米一个)
↓ downsamplePath(fullPath, 250)
动画路径250 个点(均匀间隔)
↓ setInterval(40ms) → 25fps
总时长250 × 40ms = 10 秒(恒定)
```
**为什么需要下采样?**
| 不用下采样 | 用下采样 |
|------------|----------|
| 5000 点 × 40ms = **200 秒**(太慢) | 250 点 × 40ms = **10 秒**(刚好) |
| 短距离只有 200 点 = 8 秒(不一致) | 始终 250 点 = **恒定 10 秒** |
| 每个点间隔 ~1m动画抖动 | 均匀间隔,**视觉丝滑** |
**下采样算法:**
```ts
function downsamplePath(path: [number, number][], target: number): [number, number][] {
if (path.length <= target) return [...path]
const result: [number, number][] = []
const step = (path.length - 1) / (target - 1) // 5000/249 ≈ 20.08
for (let i = 0; i < target; i++) {
result.push(path[Math.min(Math.round(i * step), path.length - 1)])
}
return result
}
```
**为什么选 250**
- 250 × 40ms = 10 秒,不短不长,刚好展示完整轨迹
- 25fps 是人眼感知流畅的最低帧率,再低会卡顿
- 250 个 `setPosition` 调用对 DOM 压力极小
### 2. 帧动画引擎
```ts
async function startAnimation(): Promise<void> {
// 🔧 容错:护理员不存在 → 自动在起点创建
if (!caregiverMarker.value) {
if (!originPoint.value) { alert('请先标记起点'); return }
await addCaregiver()
}
const total = animPath.value.length - 1 // 249 步
animTimer = setInterval(() => {
if (animStep.value >= total) {
stopAnimation() // 到达终点自动停
animProgress.value = 1
return
}
animStep.value++
animProgress.value = animStep.value / total // 0→1 驱动进度条
caregiverMarker.value?.setPosition(animPath.value[animStep.value])
}, 40) // 25fps
}
```
| 参数 | 值 | 说明 |
|------|-----|------|
| 帧间隔 | 40ms | 25fps人眼流畅感知阈值 |
| 总步数 | 249 | 250 个路径点,首点即起始位置 |
| 总时长 | ~10s | 恒定,不受实际距离影响 |
| 进度计算 | `step/total` | 0→1驱动 CSS 进度条宽度 |
### 3. 三种 status 的完整错误处理
```ts
driving.search(originLngLat, destLngLat, (status, result) => {
if (status === 'complete' && result.routes?.length > 0) {
// ✅ 成功:提取路径 → 下采样 → 绘制折线
} else if (status === 'error') {
// ❌ 参数/权限问题INVALID_USER_SCODE缺安全密钥
reject(new Error(`路线规划失败: ${result.info || result.message}`))
} else if (status === 'no_data') {
// ⚠️ 起终点之间确实无路(如海岛 → 内陆无桥梁)
reject(new Error('起终点之间无可用路线'))
} else {
// ⁉️ 未知状态,不应发生
reject(new Error(`路线规划失败 (${status})`))
}
})
```
### 4. 点击地图放标记 — 模式切换设计
```
enterSetMode('origin' | 'dest')
→ exitSetMode() // 先退出当前模式(互斥)
→ settingMode = mode // origin 或 dest
→ map.setDefaultCursor('crosshair')
→ map.on('click', handler)
→ 点击地图:
→ 获取经纬度
→ mode === 'origin' ? applyOrigin() : applyDestination()
→ exitSetMode() // 单次点击后自动退出
```
**三个关键设计决策:**
| 决策 | 理由 |
|------|------|
| 单次点击后自动退出 | 避免用户误放多个标记 |
| origin 和 dest 互斥 | 同一时间只能设一种点,防止状态混乱 |
| 退出时恢复光标 + 解绑事件 | `exitSetMode()` 统一清理,防止事件泄漏 |
---
## 考察点汇总
| 层级 | 考察点 | 难度 |
|------|--------|------|
| **算法** | 路径下采样(均匀间隔重采样) | ⭐⭐⭐ |
| **动画** | setInterval 帧动画 + 进度归一化 | ⭐⭐ |
| **API 集成** | AMap.Driving 三种 status 的完整处理 | ⭐⭐⭐ |
| **交互设计** | 模式切换 + 点击地图放标记 | ⭐⭐ |
| **容错设计** | 护理员缺失时自动创建 | ⭐ |
| **资源管理** | setInterval 清理 + 事件解绑 | ⭐⭐ |
| **调试** | INVALID_USER_SCODE 问题排查 | ⭐⭐⭐⭐ |
## 加分项
- **能解释为什么 250 个点** — 250×40ms=10s 恒定,与距离无关
- **能提出变速方案** — 起步加速/到站减速easeInOutCubic用动态 interval 替代固定 40ms
- **能指出 `new AMap.LngLat(lng, lat)` 的必要性** — 数组 `[lng, lat]` 在某些版本不被 Driving 接受
- **能说明 `map: undefined` 会导致 Driving 失败** — Driving 依赖 map 做投影计算
- **能分析不同距离下的优化策略** — 短距离提高 target 点数(更细腻),长距离降低(防卡顿)
## 追问方向
### 追问 1如果路线有 10 万个点250 的下采样会丢失多少细节?
**期望回答:** 10 万 → 250每 400 个点取 1 个城市街道级别的拐弯会被抹平。但视觉上因为标记移动只显示一个点250 个关键帧足以模拟沿途运动。如果需要更高精度,可以动态调整 target短距离<50km用 500 点,长距离(>500km用 150 点。
### 追问 2为什么用 `setInterval` 而不用 `requestAnimationFrame`
**期望回答:**
- `rAF` 60fps → 250 点只需 4 秒(太快)
- `rAF` 帧率不稳定(后台标签页降到 1fps→ 动画时间不可预测
- `setInterval(40)` 稳定 25fps后台自动暂停浏览器节流回来时从当前位置继续
- 需要配合进度条时需要可知的总时长,`setInterval` 更可控
### 追问 3`startAnimation` 中为什么要 `await addCaregiver()`
**期望回答:** `addCaregiver()` 内部调用 `loadAMap()` 是异步的。如果护理员不存在,需要等标记创建完成再开始动画。`await` 确保 `caregiverMarker.value` 已被赋值,后续 `setInterval` 中才能正常调用 `setPosition`
### 追问 4如何让动画支持「可调速」
**期望回答:** 将 40ms 提取为变量 `speed`,暴露给外部。速度加倍 = interval 减半:
```ts
const speedMap = { '1x': 40, '2x': 20, '4x': 10 }
// 或动态计算,进度条相应调整
```

View File

@@ -0,0 +1,789 @@
// ============================================================
// 电子围栏 Composable — 养老/医疗场景
//
// 功能:
// 1. 绘制围栏(多边形 / 圆形)
// 2. 添加老人标记(👴 可拖拽 + 方向键移动)
// 3. 越界检测(围栏 vs 老人位置)
// 4. 声光报警(越界时触发)
// ============================================================
import { ref, shallowRef, computed } from 'vue'
import type { Ref } from 'vue'
import { loadAMap } from './useAmap'
// ─── 常量 ────────────────────────────────────────────
/** 围栏默认样式 */
const FENCE_STYLE = {
fillColor: 'rgba(66, 184, 131, 0.18)',
strokeColor: '#42b883',
strokeWeight: 2,
strokeOpacity: 0.9,
strokeStyle: 'solid' as const,
}
/** 围栏告警样式 */
const FENCE_ALARM_STYLE = {
fillColor: 'rgba(255, 77, 79, 0.25)',
strokeColor: '#FF4D4F',
strokeWeight: 3,
strokeOpacity: 1,
strokeStyle: 'dashed' as const,
}
/** 绘制时预览线样式 */
const DRAW_LINE_STYLE = {
strokeColor: '#42b883',
strokeWeight: 2,
strokeOpacity: 0.6,
strokeStyle: 'dashed' as const,
}
/** 老人标记 HTML使用全局 CSS 类) */
const PERSON_MARKER_HTML = '<div class="geofence-person-marker">👴</div>'
/** 普通顶点标记(使用全局 CSS 类) */
function vertexMarkerHTML(index: number): string {
return `<div class="geofence-vertex-dot">${index}</div>`
}
// ─── 工具函数 ────────────────────────────────────────
/** 射线法判断点是否在多边形内兜底方案AMap 不内置此方法) */
function isPointInPolygon(
point: [number, number],
polygon: [number, number][]
): boolean {
const [x, y] = point
let inside = false
const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const [xi, yi] = polygon[i]
const [xj, yj] = polygon[j]
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
inside = !inside
}
}
return inside
}
// ─── 类型 ────────────────────────────────────────────
export interface AlarmRecord {
time: string
position: [number, number]
}
// ─── Composable ──────────────────────────────────────
export function useGeofence(mapInstance: Ref<AMap.Map | null>) {
// ========== 状态 ==========
/** 当前围栏覆盖物 */
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
/** 围栏类型 */
const fenceType = ref<'polygon' | 'circle' | null>(null)
/** 圆形围栏参数 */
const circleCenter = ref<[number, number] | null>(null)
const circleRadius = ref(0)
/** 多边形顶点(用于出入判断) */
const fenceVertices = ref<[number, number][]>([])
/** 是否正在绘制 */
const isDrawing = ref(false)
/** 绘制时的预览线 */
const drawLine = shallowRef<AMap.Polyline | null>(null)
/** 绘制时的顶点标记列表 */
const vertexMarkers = shallowRef<AMap.Marker[]>([])
/** 当前已绘制的顶点数(用于模板判断是否 >= 3 个) */
const drawVertexCount = ref(0)
/** 老人标记 */
const personMarker = shallowRef<AMap.Marker | null>(null)
/** 老人当前位置 */
const personPosition = ref<[number, number] | null>(null)
/** 老人是否在围栏内 */
const isInside = ref(true)
/** 报警是否激活 */
const alarmActive = ref(false)
/** 越界次数 */
const alarmCount = ref(0)
/** 报警历史 */
const alarmHistory = ref<AlarmRecord[]>([])
/** 报警音效振荡器引用 */
let alarmOscillator: OscillatorNode | null = null
let alarmGain: GainNode | null = null
let alarmInterval: ReturnType<typeof setInterval> | null = null
/** 是否已有围栏(可用于模板判断) */
const hasFence = computed(() => fenceOverlay.value !== null)
/** 是否已有老人标记 */
const hasPerson = computed(() => personMarker.value !== null)
// ========== 围栏绘制 ==========
/** 绘制时的暂存顶点(闭包内引用,供 finishDraw 访问) */
let _drawPoints: [number, number][] = []
/**
* 启动多边形围栏绘制
*
* 交互:
* - 在地图上逐一点击添加顶点(绿色编号圆点 + 虚线连线)
* - 点击首顶点附近±15 像素范围)→ 闭环完成 ✅
* - 点击「闭合围栏」按钮 → 闭环完成 ✅
* - 右键或 Esc → 取消 🗑️
*
* 解决双击冲突:禁用 AMap 的双击缩放doubleClickZoom = false
* 避免双击 = click+click+dblclick 的竞态。
*/
async function startDrawPolygon(): Promise<void> {
const map = mapInstance.value
if (!map) return
// 清理上一个绘制状态
cancelDraw()
const AMap = await loadAMap()
const points: [number, number][] = []
_drawPoints = points
isDrawing.value = true
fenceType.value = 'polygon'
drawVertexCount.value = 0
// 关键:禁用双击缩放,否则 dblclick 被 AMap 消耗
map.setStatus({ doubleClickZoom: false })
// ── 点击事件:添加顶点 或 闭环 ──
function onClick(e: any): void {
// 安全获取经纬度(兼容 LngLat 实例 / 普通对象 / getter 多种形式)
const lnglat = e.lnglat
const lng: number = typeof lnglat.lng === 'number'
? lnglat.lng
: lnglat.getLng?.() ?? NaN
const lat: number = typeof lnglat.lat === 'number'
? lnglat.lat
: lnglat.getLat?.() ?? NaN
if (isNaN(lng) || isNaN(lat)) return
const clicked: [number, number] = [lng, lat]
// 已有 >= 3 个顶点 + 点击位置接近首顶点(地理距离 < 30 米) → 闭环
if (points.length >= 3) {
const distToFirst = haversineDistance(clicked, points[0])
if (distToFirst < 30) {
finishPolygonDraw(points, map!, AMap)
return
}
}
// 普通点击:添加顶点
points.push(clicked)
drawVertexCount.value = points.length
// 添加编号顶点标记
const vm = new AMap.Marker({
position: clicked,
content: vertexMarkerHTML(points.length),
offset: new AMap.Pixel(-6, -6),
zIndex: 120,
})
map!.add(vm)
vertexMarkers.value = [...vertexMarkers.value, vm]
// 更新预览线
if (drawLine.value) {
drawLine.value.setPath(points)
} else {
drawLine.value = new AMap.Polyline({
path: points,
...DRAW_LINE_STYLE,
zIndex: 110,
})
map!.add(drawLine.value)
}
// 更新首顶点样式:销毁并重建为首顶点专用高亮标记
if (points.length >= 3 && vertexMarkers.value[0]) {
const oldFirst = vertexMarkers.value[0]
const firstPos = oldFirst.getPosition()
map!.remove(oldFirst)
const highlightedFirst = new AMap.Marker({
position: [firstPos.lng, firstPos.lat],
content: `<div class="geofence-first-vertex">1</div>`,
offset: new AMap.Pixel(-10, -10),
zIndex: 125, // 高于其他顶点
})
map!.add(highlightedFirst)
// 替换数组中的第一个
const rest = vertexMarkers.value.slice(1)
vertexMarkers.value = [highlightedFirst, ...rest]
}
}
// ── 右键:取消绘制 ──
function onRightClick(e: any): void {
// 阻止浏览器默认右键菜单
e.domEvent?.preventDefault?.()
cancelDraw()
}
// 绑定事件
;(map as any)._geofenceClick = onClick
;(map as any)._geofenceRightClick = onRightClick
map.on('click', onClick)
map.on('rightclick', onRightClick)
// 切换光标为十字
map.setDefaultCursor('crosshair')
}
/** 手动闭合多边形(供 UI 按钮调用) */
async function finishDraw(): Promise<void> {
const map = mapInstance.value
if (!map || !isDrawing.value) return
const AMap = await loadAMap()
if (fenceType.value === 'polygon') {
finishPolygonDraw(_drawPoints, map, AMap)
}
// 圆形暂不支持手动完成(只能点击第二次完成)
}
/** 完成多边形绘制,创建最终围栏 */
function finishPolygonDraw(
points: [number, number][],
map: AMap.Map,
AMap: typeof import('@amap/amap-jsapi-loader')
): void {
if (points.length < 3) {
cancelDraw()
alert('至少需要 3 个顶点才能形成电子围栏。请继续点击添加更多顶点。')
return
}
// 清理绘制中间状态(预览线、顶点标记、事件监听)
cleanupDrawing(map)
isDrawing.value = false
drawVertexCount.value = 0
_drawPoints = []
// 恢复双击缩放
map.setStatus({ doubleClickZoom: true })
// 创建围栏多边形
const polygon = new AMap.Polygon({
path: points,
...FENCE_STYLE,
zIndex: 100,
})
map.add(polygon)
fenceOverlay.value = polygon
fenceVertices.value = [...points]
map.setDefaultCursor('default')
console.log(`[电子围栏] ✅ 多边形围栏已创建,顶点数: ${points.length}`)
}
/** 启动圆形围栏绘制 */
async function startDrawCircle(): Promise<void> {
const map = mapInstance.value
if (!map) return
cancelDraw()
const AMap = await loadAMap()
isDrawing.value = true
fenceType.value = 'circle'
drawVertexCount.value = 0
// 禁用双击缩放
map.setStatus({ doubleClickZoom: false })
let centerPoint: [number, number] | null = null
let previewCircle: AMap.Circle | null = null
// ── 第一次点击:确定圆心 ──
function onClick(e: any): void {
const lng = e.lnglat.getLng()
const lat = e.lnglat.getLat()
if (!centerPoint) {
// 设置圆心
centerPoint = [lng, lat]
map!.off('click', onClick)
// 添加圆心标记
const cm = new AMap.Marker({
position: [lng, lat],
content: `<div style="width:16px;height:16px;border-radius:50%;background:#FF4D4F;border:2px solid #fff;box-shadow:0 1px 6px rgba(0,0,0,0.3);"></div>`,
offset: new AMap.Pixel(-8, -8),
zIndex: 120,
})
map!.add(cm)
vertexMarkers.value = [cm]
// 监听鼠标移动,动态预览圆形
function onMouseMove(me: any): void {
const mlng = me.lnglat.getLng()
const mlat = me.lnglat.getLat()
const r = haversineDistance(centerPoint!, [mlng, mlat])
if (previewCircle) {
previewCircle.setCenter(centerPoint!)
previewCircle.setRadius(r)
} else {
previewCircle = new AMap.Circle({
center: centerPoint!,
radius: r,
fillColor: 'rgba(66, 184, 131, 0.12)',
fillOpacity: 0.3,
strokeColor: '#42b883',
strokeWeight: 2,
strokeOpacity: 0.6,
strokeStyle: 'dashed',
zIndex: 110,
})
map!.add(previewCircle)
}
}
function onSecondClick(se: any): void {
const slng = se.lnglat.getLng()
const slat = se.lnglat.getLat()
const r = haversineDistance(centerPoint!, [slng, slat])
map!.off('mousemove', onMouseMove)
map!.off('click', onSecondClick)
// 清理预览
if (previewCircle) {
map!.remove(previewCircle)
previewCircle = null
}
cleanupDrawing(map!)
isDrawing.value = false
drawVertexCount.value = 0
// 创建正式围栏
const circle = new AMap.Circle({
center: centerPoint!,
radius: r,
...FENCE_STYLE,
zIndex: 100,
})
map!.add(circle)
fenceOverlay.value = circle
circleCenter.value = centerPoint
circleRadius.value = r
fenceVertices.value = [centerPoint!]
// 恢复双击缩放
map!.setStatus({ doubleClickZoom: true })
map!.setDefaultCursor('default')
console.log(`[电子围栏] ✅ 圆形围栏已创建,半径: ${Math.round(r)}`)
}
// ── 右键取消 ──
function onCircleRightClick(e: any): void {
e.domEvent?.preventDefault?.()
map!.off('mousemove', onMouseMove)
map!.off('click', onSecondClick)
cancelDraw()
}
;(map as any)._geofenceSecondClick = onSecondClick
;(map as any)._geofenceMouseMove = onMouseMove
;(map as any)._geofenceCircleRightClick = onCircleRightClick
map!.on('click', onSecondClick)
map!.on('mousemove', onMouseMove)
map!.on('rightclick', onCircleRightClick)
}
}
// ── 绘制阶段的右键取消 ──
function onCancelRightClick(e: any): void {
e.domEvent?.preventDefault?.()
cancelDraw()
}
;(map as any)._geofenceClick = onClick
;(map as any)._geofenceCircleCancelRight = onCancelRightClick
map.on('click', onClick)
map.on('rightclick', onCancelRightClick)
map.setDefaultCursor('crosshair')
}
/** 取消当前绘制 */
function cancelDraw(): void {
const map = mapInstance.value
if (!map) return
cleanupDrawing(map)
isDrawing.value = false
drawVertexCount.value = 0
_drawPoints = []
// 恢复双击缩放
map.setStatus({ doubleClickZoom: true })
map.setDefaultCursor('default')
}
/** 清理绘制中的中间元素 */
function cleanupDrawing(map: AMap.Map): void {
// 移除预览线
if (drawLine.value) {
map.remove(drawLine.value)
drawLine.value = null
}
// 移除顶点标记
vertexMarkers.value.forEach((m) => map.remove(m))
vertexMarkers.value = []
// 移除事件监听
const m = map as any
if (m._geofenceClick) { map.off('click', m._geofenceClick); delete m._geofenceClick }
if (m._geofenceRightClick) { map.off('rightclick', m._geofenceRightClick); delete m._geofenceRightClick }
if (m._geofenceSecondClick) { map.off('click', m._geofenceSecondClick); delete m._geofenceSecondClick }
if (m._geofenceMouseMove) { map.off('mousemove', m._geofenceMouseMove); delete m._geofenceMouseMove }
if (m._geofenceCircleRightClick) { map.off('rightclick', m._geofenceCircleRightClick); delete m._geofenceCircleRightClick }
if (m._geofenceCircleCancelRight) { map.off('rightclick', m._geofenceCircleCancelRight); delete m._geofenceCircleCancelRight }
// 恢复双击缩放
map.setStatus({ doubleClickZoom: true })
}
/** 删除当前围栏 */
function clearFence(): void {
const map = mapInstance.value
if (!map) return
if (fenceOverlay.value) {
map.remove(fenceOverlay.value)
fenceOverlay.value = null
}
fenceType.value = null
fenceVertices.value = []
circleCenter.value = null
circleRadius.value = 0
isInside.value = true
alarmActive.value = false
stopAlarmSound()
console.log('[电子围栏] 🗑️ 围栏已清除')
}
// ========== 老人标记 ==========
/** 在地图中心添加老人标记 */
async function addPerson(): Promise<void> {
const map = mapInstance.value
if (!map) return
// 先移除旧标记
removePerson()
const AMap = await loadAMap()
// 默认放在地图当前中心
const center = map.getCenter()
const position: [number, number] = [center.lng, center.lat]
// 如果围栏存在,放在围栏内部
if (fenceOverlay.value && fenceType.value === 'polygon') {
// 尝试放在第一个顶点的附近(围栏内部)
const v0 = fenceVertices.value[0]
if (v0) {
position[0] = v0[0] + 0.001 // 稍偏移
position[1] = v0[1] + 0.001
}
}
const marker = new AMap.Marker({
position,
content: PERSON_MARKER_HTML,
offset: new AMap.Pixel(-17, -17),
draggable: true,
zIndex: 200,
})
// 拖拽结束 → 检测越界
marker.on('dragend', () => {
const pos = marker.getPosition()
personPosition.value = [pos.lng, pos.lat]
checkBoundary()
})
map.add(marker)
personMarker.value = marker
personPosition.value = position
// 创建后立刻检测位置
checkBoundary()
console.log(`[电子围栏] 👴 老人已添加,位置: [${position[0].toFixed(5)}, ${position[1].toFixed(5)}]`)
}
/** 移除老人标记 */
function removePerson(): void {
const map = mapInstance.value
if (!map) return
if (personMarker.value) {
map.remove(personMarker.value)
personMarker.value = null
}
personPosition.value = null
isInside.value = true
alarmActive.value = false
stopAlarmSound()
}
// ========== 移动控制 ==========
/** 步长(经纬度偏移),约 20-50 米 */
const MOVE_STEP = 0.0005
/** 方向移动老人 */
function movePerson(direction: 'up' | 'down' | 'left' | 'right'): void {
const marker = personMarker.value
if (!marker) return
const pos = marker.getPosition()
let { lng, lat } = pos
switch (direction) {
case 'up': lat += MOVE_STEP; break
case 'down': lat -= MOVE_STEP; break
case 'left': lng -= MOVE_STEP; break
case 'right': lng += MOVE_STEP; break
}
const newPosition: [number, number] = [lng, lat]
marker.setPosition(newPosition)
personPosition.value = newPosition
// 每次移动后检测越界
checkBoundary()
}
// ========== 越界检测 ==========
/**
* 检测老人是否在围栏内
* 更新 isInside / alarmActive必要时触发报警
*/
function checkBoundary(): void {
const pp = personPosition.value
if (!pp) return
const fence = fenceOverlay.value
if (!fence) {
// 没有围栏,不判断
isInside.value = true
return
}
let inside: boolean
if (fenceType.value === 'circle') {
// 圆形:使用 AMap.Circle.contains()
inside = (fence as AMap.Circle).contains(pp)
} else {
// 多边形:先用 GeometryUtil兜底用射线法
const AMap = (window as any).AMap
try {
inside = AMap.GeometryUtil.isPointInRing(
{ lng: pp[0], lat: pp[1] },
fenceVertices.value
)
} catch {
inside = isPointInPolygon(pp, fenceVertices.value)
}
}
const wasInside = isInside.value
isInside.value = inside
if (!inside && wasInside) {
// ── 刚刚越界!触发报警 ──
triggerAlarm(pp)
}
if (inside && !wasInside) {
// ── 回到围栏内 ──
resetAlarm()
updateFenceStyle(false)
}
}
// ========== 报警 ==========
function triggerAlarm(position: [number, number]): void {
if (alarmActive.value) return // 已激活,不重复
alarmActive.value = true
alarmCount.value++
alarmHistory.value.push({
time: new Date().toLocaleTimeString('zh-CN'),
position,
})
updateFenceStyle(true)
startAlarmSound()
console.warn(`[电子围栏] 🚨 第 ${alarmCount.value} 次越界报警!位置: [${position[0].toFixed(5)}, ${position[1].toFixed(5)}]`)
}
function resetAlarm(): void {
alarmActive.value = false
stopAlarmSound()
updateFenceStyle(false)
console.log('[电子围栏] ✅ 老人已回到围栏内')
}
/** 手动解除报警 */
function dismissAlarm(): void {
alarmActive.value = false
stopAlarmSound()
updateFenceStyle(false)
}
/** 围栏样式切换 */
function updateFenceStyle(alarm: boolean): void {
const fence = fenceOverlay.value
if (!fence) return
const style = alarm ? FENCE_ALARM_STYLE : FENCE_STYLE
if (fenceType.value === 'polygon') {
const poly = fence as AMap.Polygon
poly.setOptions?.(style) || Object.assign(poly as any, style)
} else {
const circle = fence as AMap.Circle
circle.setOptions?.(style) || Object.assign(circle as any, style)
}
}
/** 播放报警音Web Audio API — 蜂鸣声) */
function startAlarmSound(): void {
stopAlarmSound()
try {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
if (!AudioCtx) return
const ctx = new AudioCtx()
function beep(): void {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.type = 'square'
osc.frequency.value = 880
gain.gain.value = 0.2
osc.start(ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
osc.stop(ctx.currentTime + 0.3)
alarmOscillator = osc
alarmGain = gain
}
// 每 800ms 蜂鸣一次
beep()
alarmInterval = setInterval(() => {
beep()
}, 800)
} catch {
// 静默失败(部分环境不支持 Web Audio
}
}
/** 停止报警音 */
function stopAlarmSound(): void {
if (alarmInterval) {
clearInterval(alarmInterval)
alarmInterval = null
}
try {
alarmOscillator?.stop?.()
alarmGain?.disconnect?.()
} catch { /* ignore */ }
alarmOscillator = null
alarmGain = null
}
// ========== 生命周期清理 ==========
/** 销毁所有围栏相关资源 */
function destroyGeofence(): void {
stopAlarmSound()
clearFence()
removePerson()
cancelDraw()
}
return {
// 状态
fenceOverlay,
fenceType,
fenceVertices,
isDrawing,
isInside,
alarmActive,
alarmCount,
alarmHistory,
hasFence,
hasPerson,
personMarker,
personPosition,
// 围栏操作
startDrawPolygon,
startDrawCircle,
finishDraw,
cancelDraw,
clearFence,
drawVertexCount,
// 老人标记操作
addPerson,
removePerson,
movePerson,
// 检测 & 报警
checkBoundary,
dismissAlarm,
destroyGeofence,
}
}
// ─── 工具函数 ────────────────────────────────────────
/** Haversine 公式计算两点间距离(米) */
function haversineDistance(
[lng1, lat1]: [number, number],
[lng2, lat2]: [number, number]
): number {
const R = 6371000 // 地球半径(米)
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLng = ((lng2 - lng1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}

View File

@@ -0,0 +1,413 @@
// ============================================================
// 护理员轨迹 Composable — 手动标记起点/终点 + 路线 + 动画
//
// 交互模型:
// 1. [📍标记起点] → 点击地图 → 放 📍 marker
// 2. [🏁标记终点] → 点击地图 → 放 🏁 marker
// 3. [👩‍⚕️添加护理员] → 放在起点位置(可拖拽)
// 4. [🚗规划路线] → AMap.Driving → 蓝色折线
// 5. [▶出发] → 护理员沿路线动画移动
// ============================================================
import { ref, shallowRef, computed } from 'vue'
import type { Ref } from 'vue'
import { loadAMap } from './useAmap'
// ─── 标记 HTML ─────────────────────────────────────
const CAREGIVER_HTML = '<div class="geofence-caregiver-marker">👩‍⚕️</div>'
const ORIGIN_HTML = '<div class="geofence-origin-marker">📍</div>'
const DEST_HTML = '<div class="geofence-dest-marker">🏁</div>'
// ─── 类型 ──────────────────────────────────────────
export interface RouteInfo {
distance: number
duration: number
path: [number, number][]
}
// ─── 工具 ──────────────────────────────────────────
function downsamplePath(path: [number, number][], target: number): [number, number][] {
if (path.length <= target) return [...path]
const result: [number, number][] = []
const step = (path.length - 1) / (target - 1)
for (let i = 0; i < target; i++) {
result.push(path[Math.min(Math.round(i * step), path.length - 1)])
}
return result
}
/** 安全获取事件经纬度 */
function eventLngLat(e: any): [number, number] | null {
const lng: number = e.lnglat?.lng ?? e.lnglat?.getLng?.()
const lat: number = e.lnglat?.lat ?? e.lnglat?.getLat?.()
if (isNaN(lng) || isNaN(lat)) return null
return [lng, lat]
}
// ─── Composable ────────────────────────────────────
export function useRouteTrack(mapInstance: Ref<AMap.Map | null>) {
// ===== 状态 =====
const caregiverMarker = shallowRef<AMap.Marker | null>(null)
const originMarker = shallowRef<AMap.Marker | null>(null)
const destMarker = shallowRef<AMap.Marker | null>(null)
const routePolyline = shallowRef<AMap.Polyline | null>(null)
const routePath = ref<[number, number][]>([])
const animPath = ref<[number, number][]>([])
const routeInfo = ref<RouteInfo | null>(null)
const isPlanning = ref(false)
const isAnimating = ref(false)
const animProgress = ref(0)
const animStep = ref(0)
/** 起点坐标 */
const originPoint = ref<[number, number] | null>(null)
/** 终点坐标 */
const destPoint = ref<[number, number] | null>(null)
/** 当前模式:'origin' | 'dest' | null */
const settingMode = ref<'origin' | 'dest' | null>(null)
let animTimer: ReturnType<typeof setInterval> | null = null
let _clickHandler: ((e: any) => void) | null = null
// ── 派生 ──
const hasCaregiver = () => caregiverMarker.value !== null
const hasRoute = () => routePolyline.value !== null
const hasOrigin = () => originPoint.value !== null
const hasDest = () => destPoint.value !== null
const canPlanRoute = computed(() =>
hasOrigin() && hasDest() && !isPlanning.value
)
// ===== 点击地图通用处理 =====
async function enterSetMode(mode: 'origin' | 'dest'): Promise<void> {
const map = mapInstance.value
if (!map) return
exitSetMode()
settingMode.value = mode
map.setDefaultCursor('crosshair')
const AMap = await loadAMap()
function onClick(e: any): void {
const pt = eventLngLat(e)
if (!pt) return
if (mode === 'origin') {
applyOrigin(pt, AMap, map!)
} else {
applyDestination(pt)
}
exitSetMode()
}
_clickHandler = onClick
;(map as any)._rtClick = onClick
map.on('click', onClick)
}
function exitSetMode(): void {
const map = mapInstance.value
if (!map) return
settingMode.value = null
map.setDefaultCursor('default')
if (_clickHandler && (map as any)._rtClick) {
map.off('click', _clickHandler)
delete (map as any)._rtClick
_clickHandler = null
}
}
// ===== 起点标记 =====
function applyOrigin(
point: [number, number],
AMap: typeof import('@amap/amap-jsapi-loader'),
map: AMap.Map
): void {
originPoint.value = point
if (originMarker.value) {
originMarker.value.setPosition(point)
} else {
const m = new AMap.Marker({
position: point,
content: ORIGIN_HTML,
offset: new AMap.Pixel(-10, -10),
zIndex: 205,
})
map.add(m)
originMarker.value = m
}
console.log(`[护理员] 📍 起点: [${point[0].toFixed(4)}, ${point[1].toFixed(4)}]`)
}
function removeOrigin(): void {
const map = mapInstance.value
if (!map) return
if (originMarker.value) { map.remove(originMarker.value); originMarker.value = null }
originPoint.value = null
// 清除起点也清除路线
clearRoute()
}
// ===== 终点标记 =====
function applyDestination(point: [number, number]): void {
const map = mapInstance.value
if (!map) return
destPoint.value = point
if (destMarker.value) {
destMarker.value.setPosition(point)
} else {
const m = new AMap.Marker({
position: point,
content: DEST_HTML,
offset: new AMap.Pixel(-12, -32),
zIndex: 215,
})
map.add(m)
destMarker.value = m
}
console.log(`[护理员] 🏁 终点: [${point[0].toFixed(4)}, ${point[1].toFixed(4)}]`)
}
function removeDest(): void {
const map = mapInstance.value
if (!map) return
if (destMarker.value) { map.remove(destMarker.value); destMarker.value = null }
destPoint.value = null
clearRoute()
}
// ===== 护理员标记 =====
/** 添加护理员:放在起点位置,没有起点则放地图中心 */
async function addCaregiver(): Promise<void> {
const map = mapInstance.value
if (!map) return
const pos = originPoint.value
? [...originPoint.value]
: (() => { const c = map.getCenter(); return [c.lng, c.lat] as [number, number] })()
removeCaregiver()
const AMap = await loadAMap()
const marker = new AMap.Marker({
position: pos,
content: CAREGIVER_HTML,
offset: new AMap.Pixel(-17, -17),
draggable: true,
zIndex: 210,
})
map.add(marker)
caregiverMarker.value = marker
// 如果没起点,护理员的位置同时作为起点
if (!originPoint.value) {
applyOrigin(pos, AMap, map)
}
if (!originPoint.value && !destPoint.value) {
map.setZoomAndCenter(5, pos)
}
console.log(`[护理员] 👩‍⚕️ 护理员已添加: [${pos[0].toFixed(4)}, ${pos[1].toFixed(4)}]`)
}
function removeCaregiver(): void {
const map = mapInstance.value
if (!map) return
stopAnimation()
if (caregiverMarker.value) { map.remove(caregiverMarker.value); caregiverMarker.value = null }
}
// ===== 全部清除 =====
function clearAll(): void {
exitSetMode()
stopAnimation()
clearRoute()
removeCaregiver()
removeOrigin()
removeDest()
}
// ===== 路线规划 =====
async function planRoute(): Promise<void> {
const map = mapInstance.value
if (!map || !originPoint.value || !destPoint.value) return
clearRoute()
isPlanning.value = true
try {
const AMap = await loadAMap()
// 检查 Driving 是否可用
if (!AMap.Driving) {
throw new Error('AMap.Driving 插件未加载,请检查 config/amap.ts 中的 AMAP_PLUGINS 是否包含 "AMap.Driving"')
}
const [orgLng, orgLat] = originPoint.value
const [destLng, destLat] = destPoint.value
console.log(`[护理员] 🚗 规划路线: [${orgLng.toFixed(4)}, ${orgLat.toFixed(4)}] → [${destLng.toFixed(4)}, ${destLat.toFixed(4)}]`)
await new Promise<void>((resolve, reject) => {
// 必须传入 map 实例,否则 Driving 无法正确初始化
const driving = new AMap.Driving({
map: map as any,
policy: 0, // 0 = 速度优先
showTraffic: false,
})
// 参数使用 AMap.LngLat 实例(更可靠)
const originLngLat = new AMap.LngLat(orgLng, orgLat)
const destLngLat = new AMap.LngLat(destLng, destLat)
driving.search(originLngLat, destLngLat, (status: string, result: any) => {
console.log(`[护理员] Driving 回调: status="${status}", routes=${result?.routes?.length ?? 0}`)
if (status === 'complete' && result.routes?.length > 0) {
const route = result.routes[0]
const fullPath: [number, number][] = []
for (const step of route.steps) {
for (const pt of step.path) fullPath.push([pt.lng, pt.lat])
}
if (fullPath.length < 2) { reject(new Error('路线点数不足')); return }
routePath.value = fullPath
animPath.value = downsamplePath(fullPath, 250)
routeInfo.value = { distance: route.distance, duration: route.time, path: fullPath }
const polyline = new AMap.Polyline({
path: fullPath,
strokeColor: '#4A90D9', strokeWeight: 4, strokeOpacity: 0.75,
lineJoin: 'round', lineCap: 'round', zIndex: 85,
})
map!.add(polyline)
routePolyline.value = polyline
map!.setFitView([polyline], false, [60, 60, 60, 60])
console.log(`[护理员] 🚗 路线完成 — ${(route.distance/1000).toFixed(1)}km${animPath.value.length}动画点`)
resolve()
} else if (status === 'error') {
const msg = result?.info || result?.message || '未知错误'
console.error(`[护理员] ❌ Driving 返回错误: ${msg}`, result)
reject(new Error(`路线规划失败: ${msg}`))
} else if (status === 'no_data') {
reject(new Error('起终点之间无可用路线,请尝试缩短距离或调整位置'))
} else {
console.error(`[护理员] ❌ 未知状态: ${status}`, result)
reject(new Error(`路线规划失败 (${status}),请重试`))
}
})
})
} catch (err: any) {
alert(`路线规划失败: ${err.message}`)
clearRoute()
} finally {
isPlanning.value = false
}
}
function clearRoute(): void {
const map = mapInstance.value
if (!map) return
stopAnimation()
if (routePolyline.value) { map.remove(routePolyline.value); routePolyline.value = null }
routePath.value = []
animPath.value = []
routeInfo.value = null
animProgress.value = 0
animStep.value = 0
}
// ===== 动画 =====
async function startAnimation(): Promise<void> {
// 如果护理员不存在,自动在起点创建
if (!caregiverMarker.value) {
if (!originPoint.value) {
alert('请先标记起点和终点,再规划路线')
return
}
await addCaregiver()
}
if (animPath.value.length < 2) {
alert('路线数据为空,请先规划路线')
return
}
if (isAnimating.value) return
isAnimating.value = true
animStep.value = 0
animProgress.value = 0
const total = animPath.value.length - 1
animTimer = setInterval(() => {
if (animStep.value >= total) {
stopAnimation()
animProgress.value = 1
return
}
animStep.value++
animProgress.value = animStep.value / total
caregiverMarker.value?.setPosition(animPath.value[animStep.value])
}, 40)
console.log(`[护理员] ▶ 出发,${total}步 ~${(total*40/1000).toFixed(1)}`)
}
function stopAnimation(): void {
if (animTimer) { clearInterval(animTimer); animTimer = null }
isAnimating.value = false
}
function resetToOrigin(): void {
stopAnimation()
if (caregiverMarker.value && originPoint.value) {
caregiverMarker.value.setPosition(originPoint.value)
}
animProgress.value = 0
animStep.value = 0
}
// ===== 清理 =====
function destroyRouteTrack(): void {
clearAll()
}
return {
caregiverMarker, routePolyline, routePath, routeInfo,
isPlanning, isAnimating, animProgress,
originPoint, destPoint, settingMode,
hasCaregiver, hasRoute, hasOrigin, hasDest, canPlanRoute,
enterSetMode, exitSetMode,
addCaregiver, removeCaregiver,
removeOrigin, removeDest,
planRoute, clearRoute,
startAnimation, stopAnimation, resetToOrigin,
clearAll,
destroyRouteTrack,
}
}