高德地图添加轨迹规划和重放功能
This commit is contained in:
4
.env
4
.env
@@ -7,3 +7,7 @@ VITE_AMAP_JSAPI_KEY=f71bdfbf074620937b6b127128f49086
|
||||
|
||||
# 高德 Web Service Key(用于服务端 API,如地理编码、路径规划等)
|
||||
VITE_AMAP_WEB_KEY=6a30265609e6099d02eddc4e3085de5c
|
||||
|
||||
# 高德安全密钥 — 2021/12/02 后申请的 Key 必须配置
|
||||
# 在 https://console.amap.com/dev/key/app 找到对应应用,复制「安全密钥」
|
||||
VITE_AMAP_SECURITY_CODE=999344a49e71dbbc97bcf59ba242bb50
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { ref, shallowRef, onUnmounted } from 'vue'
|
||||
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 脚本 */
|
||||
let amapPromise: Promise<typeof AMap> | null = null
|
||||
@@ -20,6 +20,15 @@ export async function loadAMap(): Promise<typeof AMap> {
|
||||
if (AMapGlobal) return AMapGlobal
|
||||
|
||||
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({
|
||||
key: AMAP_JSAPI_KEY,
|
||||
version: AMAP_VERSION,
|
||||
|
||||
@@ -9,6 +9,9 @@ export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
|
||||
/** 高德地图 Web Service Key — 用于服务端 API 调用 */
|
||||
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 版本 */
|
||||
export const AMAP_VERSION = '2.0'
|
||||
|
||||
@@ -19,4 +22,5 @@ export const AMAP_PLUGINS = [
|
||||
'AMap.PlaceSearch', // 搜索服务
|
||||
'AMap.Geolocation', // 定位
|
||||
'AMap.MarkerClusterer', // 点聚合
|
||||
'AMap.Driving', // 驾车路线规划
|
||||
] as const
|
||||
|
||||
@@ -119,38 +119,126 @@
|
||||
➕ 添加老人
|
||||
</button>
|
||||
<template v-else>
|
||||
<!-- 方向键 -->
|
||||
<div class="dpad">
|
||||
<button
|
||||
class="dpad-btn dpad-up"
|
||||
:disabled="isDrawing"
|
||||
@click="movePerson('up')"
|
||||
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>
|
||||
<button class="dpad-btn dpad-up" :disabled="isDrawing" @click="movePerson('up')" 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>
|
||||
<span class="hint-text">或拖拽地图上的 👴 标记</span>
|
||||
<button class="btn-danger" @click="removePerson">🗑️ 移除</button>
|
||||
</template>
|
||||
</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' ? '起点(📍)' : '终点(🏁)' }}标记 | 再次点击按钮取消
|
||||
</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>
|
||||
|
||||
<!-- ═══ 报警历史侧边信息(桌面端) ═══ -->
|
||||
@@ -177,6 +265,7 @@
|
||||
import { onMounted, nextTick, onUnmounted } from 'vue'
|
||||
import { useAmap } from '@/composables/useAmap'
|
||||
import { useGeofence } from '@/composables/useGeofence'
|
||||
import { useRouteTrack } from '@/composables/useRouteTrack'
|
||||
|
||||
// ═══ 地图初始化 ═══
|
||||
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
|
||||
@@ -218,9 +307,37 @@ const {
|
||||
destroyGeofence,
|
||||
} = 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(() => {
|
||||
destroyGeofence()
|
||||
destroyRouteTrack()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -623,6 +740,86 @@ onUnmounted(() => {
|
||||
background: #f5f5f5;
|
||||
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>
|
||||
|
||||
<!-- AMap 自定义 Marker 全局样式(不能 scoped,因为 Marker DOM 由 AMap 直接注入) -->
|
||||
@@ -686,4 +883,33 @@ onUnmounted(() => {
|
||||
cursor: grab;
|
||||
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>
|
||||
|
||||
52
src/vite-env.d.ts
vendored
52
src/vite-env.d.ts
vendored
@@ -7,6 +7,8 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AMAP_JSAPI_KEY: string
|
||||
readonly VITE_AMAP_WEB_KEY: string
|
||||
/** 高德安全密钥 — 2021/12/02 后申请的 Key 必须配置,否则 Driving 等服务报 INVALID_USER_SCODE */
|
||||
readonly VITE_AMAP_SECURITY_CODE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
@@ -104,6 +106,15 @@ declare namespace AMap {
|
||||
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 {
|
||||
setMap(map: Map | null): void
|
||||
@@ -240,6 +251,47 @@ declare namespace AMap {
|
||||
close(): 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' {
|
||||
|
||||
Reference in New Issue
Block a user