You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1495 lines
49 KiB

<template>
<section class="monitor-layout" :class="{ focused: !!selectedId, 'control-expanded': controlExpanded }">
<aside class="device-panel">
<div class="panel-heading">
<div><h1>设备列表</h1><span>{{ devices.docks.length }} · {{ devices.drones.length }} </span></div>
<button class="icon-button" title="收起面板" @click="clearSelection"><svg><use href="#i-x" /></svg></button>
</div>
<div class="device-search">
<t-input v-model="search" clearable placeholder="搜索机巢或无人机">
<template #prefix-icon><t-icon name="search" /></template>
</t-input>
</div>
<div class="filter-row">
<button class="filter" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部 <b>{{ filterCounts.all }}</b></button>
<button class="filter" :class="{ active: filter === 'online' }" @click="filter = 'online'">在线 <b>{{ filterCounts.online }}</b></button>
<button class="filter" :class="{ active: filter === 'alarm' }" @click="filter = 'alarm'">告警 <b>{{ filterCounts.alarm }}</b></button>
</div>
<div v-if="filteredDocks.length" class="multi-select-bar" :class="{ 'has-selection': selectedDockIds.size }">
<span>已选择 {{ selectedDockIds.size }} 个机巢</span>
<button type="button" @click="selectAllDocks">全选</button>
<button type="button" @click="clearDockSelection">取消选择</button>
</div>
<div class="device-list">
<DockDeviceCard
v-for="dock in filteredDocks"
:key="dock.id"
:dock="dock"
:active="isDockCardActive(dock)"
:multi-selected="selectedDockIds.has(dock.id)"
:selected-drone-id="selectedId"
:altitude-of="droneAltitude"
@select="select"
@toggle-select="setDockSelection"
/>
<div v-if="!filteredDocks.length" class="table-empty" style="height:120px;display:grid;place-items:center;color:var(--muted);font-size:10px">暂无匹配设备</div>
</div>
</aside>
<div class="map-panel">
<div class="map-toolbar">
<button class="map-type" type="button" :class="{ active: mapMode === 'satellite' }" @click="setMapMode('satellite')">卫星</button>
<button class="map-type" type="button" :class="{ active: mapMode === 'street' }" @click="setMapMode('street')">地图</button>
<button class="icon-button" title="查看全部设备" @click="fitAll"><svg><use href="#i-maximize" /></svg></button>
</div>
<div class="map-legend">
<span><i class="green"></i>在线机巢</span>
<span><i class="blue"></i>飞行中</span>
<span><i class="red"></i>告警</span>
<span><i class="gray"></i>离线</span>
</div>
<div class="map-scale">{{ mapScaleText }}</div>
<div
v-if="commandProgress.open"
class="command-progress-float"
role="status"
aria-live="polite"
>
<header>
<div>
<strong>{{ commandProgress.title }}</strong>
<small>{{ commandProgress.deviceName }}</small>
</div>
<button type="button" class="icon-button" title="关闭" @click="closeCommandProgress">
<svg><use href="#i-x" /></svg>
</button>
</header>
<ol>
<li :class="{ done: commandProgress.step > 1, active: commandProgress.step === 1 }">
<i></i><span>确认下发</span><em>已确认</em>
</li>
<li :class="{ done: commandProgress.step > 2, active: commandProgress.step === 2 }">
<i></i><span>已下发</span><em>{{ commandProgress.step >= 2 ? '等待应答' : '…' }}</em>
</li>
<li
:class="{
done: commandProgress.step >= 3 && !commandProgress.error && commandProgress.status === 'acked',
active: commandProgress.step === 3,
fail: commandProgress.step >= 3 && (commandProgress.status === 'timeout' || commandProgress.status === 'terminal' || !!commandProgress.error),
}"
>
<i></i>
<span>{{ commandProgressTerminalLabel }}</span>
<em>{{ commandProgress.error || commandProgress.result || '…' }}</em>
</li>
</ol>
</div>
</div>
<MonitorDetailPanel
ref="detailPanelRef"
:selected="!!selectedAsset"
:header="detailHeader"
:relation="detailRelation"
:live-view="detailLiveView"
:dock-status="detailDockStatus"
:flight-status="detailFlightStatus"
:mission="detailMission"
:environment="detailEnvironment"
:actions="detailActions"
v-model:control-expanded="controlExpanded"
@close="clearSelection"
@open-device-detail="goDetail"
@select-parent="select"
@toggle-live="toggleLive"
@fullscreen-live="fullscreenLive"
@live-error="ui.toast('视频播放失败')"
@open-dock-status="openDockStatus"
@open-mission-detail="goMissionDetail"
@command="onDetailCommand"
/>
</section>
<t-dialog
v-model:visible="dockStatusOpen"
:header="dockStatusTitle"
width="700px"
destroy-on-close
:footer="false"
dialog-class-name="dock-status-dialog"
@close="closeDockStatus"
>
<div class="dock-status-body">
<div class="dock-status-grid">
<div>
<span>设备连接</span>
<strong :class="{ 'green-text': dockStatusSummary.online }">{{ dockStatusSummary.connection }}</strong>
<small>{{ dockStatusSummary.connectionHint }}</small>
</div>
<div>
<span>无人机状态</span>
<strong>{{ dockStatusSummary.drone }}</strong>
<small>{{ dockStatusSummary.droneHint }}</small>
</div>
<div>
<span>舱门状态</span>
<strong>{{ dockStatusSummary.door }}</strong>
<small>{{ dockStatusSummary.doorHint }}</small>
</div>
<div>
<span>充电模块</span>
<strong :class="{ 'green-text': chargeTone }">{{ dockStatusSummary.charge }}</strong>
<small>{{ dockStatusSummary.chargeHint }}</small>
</div>
<div>
<span>控制模式</span>
<strong>{{ dockStatusSummary.mode }}</strong>
<small>{{ dockStatusSummary.modeHint }}</small>
</div>
<div>
<span>当前告警</span>
<strong :class="{ 'green-text': !dockStatusSummary.alarmCount }">{{ dockStatusSummary.alarm }}</strong>
<small>{{ dockStatusSummary.alarmHint }}</small>
</div>
</div>
<section class="dock-state-section">
<div class="dock-state-heading">
<h4>状态</h4>
<span>PLC 实时反馈 · {{ selectedDock?.updated || record?.updated || '刚刚' }}</span>
</div>
<div class="dock-state-board">
<article>
<h5>舱门状态</h5>
<ul>
<li :class="{ active: isClosed(doorValue('left')) }"><i></i>左顶门关</li>
<li :class="{ active: doorValue('left') === 'open' }"><i></i>左顶门开</li>
<li :class="{ active: isClosed(doorValue('right')) }"><i></i>右顶门关</li>
<li :class="{ active: doorValue('right') === 'open' }"><i></i>右顶门开</li>
</ul>
</article>
<article>
<h5>杆件状态</h5>
<ul>
<li :class="{ active: isTight(centeringValue('leftRight')) }"><i></i>左右归中闭合</li>
<li :class="{ active: centeringValue('leftRight') === 'loose' }"><i></i>左右归中打开</li>
<li :class="{ active: isTight(centeringValue('frontBack')) }"><i></i>前后归中闭合</li>
<li :class="{ active: centeringValue('frontBack') === 'loose' }"><i></i>前后归中打开</li>
</ul>
</article>
<article>
<h5>系统状态</h5>
<ul>
<li :class="{ active: isTrue(rt('emergencyStop')) }"><i :class="{ warning: isTrue(rt('emergencyStop')) }"></i>急停</li>
<li :class="{ active: isAutoMode }"><i></i>自动模式</li>
<li :class="{ active: !dockStatusSummary.alarmCount }"><i></i>无报警</li>
<li :class="{ active: isTrue(rt('resetDone', 'resetComplete')) }"><i></i>复位完成</li>
</ul>
</article>
<article>
<h5>充电与在位</h5>
<ul>
<li :class="{ active: chargeTone }"><i></i>充电中</li>
<li :class="{ active: String(rt('chargingState') || '').toLowerCase() === 'idle' }"><i></i>充电空闲</li>
<li :class="{ active: isTrue(rt('dronePresent')) }"><i></i>无人机在位</li>
<li :class="{ active: isTrue(rt('powerOnline', 'powerOn')) }"><i></i>电源在线</li>
</ul>
</article>
</div>
</section>
</div>
</t-dialog>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDeviceStore } from '@/stores/modules/devicesStore'
import { useUiStore } from '@/stores/modules/uiStore'
import { useUserStore } from '@/stores/modules/userStore'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map'
import { applyChineseLabels } from '@/utils/mapLabels'
import MonitorDetailPanel from '@/components/MonitorDetailPanel.vue'
import DockDeviceCard from '@/components/DockDeviceCard.vue'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live'
import request from '@/utils/http'
import * as urls from '@/config/urls'
const DEVICE_SOURCE = 'monitor-devices'
const DEVICE_SYMBOL = 'monitor-devices-symbol'
const DEVICE_HALO = 'monitor-devices-halo'
const DEVICE_LABEL = 'monitor-devices-label'
const BINDING_SOURCE = 'monitor-binding'
const BINDING_LINE = 'monitor-binding-line'
const PIN_KINDS = ['dock', 'drone']
const PIN_STATUSES = ['online', 'mission', 'alarm', 'offline', 'pending']
function pinImageId(kind, status) {
const s = PIN_STATUSES.includes(status) ? status : 'offline'
const k = PIN_KINDS.includes(kind) ? kind : 'dock'
return `${k}-pin-${s}`
}
const devices = useDeviceStore()
const ui = useUiStore()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin())
const selectedId = ref(null)
const live = reactive({ session: null, dockId: '', playUrl: '', heartbeatTimer: null })
const detailPanelRef = ref(null)
const filter = ref('all')
const search = ref('')
const controlExpanded = ref(false)
const dockStatusOpen = ref(false)
const selectedDockIds = reactive(new Set())
let mapInstance = null
let layersReady = false
let pendingLoadHandler = null
let pendingStyleHandler = null
const mapMode = ref('satellite')
const mapScaleText = ref('2 km')
let suppressMapClick = false
const dockExtraCommands = [
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' },
{ title: '一键降落', desc: '无人机将返回机巢并自动降落。', icon: '#i-home', hint: '下发指令' },
{ title: '起飞准备', desc: '将执行开舱并解除归中。', icon: '#i-plane', hint: '下发指令' },
{ title: '降落准备', desc: '将开舱并等待无人机返航。', icon: '#i-home', hint: '下发指令' },
{ title: '打开舱门', desc: '将打开机巢舱门。', icon: '#i-door', hint: '下发指令' },
{ title: '关闭舱门', desc: '将关闭机巢舱门。', icon: '#i-door', hint: '下发指令' },
{ title: '开始充电', desc: '将连接充电回路。', icon: '#i-zap', hint: '下发指令' },
{ title: '无人机开机', desc: '将开启舱内无人机电源。', icon: '#i-zap', hint: '下发指令' },
{ title: '无人机关机', desc: '将关闭舱内无人机电源。', icon: '#i-zap', hint: '下发指令' },
{ title: '整机复位', desc: '机巢将恢复待命状态。', icon: '#i-settings', hint: '下发指令' },
{ title: '远程重启', desc: '设备将短暂离线后重新连接。', icon: '#i-settings', hint: '下发指令' },
{ title: '清除设备告警', desc: '将清除机巢当前未处理告警。', icon: '#i-check', hint: '下发指令' }
]
const droneExtraCommands = [
{ title: '起飞', desc: '无人机将立即起飞进入待命状态。', icon: '#i-plane', hint: '离巢待命' },
{ title: '降落', desc: '无人机将就地降落。', icon: '#i-home', hint: '就地降落' },
{ title: '一键返航', desc: '无人机将返回绑定机巢并自动降落。', icon: '#i-home', hint: '自动回巢' },
{ title: '悬停', desc: '无人机将在当前位置悬停。', icon: '#i-stop', hint: '保持位置' },
{ title: '开始任务', desc: '向绑定机巢下发开始任务指令。', icon: '#i-play', hint: '下发指令' },
{ title: '暂停任务', desc: '向绑定机巢下发暂停指令。', icon: '#i-stop', hint: '下发指令' },
{ title: '继续任务', desc: '向绑定机巢下发继续指令。', icon: '#i-play', hint: '下发指令' },
{ title: '取消任务', desc: '向绑定机巢下发取消指令。', icon: '#i-x', hint: '下发指令' },
{ title: '紧急停止', desc: '立即中止飞行,无人机原地悬停待命。', icon: '#i-stop', hint: '原地悬停', danger: true }
]
const mapMarkers = computed(() =>
Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock)
)
function devicesFeatureCollection() {
return {
type: 'FeatureCollection',
features: mapMarkers.value
.map((asset) => {
const lon = Number(asset.longitude)
const lat = Number(asset.latitude)
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null
const selected = selectedId.value === asset.id
const status = asset.statusClass || 'offline'
return {
type: 'Feature',
// asset.id 多为字符串,省略 numeric Feature.id,靠 setData 全量刷新
properties: {
assetId: String(asset.id),
name: asset.name || '',
kind: asset.type,
status,
selected: selected ? 1 : 0,
icon: pinImageId(asset.type, status),
},
geometry: { type: 'Point', coordinates: [lon, lat] },
}
})
.filter(Boolean),
}
}
function bindingFeatureCollection() {
const ends = bindingEndpoints()
if (!ends) {
return { type: 'FeatureCollection', features: [] }
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { label: '绑定设备' },
geometry: { type: 'LineString', coordinates: ends },
},
],
}
}
async function loadPinImageData(url) {
const img = await new Promise((resolve, reject) => {
const el = new Image()
el.onload = () => resolve(el)
el.onerror = () => reject(new Error(`图标加载失败: ${url}`))
el.src = url
})
// 仅有 viewBox 的 SVG 在部分环境下 naturalWidth/Height 为 0
const width = img.naturalWidth || img.width || 48
const height = img.naturalHeight || img.height || 64
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建 canvas 上下文')
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}
async function ensurePinImages(map) {
if (!map) return
const jobs = []
for (const kind of PIN_KINDS) {
for (const status of PIN_STATUSES) {
const id = pinImageId(kind, status)
if (map.hasImage(id)) continue
const url = `/assets/icons/${id}.svg`
jobs.push(
loadPinImageData(url)
.then((image) => {
if (!map.hasImage(id)) {
// Mapbox loadImage 对 SVG 常因 createImageBitmap 失败而静默跳过;
// 先栅格成 ImageData 再 addImage,兼容稳定。
map.addImage(id, image, { pixelRatio: 2 })
}
})
.catch((err) => {
console.warn('[Monitor] pin image failed:', id, err)
}),
)
}
}
await Promise.all(jobs)
}
async function waitStyleLoaded(map, timeoutMs = 8000) {
if (!map) return false
if (map.isStyleLoaded()) return true
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (map.isStyleLoaded()) return true
await new Promise((resolve) => setTimeout(resolve, 50))
}
return !!map.isStyleLoaded()
}
async function ensureMonitorLayers() {
if (!mapInstance) return
const styleReady = await waitStyleLoaded(mapInstance)
if (!styleReady) {
console.warn('[Monitor] map style not ready, skip layer ensure')
return
}
await ensurePinImages(mapInstance)
if (!mapInstance.getSource(DEVICE_SOURCE)) {
mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() })
}
if (!mapInstance.getSource(BINDING_SOURCE)) {
mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() })
}
if (!mapInstance.getLayer(BINDING_LINE)) {
mapInstance.addLayer({
id: BINDING_LINE,
type: 'line',
source: BINDING_SOURCE,
paint: {
'line-color': 'rgba(117,191,246,0.95)',
'line-width': 2,
'line-dasharray': [2, 1.5],
'line-opacity': 0.95,
},
})
}
if (!mapInstance.getLayer(DEVICE_HALO)) {
mapInstance.addLayer({
id: DEVICE_HALO,
type: 'circle',
source: DEVICE_SOURCE,
filter: ['==', ['get', 'selected'], 1],
paint: {
'circle-radius': 22,
'circle-color': 'rgba(45,130,218,0.22)',
'circle-stroke-width': 0,
},
})
}
if (!mapInstance.getLayer(DEVICE_SYMBOL)) {
mapInstance.addLayer({
id: DEVICE_SYMBOL,
type: 'symbol',
source: DEVICE_SOURCE,
layout: {
'icon-image': ['get', 'icon'],
'icon-size': [
'interpolate', ['linear'], ['zoom'],
1.5, ['case', ['==', ['get', 'selected'], 1], 0.95, 0.8],
4, ['case', ['==', ['get', 'selected'], 1], 1.15, 0.95],
10, ['case', ['==', ['get', 'selected'], 1], 1.25, 1],
],
'icon-anchor': 'bottom',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
},
})
}
if (!mapInstance.getLayer(DEVICE_LABEL)) {
mapInstance.addLayer({
id: DEVICE_LABEL,
type: 'symbol',
source: DEVICE_SOURCE,
minzoom: 4,
layout: {
'text-field': ['get', 'name'],
'text-size': 11,
'text-offset': [0, 0.6],
'text-anchor': 'top',
'text-allow-overlap': false,
},
paint: {
'text-color': '#fff',
'text-halo-color': 'rgba(24,35,45,.86)',
'text-halo-width': 1.2,
},
})
}
layersReady = true
}
function clearMonitorLayers() {
if (!mapInstance) return
for (const id of [DEVICE_LABEL, DEVICE_SYMBOL, DEVICE_HALO, BINDING_LINE]) {
if (mapInstance.getLayer(id)) mapInstance.removeLayer(id)
}
for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) {
if (mapInstance.getSource(id)) mapInstance.removeSource(id)
}
layersReady = false
}
function syncMarkers() {
if (!mapInstance || !layersReady) return
const src = mapInstance.getSource(DEVICE_SOURCE)
if (src) src.setData(devicesFeatureCollection())
syncBindingLine()
}
function updateMapScale() {
if (!mapInstance) return
const zoom = mapInstance.getZoom()
// rough WebMercator meters-per-pixel at equator * 100px bar
const meters = (156543.03392 / (2 ** zoom)) * 100
if (meters >= 1000) mapScaleText.value = `${Math.round(meters / 1000)} km`
else if (meters >= 100) mapScaleText.value = `${Math.round(meters / 10) * 10} m`
else mapScaleText.value = `${Math.max(1, Math.round(meters))} m`
}
function bindingEndpoints() {
if (!selectedId.value) return null
const asset = devices.asset(selectedId.value)
if (!asset) return null
let dockAsset = null
let droneAsset = null
if (asset.type === 'dock') {
dockAsset = asset
const dockRec = devices.record(asset.id)
const flying = devices.drones.find((d) => d.dockId === dockRec?.dockId && d.statusClass === 'mission')
droneAsset = flying ? devices.asset(flying.id) : null
} else {
droneAsset = asset.inDock ? null : asset
dockAsset = asset.parent ? devices.asset(asset.parent) : null
}
if (!dockAsset?.hasCoordinates || !droneAsset?.hasCoordinates || droneAsset.inDock) return null
const a = [Number(dockAsset.longitude), Number(dockAsset.latitude)]
const b = [Number(droneAsset.longitude), Number(droneAsset.latitude)]
if (!a.every(Number.isFinite) || !b.every(Number.isFinite)) return null
return [a, b]
}
function syncBindingLine() {
if (!mapInstance || !layersReady) return
const src = mapInstance.getSource(BINDING_SOURCE)
if (src) src.setData(bindingFeatureCollection())
}
function cancelPendingMapHandlers() {
if (!mapInstance) {
pendingLoadHandler = null
pendingStyleHandler = null
return
}
if (pendingLoadHandler) {
mapInstance.off('load', pendingLoadHandler)
mapInstance.off('style.load', pendingLoadHandler)
pendingLoadHandler = null
}
if (pendingStyleHandler) {
mapInstance.off('style.load', pendingStyleHandler)
pendingStyleHandler = null
}
}
async function rebuildAfterStyle() {
pendingStyleHandler = null
if (!mapInstance) return
try {
mapInstance.setProjection('globe')
} catch (_) {
/* ignore */
}
applyChineseLabels(mapInstance)
layersReady = false
await ensureMonitorLayers()
syncMarkers()
updateMapScale()
}
function setMapMode(mode) {
if (!mapInstance || mapMode.value === mode) {
mapMode.value = mode
return
}
if (!mapConfig.token) {
ui.toast('缺少 Mapbox token,无法切换底图')
return
}
mapMode.value = mode
layersReady = false
if (pendingStyleHandler) {
mapInstance.off('style.load', pendingStyleHandler)
pendingStyleHandler = null
}
mapInstance.setStyle(MAP_STYLES[mode] || MAP_STYLES.satellite)
pendingStyleHandler = rebuildAfterStyle
mapInstance.once('style.load', pendingStyleHandler)
}
function resetOverviewCamera() {
if (!mapInstance) return
mapInstance.jumpTo({
center: MAP_OVERVIEW.center,
zoom: MAP_OVERVIEW.zoom,
pitch: 0,
bearing: 0,
})
updateMapScale()
}
function fitAll() {
if (selectedId.value) {
clearSelection()
return
}
resetOverviewCamera()
}
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
const nextZoom = Math.max(Number(mapInstance.getZoom()) || 0, MAP_VIEW.detailZoom)
mapInstance.jumpTo({
center: [lon, lat],
zoom: nextZoom,
pitch: 0,
bearing: 0,
})
updateMapScale()
}
async function waitForMap(timeoutMs = 8000) {
const readMap = () => {
const el = typeof document !== 'undefined' ? document.getElementById('map') : null
if (el?.__laicMap) return el.__laicMap
const fromHelper = mapHelper.getMap()
if (fromHelper) return fromHelper
if (typeof window !== 'undefined' && window.__LAIC_MAPBOX_MAP__) return window.__LAIC_MAPBOX_MAP__
return null
}
const immediate = readMap()
if (immediate) return immediate
const fromRefs = await Promise.race([
commonRefs.getRef('map'),
new Promise((resolve) => setTimeout(() => resolve(null), Math.min(1500, timeoutMs))),
])
if (fromRefs) return fromRefs
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const mapped = readMap()
if (mapped) return mapped
await new Promise((resolve) => setTimeout(resolve, 50))
}
return null
}
async function setupMap() {
const map = await waitForMap()
if (!map) {
ui.toast('地图未初始化(缺少 Mapbox token)')
return
}
mapInstance = map
if (typeof window !== 'undefined') window.__LAIC_MAP__ = map
mapHelper.toggleMapMode({ is2D: true })
updateMapScale()
let readyStarted = false
const onReady = async () => {
if (readyStarted) return
readyStarted = true
pendingLoadHandler = null
if (!mapInstance) return
try {
await ensureMonitorLayers()
if (!layersReady) {
// style/HMR 竞态:短暂重试,避免 readyStarted 锁死后永远无图层
for (let i = 0; i < 20 && !layersReady; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (!mapInstance) return
await ensureMonitorLayers()
}
}
syncMarkers()
if (selectedId.value) flyToSelected()
else resetOverviewCamera()
updateMapScale()
} catch (err) {
readyStarted = false
console.error('[Monitor] ensure layers failed:', err)
ui.toast(err?.message || '机巢图层初始化失败')
}
}
cancelPendingMapHandlers()
if (map.isStyleLoaded()) {
await onReady()
} else {
pendingLoadHandler = onReady
map.once('load', pendingLoadHandler)
map.once('style.load', pendingLoadHandler)
// 双事件都可能已触发:短轮询兜底,避免首屏卡在默认比例尺
const startedAt = Date.now()
const poll = () => {
if (readyStarted || mapInstance !== map) return
if (map.isStyleLoaded()) {
onReady()
return
}
if (Date.now() - startedAt < 5000) setTimeout(poll, 50)
}
setTimeout(poll, 0)
}
map.on('click', onMapClick)
map.on('mouseenter', DEVICE_SYMBOL, onSymbolEnter)
map.on('mouseleave', DEVICE_SYMBOL, onSymbolLeave)
map.on('zoom', updateMapScale)
map.on('move', updateMapScale)
}
function onMapClick(e) {
if (suppressMapClick) return
if (!layersReady) return
const layers = [DEVICE_SYMBOL, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id))
if (!layers.length) return
const feats = mapInstance.queryRenderedFeatures(e.point, {
layers,
})
const id = feats[0]?.properties?.assetId
if (id) {
suppressMapClick = true
select(String(id))
setTimeout(() => { suppressMapClick = false }, 0)
return
}
clearSelection()
}
function onSymbolEnter() {
if (!mapInstance) return
mapInstance.getCanvas().style.cursor = 'pointer'
}
function onSymbolLeave() {
if (!mapInstance) return
mapInstance.getCanvas().style.cursor = ''
}
function teardownMap() {
cancelPendingMapHandlers()
if (mapInstance) {
mapInstance.off('click', onMapClick)
mapInstance.off('mouseenter', DEVICE_SYMBOL, onSymbolEnter)
mapInstance.off('mouseleave', DEVICE_SYMBOL, onSymbolLeave)
mapInstance.off('zoom', updateMapScale)
mapInstance.off('move', updateMapScale)
clearMonitorLayers()
}
mapInstance = null
if (typeof window !== 'undefined' && window.__LAIC_MAP__) delete window.__LAIC_MAP__
layersReady = false
commonRefs.clearPendingList('map')
}
onMounted(async () => {
window.addEventListener('keydown', onGlobalKeydown)
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'
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
try {
await setupMap()
} catch (e) {
ui.toast(e.message || '地图初始化失败')
}
})
onUnmounted(() => {
window.removeEventListener('keydown', onGlobalKeydown)
closeCommandProgress()
closeLive()
teardownMap()
})
watch(mapMarkers, () => {
syncMarkers()
}, { deep: true })
watch(selectedId, () => {
syncMarkers()
if (selectedId.value) flyToSelected()
else resetOverviewCamera()
})
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))
const isFlying = computed(() => isDrone.value && record.value?.statusClass === 'mission')
const selectedDrone = computed(() => (isDrone.value ? record.value : devices.drones.find((d) => d.dockId === record.value?.dockId) || null))
const selectedDock = computed(() => (isDrone.value ? devices.docks.find((d) => d.dockId === record.value?.dockId) || null : record.value))
const selectedDockEnvironment = computed(() => selectedDock.value?.environment || null)
const hasEnvironment = computed(() => Object.values(selectedDockEnvironment.value || {}).some((value) => value != null && value !== ''))
const droneMissionState = computed(() => {
if (!isDrone.value || !record.value) return ''
if (record.value.statusClass === 'mission') return record.value.status || '任务中'
if (record.value.statusClass === 'offline') return '设备离线'
return record.value.status || '停放待命'
})
const livePhaseText = computed(() => {
if (!live.session) return '待机'
const phase = live.session.phase
if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中'
if (phase === 'starting') return '启动中'
if (phase === 'reconnecting') return '重连中'
if (phase === 'stopping') return '停止中'
if (phase === 'failed') return '启动失败'
return '已停止'
})
const canFullscreenLive = computed(() => !!live.playUrl && !live.playUrl.startsWith('fake://'))
const detailHeader = computed(() => {
const asset = selectedAsset.value
if (!asset) return null
return {
kindLabel: isDrone.value ? '无人机设备' : '机巢设备',
name: asset.name,
location: asset.location,
status: asset.status,
statusClass: asset.statusClass,
isDrone: isDrone.value,
}
})
const detailRelation = computed(() => {
if (!isDrone.value || !selectedAsset.value) return null
return {
parentId: selectedAsset.value.parent || null,
parentName: selectedDroneParentName.value,
}
})
const detailLiveView = computed(() => ({
playUrl: live.playUrl,
phase: live.session?.phase,
active: !!live.session,
phaseText: livePhaseText.value,
canFullscreen: canFullscreenLive.value,
cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头',
}))
const detailDockStatus = computed(() => {
if (isDrone.value) return null
return {
droneBattery: selectedDrone.value?.battery || '--',
chargeText: chargeText(rt('chargingState')),
chargeActive: chargeTone.value,
doorSummary: doorSummary.value,
controlMode: dockControlMode.value,
}
})
const detailFlightStatus = computed(() => {
if (!isDrone.value || !selectedAsset.value) return null
return {
missionState: droneMissionState.value,
battery: record.value?.battery || '--',
altitudeText: displayWithUnit(selectedAsset.value.altitude, 'm'),
speedText: displayWithUnit(selectedAsset.value.speed, 'm/s', 1),
satellitesText: displayWithUnit(selectedAsset.value.satellites, '颗'),
}
})
const detailMission = computed(() => {
if (!isDrone.value) return null
const detail = missionDetail.value
return {
hasMission: detail.hasMission,
name: detail.name,
route: detail.route,
progressText: detail.progressText,
progressWidth: detail.progressWidth,
waypointText: detail.waypointText,
elapsedText: detail.elapsedText,
distanceText: detail.distanceText,
canOpenDetail: !!detail.taskId,
}
})
const detailEnvironment = computed(() => ({
updatedText: selectedDock.value?.updated || record.value?.updated || '刚刚',
hasData: hasEnvironment.value,
outsideTemperatureText: displayWithUnit(selectedDockEnvironment.value?.outsideTemperature, '℃'),
windSpeedText: displayWithUnit(selectedDockEnvironment.value?.windSpeed, 'm/s'),
rainfallText: rainfallText(selectedDockEnvironment.value),
}))
const detailActions = computed(() => ({
visible: !isAdmin.value,
isDrone: isDrone.value,
isFlying: isFlying.value,
statusText: buttonStatusText,
activeTitle: commandProgress.status ? commandProgress.buttonTitle : '',
dockExtra: dockExtraCommands,
droneExtra: droneExtraCommands,
}))
function onDetailCommand({ title, desc }) {
runCommand(title, desc)
}
function toggleLive() {
if (live.session) closeLive()
else openLive()
}
function fullscreenLive() {
if (!canFullscreenLive.value) {
ui.toast('当前无可全屏播放的视频流')
return
}
const ok = detailPanelRef.value?.livePlayerRef?.requestFullscreen?.()
if (!ok) ui.toast('当前浏览器不支持全屏')
}
async function openLive() {
const dockId = selectedDock.value?.dockId
if (!dockId) return
try {
const result = await joinLive(dockId)
live.session = result.session
live.dockId = dockId
scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming') await refreshPlayURL()
} catch (e) {
ui.toast(e.message || '打开直播失败')
}
}
async function refreshPlayURL() {
if (!live.dockId) return
const play = await getLivePlayURL(live.dockId)
live.playUrl = play.playUrl || ''
}
function scheduleLiveHeartbeat(expiresAt) {
window.clearTimeout(live.heartbeatTimer)
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
live.heartbeatTimer = window.setTimeout(async () => {
try {
const result = await heartbeatLive(live.dockId, live.session.id)
live.session = result.session
scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming' && !live.playUrl) await refreshPlayURL()
} catch (e) {
ui.toast(e.message || '直播观看已结束')
await closeLive(false)
}
}, delay)
}
async function closeLive(sendRequest = true) {
window.clearTimeout(live.heartbeatTimer)
const session = live.session
const dockId = live.dockId
live.session = null
live.dockId = ''
live.playUrl = ''
if (sendRequest && session && dockId) {
try {
await leaveLive(dockId, session.id)
} catch (_) {}
}
}
function displayWithUnit(value, unit, digits) {
if (value == null || value === '') return '--'
const num = Number(value)
if (Number.isFinite(num) && digits != null) {
return `${num.toFixed(digits)}${unit}`
}
return `${value}${unit}`
}
function boolText(value) {
if (value === true || value === 'true' || value === '1' || value === 1) return '是'
if (value === false || value === 'false' || value === '0' || value === 0) return '否'
return '--'
}
function rainfallText(env) {
if (!env) return '--'
const amount = env.rainfall ?? env.rainAmount ?? env.rainFall ?? env.precipitation
if (amount != null && amount !== '') {
const num = Number(amount)
if (Number.isFinite(num)) return `${num} mm`
return `${amount} mm`
}
if (env.rain != null && env.rain !== '') {
if (env.rain === true || env.rain === 'true' || env.rain === 1 || env.rain === '1') return '有降雨'
if (env.rain === false || env.rain === 'false' || env.rain === 0 || env.rain === '0') return '0 mm'
return String(env.rain)
}
return '--'
}
function displayValue(value) {
return value == null || value === '' ? '--' : value
}
function rt(key, ...aliases) {
const realtime = record.value?.realtime || {}
for (const name of [key, ...aliases]) {
if (realtime[name] != null && realtime[name] !== '') return realtime[name]
}
return null
}
function rtObj(key) {
const raw = record.value?.realtime?.[key]
if (!raw) return {}
if (typeof raw === 'object') return raw
try { return JSON.parse(raw) || {} } catch { return {} }
}
function doorValue(side) {
return rtObj('door')[side]
}
function isClosed(value) {
return value === 'closed' || value === 'close'
}
function centeringValue(key) {
return rtObj('centering')[key]
}
function isTight(value) {
return value === 'tight'
}
function isTrue(value) {
return value === true || value === 'true' || value === '1' || value === 1
}
function chargeText(value) {
const key = String(value || '').toLowerCase()
if (key === 'charging') return '充电中'
if (key === 'idle') return '空闲'
if (key === 'full' || key === 'charged') return '已充满'
return displayValue(value)
}
const chargeTone = computed(() => String(rt('chargingState') || '').toLowerCase() === 'charging')
const doorSummary = computed(() => {
const left = doorValue('left')
const right = doorValue('right')
if (isClosed(left) && isClosed(right)) return '已关闭'
if (left === 'open' || right === 'open') return '已打开'
return displayValue(left || right)
})
const dockControlMode = computed(() => record.value?.mode || displayValue(rt('controlMode')))
function formatDuration(seconds) {
const total = Math.max(0, Math.floor(Number(seconds) || 0))
if (!Number.isFinite(total) || total <= 0) return '--'
const mm = String(Math.floor(total / 60)).padStart(2, '0')
const ss = String(total % 60).padStart(2, '0')
return `${mm}:${ss}`
}
function formatDistance(meters) {
const value = Number(meters)
if (!Number.isFinite(value) || value < 0) return '--'
if (value >= 1000) return `${(value / 1000).toFixed(1)} km`
return `${Math.round(value)} m`
}
const missionDetail = computed(() => {
const rec = record.value
const nameRaw = rec?.mission || rt('missionName', 'mission')
const name = nameRaw && nameRaw !== '--' && nameRaw !== '无' ? String(nameRaw) : ''
const route = displayValue(rt('routeName', 'routeCode', 'route'))
const progressRaw = rt('missionProgress', 'progressPercent', 'progress')
const progressNum = Number(progressRaw)
const hasProgress = Number.isFinite(progressNum)
const currentWp = rt('currentWaypoint', 'waypointIndex', 'waypoint')
const totalWp = rt('waypointCount', 'totalWaypoints', 'waypoints')
const elapsed = rt('missionElapsed', 'elapsed', 'flightDuration', 'elapsedSeconds')
const distance = rt('distanceToHome', 'distanceFromDock', 'homeDistance', 'distance')
const taskId = rt('taskId', 'missionId') || rec?.taskId || null
const active = !!name || rec?.statusClass === 'mission'
return {
hasMission: active,
name: name || (rec?.statusClass === 'mission' ? (rec.status || '执行中任务') : '暂无任务'),
route: route === '--' ? '航线 --' : `航线 ${route}`,
progressText: hasProgress ? `${Math.max(0, Math.min(100, Math.round(progressNum)))}%` : '--',
progressWidth: hasProgress ? `${Math.max(0, Math.min(100, progressNum))}%` : '0%',
waypointText: (currentWp != null || totalWp != null)
? `航点 ${displayValue(currentWp)} / ${displayValue(totalWp)}`
: '航点 -- / --',
elapsedText: `已飞行 ${formatDuration(elapsed)}`,
distanceText: `距离机巢 ${formatDistance(distance)}`,
taskId,
}
})
function goMissionDetail() {
const id = missionDetail.value.taskId
if (!id) {
ui.toast('暂无任务详情')
return
}
router.push({ name: 'tasks', query: { taskId: String(id) } })
}
const isAutoMode = computed(() => {
const mode = String(rt('controlMode') || record.value?.mode || '').toLowerCase()
return mode === 'auto' || mode === 'automatic' || mode.includes('自动')
})
const dockStatusTitle = computed(() => {
if (!isDrone.value) return selectedAsset.value?.name || '机巢'
return selectedDock.value?.name || selectedDroneParentName.value || '机巢'
})
const dockStatusSummary = computed(() => {
const dock = selectedDock.value
const drone = selectedDrone.value
const online = !!dock?.online
const alarms = Array.isArray(dock?.alarms) ? dock.alarms : []
const alarmCount = alarms.length
const left = doorValue('left')
const right = doorValue('right')
let doorHint = '门状态未知'
if (isClosed(left) && isClosed(right)) doorHint = '左门、右门反馈正常'
else if (left === 'open' || right === 'open') doorHint = '存在开启舱门'
const droneLabel = drone
? (drone.statusClass === 'mission' ? (drone.status || '任务中') : (drone.status || '在巢待命'))
: '未绑定'
const droneHint = drone
? `${drone.name || drone.code || '--'}${drone.battery && drone.battery !== '--' ? ` · 电量 ${drone.battery}` : ''}`
: '暂无绑定无人机'
return {
online,
connection: online ? '在线' : (dock?.status || '离线'),
connectionHint: online ? `心跳正常 · ${dock?.updated || '刚刚'}` : (dock?.updated ? `最后在线 ${dock.updated}` : '设备离线'),
drone: droneLabel,
droneHint,
door: doorSummary.value,
doorHint,
charge: chargeText(rt('chargingState')),
chargeHint: isTrue(rt('dronePresent')) ? '电池在位' : '在位状态未知',
mode: dockControlMode.value,
modeHint: `手自动切换:${dockControlMode.value}`,
alarmCount,
alarm: alarmCount ? `${alarmCount}` : '无告警',
alarmHint: alarmCount ? alarms.slice(0, 2).join('、') : '急停未触发 · 报警总信号正常'
}
})
function openDockStatus() {
if (!selectedId.value) return
dockStatusOpen.value = true
}
function closeDockStatus() {
dockStatusOpen.value = false
}
function onGlobalKeydown(event) {
if (event.key === 'Escape' && commandProgress.open) {
closeCommandProgress()
return
}
if (event.key === 'Escape' && dockStatusOpen.value) closeDockStatus()
}
async function reload() {
try {
await devices.load()
if (selectedId.value && !devices.record(selectedId.value)) clearSelection()
selectedDockIds.forEach((id) => {
if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id)
})
syncMarkers()
if (selectedId.value) flyToSelected()
else resetOverviewCamera()
ui.toast('设备状态已刷新')
} catch (e) {
ui.toast(e.message || '刷新设备状态失败')
}
}
const groupedDocks = computed(() =>
devices.docks.map((dock) => ({
...dock,
children: devices.drones.filter((d) => d.dockId === dock.dockId)
}))
)
const filteredDocks = computed(() => {
const q = search.value.trim().toLowerCase()
return groupedDocks.value.filter((dock) => {
const matchesFilter =
filter.value === 'all' ||
(filter.value === 'online' && dock.statusClass === 'online') ||
(filter.value === 'alarm' && dock.statusClass === 'alarm')
if (!matchesFilter) return false
if (!q) return true
return (
dock.name.toLowerCase().includes(q) ||
dock.code.toLowerCase().includes(q) ||
dock.location.toLowerCase().includes(q) ||
dock.children.some((c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
)
})
})
const filterCounts = computed(() => ({
all: devices.docks.length,
online: devices.docks.filter((d) => d.statusClass === 'online').length,
alarm: devices.docks.filter((d) => d.statusClass === 'alarm').length
}))
const selectedDroneParentName = computed(() => {
if (!isDrone.value) return ''
return selectedAsset.value?.parentName || devices.asset(selectedAsset.value?.parent)?.name || '未绑定'
})
function select(id) {
if (!id) return
const prev = selectedId.value ? devices.record(selectedId.value) : null
selectedId.value = String(id)
const rec = devices.record(id)
if (!rec || rec.kind !== prev?.kind) controlExpanded.value = false
}
function clearDockSelection() {
selectedDockIds.clear()
}
function setDockSelection(id, checked) {
if (checked) selectedDockIds.add(id)
else selectedDockIds.delete(id)
}
function selectAllDocks() {
filteredDocks.value.forEach((dock) => selectedDockIds.add(dock.id))
}
function clearSelection() {
selectedId.value = null
controlExpanded.value = false
}
function isDockCardActive(dock) {
return selectedId.value === dock.id || dock.children.some((c) => c.id === selectedId.value)
}
function droneAltitude(id) {
return devices.asset(id)?.altitude
}
function goDetail() {
router.push({ name: 'device-detail', params: { id: selectedId.value } })
}
function zoomIn() {
if (!mapInstance) return
mapInstance.zoomTo(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1), { duration: 200 })
}
function zoomOut() {
if (!mapInstance) return
mapInstance.zoomTo(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1), { duration: 200 })
}
const COMMAND_STATUS = {
sent: { name: '已下发', step: 2, result: '等待应答', terminal: false },
acked: { name: '已确认', step: 3, result: '执行成功', terminal: true, ok: true },
timeout: { name: '已超时', step: 3, result: '已超时', terminal: true, ok: false },
terminal: { name: '执行失败', step: 3, result: '执行失败', terminal: true, ok: false },
}
const commandProgress = reactive({
open: false,
title: '',
deviceName: '',
commandId: '',
status: '', // sent/acked/timeout/terminal
result: '',
step: 1, // 1 确认下发 2 已下发 3 终态
error: '',
buttonTitle: '',
})
let commandPollTimer = null
let commandAutoCloseTimer = null
let commandPollFails = 0
let commandPollInFlight = false
let commandProgressEpoch = 0
function buttonStatusText(title) {
if (commandProgress.buttonTitle !== title || !commandProgress.status) return '状态:待执行'
const meta = COMMAND_STATUS[commandProgress.status]
return meta ? `状态:${meta.name}` : '状态:待执行'
}
const commandProgressTerminalLabel = computed(() => {
if (commandProgress.status === 'acked') return '已确认'
return COMMAND_STATUS[commandProgress.status]?.name || '执行结果'
})
function clearCommandTimers() {
if (commandPollTimer) {
clearInterval(commandPollTimer)
commandPollTimer = null
}
if (commandAutoCloseTimer) {
clearTimeout(commandAutoCloseTimer)
commandAutoCloseTimer = null
}
}
function closeCommandProgress() {
clearCommandTimers()
commandProgressEpoch += 1
commandProgress.open = false
}
function applyCommandStatus(cmd) {
const status = cmd?.status || 'sent'
const meta = COMMAND_STATUS[status] || COMMAND_STATUS.sent
commandProgress.status = status
commandProgress.step = meta.step
commandProgress.result = cmd?.ackResultCode || meta.result
commandProgress.error = ''
return meta
}
function scheduleAutoClose() {
if (commandAutoCloseTimer) clearTimeout(commandAutoCloseTimer)
commandAutoCloseTimer = setTimeout(() => {
closeCommandProgress()
}, 5000)
}
async function pollCommandOnce() {
if (!commandProgress.commandId || commandPollInFlight) return
const epoch = commandProgressEpoch
commandPollInFlight = true
try {
const cmd = await request.get(urls.COMMAND(commandProgress.commandId))
if (epoch !== commandProgressEpoch) return
commandPollFails = 0
const meta = applyCommandStatus(cmd)
if (meta.terminal) {
clearInterval(commandPollTimer)
commandPollTimer = null
scheduleAutoClose()
}
} catch (e) {
if (epoch !== commandProgressEpoch) return
commandPollFails += 1
const status = e?.status ?? e?.response?.status
if (status === 403 || status === 404 || commandPollFails >= 3) {
clearInterval(commandPollTimer)
commandPollTimer = null
commandProgress.error =
status === 403
? '无权限查看进度,请稍后在操作记录确认'
: status === 404
? '未找到指令记录'
: (e.message || '进度查询失败')
scheduleAutoClose()
}
} finally {
commandPollInFlight = false
}
}
function startCommandProgress(cmd, title, deviceName) {
clearCommandTimers()
commandPollInFlight = false
commandProgressEpoch += 1
commandPollFails = 0
commandProgress.open = true
commandProgress.title = title
commandProgress.deviceName = deviceName || '--'
commandProgress.commandId = String(cmd?.id || '')
commandProgress.buttonTitle = title
commandProgress.step = 1
commandProgress.error = ''
applyCommandStatus(cmd || { status: 'sent' })
if (!commandProgress.commandId) {
commandProgress.error = '未返回指令 ID,无法跟踪进度'
scheduleAutoClose()
return
}
const ttl = Number(cmd?.ttlMs) > 0 ? Number(cmd.ttlMs) : 30000
const deadline = Date.now() + Math.max(ttl, 30000) + 5000
commandPollTimer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(commandPollTimer)
commandPollTimer = null
commandProgressEpoch += 1
if (!COMMAND_STATUS[commandProgress.status]?.terminal) {
commandProgress.error = '等待超时,请稍后在操作记录查看'
commandProgress.step = 3
}
scheduleAutoClose()
return
}
await pollCommandOnce()
}, 1500)
// 立即查一次,避免干等 1.5s
pollCommandOnce()
}
async function runCommand(title, desc) {
const rec = record.value
if (!rec) return
if (!rec.online) {
ui.toast('设备当前离线,无法下发指令')
return
}
if (rec.kind === 'drone' && rec.bindingState !== 'bound') {
ui.toast('无人机未绑定机巢,无法下发指令')
return
}
const ok = await ui.confirm(title, desc)
if (!ok) return
try {
const cmd = await devices.sendCommand(rec, title)
ui.toast(`${title}指令已下发`)
startCommandProgress(cmd, title, rec.name)
await reload()
} catch (e) {
ui.toast(e.message || '指令下发失败')
}
}
</script>
<style scoped>
.device-search {
height: auto;
margin-top: 16px;
padding: 0;
border: 0;
background: transparent;
}
.device-search :deep(.t-input) {
width: 100%;
}
.device-select-checkbox {
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
}
.device-select-checkbox :deep(.t-checkbox) {
margin: 0;
}
.device-select-checkbox :deep(.t-checkbox__input) {
margin: 0;
}
.dock-status-body {
padding: 13px 18px 15px;
}
</style>