节点详情页完成

This commit is contained in:
2026-06-04 09:20:26 +08:00
parent e945222519
commit c441fed1b3
11 changed files with 679 additions and 20 deletions
+2 -1
View File
@@ -117,7 +117,7 @@ go run .
构建后的文件位于项目根目录 `dist/`Gin 会提供静态文件服务;`/api` 路径保留给后端接口。 构建后的文件位于项目根目录 `dist/`Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret``MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。 管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret``MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo``map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。
常用 API 常用 API
@@ -132,6 +132,7 @@ GET /api/admin/users
POST /api/admin/users POST /api/admin/users
PUT /api/admin/users/:id/password PUT /api/admin/users/:id/password
DELETE /api/admin/text-messages/:id DELETE /api/admin/text-messages/:id
DELETE /api/admin/nodes/:id
GET /api/nodeinfo GET /api/nodeinfo
GET /api/nodeinfo/:id GET /api/nodeinfo/:id
GET /api/map-reports GET /api/map-reports
+29
View File
@@ -134,6 +134,35 @@ func TestNodeInfoAndMapReportAreStoredSeparately(t *testing.T) {
} }
} }
func TestDeleteNodeDeletesNodeInfoAndMapReport(t *testing.T) {
st := openTestStore(t)
defer st.Close()
if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil {
t.Fatalf("UpsertNodeInfo() error = %v", err)
}
if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil {
t.Fatalf("UpsertMapReport() error = %v", err)
}
if err := st.DeleteNode("!12345678"); err != nil {
t.Fatalf("DeleteNode() error = %v", err)
}
var nodeCount, reportCount int
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM map_report WHERE node_id = ?", "!12345678").Scan(&reportCount); err != nil {
t.Fatal(err)
}
if nodeCount != 0 || reportCount != 0 {
t.Fatalf("nodeinfo/map_report counts = %d/%d, want 0/0", nodeCount, reportCount)
}
if err := st.DeleteNode("!12345678"); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("DeleteNode(missing) error = %v, want record not found", err)
}
}
func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) { func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
+20 -2
View File
@@ -7,11 +7,16 @@ import AdminLoginLogs from './components/AdminLoginLogs.vue'
import AdminUsers from './components/AdminUsers.vue' import AdminUsers from './components/AdminUsers.vue'
import ChatPanel from './components/ChatPanel.vue' import ChatPanel from './components/ChatPanel.vue'
import MeshMap from './components/MeshMap.vue' import MeshMap from './components/MeshMap.vue'
import NodeDetailedPage from './components/NodeDetailedPage.vue'
import NodeListPanel from './components/NodeListPanel.vue' import NodeListPanel from './components/NodeListPanel.vue'
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types' import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
const adminPath = window.location.pathname const currentPath = window.location.pathname
const adminPath = currentPath
const isAdminPage = adminPath.startsWith('/admin') const isAdminPage = adminPath.startsWith('/admin')
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
const isDetailedPage = !!detailedNodeId
const adminUser = ref<AdminUser | null>(null) const adminUser = ref<AdminUser | null>(null)
const adminChecking = ref(false) const adminChecking = ref(false)
@@ -212,6 +217,9 @@ onMounted(() => {
return return
} }
checkAdminSession() checkAdminSession()
if (isDetailedPage) {
return
}
refresh() refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000) refreshTimer = window.setInterval(() => refresh(false), 5000)
}) })
@@ -228,7 +236,8 @@ onBeforeUnmount(() => {
<header class="topbar"> <header class="topbar">
<div> <div>
<p class="eyebrow">Meshtastic MQTT Server</p> <p class="eyebrow">Meshtastic MQTT Server</p>
<h1>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1> <h1 v-if="isDetailedPage">节点详情</h1>
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<template v-if="isAdminPage"> <template v-if="isAdminPage">
@@ -239,6 +248,11 @@ onBeforeUnmount(() => {
</nav> </nav>
<a class="topbar-link" href="/">返回地图</a> <a class="topbar-link" href="/">返回地图</a>
</template> </template>
<template v-else-if="isDetailedPage">
<span class="counter">{{ detailedNodeId }}</span>
<a class="topbar-link" href="/">返回地图</a>
<a class="topbar-link" href="/admin">管理</a>
</template>
<template v-else> <template v-else>
<span class="status-pill" :class="{ ok: health?.status === 'ok' }"> <span class="status-pill" :class="{ ok: health?.status === 'ok' }">
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }} {{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
@@ -267,6 +281,10 @@ onBeforeUnmount(() => {
<AdminLogin v-else @login="adminUser = $event" /> <AdminLogin v-else @login="adminUser = $event" />
</template> </template>
<template v-else-if="isDetailedPage">
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
</template>
<template v-else> <template v-else>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
+27 -6
View File
@@ -9,6 +9,7 @@ import type {
MapReport, MapReport,
NodeInfo, NodeInfo,
PositionRecord, PositionRecord,
TelemetryRecord,
TextMessage, TextMessage,
} from './types' } from './types'
@@ -29,6 +30,14 @@ async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T> return response.json() as Promise<T>
} }
function listPath(path: string, limit: number, offset: number, nodeId = ''): string {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (nodeId) {
params.set('node_id', nodeId)
}
return `${path}?${params.toString()}`
}
function getJSON<T>(path: string): Promise<T> { function getJSON<T>(path: string): Promise<T> {
return requestJSON<T>(path) return requestJSON<T>(path)
} }
@@ -58,15 +67,23 @@ export function getHealth(): Promise<HealthStatus> {
} }
export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> { export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> {
return getJSON<ListResponse<NodeInfo>>(`/api/nodeinfo?limit=${limit}&offset=${offset}`) return getJSON<ListResponse<NodeInfo>>(listPath('/api/nodeinfo', limit, offset))
}
export function getNodeInfoById(nodeId: string): Promise<NodeInfo> {
return getJSON<NodeInfo>(`/api/nodeinfo/${encodeURIComponent(nodeId)}`)
} }
export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> { export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
return getJSON<ListResponse<MapReport>>(`/api/map-reports?limit=${limit}&offset=${offset}`) return getJSON<ListResponse<MapReport>>(listPath('/api/map-reports', limit, offset))
} }
export function getTextMessages(limit = 100, offset = 0): Promise<ListResponse<TextMessage>> { export function getMapReportById(nodeId: string): Promise<MapReport> {
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}&offset=${offset}`) return getJSON<MapReport>(`/api/map-reports/${encodeURIComponent(nodeId)}`)
}
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
} }
export function deleteTextMessage(id: number): Promise<{ status: string }> { export function deleteTextMessage(id: number): Promise<{ status: string }> {
@@ -77,8 +94,12 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`) return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
} }
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> { export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`) return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
}
export function getTelemetry(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<TelemetryRecord>> {
return getJSON<ListResponse<TelemetryRecord>>(listPath('/api/telemetry', limit, offset, nodeId))
} }
export function adminLogin(username: string, password: string): Promise<AdminLoginResponse> { export function adminLogin(username: string, password: string): Promise<AdminLoginResponse> {
@@ -53,10 +53,11 @@ function closeMessageMenu() {
menuMessage.value = null menuMessage.value = null
} }
function nodeDetailHref(nodeId: string): string {
return `/detailed/${encodeURIComponent(nodeId)}`
}
function openMessageMenu(message: TextMessage, event: MouseEvent) { function openMessageMenu(message: TextMessage, event: MouseEvent) {
if (!props.isAdmin) {
return
}
emit('select-node', message.from_id) emit('select-node', message.from_id)
menuMessage.value = message menuMessage.value = message
menuX.value = event.clientX menuX.value = event.clientX
@@ -180,7 +181,8 @@ onUpdated(() => {
:style="{ left: `${menuX}px`, top: `${menuY}px` }" :style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop @click.stop
> >
<button class="danger" type="button" @click="deleteSelectedMessage">删除</button> <a :href="nodeDetailHref(menuMessage.from_id)">节点详细</a>
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedMessage">删除</button>
</div> </div>
</aside> </aside>
</template> </template>
@@ -25,6 +25,8 @@ let markerLayer: L.LayerGroup | null = null
let hasFitBounds = false let hasFitBounds = false
onMounted(async () => { onMounted(async () => {
window.addEventListener('click', closeNodeMenu)
window.addEventListener('keydown', handleKeydown)
await nextTick() await nextTick()
if (!mapEl.value) { if (!mapEl.value) {
return return
@@ -51,6 +53,8 @@ onMounted(async () => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', closeNodeMenu)
window.removeEventListener('keydown', handleKeydown)
map?.remove() map?.remove()
map = null map = null
markerLayer = null markerLayer = null
@@ -62,6 +66,35 @@ watch(
{ deep: true }, { deep: true },
) )
function closeNodeMenu() {
menuNodeId.value = null
}
function nodeDetailHref(nodeId: string): string {
return `/detailed/${encodeURIComponent(nodeId)}`
}
function openNodeMenu(node: MapNode, event: L.LeafletMouseEvent) {
L.DomEvent.stopPropagation(event)
emit('select-node', node.node_id)
menuNodeId.value = node.node_id
menuX.value = event.originalEvent.clientX
menuY.value = event.originalEvent.clientY
}
function deleteSelectedNode() {
if (menuNodeId.value) {
emit('delete-node', menuNodeId.value)
}
closeNodeMenu()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeNodeMenu()
}
}
function renderMarkers(forceFit: boolean) { function renderMarkers(forceFit: boolean) {
if (!map || !markerLayer) { if (!map || !markerLayer) {
return return
@@ -83,8 +116,10 @@ function renderMarkers(forceFit: boolean) {
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' }) marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
marker.on('click', (event) => { marker.on('click', (event) => {
L.DomEvent.stopPropagation(event) L.DomEvent.stopPropagation(event)
closeNodeMenu()
emit('select-node', node.node_id) emit('select-node', node.node_id)
}) })
marker.on('contextmenu', (event) => openNodeMenu(node, event))
marker.addTo(markerLayer) marker.addTo(markerLayer)
if (selected) { if (selected) {
marker.openPopup() marker.openPopup()
@@ -159,5 +194,14 @@ function escapeHTML(value: string): string {
<section class="map-panel panel"> <section class="map-panel panel">
<div ref="mapEl" class="map-container"></div> <div ref="mapEl" class="map-container"></div>
<div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div> <div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div>
<div
v-if="menuNodeId"
class="context-menu"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
>
<a :href="nodeDetailHref(menuNodeId)">节点详细</a>
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
</div>
</section> </section>
</template> </template>
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types'
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
const props = defineProps<{
nodeId: string
isAdmin: boolean
}>()
const nodeInfo = ref<NodeInfo | null>(null)
const mapReport = ref<MapReport | null>(null)
const messages = ref<TextMessage[]>([])
const positions = ref<PositionRecord[]>([])
const telemetry = ref<TelemetryRecord[]>([])
const loading = ref(true)
const chatLoadingOlder = ref(false)
const chatHasMore = ref(true)
const error = ref('')
const chatPageSize = 20
const chatHistoryRef = ref<HTMLElement | null>(null)
const menuMessage = ref<TextMessage | null>(null)
const menuX = ref(0)
const menuY = ref(0)
const nodeTitle = computed(() => {
return nodeInfo.value?.long_name || nodeInfo.value?.short_name || mapReport.value?.long_name || mapReport.value?.short_name || props.nodeId
})
const mergedNode = computed(() => {
return {
node_num: nodeInfo.value?.node_num ?? mapReport.value?.node_num ?? null,
long_name: nodeInfo.value?.long_name || mapReport.value?.long_name || null,
short_name: nodeInfo.value?.short_name || mapReport.value?.short_name || null,
hw_model: nodeInfo.value?.hw_model || mapReport.value?.hw_model || null,
role: nodeInfo.value?.role || mapReport.value?.role || null,
updated_at: nodeInfo.value?.updated_at || mapReport.value?.updated_at || null,
}
})
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function metricEntries(value: string | null): Array<[string, unknown]> {
if (!value) {
return []
}
try {
const parsed = JSON.parse(value) as Record<string, unknown>
return Object.entries(parsed)
} catch {
return [['raw', value]]
}
}
function metricLabel(key: string): string {
const labels: Record<string, string> = {
air_util_tx: '空口发送占用',
battery_level: '电量',
channel_utilization: '信道占用',
uptime_seconds: '运行时长',
voltage: '电压',
}
return labels[key] || key
}
function metricValue(key: string, value: unknown): string {
if (typeof value !== 'number') {
return String(value)
}
if (key === 'battery_level') {
return `${value}%`
}
if (key === 'voltage') {
return `${value.toFixed(2)} V`
}
if (key === 'air_util_tx' || key === 'channel_utilization') {
return `${value.toFixed(2)}%`
}
if (key === 'uptime_seconds') {
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const seconds = Math.floor(value % 60)
return `${hours}h ${minutes}m ${seconds}s`
}
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
function toChronological(items: TextMessage[]): TextMessage[] {
return [...items].reverse()
}
function compareMessages(a: TextMessage, b: TextMessage): number {
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
return timeDiff !== 0 ? timeDiff : a.id - b.id
}
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]): TextMessage[] {
const byId = new Map<number, TextMessage>()
for (const message of existing) {
byId.set(message.id, message)
}
for (const message of incoming) {
byId.set(message.id, message)
}
return Array.from(byId.values()).sort(compareMessages)
}
async function optional<T>(request: Promise<T>): Promise<T | null> {
try {
return await request
} catch {
return null
}
}
async function loadInitialMessages() {
const response = await getTextMessages(chatPageSize, 0, props.nodeId)
messages.value = toChronological(response.items)
chatHasMore.value = response.items.length === chatPageSize
await nextTick()
const el = chatHistoryRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
async function loadOlderMessages() {
if (chatLoadingOlder.value || !chatHasMore.value) {
return
}
const el = chatHistoryRef.value
const previousScrollHeight = el?.scrollHeight ?? 0
const previousScrollTop = el?.scrollTop ?? 0
chatLoadingOlder.value = true
try {
const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId)
messages.value = mergeMessages(messages.value, toChronological(response.items))
chatHasMore.value = response.items.length === chatPageSize
await nextTick()
if (el) {
el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop
}
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
chatLoadingOlder.value = false
}
}
function closeMessageMenu() {
menuMessage.value = null
}
function openMessageMenu(message: TextMessage, event: MouseEvent) {
if (!props.isAdmin) {
return
}
menuMessage.value = message
menuX.value = event.clientX
menuY.value = event.clientY
}
async function deleteSelectedMessage() {
if (!menuMessage.value) {
return
}
const message = menuMessage.value
closeMessageMenu()
try {
await deleteTextMessage(message.id)
messages.value = messages.value.filter((item) => item.id !== message.id)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMessageMenu()
}
}
function handleChatScroll() {
closeMessageMenu()
const el = chatHistoryRef.value
if (!el || el.scrollTop > 8) {
return
}
loadOlderMessages()
}
async function loadDetails() {
loading.value = true
error.value = ''
try {
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([
optional(getNodeInfoById(props.nodeId)),
optional(getMapReportById(props.nodeId)),
getPositions(500, 0, props.nodeId),
getTelemetry(200, 0, props.nodeId),
])
nodeInfo.value = nodeData
mapReport.value = reportData
positions.value = positionData.items
telemetry.value = telemetryData.items
await loadInitialMessages()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
onMounted(() => {
window.addEventListener('click', closeMessageMenu)
window.addEventListener('keydown', handleKeydown)
loadDetails()
})
onBeforeUnmount(() => {
window.removeEventListener('click', closeMessageMenu)
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<section class="detail-page">
<div class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Node detail</p>
<h2>{{ nodeTitle }}</h2>
</div>
<span class="badge">{{ nodeId }}</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="empty">正在加载节点详情...</div>
<div v-else class="detail-summary-grid">
<div><span>Node ID</span><strong>{{ nodeId }}</strong></div>
<div><span>Node Num</span><strong>{{ mergedNode.node_num ?? '-' }}</strong></div>
<div><span>Long Name</span><strong>{{ mergedNode.long_name || '-' }}</strong></div>
<div><span>Short Name</span><strong>{{ mergedNode.short_name || '-' }}</strong></div>
<div><span>硬件</span><strong>{{ mergedNode.hw_model || '-' }}</strong></div>
<div><span>角色</span><strong>{{ mergedNode.role || '-' }}</strong></div>
<div><span>User ID</span><strong>{{ nodeInfo?.user_id || '-' }}</strong></div>
<div><span>授权</span><strong>{{ nodeInfo?.is_licensed ?? '-' }}</strong></div>
<div><span>固件版本</span><strong>{{ mapReport?.firmware_version || '-' }}</strong></div>
<div><span>区域</span><strong>{{ mapReport?.region || '-' }}</strong></div>
<div><span>调制预设</span><strong>{{ mapReport?.modem_preset || '-' }}</strong></div>
<div><span>最新坐标</span><strong>{{ mapReport?.latitude ?? '-' }}, {{ mapReport?.longitude ?? '-' }}</strong></div>
<div><span>海拔</span><strong>{{ mapReport?.altitude ?? '-' }}</strong></div>
<div><span>位置精度</span><strong>{{ mapReport?.position_precision ?? '-' }}</strong></div>
<div><span>在线节点</span><strong>{{ mapReport?.num_online_local_nodes ?? '-' }}</strong></div>
<div><span>上报位置</span><strong>{{ mapReport?.has_opted_report_location ?? '-' }}</strong></div>
<div><span>更新时间</span><strong>{{ mergedNode.updated_at ? formatTime(mergedNode.updated_at) : '-' }}</strong></div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Public Key</p>
<h2>节点公钥</h2>
</div>
</div>
<pre class="public-key-block">{{ nodeInfo?.public_key || '-' }}</pre>
</div>
<div class="detail-section-grid">
<div class="panel detail-chat-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Chat</p>
<h2>历史聊天记录</h2>
</div>
<span class="badge">{{ messages.length }}</span>
</div>
<div ref="chatHistoryRef" class="detail-chat-history" @scroll.passive="handleChatScroll">
<div v-if="chatLoadingOlder" class="chat-loading">正在加载更早消息...</div>
<div v-else-if="!chatHasMore && messages.length > 0" class="chat-end">没有更多历史消息</div>
<div v-if="messages.length === 0" class="empty">暂无聊天记录</div>
<div
v-for="message in messages"
:key="message.id"
class="detail-chat-item"
@contextmenu.prevent.stop="openMessageMenu(message, $event)"
>
<span class="chat-meta">
<strong>{{ formatTime(message.created_at) }}</strong>
<small>{{ message.topic }}</small>
</span>
<span class="chat-text">{{ message.text || '[binary]' }}</span>
</div>
</div>
<div
v-if="menuMessage"
class="context-menu"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
>
<button class="danger" type="button" @click="deleteSelectedMessage">删除</button>
</div>
</div>
<div class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Trajectory</p>
<h2>地图轨迹</h2>
</div>
<span class="badge">{{ positions.length }}</span>
</div>
<NodeTrajectoryMap :positions="positions" />
</div>
</div>
<div class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Telemetry</p>
<h2>遥测数据</h2>
</div>
<span class="badge">{{ telemetry.length }}</span>
</div>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>指标</th>
</tr>
</thead>
<tbody>
<tr v-for="item in telemetry" :key="item.id">
<td>{{ formatTime(item.created_at) }}</td>
<td>{{ item.telemetry_type || '-' }}</td>
<td>
<div class="metrics-grid">
<div v-for="[key, value] in metricEntries(item.metrics_json)" :key="key" class="metric-chip">
<span>{{ metricLabel(key) }}</span>
<strong>{{ metricValue(key, value) }}</strong>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
</div>
</div>
</section>
</template>
@@ -33,10 +33,11 @@ function closeNodeMenu() {
menuNode.value = null menuNode.value = null
} }
function nodeDetailHref(nodeId: string): string {
return `/detailed/${encodeURIComponent(nodeId)}`
}
function openNodeMenu(node: NodeInfo, event: MouseEvent) { function openNodeMenu(node: NodeInfo, event: MouseEvent) {
if (!props.isAdmin) {
return
}
emit('select-node', node.node_id) emit('select-node', node.node_id)
menuNode.value = node menuNode.value = node
menuX.value = event.clientX menuX.value = event.clientX
@@ -118,7 +119,8 @@ onBeforeUnmount(() => {
:style="{ left: `${menuX}px`, top: `${menuY}px` }" :style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop @click.stop
> >
<button class="danger" type="button" @click="deleteSelectedNode">删除</button> <a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
</div> </div>
<div class="pagination"> <div class="pagination">
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { PositionRecord } from '../types'
const props = defineProps<{
positions: PositionRecord[]
}>()
const mapEl = ref<HTMLElement | null>(null)
let map: L.Map | null = null
let layer: L.LayerGroup | null = null
function renderTrajectory() {
if (!map || !layer) {
return
}
layer.clearLayers()
const points = [...props.positions]
.filter((position) => position.latitude != null && position.longitude != null)
.reverse()
.map((position) => [position.latitude as number, position.longitude as number] as L.LatLngTuple)
if (points.length === 0) {
map.setView([0, 0], 2)
return
}
if (points.length > 1) {
L.polyline(points, { color: '#2563eb', weight: 4, opacity: 0.8 }).addTo(layer)
}
L.circleMarker(points[0], { radius: 6, color: '#16a34a', fillColor: '#22c55e', fillOpacity: 0.9 }).bindPopup('起点').addTo(layer)
L.circleMarker(points[points.length - 1], { radius: 6, color: '#dc2626', fillColor: '#ef4444', fillOpacity: 0.9 }).bindPopup('终点').addTo(layer)
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
}
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)
layer = L.layerGroup().addTo(map)
renderTrajectory()
})
onBeforeUnmount(() => {
map?.remove()
map = null
layer = null
})
watch(
() => props.positions,
() => renderTrajectory(),
{ deep: true },
)
</script>
<template>
<div ref="mapEl" class="trajectory-map"></div>
</template>
+103 -3
View File
@@ -231,17 +231,23 @@ h3 {
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18); box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
} }
.context-menu button { .context-menu button,
.context-menu a {
display: block;
width: 100%; width: 100%;
box-sizing: border-box;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; padding: 8px 10px;
text-align: left; text-align: left;
color: inherit;
text-decoration: none;
font: inherit; font: inherit;
background: transparent; background: transparent;
} }
.context-menu button:hover { .context-menu button:hover,
.context-menu a:hover {
background: #f8fafc; background: #f8fafc;
} }
@@ -337,6 +343,99 @@ h3 {
min-height: 240px; min-height: 240px;
} }
.detail-page {
display: grid;
gap: 12px;
}
.detail-section-grid {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
gap: 12px;
}
.detail-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
padding: 16px;
}
.detail-summary-grid div {
display: grid;
gap: 5px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
background: #f8fafc;
}
.detail-summary-grid span {
color: #64748b;
font-size: 13px;
}
.detail-summary-grid strong {
color: #0f172a;
}
.detail-chat-panel {
min-height: 420px;
}
.detail-chat-history {
max-height: 420px;
overflow-y: auto;
}
.detail-chat-item {
display: grid;
gap: 6px;
border-bottom: 1px solid #e2e8f0;
padding: 13px 16px;
}
.trajectory-map {
height: 420px;
min-height: 360px;
}
.public-key-block {
max-width: 100%;
margin: 0;
padding: 16px;
white-space: pre-wrap;
word-break: break-all;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 8px;
}
.metric-chip {
display: grid;
gap: 4px;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 8px 10px;
background: #f8fafc;
}
.metric-chip span {
color: #64748b;
font-size: 12px;
}
.metric-chip strong {
color: #0f172a;
font-size: 14px;
}
.node-table-wrap { .node-table-wrap {
overflow-x: auto; overflow-x: auto;
} }
@@ -652,7 +751,8 @@ dd {
} }
.workspace, .workspace,
.detail-grid { .detail-grid,
.detail-section-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+10
View File
@@ -66,6 +66,16 @@ export interface PositionRecord {
content_json: string content_json: string
} }
export interface TelemetryRecord {
id: number
from_id: string
from_num: number
telemetry_type: string | null
metrics_json: string | null
created_at: string
content_json: string
}
export interface MapNode { export interface MapNode {
node_id: string node_id: string
label: string label: string