高德地图添加轨迹规划和重放功能
This commit is contained in:
4
.env
4
.env
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' ? '起点(📍)' : '终点(🏁)' }}标记 | 再次点击按钮取消
|
||||||
|
</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
52
src/vite-env.d.ts
vendored
@@ -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' {
|
||||||
|
|||||||
Reference in New Issue
Block a user