Files
meshtastic_mqtt_server/meshmap_frontend/src/App.vue
T
2026-06-04 09:20:26 +08:00

328 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import AdminDashboard from './components/AdminDashboard.vue'
import AdminLogin from './components/AdminLogin.vue'
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 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)
const loading = ref(true)
const nodePageLoading = ref(false)
const error = ref('')
const selectedNodeId = ref<string | null>(null)
const health = ref<HealthStatus | null>(null)
const nodeInfoSource = ref<NodeInfo[]>([])
const mapReportSource = ref<MapReport[]>([])
const pagedNodeInfo = ref<NodeInfo[]>([])
const nodePage = ref(1)
const nodePageSize = 25
const nodeTotal = ref(0)
const messages = ref<TextMessage[]>([])
const chatPageSize = 20
const chatLoadingOlder = ref(false)
const chatHasMore = ref(true)
const chatInitialized = ref(false)
const positions = ref<PositionRecord[]>([])
let refreshTimer: number | undefined
const nodesById = computed<NodeInfoById>(() => {
const map = new Map<string, NodeInfo>()
for (const node of nodeInfoSource.value) {
map.set(node.node_id, node)
}
for (const node of pagedNodeInfo.value) {
map.set(node.node_id, node)
}
return Object.fromEntries(map)
})
const mapNodes = computed<MapNode[]>(() => {
return mapReportSource.value
.filter((report) => report.latitude != null && report.longitude != null)
.map((report) => {
const nodeinfo = nodesById.value[report.node_id] ?? null
return {
node_id: report.node_id,
label: report.short_name || report.long_name || nodeinfo?.short_name || nodeinfo?.long_name || report.node_id,
latitude: report.latitude as number,
longitude: report.longitude as number,
altitude: report.altitude,
source: 'map_report',
updated_at: report.updated_at,
nodeinfo,
map_report: report,
latest_position: null,
}
})
})
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 loadInitialChatMessages() {
const response = await getTextMessages(chatPageSize, 0)
messages.value = toChronological(response.items)
chatHasMore.value = response.items.length === chatPageSize
chatInitialized.value = true
}
async function loadOlderMessages() {
if (chatLoadingOlder.value || !chatHasMore.value) {
return
}
chatLoadingOlder.value = true
try {
const response = await getTextMessages(chatPageSize, messages.value.length)
messages.value = mergeMessages(messages.value, toChronological(response.items))
chatHasMore.value = response.items.length === chatPageSize
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
chatLoadingOlder.value = false
}
}
async function pollLatestMessages() {
const response = await getTextMessages(chatPageSize, 0)
messages.value = mergeMessages(messages.value, toChronological(response.items))
}
async function loadNodePage(page: number, showLoading = true) {
if (showLoading) {
nodePageLoading.value = true
}
try {
const safePage = Math.max(1, page)
const response = await getNodeInfo(nodePageSize, (safePage - 1) * nodePageSize)
pagedNodeInfo.value = response.items
nodeTotal.value = response.total ?? response.offset + response.items.length
nodePage.value = safePage
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
if (showLoading) {
nodePageLoading.value = false
}
}
}
async function refresh(showLoading = true) {
if (showLoading) {
loading.value = true
}
error.value = ''
try {
const [healthData, nodeInfoData, mapReportData, positionData] = await Promise.all([
getHealth(),
getNodeInfo(500, 0),
getMapReports(500, 0),
getPositions(500),
])
health.value = healthData
nodeInfoSource.value = nodeInfoData.items
mapReportSource.value = mapReportData.items
positions.value = positionData.items
await Promise.all([
loadNodePage(nodePage.value, showLoading),
chatInitialized.value ? pollLatestMessages() : loadInitialChatMessages(),
])
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
if (showLoading) {
loading.value = false
}
}
}
async function checkAdminSession() {
adminChecking.value = true
try {
const response = await getAdminMe()
adminUser.value = response.user
} catch {
adminUser.value = null
} finally {
adminChecking.value = false
}
}
async function logoutAdmin() {
try {
await adminLogout()
} finally {
adminUser.value = null
}
}
async function deleteMessage(message: TextMessage) {
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)
}
}
async function deleteNodeById(nodeId: string) {
try {
await deleteNode(nodeId)
nodeInfoSource.value = nodeInfoSource.value.filter((node) => node.node_id !== nodeId)
pagedNodeInfo.value = pagedNodeInfo.value.filter((node) => node.node_id !== nodeId)
mapReportSource.value = mapReportSource.value.filter((report) => report.node_id !== nodeId)
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null
}
await loadNodePage(nodePage.value, false)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
onMounted(() => {
if (isAdminPage) {
checkAdminSession()
return
}
checkAdminSession()
if (isDetailedPage) {
return
}
refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000)
})
onBeforeUnmount(() => {
if (refreshTimer !== undefined) {
window.clearInterval(refreshTimer)
}
})
</script>
<template>
<main class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">Meshtastic MQTT Server</p>
<h1 v-if="isDetailedPage">节点详情</h1>
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
</div>
<div class="topbar-actions">
<template v-if="isAdminPage">
<nav v-if="adminUser" class="admin-nav">
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
</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' }}
</span>
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
<a class="topbar-link" href="/admin">管理</a>
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
</template>
</div>
</header>
<template v-if="isAdminPage">
<div v-if="adminChecking" class="panel admin-loading">正在检查登录状态...</div>
<template v-else-if="adminUser">
<div class="panel admin-session-card">
<div>
<p class="eyebrow">Session</p>
<h2>当前登录{{ adminUser.username }}</h2>
</div>
<button class="admin-button" @click="logoutAdmin">退出登录</button>
</div>
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
<AdminDashboard v-else />
</template>
<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>
<section class="workspace">
<ChatPanel
:messages="messages"
:nodes-by-id="nodesById"
:selected-node-id="selectedNodeId"
:loading-older="chatLoadingOlder"
:has-more-messages="chatHasMore"
:is-admin="!!adminUser"
@select-node="selectedNodeId = $event"
@load-older="loadOlderMessages"
@delete-message="deleteMessage"
/>
<MeshMap
:nodes="mapNodes"
:selected-node-id="selectedNodeId"
:is-admin="!!adminUser"
@select-node="selectedNodeId = $event"
@clear-node="selectedNodeId = null"
@delete-node="deleteNodeById"
/>
</section>
<NodeListPanel
:nodes="pagedNodeInfo"
:selected-node-id="selectedNodeId"
:page="nodePage"
:page-size="nodePageSize"
:total="nodeTotal"
:loading="nodePageLoading || loading"
:is-admin="!!adminUser"
@select-node="selectedNodeId = $event"
@page-change="loadNodePage"
@delete-node="deleteNodeById"
/>
</template>
</main>
</template>