Browse Source

feat: hook Monitor into Tianditu map with device markers

main
xiaosi 1 month ago
parent
commit
b5de46bc1d
  1. 2
      .env.development
  2. 2
      src/layout/MainContainer.vue
  3. 2
      src/layout/MapLayer.vue
  4. 4
      src/stores/modules/devicesStore.js
  5. 13
      src/styles/prototype.css
  6. 12
      src/utils/commonRefs.js
  7. 250
      src/views/MonitorView/MonitorView.vue

2
.env.development

@ -1,4 +1,4 @@
APP_TIANDITU_TOKEN=
APP_TIANDITU_TOKEN=1e3adb7649d896631c574461b15cb9d4
APP_MAP_CENTER_LNG=106.62341584179686
APP_MAP_CENTER_LAT=26.657826154258505
APP_MAP_ZOOM=5

2
src/layout/MainContainer.vue

@ -91,7 +91,7 @@ function closeSidebar() {
position: relative;
height: 100%;
overflow: hidden;
background: #eef3f7;
background: transparent;
}
.mask {

2
src/layout/MapLayer.vue

@ -12,6 +12,7 @@ function initMap() {
const tk = mapConfig.token
if (!tk) {
console.error('[MapLayer] 缺少 APP_TIANDITU_TOKEN,跳过地图初始化')
commonRefs.setRef('map', null)
return
}
@ -40,6 +41,7 @@ onMounted(() => {
initMap()
} catch (error) {
console.error('[MapLayer] 地图初始化失败:', error)
commonRefs.setRef('map', null)
}
})
})

4
src/stores/modules/devicesStore.js

