高德地图添加电子围栏和老人出来电子围栏之后的报警
This commit is contained in:
@@ -1,64 +1,192 @@
|
||||
<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 v-if="locating" class="map-status map-locating">
|
||||
{{ locatingSource === 'amap' ? '📡 基站/IP 定位中...' : '🛰️ GPS 定位中...' }}
|
||||
<!-- ═══ 🚨 越界报警浮层 ═══ -->
|
||||
<div v-if="alarmActive" class="alarm-overlay" @click="dismissAlarm">
|
||||
<div class="alarm-box">
|
||||
<div class="alarm-icon">🚨</div>
|
||||
<div class="alarm-content">
|
||||
<div class="alarm-title">电子围栏报警!</div>
|
||||
<div class="alarm-desc">
|
||||
老人已离开安全区域
|
||||
<br />
|
||||
<span v-if="alarmCount > 1">(累计越界 {{ alarmCount }} 次)</span>
|
||||
</div>
|
||||
<div class="alarm-hint">点击任意处解除警报</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地图容器 -->
|
||||
<!-- ═══ 围栏内/外状态标签 ═══ -->
|
||||
<div
|
||||
v-if="hasPerson && hasFence && !isDrawing"
|
||||
class="fence-status"
|
||||
:class="alarmActive ? 'fence-status--alarm' : 'fence-status--safe'"
|
||||
>
|
||||
{{ alarmActive ? '🚨 老人已越界!' : '✅ 老人在安全区域内' }}
|
||||
</div>
|
||||
|
||||
<!-- ═══ 地图容器 ═══ -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="map-container"
|
||||
:class="{ 'map-hidden': loading || error }"
|
||||
/>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<!-- ═══ 底部控制栏 ═══ -->
|
||||
<div class="map-controls">
|
||||
<span class="map-title">
|
||||
🗺️ 高德地图
|
||||
<span v-if="locatingSourceLabel" class="locate-badge">{{ locatingSourceLabel }}</span>
|
||||
</span>
|
||||
<!-- ── 第一行:围栏操作 ── -->
|
||||
<div class="controls-row">
|
||||
<span class="map-title">🗺️ 电子围栏</span>
|
||||
<div class="map-actions">
|
||||
<button @click="resetView" :disabled="!mapInstance || locating">📍 回到默认位置</button>
|
||||
<!-- 绘制按钮 -->
|
||||
<button
|
||||
@click="locate"
|
||||
:disabled="!mapInstance || locating"
|
||||
class="btn-locate"
|
||||
:disabled="!mapInstance || isDrawing"
|
||||
@click="startDrawPolygon"
|
||||
title="点击地图添加顶点 → 点击首顶点(红色①)闭合 → 或点击「闭合」按钮"
|
||||
>
|
||||
<span v-if="locating" class="spinner" />
|
||||
{{ locating ? '定位中...' : '🎯 我的位置' }}
|
||||
{{ isDrawing && fenceType === 'polygon' ? '⏳ 绘制中...' : '🔷 绘制围栏' }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!mapInstance || isDrawing"
|
||||
@click="startDrawCircle"
|
||||
title="第一次点击确定圆心,第二次点击确定半径"
|
||||
>
|
||||
{{ isDrawing && fenceType === 'circle' ? '⏳ 绘制中...' : '⭕ 圆形围栏' }}
|
||||
</button>
|
||||
|
||||
<!-- ✅ 闭合围栏按钮(多边形绘制中 + 已添加 3+ 顶点) -->
|
||||
<button
|
||||
v-if="isDrawing && fenceType === 'polygon' && drawVertexCount >= 3"
|
||||
class="btn-finish"
|
||||
@click="finishDraw"
|
||||
>
|
||||
✅ 闭合围栏({{ drawVertexCount }}点)
|
||||
</button>
|
||||
|
||||
<!-- 绘制中 → 取消 -->
|
||||
<button
|
||||
v-if="isDrawing"
|
||||
class="btn-cancel"
|
||||
@click="cancelDraw"
|
||||
>
|
||||
❌ 取消
|
||||
</button>
|
||||
|
||||
<!-- 清除围栏 -->
|
||||
<button
|
||||
v-if="hasFence && !isDrawing"
|
||||
class="btn-danger"
|
||||
@click="clearFence"
|
||||
>
|
||||
🗑️ 清除围栏
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 绘制提示条(正在绘制时显示)── -->
|
||||
<div v-if="isDrawing" class="controls-row draw-hint-row">
|
||||
<span class="draw-hint">
|
||||
<template v-if="fenceType === 'polygon'">
|
||||
🎯 点击地图添加顶点 |
|
||||
<strong>点击红色 ① 号首顶点</strong> 或 <strong>「闭合围栏」按钮</strong> 完成 |
|
||||
右键取消 |
|
||||
已添加 <strong>{{ drawVertexCount }}</strong> 个点
|
||||
<span v-if="drawVertexCount >= 3" class="draw-hint-ok"> ✅ 可以闭合了!</span>
|
||||
<span v-else class="draw-hint-min">(至少还需要 {{ 3 - drawVertexCount }} 个点)</span>
|
||||
</template>
|
||||
<template v-else-if="fenceType === 'circle'">
|
||||
🎯 <strong>第 1 步</strong>:点击地图设置圆心 |
|
||||
<strong>第 2 步</strong>:再次点击设定半径 |
|
||||
右键取消
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ── 第二行:老人标记操作 ── -->
|
||||
<div class="controls-row">
|
||||
<span class="controls-label">👴 老人控制</span>
|
||||
<div class="map-actions">
|
||||
<button
|
||||
v-if="!hasPerson"
|
||||
:disabled="!mapInstance || isDrawing"
|
||||
@click="addPerson"
|
||||
>
|
||||
➕ 添加老人
|
||||
</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>
|
||||
</div>
|
||||
<span class="hint-text">或拖拽地图上的 👴 标记</span>
|
||||
<button class="btn-danger" @click="removePerson">🗑️ 移除</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 报警历史侧边信息(桌面端) ═══ -->
|
||||
<div v-if="alarmHistory.length > 0" class="alarm-log">
|
||||
<div class="alarm-log-title">📋 报警记录</div>
|
||||
<div
|
||||
v-for="(record, i) in alarmHistory"
|
||||
:key="i"
|
||||
class="alarm-log-item"
|
||||
>
|
||||
<span class="alarm-log-time">{{ record.time }}</span>
|
||||
<span class="alarm-log-pos">
|
||||
[{{ record.position[0].toFixed(4) }}, {{ record.position[1].toFixed(4) }}]
|
||||
</span>
|
||||
</div>
|
||||
<button class="alarm-log-clear" @click="alarmHistory.length = 0; alarmCount = 0">
|
||||
清空记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useAmap, loadAMap } from '@/composables/useAmap'
|
||||
import { onMounted, nextTick, onUnmounted } from 'vue'
|
||||
import { useAmap } from '@/composables/useAmap'
|
||||
import { useGeofence } from '@/composables/useGeofence'
|
||||
|
||||
// ═══ 地图初始化 ═══
|
||||
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
|
||||
center: [116.397428, 39.90923], // 北京天安门
|
||||
zoom: 12,
|
||||
pitch: 30,
|
||||
center: [116.397428, 39.90923],
|
||||
zoom: 15, // 围栏场景用更大比例尺
|
||||
pitch: 0, // 2D 俯视图更便于绘制
|
||||
})
|
||||
|
||||
void containerRef
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 定位状态
|
||||
// ─────────────────────────────────────────
|
||||
const locating = ref(false)
|
||||
const locatingSource = ref<'gps' | 'amap' | ''>('')
|
||||
const locatingSourceLabel = ref('')
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 地图初始化
|
||||
// ─────────────────────────────────────────
|
||||
async function bootstrapMap() {
|
||||
const map = await initMap()
|
||||
if (map) {
|
||||
@@ -67,143 +195,39 @@ async function bootstrapMap() {
|
||||
}
|
||||
onMounted(() => bootstrapMap())
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 📍 回到默认视图
|
||||
// ─────────────────────────────────────────
|
||||
function resetView(): void {
|
||||
locatingSourceLabel.value = ''
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
map.setCenter([116.397428, 39.90923])
|
||||
map.setZoom(12)
|
||||
}
|
||||
// ═══ 电子围栏 ═══
|
||||
const {
|
||||
fenceType,
|
||||
isDrawing,
|
||||
drawVertexCount,
|
||||
isInside,
|
||||
alarmActive,
|
||||
alarmCount,
|
||||
alarmHistory,
|
||||
hasFence,
|
||||
hasPerson,
|
||||
startDrawPolygon,
|
||||
startDrawCircle,
|
||||
finishDraw,
|
||||
cancelDraw,
|
||||
clearFence,
|
||||
addPerson,
|
||||
removePerson,
|
||||
movePerson,
|
||||
dismissAlarm,
|
||||
destroyGeofence,
|
||||
} = useGeofence(mapInstance)
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 🎯 我的位置 — 双通道定位
|
||||
// ─────────────────────────────────────────
|
||||
async function locate(): Promise<void> {
|
||||
if (locating.value || !mapInstance.value) return
|
||||
|
||||
locating.value = true
|
||||
|
||||
// ── 通道 1:浏览器原生 GPS(高精度,需 HTTPS / localhost)──
|
||||
const gpsResult = await tryBrowserGPS()
|
||||
if (gpsResult) {
|
||||
applyLocation(gpsResult.lng, gpsResult.lat, 16, '🛰️ GPS')
|
||||
locating.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// ── 通道 2:高德 IP / 基站定位(低精度,但无需用户授权)──
|
||||
const amapResult = await tryAmapGeolocation()
|
||||
if (amapResult) {
|
||||
applyLocation(amapResult.lng, amapResult.lat, 14, '📡 IP定位')
|
||||
locating.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// ── 通道 3:完全失败 ──
|
||||
locating.value = false
|
||||
alert('无法获取您的位置,请检查浏览器定位权限或网络连接')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 通道 1:浏览器原生 Geolocation API
|
||||
// ─────────────────────────────────────────
|
||||
function tryBrowserGPS(): Promise<{ lng: number; lat: number } | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
console.log('[定位] 浏览器不支持 Geolocation API,降级到高德定位')
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
locatingSource.value = 'gps'
|
||||
console.log('[定位] 🛰️ 尝试浏览器 GPS 定位...')
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const { longitude: lng, latitude: lat, accuracy } = pos.coords
|
||||
console.log(`[定位] ✅ GPS 成功 — 经度:${lng} 纬度:${lat} 精度:${accuracy}米`)
|
||||
resolve({ lng, lat })
|
||||
},
|
||||
(err) => {
|
||||
// GeolocationPositionError.code:
|
||||
// 1 = PERMISSION_DENIED(用户拒绝)
|
||||
// 2 = POSITION_UNAVAILABLE(信号不可用)
|
||||
// 3 = TIMEOUT(超时)
|
||||
const reason = ['', '用户拒绝授权', '信号不可用', '定位超时'][err.code] || err.message
|
||||
console.log(`[定位] ❌ GPS 失败 (${reason}),降级到高德定位`)
|
||||
resolve(null)
|
||||
},
|
||||
{
|
||||
timeout: 8000, // 8 秒超时
|
||||
enableHighAccuracy: true, // 强制启用 GPS 芯片
|
||||
maximumAge: 60000, // 允许 60 秒内的缓存(避免每次冷启动 GPS)
|
||||
}
|
||||
)
|
||||
// ═══ 生命周期清理 ═══
|
||||
onUnmounted(() => {
|
||||
destroyGeofence()
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 通道 2:高德 IP / 基站定位
|
||||
// ─────────────────────────────────────────
|
||||
async function tryAmapGeolocation(): Promise<{ lng: number; lat: number } | null> {
|
||||
try {
|
||||
const AMap = await loadAMap()
|
||||
locatingSource.value = 'amap'
|
||||
console.log('[定位] 📡 尝试高德 IP 定位...')
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const geolocation = new AMap.Geolocation({
|
||||
enableHighAccuracy: true, // 混合定位(优先 GPS → Wi-Fi → 基站 → IP)
|
||||
timeout: 6000,
|
||||
panToLocation: false, // 我们自己控制地图移动
|
||||
})
|
||||
|
||||
geolocation.getCurrentPosition((status, result) => {
|
||||
if (status === 'complete' && result.position) {
|
||||
const { lng, lat } = result.position
|
||||
console.log(
|
||||
`[定位] ✅ 高德定位成功 — 经度:${lng} 纬度:${lat}`
|
||||
+ ` 地址:${result.formattedAddress} 精度:${result.accuracy}米`
|
||||
)
|
||||
resolve({ lng, lat })
|
||||
} else {
|
||||
console.log(`[定位] ❌ 高德定位失败: ${result.message}`)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.log(`[定位] ❌ 高德定位插件加载失败: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 将定位结果应用到地图
|
||||
// ─────────────────────────────────────────
|
||||
function applyLocation(lng: number, lat: number, zoom: number, label: string): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
map.setZoomAndCenter(zoom, [lng, lat])
|
||||
locatingSourceLabel.value = label
|
||||
|
||||
// 3 秒后自动隐藏标签
|
||||
setTimeout(() => {
|
||||
if (locatingSourceLabel.value === label) {
|
||||
locatingSourceLabel.value = ''
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================
|
||||
地图页面布局:header(48px) | map(剩余) | controls(52px)
|
||||
============================================================ */
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
地图页面布局
|
||||
═══════════════════════════════════════════ */
|
||||
.map-page {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -213,7 +237,7 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 状态提示(加载/错误/定位中)---- */
|
||||
/* ── 状态浮层 ── */
|
||||
.map-status {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -236,13 +260,7 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.map-locating {
|
||||
color: #2d6cdf;
|
||||
background: rgba(232, 240, 255, 0.95);
|
||||
border: 1px solid #93c5fd;
|
||||
}
|
||||
|
||||
/* ---- 地图容器 ---- */
|
||||
/* ── 地图容器 ── */
|
||||
.map-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
@@ -253,13 +271,121 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ---- 底部操作栏 ---- */
|
||||
.map-controls {
|
||||
/* ═══════════════════════════════════════════
|
||||
🚨 越界报警浮层
|
||||
═══════════════════════════════════════════ */
|
||||
.alarm-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 52px;
|
||||
padding: 0 20px;
|
||||
justify-content: center;
|
||||
background: rgba(255, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
animation: alarm-bg-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes alarm-bg-pulse {
|
||||
0%, 100% { background: rgba(255, 0, 0, 0.04); }
|
||||
50% { background: rgba(255, 0, 0, 0.12); }
|
||||
}
|
||||
|
||||
.alarm-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px 48px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 40px rgba(255, 0, 0, 0.25);
|
||||
border: 2px solid #FF4D4F;
|
||||
animation: alarm-box-shake 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes alarm-box-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
.alarm-icon {
|
||||
font-size: 48px;
|
||||
animation: alarm-icon-pulse 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes alarm-icon-pulse {
|
||||
from { transform: scale(1); }
|
||||
to { transform: scale(1.15); }
|
||||
}
|
||||
|
||||
.alarm-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alarm-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #FF4D4F;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.alarm-desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.alarm-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
围栏状态标签
|
||||
═══════════════════════════════════════════ */
|
||||
.fence-status {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 800;
|
||||
padding: 6px 18px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fence-status--safe {
|
||||
background: rgba(66, 184, 131, 0.12);
|
||||
color: #2e7d32;
|
||||
border: 1px solid rgba(66, 184, 131, 0.3);
|
||||
}
|
||||
|
||||
.fence-status--alarm {
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
color: #c62828;
|
||||
border: 1px solid rgba(255, 77, 79, 0.3);
|
||||
animation: status-alarm-pulse 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes status-alarm-pulse {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
底部控制栏(双行)
|
||||
═══════════════════════════════════════════ */
|
||||
.map-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.04);
|
||||
@@ -267,32 +393,44 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.map-title {
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls-row + .controls-row {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.map-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 定位来源标签 */
|
||||
.locate-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
.controls-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 通用按钮 ── */
|
||||
.map-actions button {
|
||||
padding: 7px 18px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #42b883;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
@@ -300,9 +438,7 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-actions button:hover:not(:disabled) {
|
||||
@@ -311,22 +447,243 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
||||
}
|
||||
|
||||
.map-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 定位按钮 loading 动画 */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
.btn-cancel {
|
||||
border-color: #faad14 !important;
|
||||
color: #faad14 !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: #faad14 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-finish {
|
||||
border-color: #42b883 !important;
|
||||
background: #42b883 !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
animation: btn-finish-pulse 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes btn-finish-pulse {
|
||||
from { box-shadow: 0 0 0 rgba(66, 184, 131, 0); }
|
||||
to { box-shadow: 0 0 12px rgba(66, 184, 131, 0.5); }
|
||||
}
|
||||
|
||||
.btn-finish:hover:not(:disabled) {
|
||||
background: #38a978 !important;
|
||||
border-color: #38a978 !important;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #FF4D4F !important;
|
||||
color: #FF4D4F !important;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #FF4D4F !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 绘制提示条 ── */
|
||||
.draw-hint-row {
|
||||
background: #f0faf5;
|
||||
border-top: 1px solid #d0ede0;
|
||||
}
|
||||
|
||||
.draw-hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.draw-hint strong {
|
||||
color: #42b883;
|
||||
}
|
||||
|
||||
.draw-hint-ok {
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.draw-hint-min {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
/* ── 方向键 ── */
|
||||
.dpad {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
". up ."
|
||||
"left . right"
|
||||
". down .";
|
||||
grid-template-columns: repeat(3, 32px);
|
||||
grid-template-rows: repeat(3, 32px);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dpad-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
color: #555;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dpad-btn:hover:not(:disabled) {
|
||||
background: #42b883;
|
||||
border-color: #42b883;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dpad-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dpad-up { grid-area: up; }
|
||||
.dpad-left { grid-area: left; }
|
||||
.dpad-right { grid-area: right; }
|
||||
.dpad-down { grid-area: down; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
报警历史侧边记录
|
||||
═══════════════════════════════════════════ */
|
||||
.alarm-log {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 130px;
|
||||
z-index: 700;
|
||||
width: 200px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alarm-log-title {
|
||||
font-weight: 600;
|
||||
color: #FF4D4F;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alarm-log-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.alarm-log-time {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.alarm-log-pos {
|
||||
color: #555;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.alarm-log-clear {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alarm-log-clear:hover {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- AMap 自定义 Marker 全局样式(不能 scoped,因为 Marker DOM 由 AMap 直接注入) -->
|
||||
<style>
|
||||
/* ── 普通顶点标记 ── */
|
||||
.geofence-vertex-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #42b883;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
font-size: 8px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── 首顶点高亮(≥3 顶点后出现,提示可以点击闭合)── */
|
||||
.geofence-first-vertex {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #FF4D4F;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 10px rgba(255, 77, 79, 0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
user-select: none;
|
||||
animation: geofence-vertex-pulse 0.8s ease-in-out infinite alternate;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes geofence-vertex-pulse {
|
||||
from {
|
||||
box-shadow: 0 0 6px rgba(255, 77, 79, 0.5);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 16px rgba(255, 77, 79, 0.9);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 老人标记 ── */
|
||||
.geofence-person-marker {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF6B6B, #FF4D4F);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(255, 77, 79, 0.35);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
143
src/vite-env.d.ts
vendored
143
src/vite-env.d.ts
vendored
@@ -40,6 +40,12 @@ declare namespace AMap {
|
||||
destroy(): void
|
||||
on(event: string, handler: (...args: any[]) => void): void
|
||||
off(event: string, handler: (...args: any[]) => void): void
|
||||
setStatus(status: Partial<{ doubleClickZoom: boolean; dragEnable: boolean; zoomEnable: boolean }>): void
|
||||
getStatus(): { doubleClickZoom: boolean; dragEnable: boolean; zoomEnable: boolean }
|
||||
setDefaultCursor(cursor: string): void
|
||||
getDefaultCursor(): string
|
||||
getCenter(): { lng: number; lat: number }
|
||||
lngLatToContainer(lnglat: { lng: number; lat: number }): { x: number; y: number }
|
||||
}
|
||||
|
||||
class Geocoder {
|
||||
@@ -97,6 +103,143 @@ declare namespace AMap {
|
||||
class MarkerClusterer {
|
||||
constructor(map: Map, markers: any[], opts?: Record<string, any>)
|
||||
}
|
||||
|
||||
// ── 覆盖物基类 ──
|
||||
class Overlay {
|
||||
setMap(map: Map | null): void
|
||||
getMap(): Map | null
|
||||
destroy(): void
|
||||
show(): void
|
||||
hide(): void
|
||||
}
|
||||
|
||||
// ── 点标记 ──
|
||||
interface MarkerOptions {
|
||||
position?: [number, number]
|
||||
content?: string
|
||||
icon?: string | Icon
|
||||
offset?: Pixel
|
||||
draggable?: boolean
|
||||
zIndex?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
class Marker extends Overlay {
|
||||
constructor(opts?: MarkerOptions)
|
||||
setPosition(position: [number, number]): void
|
||||
getPosition(): { lng: number; lat: number }
|
||||
on(event: string, handler: (...args: any[]) => void): void
|
||||
off(event: string, handler: (...args: any[]) => void): void
|
||||
}
|
||||
|
||||
// ── 像素偏移 ──
|
||||
class Pixel {
|
||||
constructor(x: number, y: number)
|
||||
}
|
||||
|
||||
// ── 图标 ──
|
||||
interface IconOptions {
|
||||
size?: [number, number]
|
||||
image?: string
|
||||
imageSize?: [number, number]
|
||||
imageOffset?: [number, number]
|
||||
}
|
||||
class Icon {
|
||||
constructor(opts?: IconOptions)
|
||||
}
|
||||
|
||||
// ── 多边形 ──
|
||||
interface PolygonOptions {
|
||||
path?: [number, number][]
|
||||
fillColor?: string
|
||||
fillOpacity?: number
|
||||
strokeColor?: string
|
||||
strokeWeight?: number
|
||||
strokeOpacity?: number
|
||||
strokeStyle?: 'solid' | 'dashed'
|
||||
zIndex?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
class Polygon extends Overlay {
|
||||
constructor(opts?: PolygonOptions)
|
||||
setPath(path: [number, number][]): void
|
||||
getPath(): [number, number][]
|
||||
}
|
||||
|
||||
// ── 折线(绘制围栏时连线预览)──
|
||||
interface PolylineOptions {
|
||||
path?: [number, number][]
|
||||
strokeColor?: string
|
||||
strokeWeight?: number
|
||||
strokeOpacity?: number
|
||||
strokeStyle?: 'solid' | 'dashed'
|
||||
borderWeight?: number
|
||||
zIndex?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
class Polyline extends Overlay {
|
||||
constructor(opts?: PolylineOptions)
|
||||
setPath(path: [number, number][]): void
|
||||
getPath(): [number, number][]
|
||||
}
|
||||
|
||||
// ── 圆形 ──
|
||||
interface CircleOptions {
|
||||
center?: [number, number]
|
||||
radius?: number
|
||||
fillColor?: string
|
||||
fillOpacity?: number
|
||||
strokeColor?: string
|
||||
strokeWeight?: number
|
||||
strokeOpacity?: number
|
||||
zIndex?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
class Circle extends Overlay {
|
||||
constructor(opts?: CircleOptions)
|
||||
setCenter(center: [number, number]): void
|
||||
getCenter(): { lng: number; lat: number }
|
||||
setRadius(radius: number): void
|
||||
getRadius(): number
|
||||
contains(point: [number, number]): boolean
|
||||
}
|
||||
|
||||
// ── 几何计算工具 ──
|
||||
namespace GeometryUtil {
|
||||
function isPointInRing(point: { lng: number; lat: number }, path: [number, number][]): boolean
|
||||
function distance(p1: [number, number], p2: [number, number]): number
|
||||
}
|
||||
|
||||
// ── 鼠标绘制工具 ──
|
||||
interface MouseToolOptions {
|
||||
[key: string]: any
|
||||
}
|
||||
class MouseTool {
|
||||
constructor(map: Map, opts?: MouseToolOptions)
|
||||
polygon(opts?: Record<string, any>): void
|
||||
circle(opts?: Record<string, any>): void
|
||||
rectangle(opts?: Record<string, any>): void
|
||||
close(): void
|
||||
on(event: string, handler: (...args: any[]) => void): void
|
||||
off(event: string, handler: (...args: any[]) => void): void
|
||||
}
|
||||
|
||||
// ── 信息窗体 ──
|
||||
interface InfoWindowOptions {
|
||||
content?: string
|
||||
position?: [number, number]
|
||||
offset?: Pixel
|
||||
[key: string]: any
|
||||
}
|
||||
class InfoWindow extends Overlay {
|
||||
constructor(opts?: InfoWindowOptions)
|
||||
open(map: Map, position?: [number, number]): void
|
||||
close(): void
|
||||
setContent(content: string): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
|
||||
Reference in New Issue
Block a user