高德地图添加电子围栏和老人出来电子围栏之后的报警
This commit is contained in:
@@ -1,64 +1,192 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="map-page">
|
<div class="map-page">
|
||||||
<!-- 地图初始化加载/错误状态 -->
|
<!-- ═══ 地图初始化状态 ═══ -->
|
||||||
<div v-if="loading" class="map-status">⏳ 地图加载中...</div>
|
<div v-if="loading" class="map-status">⏳ 地图加载中...</div>
|
||||||
<div v-else-if="error" class="map-status map-error">❌ {{ error }}</div>
|
<div v-else-if="error" class="map-status map-error">❌ {{ error }}</div>
|
||||||
|
|
||||||
<!-- 定位进行中提示 -->
|
<!-- ═══ 🚨 越界报警浮层 ═══ -->
|
||||||
<div v-if="locating" class="map-status map-locating">
|
<div v-if="alarmActive" class="alarm-overlay" @click="dismissAlarm">
|
||||||
{{ locatingSource === 'amap' ? '📡 基站/IP 定位中...' : '🛰️ GPS 定位中...' }}
|
<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>
|
||||||
|
|
||||||
<!-- 地图容器 -->
|
<!-- ═══ 围栏内/外状态标签 ═══ -->
|
||||||
|
<div
|
||||||
|
v-if="hasPerson && hasFence && !isDrawing"
|
||||||
|
class="fence-status"
|
||||||
|
:class="alarmActive ? 'fence-status--alarm' : 'fence-status--safe'"
|
||||||
|
>
|
||||||
|
{{ alarmActive ? '🚨 老人已越界!' : '✅ 老人在安全区域内' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ 地图容器 ═══ -->
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="map-container"
|
class="map-container"
|
||||||
:class="{ 'map-hidden': loading || error }"
|
:class="{ 'map-hidden': loading || error }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
<!-- ═══ 底部控制栏 ═══ -->
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<span class="map-title">
|
<!-- ── 第一行:围栏操作 ── -->
|
||||||
🗺️ 高德地图
|
<div class="controls-row">
|
||||||
<span v-if="locatingSourceLabel" class="locate-badge">{{ locatingSourceLabel }}</span>
|
<span class="map-title">🗺️ 电子围栏</span>
|
||||||
</span>
|
|
||||||
<div class="map-actions">
|
<div class="map-actions">
|
||||||
<button @click="resetView" :disabled="!mapInstance || locating">📍 回到默认位置</button>
|
<!-- 绘制按钮 -->
|
||||||
<button
|
<button
|
||||||
@click="locate"
|
:disabled="!mapInstance || isDrawing"
|
||||||
:disabled="!mapInstance || locating"
|
@click="startDrawPolygon"
|
||||||
class="btn-locate"
|
title="点击地图添加顶点 → 点击首顶点(红色①)闭合 → 或点击「闭合」按钮"
|
||||||
>
|
>
|
||||||
<span v-if="locating" class="spinner" />
|
{{ isDrawing && fenceType === 'polygon' ? '⏳ 绘制中...' : '🔷 绘制围栏' }}
|
||||||
{{ locating ? '定位中...' : '🎯 我的位置' }}
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { onMounted, nextTick, onUnmounted } from 'vue'
|
||||||
import { useAmap, loadAMap } from '@/composables/useAmap'
|
import { useAmap } from '@/composables/useAmap'
|
||||||
|
import { useGeofence } from '@/composables/useGeofence'
|
||||||
|
|
||||||
|
// ═══ 地图初始化 ═══
|
||||||
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
|
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
|
||||||
center: [116.397428, 39.90923], // 北京天安门
|
center: [116.397428, 39.90923],
|
||||||
zoom: 12,
|
zoom: 15, // 围栏场景用更大比例尺
|
||||||
pitch: 30,
|
pitch: 0, // 2D 俯视图更便于绘制
|
||||||
})
|
})
|
||||||
|
|
||||||
void containerRef
|
void containerRef
|
||||||
|
|
||||||
// ─────────────────────────────────────────
|
|
||||||
// 定位状态
|
|
||||||
// ─────────────────────────────────────────
|
|
||||||
const locating = ref(false)
|
|
||||||
const locatingSource = ref<'gps' | 'amap' | ''>('')
|
|
||||||
const locatingSourceLabel = ref('')
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────
|
|
||||||
// 地图初始化
|
|
||||||
// ─────────────────────────────────────────
|
|
||||||
async function bootstrapMap() {
|
async function bootstrapMap() {
|
||||||
const map = await initMap()
|
const map = await initMap()
|
||||||
if (map) {
|
if (map) {
|
||||||
@@ -67,143 +195,39 @@ async function bootstrapMap() {
|
|||||||
}
|
}
|
||||||
onMounted(() => bootstrapMap())
|
onMounted(() => bootstrapMap())
|
||||||
|
|
||||||
// ─────────────────────────────────────────
|
// ═══ 电子围栏 ═══
|
||||||
// 📍 回到默认视图
|
const {
|
||||||
// ─────────────────────────────────────────
|
fenceType,
|
||||||
function resetView(): void {
|
isDrawing,
|
||||||
locatingSourceLabel.value = ''
|
drawVertexCount,
|
||||||
const map = mapInstance.value
|
isInside,
|
||||||
if (!map) return
|
alarmActive,
|
||||||
map.setCenter([116.397428, 39.90923])
|
alarmCount,
|
||||||
map.setZoom(12)
|
alarmHistory,
|
||||||
}
|
hasFence,
|
||||||
|
hasPerson,
|
||||||
|
startDrawPolygon,
|
||||||
|
startDrawCircle,
|
||||||
|
finishDraw,
|
||||||
|
cancelDraw,
|
||||||
|
clearFence,
|
||||||
|
addPerson,
|
||||||
|
removePerson,
|
||||||
|
movePerson,
|
||||||
|
dismissAlarm,
|
||||||
|
destroyGeofence,
|
||||||
|
} = useGeofence(mapInstance)
|
||||||
|
|
||||||
// ─────────────────────────────────────────
|
// ═══ 生命周期清理 ═══
|
||||||
// 🎯 我的位置 — 双通道定位
|
onUnmounted(() => {
|
||||||
// ─────────────────────────────────────────
|
destroyGeofence()
|
||||||
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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────
|
|
||||||
// 通道 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ============================================================
|
/* ═══════════════════════════════════════════
|
||||||
地图页面布局:header(48px) | map(剩余) | controls(52px)
|
地图页面布局
|
||||||
============================================================ */
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
.map-page {
|
.map-page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -213,7 +237,7 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 状态提示(加载/错误/定位中)---- */
|
/* ── 状态浮层 ── */
|
||||||
.map-status {
|
.map-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -236,13 +260,7 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
|||||||
border: 1px solid #fecaca;
|
border: 1px solid #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-locating {
|
/* ── 地图容器 ── */
|
||||||
color: #2d6cdf;
|
|
||||||
background: rgba(232, 240, 255, 0.95);
|
|
||||||
border: 1px solid #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 地图容器 ---- */
|
|
||||||
.map-container {
|
.map-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -253,13 +271,121 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 底部操作栏 ---- */
|
/* ═══════════════════════════════════════════
|
||||||
.map-controls {
|
🚨 越界报警浮层
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
.alarm-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
height: 52px;
|
background: rgba(255, 0, 0, 0.08);
|
||||||
padding: 0 20px;
|
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;
|
background: #fff;
|
||||||
border-top: 1px solid #e8e8e8;
|
border-top: 1px solid #e8e8e8;
|
||||||
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.04);
|
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;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-title {
|
.controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 定位来源标签 */
|
.controls-label {
|
||||||
.locate-badge {
|
font-size: 13px;
|
||||||
font-size: 11px;
|
font-weight: 600;
|
||||||
font-weight: 500;
|
color: #555;
|
||||||
padding: 2px 8px;
|
white-space: nowrap;
|
||||||
border-radius: 10px;
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-actions {
|
.map-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 通用按钮 ── */
|
||||||
.map-actions button {
|
.map-actions button {
|
||||||
padding: 7px 18px;
|
padding: 6px 14px;
|
||||||
border: 1px solid #42b883;
|
border: 1px solid #42b883;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -300,9 +438,7 @@ function applyLocation(lng: number, lat: number, zoom: number, label: string): v
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-actions button:hover:not(:disabled) {
|
.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 {
|
.map-actions button:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 定位按钮 loading 动画 */
|
.btn-cancel {
|
||||||
.spinner {
|
border-color: #faad14 !important;
|
||||||
display: inline-block;
|
color: #faad14 !important;
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-top-color: currentColor;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.btn-cancel:hover:not(:disabled) {
|
||||||
to { transform: rotate(360deg); }
|
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>
|
</style>
|
||||||
|
|||||||
143
src/vite-env.d.ts
vendored
143
src/vite-env.d.ts
vendored
@@ -40,6 +40,12 @@ declare namespace AMap {
|
|||||||
destroy(): void
|
destroy(): void
|
||||||
on(event: string, handler: (...args: any[]) => void): void
|
on(event: string, handler: (...args: any[]) => void): void
|
||||||
off(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 {
|
class Geocoder {
|
||||||
@@ -97,6 +103,143 @@ declare namespace AMap {
|
|||||||
class MarkerClusterer {
|
class MarkerClusterer {
|
||||||
constructor(map: Map, markers: any[], opts?: Record<string, any>)
|
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' {
|
declare module '*.vue' {
|
||||||
|
|||||||
Reference in New Issue
Block a user