@ -286,6 +286,8 @@ export const useDeviceStore = defineStore('devices', () => {
assets[dk.id] = {
id: dk.id, type: 'dock', name: dk.name, location: dk.location,
status: dk.status, statusClass: dk.statusClass, x: p.x, y: p.y, hasCoordinates,
longitude: hasCoordinates ? dk.longitude : null,
latitude: hasCoordinates ? dk.latitude : null,
realtime: dk.realtime, environment: dk.environment, alarms: dk.alarms, online: dk.online
}
}
@ -299,6 +301,8 @@ export const useDeviceStore = defineStore('devices', () => {
id: dr.id, type: 'drone', name: dr.name,
location: dr.statusClass === 'mission' ? `${dock?.location || '--'} · 飞行中` : `停放于${dock?.name || '--'}`,
status: dr.status, statusClass: dr.statusClass, x: p.x, y: p.y, hasCoordinates,
longitude: hasCoordinates ? lon : null,
latitude: hasCoordinates ? lat : null,
parent: dock?.id || '', parentName: dock?.name || '--',
inDock: dr.statusClass !== 'mission', altitude: dr._alt, speed: dr._speed, satellites: dr._sats,
realtime: dr.realtime, alarms: dr.alarms, online: dr.online

13
src/styles/prototype.css

@ -86,14 +86,14 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.sidebar-bottom .sidebar-profile > svg { transform: rotate(-90deg); color: #8fa0ad; }
.mobile-menu { display: none; }
.monitor-layout { position: relative; flex: 1; min-height: 0; display: grid; grid-template-columns: 0 minmax(0, 1fr) 0; overflow: hidden; transition: grid-template-columns .38s cubic-bezier(.22,.8,.25,1); }
.monitor-layout { position: relative; flex: 1; min-height: 0; display: grid; grid-template-columns: 0 minmax(0, 1fr) 0; overflow: hidden; background: transparent; pointer-events: none; transition: grid-template-columns .38s cubic-bezier(.22,.8,.25,1); }
.monitor-layout.focused { grid-template-columns: 276px minmax(360px, 1fr) 348px; }
.monitor-layout.focused.control-expanded { grid-template-columns: 276px minmax(360px, 1fr) 560px; }
.device-panel, .detail-panel { background: #fff; min-width: 0; min-height: 0; overflow: auto; opacity: 0; visibility: hidden; z-index: 8; transition: transform .38s cubic-bezier(.22,.8,.25,1), opacity .2s ease, visibility 0s linear .38s; }
.device-panel, .detail-panel { background: #fff; min-width: 0; min-height: 0; overflow: auto; opacity: 0; visibility: hidden; z-index: 8; pointer-events: none; transition: transform .38s cubic-bezier(.22,.8,.25,1), opacity .2s ease, visibility 0s linear .38s; }
.device-panel { width: 276px; border-right: 1px solid var(--line); padding: 18px 14px; transform: translateX(-100%); }
.detail-panel { width: 348px; transform: translateX(100%); }
.monitor-layout.control-expanded .detail-panel { width: 560px; }
.monitor-layout.focused .device-panel, .monitor-layout.focused .detail-panel { opacity: 1; visibility: visible; transform: translateX(0); transition-delay: 0s; }
.monitor-layout.focused .device-panel, .monitor-layout.focused .detail-panel { opacity: 1; visibility: visible; transform: translateX(0); pointer-events: auto; transition-delay: 0s; }
.panel-heading { display: flex; justify-content: space-between; align-items: center; }
.panel-heading > div { display: flex; align-items: baseline; gap: 8px; }
.panel-heading h1 { margin: 0; font-size: 20px; }
@ -145,10 +145,9 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.bound-drone.flying > i { color: var(--blue); }.bound-drone.offline > i { color: #8b969f; }.tree-line { position: absolute; left: 13px; top: -9px; width: 10px; height: 29px; border-left: 1px solid #c4ced6; border-bottom: 1px solid #c4ced6; border-radius: 0 0 0 5px; }
.device-alert { margin: 0 10px 10px; padding-top: 8px; border-top: 1px solid var(--line); display: flex; align-items: center; gap: 5px; color: var(--red); font-size: 11px; }.device-alert svg { width: 12px; }.device-alert span { margin-left: auto; color: var(--muted); }.device-alert.muted { color: var(--muted); }
.map-panel { position: relative; min-width: 0; overflow: hidden; background: #71826f; isolation: isolate; }
.map-world { position: absolute; inset: 0; transform-origin: center; transition: transform .72s cubic-bezier(.2,.75,.25,1); will-change: transform; }
.map-surface { position: absolute; inset: 0; background-image: linear-gradient(rgba(16,40,38,.12), rgba(16,40,38,.22)), url("/assets/map-base-clean.png"); background-size: cover; background-position: center; filter: saturate(.42) contrast(.92); transform: scale(1.02); transition: filter .2s ease; }
.map-panel::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(90deg, rgba(18,28,32,.10), transparent 30%, rgba(10,20,26,.06)); z-index: 1; }
.map-panel { position: relative; min-width: 0; overflow: visible; background: transparent; isolation: isolate; pointer-events: none; }
.map-toolbar, .map-legend { pointer-events: auto; }
.map-panel::after { display: none; }
.map-toolbar { position: absolute; z-index: 4; top: 14px; left: 14px; height: 34px; display: flex; background: #fff; border-radius: 5px; box-shadow: 0 4px 14px rgba(19,31,39,.17); overflow: hidden; }
.map-type { padding: 0 13px; background: #fff; color: var(--muted); border-right: 1px solid var(--line); font-size: 13px; }
.map-type.active { background: #263846; color: #fff; }

12
src/utils/commonRefs.js

@ -20,7 +20,8 @@ class CommonRefs {
}
setRef(key, ref) {
if (key in this[refs]) return
// 允许显式 set null 唤醒 pending(无 token 等失败路径)
if (key in this[refs] && this[refs][key] != null) return
this[refs][key] = ref
const { [key]: resolves } = this[pendingList]
@ -33,10 +34,11 @@ class CommonRefs {
getRef(key) {
return new Promise((resolve) => {
const { [key]: ref } = this[refs]
if (ref) {
resolve(ref)
} else if (key in this[pendingList]) {
if (key in this[refs]) {
resolve(this[refs][key])
return
}
if (key in this[pendingList]) {
this[pendingList][key].push(resolve)
} else {
this[pendingList][key] = [resolve]

250
src/views/MonitorView/MonitorView.vue

@ -67,30 +67,8 @@
</aside>
<div class="map-panel">
<div class="map-world" :style="mapStyle" @click="handleMapClick">
<div class="map-surface"></div>
<svg v-if="bindingLine" class="binding-line" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line :x1="bindingLine.x1" :y1="bindingLine.y1" :x2="bindingLine.x2" :y2="bindingLine.y2" />
<text :x="(bindingLine.x1 + bindingLine.x2) / 2" :y="(bindingLine.y1 + bindingLine.y2) / 2 - 2">绑定机巢</text>
</svg>
<button
v-for="a in mapMarkers"
:key="a.id"
class="map-marker"
:class="[a.statusClass, { selected: selectedId === a.id }]"
:style="{ left: a.x + '%', top: a.y + '%' }"
@click.stop="select(a.id)"
>
<span><svg><use :href="a.type === 'drone' ? '#i-plane' : '#i-home'" /></svg></span>
<b>{{ a.name }}</b>
<small v-if="a.type === 'drone' && a.altitude != null">{{ a.altitude }} m</small>
<em v-if="a.type === 'drone' && a.statusClass === 'mission'"><svg><use href="#i-zap" /></svg></em>
</button>
</div>
<div class="map-toolbar">
<span class="map-type active">静态底</span>
<span class="map-type active">天地图</span>
<button class="icon-button" title="放大" @click="zoomIn"><svg><use href="#i-plus" /></svg></button>
<button class="icon-button" title="缩小" @click="zoomOut"><svg><use href="#i-minus" /></svg></button>
<button class="icon-button" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button>
@ -244,12 +222,25 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as maptalks from 'maptalks'
import { useDeviceStore } from '@/stores/modules/devicesStore'
import { useUiStore } from '@/stores/modules/uiStore'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/userStore'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
import mapConfig from '@/config/map'
const LAYER_ID = 'monitor-devices'
const STATUS_COLORS = {
online: '#21a06a',
mission: '#2d82da',
alarm: '#cc3d43',
offline: '#84909b',
pending: '#c48a2a',
}
const devices = useDeviceStore()
const ui = useUiStore()
@ -263,7 +254,12 @@ const filter = ref('all')
const search = ref('')
const controlExpanded = ref(false)
const selectedDockIds = reactive(new Set())
const mapZoom = ref(1)
let mapInstance = null
let deviceLayer = null
let suppressMapClick = false
const markerById = new Map()
const dockExtraCommands = [
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' },
{ title: '一键降落', desc: '无人机将返回机巢并自动降落。', icon: '#i-home', hint: '下发指令' },
@ -291,17 +287,179 @@ const droneExtraCommands = [
{ title: '紧急停止', desc: '立即中止飞行,无人机原地悬停待命。', icon: '#i-stop', hint: '原地悬停', danger: true }
]
const mapMarkers = computed(() =>
Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock)
)
function markerSymbol(asset, selected = false) {
const fill = STATUS_COLORS[asset.statusClass] || STATUS_COLORS.offline
const radius = asset.type === 'drone' ? 8 : 10
return [
{
markerType: 'ellipse',
markerFill: fill,
markerLineColor: '#fff',
markerLineWidth: selected ? 3 : 2,
markerWidth: selected ? radius * 2.4 : radius * 2,
markerHeight: selected ? radius * 2.4 : radius * 2,
markerDx: 0,
markerDy: 0,
markerOpacity: 1,
},
{
textName: asset.name || '',
textFill: '#fff',
textHaloFill: 'rgba(24,35,45,.86)',
textHaloRadius: 3,
textSize: 11,
textDy: selected ? 18 : 16,
textWeight: 500,
},
]
}
function syncMarkers() {
if (!deviceLayer) return
const nextIds = new Set()
for (const asset of mapMarkers.value) {
const lon = Number(asset.longitude)
const lat = Number(asset.latitude)
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue
nextIds.add(asset.id)
const selected = selectedId.value === asset.id
let marker = markerById.get(asset.id)
if (!marker) {
marker = new maptalks.Marker([lon, lat], {
id: asset.id,
cursor: 'pointer',
properties: { assetId: asset.id },
symbol: markerSymbol(asset, selected),
})
marker.on('click', (e) => {
e?.domEvent?.stopPropagation?.()
suppressMapClick = true
select(asset.id)
setTimeout(() => { suppressMapClick = false }, 0)
})
marker.addTo(deviceLayer)
markerById.set(asset.id, marker)
} else {
marker.setCoordinates([lon, lat])
marker.setSymbol(markerSymbol(asset, selected))
}
}
for (const [id, marker] of markerById) {
if (!nextIds.has(id)) {
marker.remove()
markerById.delete(id)
}
}
}
function fitAllMarkers() {
if (!mapInstance) return
const coords = mapMarkers.value
.map((a) => [Number(a.longitude), Number(a.latitude)])
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
if (!coords.length) {
mapInstance.setCenter(mapConfig.center)
mapInstance.setZoom(mapConfig.zoom)
return
}
if (coords.length === 1) {
mapInstance.animateTo({ center: coords[0], zoom: 16 }, { duration: 450 })
return
}
const extent = new maptalks.Extent(
Math.min(...coords.map((c) => c[0])),
Math.min(...coords.map((c) => c[1])),
Math.max(...coords.map((c) => c[0])),
Math.max(...coords.map((c) => c[1])),
)
mapInstance.fitExtent(extent, 0, { animation: true, duration: 450, padding: [60, 60, 60, 60] })
}
function flyToSelected() {
if (!mapInstance || !selectedId.value) return
const asset = devices.asset(selectedId.value)
if (!asset?.hasCoordinates) return
const lon = Number(asset.longitude)
const lat = Number(asset.latitude)
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return
mapInstance.animateTo({ center: [lon, lat], zoom: Math.max(mapInstance.getZoom(), 16) }, { duration: 450 })
}
function onMapBlankClick() {
if (suppressMapClick) return
clearSelection()
}
async function setupMap() {
const map = await commonRefs.getRef('map')
if (!map) {
ui.toast('地图未初始化(缺少天地图 token)')
return
}
mapInstance = map
mapHelper.toggleMapMode({ is2D: true })
const existing = map.getLayer(LAYER_ID)
if (existing) existing.remove()
deviceLayer = new maptalks.VectorLayer(LAYER_ID, [], { zIndex: 120, forceRenderOnMoving: true })
deviceLayer.addTo(map)
map.on('click', onMapBlankClick)
syncMarkers()
if (selectedId.value) flyToSelected()
else fitAllMarkers()
}
function teardownMap() {
if (mapInstance) {
mapInstance.off('click', onMapBlankClick)
const layer = mapInstance.getLayer(LAYER_ID)
if (layer) layer.remove()
}
markerById.clear()
deviceLayer = null
mapInstance = null
commonRefs.clearPendingList('map')
}
onMounted(async () => {
try {
await devices.load()
if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId)
const rec = selectedId.value ? devices.record(selectedId.value) : null
controlExpanded.value = route.query.control === '1' && rec?.kind === 'dock'
await setupMap()
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
})
onUnmounted(() => {
teardownMap()
})
watch(mapMarkers, () => {
syncMarkers()
}, { deep: true })
watch(selectedId, () => {
syncMarkers()
if (selectedId.value) flyToSelected()
else fitAllMarkers()
})
const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null))
const isDrone = computed(() => selectedAsset.value?.type === 'drone')
const record = computed(() => (selectedId.value ? devices.record(selectedId.value) : null))
@ -328,6 +486,9 @@ async function reload() {
selectedDockIds.forEach((id) => {
if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id)
})
syncMarkers()
if (selectedId.value) flyToSelected()
else fitAllMarkers()
ui.toast('设备状态已刷新')
} catch (e) {
ui.toast(e.message || '刷新设备状态失败')
@ -365,29 +526,11 @@ const filterCounts = computed(() => ({
alarm: devices.docks.filter((d) => d.statusClass === 'alarm').length
}))
const mapMarkers = computed(() => Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock))
const selectedDroneParentName = computed(() => {
if (!isDrone.value) return ''
return selectedAsset.value?.parentName || devices.asset(selectedAsset.value?.parent)?.name || '未绑定'
})
const bindingLine = computed(() => {
if (!isDrone.value || !selectedAsset.value?.hasCoordinates || !selectedAsset.value?.parent) return null
const dock = devices.asset(selectedAsset.value.parent)
if (!dock?.hasCoordinates) return null
return { x1: dock.x, y1: dock.y, x2: selectedAsset.value.x, y2: selectedAsset.value.y }
})
const mapStyle = computed(() => {
const a = selectedAsset.value
if (!a || a.x == null) return { transform: `scale(${mapZoom.value})` }
return {
transform: `scale(${mapZoom.value * 1.34})`,
transformOrigin: `${a.x}% ${a.y}%`
}
})
function select(id) {
if (!id) return
const prev = selectedId.value ? devices.record(selectedId.value) : null
@ -412,11 +555,6 @@ function selectAllDocks() {
function clearSelection() {
selectedId.value = null
controlExpanded.value = false
mapZoom.value = 1
}
function handleMapClick(event) {
if (event.target === event.currentTarget || event.target.classList.contains('map-surface')) clearSelection()
}
function isDockCardActive(dock) {
@ -439,20 +577,18 @@ function droneTag(drone) {
return '在巢'
}
function num(v) {
if (v == null) return '--'
return String(v).replace('%', '')
}
function goDetail() {
router.push({ name: 'device-detail', params: { id: selectedId.value } })
}
function zoomIn() {
mapZoom.value = Math.min(2, Math.round((mapZoom.value + 0.15) * 100) / 100)
if (!mapInstance) return
mapInstance.setZoom(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1))
}
function zoomOut() {
mapZoom.value = Math.max(0.7, Math.round((mapZoom.value - 0.15) * 100) / 100)
if (!mapInstance) return
mapInstance.setZoom(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1))
}
async function runCommand(title, desc) {

Loading…
Cancel
Save