节点详情页完成
This commit is contained in:
@@ -7,11 +7,16 @@ import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||
import AdminUsers from './components/AdminUsers.vue'
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||
import NodeListPanel from './components/NodeListPanel.vue'
|
||||
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 detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||
const isDetailedPage = !!detailedNodeId
|
||||
const adminUser = ref<AdminUser | null>(null)
|
||||
const adminChecking = ref(false)
|
||||
|
||||
@@ -212,6 +217,9 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
checkAdminSession()
|
||||
if (isDetailedPage) {
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
||||
})
|
||||
@@ -228,7 +236,8 @@ onBeforeUnmount(() => {
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<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 class="topbar-actions">
|
||||
<template v-if="isAdminPage">
|
||||
@@ -239,6 +248,11 @@ onBeforeUnmount(() => {
|
||||
</nav>
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
</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>
|
||||
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
||||
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
||||
@@ -267,6 +281,10 @@ onBeforeUnmount(() => {
|
||||
<AdminLogin v-else @login="adminUser = $event" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isDetailedPage">
|
||||
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
MapReport,
|
||||
NodeInfo,
|
||||
PositionRecord,
|
||||
TelemetryRecord,
|
||||
TextMessage,
|
||||
} from './types'
|
||||
|
||||
@@ -29,6 +30,14 @@ async function requestJSON<T>(path: string, init?: RequestInit): 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> {
|
||||
return requestJSON<T>(path)
|
||||
}
|
||||
@@ -58,15 +67,23 @@ export function getHealth(): Promise<HealthStatus> {
|
||||
}
|
||||
|
||||
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>> {
|
||||
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>> {
|
||||
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}&offset=${offset}`)
|
||||
export function getMapReportById(nodeId: string): Promise<MapReport> {
|
||||
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 }> {
|
||||
@@ -77,8 +94,12 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
||||
}
|
||||
|
||||
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`)
|
||||
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> {
|
||||
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> {
|
||||
|
||||
@@ -53,10 +53,11 @@ function closeMessageMenu() {
|
||||
menuMessage.value = null
|
||||
}
|
||||
|
||||
function nodeDetailHref(nodeId: string): string {
|
||||
return `/detailed/${encodeURIComponent(nodeId)}`
|
||||
}
|
||||
|
||||
function openMessageMenu(message: TextMessage, event: MouseEvent) {
|
||||
if (!props.isAdmin) {
|
||||
return
|
||||
}
|
||||
emit('select-node', message.from_id)
|
||||
menuMessage.value = message
|
||||
menuX.value = event.clientX
|
||||
@@ -180,7 +181,8 @@ onUpdated(() => {
|
||||
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
||||
@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>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +25,8 @@ let markerLayer: L.LayerGroup | null = null
|
||||
let hasFitBounds = false
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('click', closeNodeMenu)
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
await nextTick()
|
||||
if (!mapEl.value) {
|
||||
return
|
||||
@@ -51,6 +53,8 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', closeNodeMenu)
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
map?.remove()
|
||||
map = null
|
||||
markerLayer = null
|
||||
@@ -62,6 +66,35 @@ watch(
|
||||
{ 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) {
|
||||
if (!map || !markerLayer) {
|
||||
return
|
||||
@@ -83,8 +116,10 @@ function renderMarkers(forceFit: boolean) {
|
||||
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
|
||||
marker.on('click', (event) => {
|
||||
L.DomEvent.stopPropagation(event)
|
||||
closeNodeMenu()
|
||||
emit('select-node', node.node_id)
|
||||
})
|
||||
marker.on('contextmenu', (event) => openNodeMenu(node, event))
|
||||
marker.addTo(markerLayer)
|
||||
if (selected) {
|
||||
marker.openPopup()
|
||||
@@ -159,5 +194,14 @@ function escapeHTML(value: string): string {
|
||||
<section class="map-panel panel">
|
||||
<div ref="mapEl" class="map-container"></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>
|
||||
</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
|
||||
}
|
||||
|
||||
function nodeDetailHref(nodeId: string): string {
|
||||
return `/detailed/${encodeURIComponent(nodeId)}`
|
||||
}
|
||||
|
||||
function openNodeMenu(node: NodeInfo, event: MouseEvent) {
|
||||
if (!props.isAdmin) {
|
||||
return
|
||||
}
|
||||
emit('select-node', node.node_id)
|
||||
menuNode.value = node
|
||||
menuX.value = event.clientX
|
||||
@@ -118,7 +119,8 @@ onBeforeUnmount(() => {
|
||||
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
||||
@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 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: '© 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>
|
||||
@@ -231,17 +231,23 @@ h3 {
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
.context-menu button,
|
||||
.context-menu a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.context-menu button:hover {
|
||||
.context-menu button:hover,
|
||||
.context-menu a:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@@ -337,6 +343,99 @@ h3 {
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -652,7 +751,8 @@ dd {
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.detail-grid {
|
||||
.detail-grid,
|
||||
.detail-section-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,16 @@ export interface PositionRecord {
|
||||
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 {
|
||||
node_id: string
|
||||
label: string
|
||||
|
||||
Reference in New Issue
Block a user