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

This commit is contained in:
2026-06-21 23:26:42 +08:00
parent 09256b2808
commit 0089e5c107
5 changed files with 325 additions and 30 deletions

4
.env
View File

@@ -7,3 +7,7 @@ VITE_AMAP_JSAPI_KEY=f71bdfbf074620937b6b127128f49086
# 高德 Web Service Key用于服务端 API如地理编码、路径规划等 # 高德 Web Service Key用于服务端 API如地理编码、路径规划等
VITE_AMAP_WEB_KEY=6a30265609e6099d02eddc4e3085de5c VITE_AMAP_WEB_KEY=6a30265609e6099d02eddc4e3085de5c
# 高德安全密钥 — 2021/12/02 后申请的 Key 必须配置
# 在 https://console.amap.com/dev/key/app 找到对应应用,复制「安全密钥」
VITE_AMAP_SECURITY_CODE=999344a49e71dbbc97bcf59ba242bb50

View File

@@ -4,7 +4,7 @@
import { ref, shallowRef, onUnmounted } from 'vue' import { ref, shallowRef, onUnmounted } from 'vue'
import AMapLoader from '@amap/amap-jsapi-loader' import AMapLoader from '@amap/amap-jsapi-loader'
import { AMAP_JSAPI_KEY, AMAP_VERSION, AMAP_PLUGINS } from '@/config/amap' import { AMAP_JSAPI_KEY, AMAP_SECURITY_CODE, AMAP_VERSION, AMAP_PLUGINS } from '@/config/amap'
/** 全局加载状态:避免重复加载 JSAPI 脚本 */ /** 全局加载状态:避免重复加载 JSAPI 脚本 */
let amapPromise: Promise<typeof AMap> | null = null let amapPromise: Promise<typeof AMap> | null = null
@@ -20,6 +20,15 @@ export async function loadAMap(): Promise<typeof AMap> {
if (AMapGlobal) return AMapGlobal if (AMapGlobal) return AMapGlobal
if (!amapPromise) { if (!amapPromise) {
// ⚠️ 安全密钥必须在 JSAPI 脚本加载之前设置
// 2021/12/02 之后申请的 Key 必须配合 securityJsCode 使用
// 否则 Driving / Geocoder 等服务会报 INVALID_USER_SCODE
if (AMAP_SECURITY_CODE && !(window as any)._AMapSecurityConfig) {
;(window as any)._AMapSecurityConfig = {
securityJsCode: AMAP_SECURITY_CODE,
}
}
amapPromise = AMapLoader.load({ amapPromise = AMapLoader.load({
key: AMAP_JSAPI_KEY, key: AMAP_JSAPI_KEY,
version: AMAP_VERSION, version: AMAP_VERSION,

View File

@@ -9,6 +9,9 @@ export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
/** 高德地图 Web Service Key — 用于服务端 API 调用 */ /** 高德地图 Web Service Key — 用于服务端 API 调用 */
export const AMAP_WEB_KEY = import.meta.env.VITE_AMAP_WEB_KEY as string export const AMAP_WEB_KEY = import.meta.env.VITE_AMAP_WEB_KEY as string
/** 高德地图安全密钥 — 2021/12/02 后申请的 Key 必须配合此密钥使用 */
export const AMAP_SECURITY_CODE = import.meta.env.VITE_AMAP_SECURITY_CODE as string
/** 高德地图 JSAPI 版本 */ /** 高德地图 JSAPI 版本 */
export const AMAP_VERSION = '2.0' export const AMAP_VERSION = '2.0'
@@ -19,4 +22,5 @@ export const AMAP_PLUGINS = [
'AMap.PlaceSearch', // 搜索服务 'AMap.PlaceSearch', // 搜索服务
'AMap.Geolocation', // 定位 'AMap.Geolocation', // 定位
'AMap.MarkerClusterer', // 点聚合 'AMap.MarkerClusterer', // 点聚合
'AMap.Driving', // 驾车路线规划
] as const ] as const

View File

@@ -119,38 +119,126 @@
添加老人 添加老人
</button> </button>
<template v-else> <template v-else>
<!-- 方向键 -->
<div class="dpad"> <div class="dpad">
<button <button class="dpad-btn dpad-up" :disabled="isDrawing" @click="movePerson('up')" title="向北移动"></button>
class="dpad-btn dpad-up" <button class="dpad-btn dpad-left" :disabled="isDrawing" @click="movePerson('left')" title="向西移动"></button>
:disabled="isDrawing" <button class="dpad-btn dpad-right" :disabled="isDrawing" @click="movePerson('right')" title="向东移动"></button>
@click="movePerson('up')" <button class="dpad-btn dpad-down" :disabled="isDrawing" @click="movePerson('down')" title="向南移动"></button>
title="向北移动"
></button>
<button
class="dpad-btn dpad-left"
:disabled="isDrawing"
@click="movePerson('left')"
title="向西移动"
></button>
<button
class="dpad-btn dpad-right"
:disabled="isDrawing"
@click="movePerson('right')"
title="向东移动"
></button>
<button
class="dpad-btn dpad-down"
:disabled="isDrawing"
@click="movePerson('down')"
title="向南移动"
></button>
</div> </div>
<span class="hint-text">或拖拽地图上的 👴 标记</span> <span class="hint-text">或拖拽地图上的 👴 标记</span>
<button class="btn-danger" @click="removePerson">🗑 移除</button> <button class="btn-danger" @click="removePerson">🗑 移除</button>
</template> </template>
</div> </div>
</div> </div>
<!-- 第三行标记起点 / 终点 -->
<div class="controls-row">
<span class="controls-label">
📍🏁 标记
<span v-if="hasOrigin && hasDest && !isPlanning" class="route-ready"> 就绪</span>
<span v-if="isPlanning" class="route-planning"></span>
</span>
<div class="map-actions">
<!-- 标记起点 -->
<button
v-if="!settingMode"
:disabled="isPlanning || isAnimating || isDrawing"
@click="enterSetMode('origin')"
>
📍 标记起点
</button>
<button v-else-if="settingMode === 'origin'" class="btn-finish" @click="exitSetMode">
📍 点击地图放起点...
</button>
<!-- 标记终点 -->
<button
v-if="!settingMode"
:disabled="isPlanning || isAnimating || isDrawing"
@click="enterSetMode('dest')"
>
🏁 标记终点
</button>
<button v-else-if="settingMode === 'dest'" class="btn-finish" @click="exitSetMode">
🏁 点击地图放终点...
</button>
<!-- 清除已放的点 -->
<button v-if="hasOrigin && !settingMode" class="btn-reset" :disabled="isAnimating" @click="removeOrigin()">
清起点
</button>
<button v-if="hasDest && !settingMode" class="btn-reset" :disabled="isAnimating" @click="removeDest()">
清终点
</button>
</div>
</div>
<!-- 第四行护理员 + 路线 + 动画 -->
<div class="controls-row">
<span class="controls-label">
👩 路线
<span v-if="!hasCaregiver && hasOrigin" class="route-planning">未添加护理员</span>
</span>
<div class="map-actions">
<!-- 护理员 -->
<button
v-if="!hasCaregiver"
:disabled="!mapInstance || isDrawing"
@click="addCaregiver"
>
添加护理员
</button>
<button v-else class="btn-reset" :disabled="isAnimating" @click="removeCaregiver()">
移除护理员
</button>
<!-- 规划路线 -->
<button
:disabled="!canPlanRoute || isDrawing"
class="btn-route"
@click="planRoute"
>
{{ isPlanning ? '⏳' : '🚗' }} 规划路线
</button>
<!-- 路线摘要 -->
<span v-if="routeInfo" class="route-summary">{{ (routeInfo.distance / 1000).toFixed(0) }}km</span>
<!-- 动画 -->
<template v-if="hasRoute">
<button v-if="!isAnimating" class="btn-start" :disabled="isDrawing" @click="startAnimation">
出发
</button>
<button v-else class="btn-cancel" @click="stopAnimation">
暂停
</button>
<button
v-if="!isAnimating && animProgress > 0 && animProgress < 1"
class="btn-reset" @click="resetToOrigin"
>
🔄 回起点
</button>
</template>
<!-- 全部清除 -->
<button class="btn-danger" :disabled="isAnimating" @click="clearAll()">
🗑 清除
</button>
</div>
</div>
<!-- 标记模式提示条 -->
<div v-if="settingMode" class="controls-row draw-hint-row">
<span class="draw-hint">
🎯 <strong>点击地图</strong>放置{{ settingMode === 'origin' ? '起点(📍)' : '终点(🏁)' }}标记 &nbsp;|&nbsp; 再次点击按钮取消
</span>
</div>
<!-- 动画进度条 -->
<div v-if="isAnimating" class="route-progress-bar">
<div class="route-progress-fill" :style="{ width: (animProgress * 100).toFixed(1) + '%' }" />
<span class="route-progress-text">👩 护理员移动中... {{ (animProgress * 100).toFixed(0) }}%</span>
</div>
</div> </div>
<!-- 报警历史侧边信息桌面端 --> <!-- 报警历史侧边信息桌面端 -->
@@ -177,6 +265,7 @@
import { onMounted, nextTick, onUnmounted } from 'vue' import { onMounted, nextTick, onUnmounted } from 'vue'
import { useAmap } from '@/composables/useAmap' import { useAmap } from '@/composables/useAmap'
import { useGeofence } from '@/composables/useGeofence' import { useGeofence } from '@/composables/useGeofence'
import { useRouteTrack } from '@/composables/useRouteTrack'
// ═══ 地图初始化 ═══ // ═══ 地图初始化 ═══
const { containerRef, mapInstance, loading, error, initMap } = useAmap({ const { containerRef, mapInstance, loading, error, initMap } = useAmap({
@@ -218,9 +307,37 @@ const {
destroyGeofence, destroyGeofence,
} = useGeofence(mapInstance) } = useGeofence(mapInstance)
// ═══ 护理员轨迹 ═══
const {
routeInfo,
isPlanning,
isAnimating,
animProgress,
settingMode,
hasCaregiver,
hasRoute,
hasOrigin,
hasDest,
canPlanRoute,
enterSetMode,
exitSetMode,
addCaregiver,
removeCaregiver,
removeOrigin,
removeDest,
planRoute,
clearRoute,
startAnimation,
stopAnimation,
resetToOrigin,
clearAll,
destroyRouteTrack,
} = useRouteTrack(mapInstance)
// ═══ 生命周期清理 ═══ // ═══ 生命周期清理 ═══
onUnmounted(() => { onUnmounted(() => {
destroyGeofence() destroyGeofence()
destroyRouteTrack()
}) })
</script> </script>
@@ -623,6 +740,86 @@ onUnmounted(() => {
background: #f5f5f5; background: #f5f5f5;
color: #666; color: #666;
} }
/* ── 路线按钮 ── */
.btn-route {
border-color: #4A90D9 !important;
color: #4A90D9 !important;
}
.btn-route:hover:not(:disabled) {
background: #4A90D9 !important;
color: #fff !important;
}
.btn-start {
border-color: #27ae60 !important;
background: #27ae60 !important;
color: #fff !important;
font-weight: 600;
}
.btn-start:hover:not(:disabled) {
background: #219a52 !important;
}
.btn-reset {
border-color: #95a5a6 !important;
color: #95a5a6 !important;
}
.btn-reset:hover:not(:disabled) {
background: #95a5a6 !important;
color: #fff !important;
}
.route-summary {
font-size: 12px;
color: #4A90D9;
font-weight: 600;
white-space: nowrap;
}
.route-ready {
font-size: 11px;
color: #27ae60;
margin-left: 4px;
}
.route-planning {
font-size: 11px;
color: #faad14;
margin-left: 4px;
animation: status-alarm-pulse 0.6s ease-in-out infinite alternate;
}
/* ── 动画进度条 ── */
.route-progress-bar {
position: relative;
height: 22px;
background: #e8f0fe;
border-radius: 0;
overflow: hidden;
}
.route-progress-fill {
height: 100%;
background: linear-gradient(90deg, #4A90D9, #357ABD);
transition: width 0.04s linear;
border-radius: 0;
}
.route-progress-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: #2c5f8a;
mix-blend-mode: multiply;
}
</style> </style>
<!-- AMap 自定义 Marker 全局样式不能 scoped因为 Marker DOM AMap 直接注入 --> <!-- AMap 自定义 Marker 全局样式不能 scoped因为 Marker DOM AMap 直接注入 -->
@@ -686,4 +883,33 @@ onUnmounted(() => {
cursor: grab; cursor: grab;
user-select: none; user-select: none;
} }
/* ── 护理员标记 ── */
.geofence-caregiver-marker {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #4A90D9, #357ABD);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 2px solid #fff;
box-shadow: 0 2px 10px rgba(74, 144, 217, 0.4);
user-select: none;
}
/* ── 终点旗帜标记 ── */
.geofence-dest-marker {
font-size: 24px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
user-select: none;
}
/* ── 起点标记 ── */
.geofence-origin-marker {
font-size: 20px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
user-select: none;
}
</style> </style>

52
src/vite-env.d.ts vendored
View File

@@ -7,6 +7,8 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_AMAP_JSAPI_KEY: string readonly VITE_AMAP_JSAPI_KEY: string
readonly VITE_AMAP_WEB_KEY: string readonly VITE_AMAP_WEB_KEY: string
/** 高德安全密钥 — 2021/12/02 后申请的 Key 必须配置,否则 Driving 等服务报 INVALID_USER_SCODE */
readonly VITE_AMAP_SECURITY_CODE: string
} }
interface ImportMeta { interface ImportMeta {
@@ -104,6 +106,15 @@ declare namespace AMap {
constructor(map: Map, markers: any[], opts?: Record<string, any>) constructor(map: Map, markers: any[], opts?: Record<string, any>)
} }
// ── 经纬度 ──
class LngLat {
constructor(lng: number, lat: number)
lng: number
lat: number
getLng(): number
getLat(): number
}
// ── 覆盖物基类 ── // ── 覆盖物基类 ──
class Overlay { class Overlay {
setMap(map: Map | null): void setMap(map: Map | null): void
@@ -240,6 +251,47 @@ declare namespace AMap {
close(): void close(): void
setContent(content: string): void setContent(content: string): void
} }
// ── 驾车路线规划 ──
interface DrivingOptions {
map?: Map
panel?: string | HTMLElement
policy?: number // 0-5 驾车策略
[key: string]: any
}
interface DrivingStep {
path: { lng: number; lat: number }[]
instruction: string
distance: number
[key: string]: any
}
interface DrivingRoute {
steps: DrivingStep[]
distance: number
time: number
policy: string
[key: string]: any
}
interface DrivingResult {
routes: DrivingRoute[]
info: string
origin: { lng: number; lat: number }
destination: { lng: number; lat: number }
[key: string]: any
}
class Driving {
constructor(opts?: DrivingOptions)
search(
origin: [number, number] | { keyword: string; city?: string },
destination: [number, number] | { keyword: string; city?: string },
callback: (status: string, result: DrivingResult) => void
): void
clear(): void
}
} }
declare module '*.vue' { declare module '*.vue' {