更新地图

This commit is contained in:
2026-06-03 19:59:49 +08:00
parent 9748a1e681
commit 191651fce9
13 changed files with 946 additions and 173 deletions
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { NodeInfoById, TextMessage } from '../types'
const props = defineProps<{
messages: TextMessage[]
nodesById: NodeInfoById
selectedNodeId: string | null
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
}>()
function senderName(message: TextMessage): string {
const node = props.nodesById[message.from_id]
return node?.long_name || node?.short_name || message.from_id
}
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
</script>
<template>
<aside class="chat-panel panel">
<div class="panel-header">
<div>
<p class="eyebrow">Chat</p>
<h2>聊天信息</h2>
</div>
<span class="badge">{{ messages.length }}</span>
</div>
<div v-if="messages.length === 0" class="empty">暂无聊天消息</div>
<button
v-for="message in messages"
:key="message.id"
class="chat-item"
:class="{ selected: selectedNodeId === message.from_id }"
type="button"
@click="emit('select-node', message.from_id)"
>
<span class="chat-meta">
<strong>{{ senderName(message) }}</strong>
<small>{{ formatTime(message.created_at) }}</small>
</span>
<span class="chat-text">{{ message.text || '[binary]' }}</span>
<span class="chat-host">{{ message.mqtt_remote_host || message.topic }}</span>
</button>
</aside>
</template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { MapNode } from '../types'
const props = defineProps<{
nodes: MapNode[]
selectedNodeId: string | null
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
'clear-node': []
}>()
const mapEl = ref<HTMLElement | null>(null)
let map: L.Map | null = null
let markerLayer: L.LayerGroup | null = null
let hasFitBounds = false
onMounted(async () => {
await nextTick()
if (!mapEl.value) {
return
}
map = L.map(mapEl.value, {
zoomControl: true,
maxBounds: [
[-85, -180],
[85, 180],
],
maxBoundsViscosity: 1.0,
worldCopyJump: false,
}).setView([0, 0], 2)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map)
map.on('click', () => emit('clear-node'))
markerLayer = L.layerGroup().addTo(map)
renderMarkers(true)
})
onBeforeUnmount(() => {
map?.remove()
map = null
markerLayer = null
})
watch(
() => [props.nodes, props.selectedNodeId] as const,
() => renderMarkers(false),
{ deep: true },
)
function renderMarkers(forceFit: boolean) {
if (!map || !markerLayer) {
return
}
markerLayer.clearLayers()
const bounds = L.latLngBounds([])
for (const node of props.nodes) {
const selected = node.node_id === props.selectedNodeId
const marker = L.marker([node.latitude, node.longitude], {
icon: L.divIcon({
className: `node-marker${selected ? ' selected' : ''}`,
html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`,
iconSize: [34, 22],
iconAnchor: [17, 11],
}),
title: node.label,
})
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
marker.on('click', (event) => {
L.DomEvent.stopPropagation(event)
emit('select-node', node.node_id)
})
marker.addTo(markerLayer)
if (selected) {
marker.openPopup()
}
bounds.extend([node.latitude, node.longitude])
}
if (props.nodes.length > 0 && (forceFit || !hasFitBounds)) {
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 13 })
hasFitBounds = true
}
}
function buildNodePopupHTML(node: MapNode): string {
const info = node.node
return `
<div class="node-popup">
<strong>${escapeHTML(node.node_id)}</strong>
<dl>
<div><dt>长名称</dt><dd>${escapeHTML(info?.long_name || '-')}</dd></div>
<div><dt>短名称</dt><dd>${escapeHTML(info?.short_name || '-')}</dd></div>
<div><dt>硬件型号</dt><dd>${escapeHTML(info?.hw_model || '-')}</dd></div>
<div><dt>角色</dt><dd>${escapeHTML(info?.role || '-')}</dd></div>
<div><dt>固件版本</dt><dd>${escapeHTML(info?.firmware_version || '-')}</dd></div>
<div><dt>海拔</dt><dd>${node.altitude ?? '-'}</dd></div>
<div><dt>经度</dt><dd>${node.longitude.toFixed(5)}</dd></div>
<div><dt>纬度</dt><dd>${node.latitude.toFixed(5)}</dd></div>
<div><dt>位置精度</dt><dd>${info?.position_precision ?? '-'}</dd></div>
<div><dt>在线节点</dt><dd>${info?.num_online_local_nodes ?? '-'}</dd></div>
</dl>
</div>
`
}
function nodeColor(nodeId: string): string {
let hash = 0
for (let index = 0; index < nodeId.length; index += 1) {
hash = (hash * 31 + nodeId.charCodeAt(index)) >>> 0
}
const hueRanges = [
[35, 75],
[95, 165],
[185, 250],
[265, 315],
]
const range = hueRanges[hash % hueRanges.length]
const hue = range[0] + (hash % (range[1] - range[0]))
const saturation = 68 + (hash % 18)
const lightness = 32 + (hash % 10)
return `hsl(${hue} ${saturation}% ${lightness}%)`
}
function escapeHTML(value: string): string {
return value.replace(/[&<>'"]/g, (char) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
}
return entities[char]
})
}
</script>
<template>
<section class="map-panel panel">
<div ref="mapEl" class="map-container"></div>
<div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div>
</section>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { MapNode, NodeInfoMap, PositionRecord, TextMessage } from '../types'
const props = defineProps<{
node: NodeInfoMap | null
mapNode: MapNode | null
messages: TextMessage[]
positions: PositionRecord[]
}>()
function nodeLabel(): string {
return props.node?.long_name || props.node?.short_name || props.mapNode?.label || props.mapNode?.node_id || '未选择节点'
}
function formatTime(value: string | null | undefined): string {
if (!value) {
return '-'
}
return new Date(value).toLocaleString()
}
</script>
<template>
<section class="node-detail-panel panel">
<div class="panel-header">
<div>
<p class="eyebrow">Node detail</p>
<h2>{{ nodeLabel() }}</h2>
</div>
<span v-if="mapNode" class="badge">{{ mapNode.source }}</span>
</div>
<div v-if="!node && !mapNode" class="empty">点击聊天消息或地图节点查看详情</div>
<div v-else class="detail-grid">
<div class="detail-main">
<dl>
<div><dt>Node ID</dt><dd>{{ node?.node_id || mapNode?.node_id }}</dd></div>
<div><dt>Role</dt><dd>{{ node?.role || '-' }}</dd></div>
<div><dt>Hardware</dt><dd>{{ node?.hw_model || '-' }}</dd></div>
<div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? node?.latitude ?? '-' }}</dd></div>
<div><dt>Longitude</dt><dd>{{ mapNode?.longitude ?? node?.longitude ?? '-' }}</dd></div>
<div><dt>Altitude</dt><dd>{{ mapNode?.altitude ?? node?.altitude ?? '-' }}</dd></div>
<div><dt>Updated</dt><dd>{{ formatTime(node?.updated_at || mapNode?.updated_at) }}</dd></div>
</dl>
</div>
<div class="detail-side">
<h3>最近消息</h3>
<p v-if="messages.length === 0" class="muted">暂无消息</p>
<ul v-else>
<li v-for="message in messages.slice(0, 5)" :key="message.id">
{{ message.text || '[binary]' }}
</li>
</ul>
</div>
<div class="detail-side">
<h3>最近位置</h3>
<p v-if="positions.length === 0" class="muted">暂无位置</p>
<ul v-else>
<li v-for="position in positions.slice(0, 5)" :key="position.id">
{{ position.latitude ?? '-' }}, {{ position.longitude ?? '-' }} · {{ formatTime(position.created_at) }}
</li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { NodeInfoMap } from '../types'
const props = defineProps<{
nodes: NodeInfoMap[]
selectedNodeId: string | null
page: number
pageSize: number
total: number
loading: boolean
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
'page-change': [page: number]
}>()
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
const canPrev = computed(() => props.page > 1)
const canNext = computed(() => props.page < totalPages.value)
function nodeName(node: NodeInfoMap): string {
return node.long_name || node.short_name || node.node_id
}
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
</script>
<template>
<section class="node-list-panel panel">
<div class="panel-header">
<div>
<p class="eyebrow">NodeInfo Map</p>
<h2>节点列表</h2>
</div>
<span class="badge"> {{ total }} </span>
</div>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>节点</th>
<th>Node ID</th>
<th>类型</th>
<th>角色</th>
<th>硬件</th>
<th>坐标</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
<tr
v-for="node in nodes"
:key="node.node_id"
class="node-row"
:class="{ selected: selectedNodeId === node.node_id }"
@click="emit('select-node', node.node_id)"
>
<td>{{ nodeName(node) }}</td>
<td>{{ node.node_id }}</td>
<td>{{ node.latest_type }}</td>
<td>{{ node.role || '-' }}</td>
<td>{{ node.hw_model || '-' }}</td>
<td>{{ node.latitude ?? '-' }}, {{ node.longitude ?? '-' }}</td>
<td>{{ formatTime(node.updated_at) }}</td>
</tr>
</tbody>
</table>
<div v-if="nodes.length === 0" class="empty">暂无节点数据</div>
</div>
<div class="pagination">
<button :disabled="loading || !canPrev" @click="emit('page-change', page - 1)">上一页</button>
<span> {{ page }} / {{ totalPages }} </span>
<span>每页 {{ pageSize }} </span>
<button :disabled="loading || !canNext" @click="emit('page-change', page + 1)">下一页</button>
</div>
</section>
</template>