|
|
@ -67,30 +67,8 @@ |
|
|
</aside> |
|
|
</aside> |
|
|
|
|
|
|
|
|
<div class="map-panel"> |
|
|
<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"> |
|
|
<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="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="zoomOut"><svg><use href="#i-minus" /></svg></button> |
|
|
<button class="icon-button" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button> |
|
|
<button class="icon-button" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button> |
|
|
@ -244,12 +222,25 @@ |
|
|
</template> |
|
|
</template> |
|
|
|
|
|
|
|
|
<script setup> |
|
|
<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 { useDeviceStore } from '@/stores/modules/devicesStore' |
|
|
import { useUiStore } from '@/stores/modules/uiStore' |
|
|
import { useUiStore } from '@/stores/modules/uiStore' |
|
|
import { useRouter } from 'vue-router' |
|
|
|
|
|
import { useUserStore } from '@/stores/modules/userStore' |
|
|
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 devices = useDeviceStore() |
|
|
const ui = useUiStore() |
|
|
const ui = useUiStore() |
|
|
@ -263,7 +254,12 @@ const filter = ref('all') |
|
|
const search = ref('') |
|
|
const search = ref('') |
|
|
const controlExpanded = ref(false) |
|
|
const controlExpanded = ref(false) |
|
|
const selectedDockIds = reactive(new Set()) |
|
|
const selectedDockIds = reactive(new Set()) |
|
|
const mapZoom = ref(1) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let mapInstance = null |
|
|
|
|
|
let deviceLayer = null |
|
|
|
|
|
let suppressMapClick = false |
|
|
|
|
|
const markerById = new Map() |
|
|
|
|
|
|
|
|
const dockExtraCommands = [ |
|
|
const dockExtraCommands = [ |
|
|
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' }, |
|
|
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' }, |
|
|
{ title: '一键降落', desc: '无人机将返回机巢并自动降落。', icon: '#i-home', hint: '下发指令' }, |
|
|
{ title: '一键降落', desc: '无人机将返回机巢并自动降落。', icon: '#i-home', hint: '下发指令' }, |
|
|
@ -291,17 +287,179 @@ const droneExtraCommands = [ |
|
|
{ title: '紧急停止', desc: '立即中止飞行,无人机原地悬停待命。', icon: '#i-stop', hint: '原地悬停', danger: true } |
|
|
{ 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 () => { |
|
|
onMounted(async () => { |
|
|
try { |
|
|
try { |
|
|
await devices.load() |
|
|
await devices.load() |
|
|
if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId) |
|
|
if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId) |
|
|
const rec = selectedId.value ? devices.record(selectedId.value) : null |
|
|
const rec = selectedId.value ? devices.record(selectedId.value) : null |
|
|
controlExpanded.value = route.query.control === '1' && rec?.kind === 'dock' |
|
|
controlExpanded.value = route.query.control === '1' && rec?.kind === 'dock' |
|
|
|
|
|
await setupMap() |
|
|
} catch (e) { |
|
|
} catch (e) { |
|
|
ui.toast(e.message || '加载设备失败') |
|
|
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 selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null)) |
|
|
const isDrone = computed(() => selectedAsset.value?.type === 'drone') |
|
|
const isDrone = computed(() => selectedAsset.value?.type === 'drone') |
|
|
const record = computed(() => (selectedId.value ? devices.record(selectedId.value) : null)) |
|
|
const record = computed(() => (selectedId.value ? devices.record(selectedId.value) : null)) |
|
|
@ -328,6 +486,9 @@ async function reload() { |
|
|
selectedDockIds.forEach((id) => { |
|
|
selectedDockIds.forEach((id) => { |
|
|
if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id) |
|
|
if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id) |
|
|
}) |
|
|
}) |
|
|
|
|
|
syncMarkers() |
|
|
|
|
|
if (selectedId.value) flyToSelected() |
|
|
|
|
|
else fitAllMarkers() |
|
|
ui.toast('设备状态已刷新') |
|
|
ui.toast('设备状态已刷新') |
|
|
} catch (e) { |
|
|
} catch (e) { |
|
|
ui.toast(e.message || '刷新设备状态失败') |
|
|
ui.toast(e.message || '刷新设备状态失败') |
|
|
@ -365,29 +526,11 @@ const filterCounts = computed(() => ({ |
|
|
alarm: devices.docks.filter((d) => d.statusClass === 'alarm').length |
|
|
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(() => { |
|
|
const selectedDroneParentName = computed(() => { |
|
|
if (!isDrone.value) return '' |
|
|
if (!isDrone.value) return '' |
|
|
return selectedAsset.value?.parentName || devices.asset(selectedAsset.value?.parent)?.name || '未绑定' |
|
|
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) { |
|
|
function select(id) { |
|
|
if (!id) return |
|
|
if (!id) return |
|
|
const prev = selectedId.value ? devices.record(selectedId.value) : null |
|
|
const prev = selectedId.value ? devices.record(selectedId.value) : null |
|
|
@ -412,11 +555,6 @@ function selectAllDocks() { |
|
|
function clearSelection() { |
|
|
function clearSelection() { |
|
|
selectedId.value = null |
|
|
selectedId.value = null |
|
|
controlExpanded.value = false |
|
|
controlExpanded.value = false |
|
|
mapZoom.value = 1 |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function handleMapClick(event) { |
|
|
|
|
|
if (event.target === event.currentTarget || event.target.classList.contains('map-surface')) clearSelection() |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function isDockCardActive(dock) { |
|
|
function isDockCardActive(dock) { |
|
|
@ -439,20 +577,18 @@ function droneTag(drone) { |
|
|
return '在巢' |
|
|
return '在巢' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function num(v) { |
|
|
|
|
|
if (v == null) return '--' |
|
|
|
|
|
return String(v).replace('%', '') |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function goDetail() { |
|
|
function goDetail() { |
|
|
router.push({ name: 'device-detail', params: { id: selectedId.value } }) |
|
|
router.push({ name: 'device-detail', params: { id: selectedId.value } }) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function zoomIn() { |
|
|
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() { |
|
|
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) { |
|
|
async function runCommand(title, desc) { |
|
|
|