This commit is contained in:
2026-06-03 22:34:25 +08:00
parent f471905b33
commit 3ae2ffa098
14 changed files with 600 additions and 244 deletions
+86 -1
View File
@@ -1,16 +1,30 @@
<script setup lang="ts">
import { nextTick, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
import type { NodeInfoById, TextMessage } from '../types'
const props = defineProps<{
messages: TextMessage[]
nodesById: NodeInfoById
selectedNodeId: string | null
loadingOlder: boolean
hasMoreMessages: boolean
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
'load-older': []
}>()
const panelRef = ref<HTMLElement | null>(null)
const topThreshold = 8
const bottomThreshold = 40
let didInitialScroll = false
let shouldStickToBottom = true
let restoreScrollHeight: number | null = null
let restoreScrollTop = 0
let restoreMessageCount = 0
function senderName(message: TextMessage): string {
const node = props.nodesById[message.from_id]
return node?.long_name || node?.short_name || message.from_id
@@ -19,10 +33,79 @@ function senderName(message: TextMessage): string {
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= bottomThreshold
}
function clearRestoreState() {
restoreScrollHeight = null
restoreScrollTop = 0
restoreMessageCount = 0
}
function handleScroll() {
const el = panelRef.value
if (
!el ||
props.loadingOlder ||
!props.hasMoreMessages ||
props.messages.length === 0 ||
restoreScrollHeight != null
) {
return
}
if (el.scrollTop <= topThreshold) {
restoreScrollHeight = el.scrollHeight
restoreScrollTop = el.scrollTop
restoreMessageCount = props.messages.length
emit('load-older')
}
}
onBeforeUpdate(() => {
const el = panelRef.value
if (el) {
shouldStickToBottom = isNearBottom(el)
}
})
onMounted(async () => {
await nextTick()
const el = panelRef.value
if (el) {
el.scrollTop = el.scrollHeight
didInitialScroll = true
}
})
onUpdated(() => {
const el = panelRef.value
if (!el) {
return
}
if (restoreScrollHeight != null) {
if (props.messages.length > restoreMessageCount) {
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
clearRestoreState()
return
}
if (!props.loadingOlder) {
clearRestoreState()
}
}
if (!didInitialScroll || shouldStickToBottom) {
el.scrollTop = el.scrollHeight
didInitialScroll = true
}
})
</script>
<template>
<aside class="chat-panel panel">
<aside ref="panelRef" class="chat-panel panel" @scroll.passive="handleScroll">
<div class="panel-header">
<div>
<p class="eyebrow">Chat</p>
@@ -31,6 +114,8 @@ function formatTime(value: string): string {
<span class="badge">{{ messages.length }}</span>
</div>
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
<div v-else-if="!hasMoreMessages && messages.length > 0" class="chat-end">没有更多历史消息</div>
<div v-if="messages.length === 0" class="empty">暂无聊天消息</div>
<button
v-for="message in messages"
+11 -8
View File
@@ -91,21 +91,24 @@ function renderMarkers(forceFit: boolean) {
}
function buildNodePopupHTML(node: MapNode): string {
const info = node.node
const info = node.nodeinfo
const report = node.map_report
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>${escapeHTML(report?.long_name || info?.long_name || '-')}</dd></div>
<div><dt>短名称</dt><dd>${escapeHTML(report?.short_name || info?.short_name || '-')}</dd></div>
<div><dt>硬件型号</dt><dd>${escapeHTML(report?.hw_model || info?.hw_model || '-')}</dd></div>
<div><dt>角色</dt><dd>${escapeHTML(report?.role || info?.role || '-')}</dd></div>
<div><dt>固件版本</dt><dd>${escapeHTML(report?.firmware_version || '-')}</dd></div>
<div><dt>区域</dt><dd>${escapeHTML(report?.region || '-')}</dd></div>
<div><dt>调制预设</dt><dd>${escapeHTML(report?.modem_preset || '-')}</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>
<div><dt>位置精度</dt><dd>${report?.position_precision ?? '-'}</dd></div>
<div><dt>在线节点</dt><dd>${report?.num_online_local_nodes ?? '-'}</dd></div>
</dl>
</div>
`
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { MapNode, NodeInfoMap, PositionRecord, TextMessage } from '../types'
import type { MapNode, NodeInfo, PositionRecord, TextMessage } from '../types'
const props = defineProps<{
node: NodeInfoMap | null
node: NodeInfo | null
mapNode: MapNode | null
messages: TextMessage[]
positions: PositionRecord[]
@@ -35,11 +35,12 @@ function formatTime(value: string | null | undefined): string {
<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>User ID</dt><dd>{{ node?.user_id || '-' }}</dd></div>
<div><dt>Role</dt><dd>{{ node?.role || mapNode?.map_report?.role || '-' }}</dd></div>
<div><dt>Hardware</dt><dd>{{ node?.hw_model || mapNode?.map_report?.hw_model || '-' }}</dd></div>
<div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? '-' }}</dd></div>
<div><dt>Longitude</dt><dd>{{ mapNode?.longitude ?? '-' }}</dd></div>
<div><dt>Altitude</dt><dd>{{ mapNode?.altitude ?? '-' }}</dd></div>
<div><dt>Updated</dt><dd>{{ formatTime(node?.updated_at || mapNode?.updated_at) }}</dd></div>
</dl>
</div>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { NodeInfoMap } from '../types'
import type { NodeInfo } from '../types'
const props = defineProps<{
nodes: NodeInfoMap[]
nodes: NodeInfo[]
selectedNodeId: string | null
page: number
pageSize: number
@@ -20,7 +20,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.page
const canPrev = computed(() => props.page > 1)
const canNext = computed(() => props.page < totalPages.value)
function nodeName(node: NodeInfoMap): string {
function nodeName(node: NodeInfo): string {
return node.long_name || node.short_name || node.node_id
}
@@ -33,7 +33,7 @@ function formatTime(value: string): string {
<section class="node-list-panel panel">
<div class="panel-header">
<div>
<p class="eyebrow">NodeInfo Map</p>
<p class="eyebrow">NodeInfo</p>
<h2>节点列表</h2>
</div>
<span class="badge"> {{ total }} </span>
@@ -45,10 +45,9 @@ function formatTime(value: string): string {
<tr>
<th>节点</th>
<th>Node ID</th>
<th>类型</th>
<th>User ID</th>
<th>角色</th>
<th>硬件</th>
<th>坐标</th>
<th>更新时间</th>
</tr>
</thead>
@@ -62,10 +61,9 @@ function formatTime(value: string): string {
>
<td>{{ nodeName(node) }}</td>
<td>{{ node.node_id }}</td>
<td>{{ node.latest_type }}</td>
<td>{{ node.user_id || '-' }}</td>
<td>{{ node.role || '-' }}</td>
<td>{{ node.hw_model || '-' }}</td>
<td>{{ node.latitude ?? '-' }}, {{ node.longitude ?? '-' }}</td>
<td>{{ formatTime(node.updated_at) }}</td>
</tr>
</tbody>