更新地图
This commit is contained in:
@@ -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>
|
||||
@@ -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: '© 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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
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>
|
||||
Reference in New Issue
Block a user