基本功能差不多完成
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
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 AdminDiscardDetails from './components/AdminDiscardDetails.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 HelpPage from './components/HelpPage.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||
import NodeListPanel from './components/NodeListPanel.vue'
|
||||
@@ -17,6 +19,7 @@ const isAdminPage = adminPath.startsWith('/admin')
|
||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||
const isDetailedPage = !!detailedNodeId
|
||||
const isHelpPage = currentPath === '/help'
|
||||
const adminUser = ref<AdminUser | null>(null)
|
||||
const adminChecking = ref(false)
|
||||
|
||||
@@ -217,7 +220,7 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
checkAdminSession()
|
||||
if (isDetailedPage) {
|
||||
if (isDetailedPage || isHelpPage) {
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
@@ -237,6 +240,7 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||
<h1 v-if="isDetailedPage">节点详情</h1>
|
||||
<h1 v-else-if="isHelpPage">使用帮助</h1>
|
||||
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
@@ -245,6 +249,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||
</nav>
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
</template>
|
||||
@@ -253,10 +258,12 @@ onBeforeUnmount(() => {
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
</template>
|
||||
<template v-else-if="isHelpPage">
|
||||
<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>
|
||||
<a class="topbar-link" href="/help">使用帮助</a>
|
||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
@@ -276,6 +283,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||
<AdminDashboard v-else />
|
||||
</template>
|
||||
<AdminLogin v-else @login="adminUser = $event" />
|
||||
@@ -285,6 +293,10 @@ onBeforeUnmount(() => {
|
||||
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isHelpPage">
|
||||
<HelpPage />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AdminManagedUserResponse,
|
||||
AdminMqttStatus,
|
||||
AdminUsersResponse,
|
||||
DiscardDetails,
|
||||
HealthStatus,
|
||||
ListResponse,
|
||||
MapReport,
|
||||
@@ -98,6 +99,10 @@ export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<List
|
||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
|
||||
}
|
||||
|
||||
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
|
||||
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/discard-details', limit, offset))
|
||||
}
|
||||
|
||||
export function getTelemetry(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<TelemetryRecord>> {
|
||||
return getJSON<ListResponse<TelemetryRecord>>(listPath('/api/telemetry', limit, offset, nodeId))
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ onBeforeUnmount(() => {
|
||||
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
|
||||
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
||||
<div><span>转发消息</span><strong>{{ status.messages_sent }}</strong></div>
|
||||
<div><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></div>
|
||||
<a class="status-card-link" href="/admin/discard_details"><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></a>
|
||||
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
||||
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getDiscardDetails } from '../api'
|
||||
import type { DiscardDetails } from '../types'
|
||||
|
||||
const items = ref<DiscardDetails[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 25
|
||||
|
||||
const canPrev = () => page.value > 1
|
||||
const canNext = () => items.value.length === pageSize
|
||||
|
||||
function formatTime(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
async function refreshItems() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await getDiscardDetails(pageSize, (page.value - 1) * pageSize)
|
||||
items.value = response.items
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(nextPage: number) {
|
||||
page.value = Math.max(1, nextPage)
|
||||
refreshItems()
|
||||
}
|
||||
|
||||
onMounted(refreshItems)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-dashboard">
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Discard details</p>
|
||||
<h2>丢弃数据</h2>
|
||||
</div>
|
||||
<button class="admin-button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="node-table-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>Topic</th>
|
||||
<th>Error</th>
|
||||
<th>Payload Len</th>
|
||||
<th>Client ID</th>
|
||||
<th>Username</th>
|
||||
<th>Listener</th>
|
||||
<th>Remote Host</th>
|
||||
<th>Raw Base64</th>
|
||||
<th>Content JSON</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td>{{ formatTime(item.created_at) }}</td>
|
||||
<td>{{ item.topic || '-' }}</td>
|
||||
<td>{{ item.error || '-' }}</td>
|
||||
<td>{{ item.payload_len }}</td>
|
||||
<td>{{ item.mqtt_client_id || '-' }}</td>
|
||||
<td>{{ item.mqtt_username || '-' }}</td>
|
||||
<td>{{ item.mqtt_listener || '-' }}</td>
|
||||
<td>{{ item.mqtt_remote_host || '-' }}</td>
|
||||
<td><pre class="discard-raw">{{ item.raw_base64 }}</pre></td>
|
||||
<td><pre class="discard-json">{{ item.content_json }}</pre></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="items.length === 0" class="empty">暂无丢弃数据</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button :disabled="loading || !canPrev()" @click="changePage(page - 1)">上一页</button>
|
||||
<span>第 {{ page }} 页</span>
|
||||
<span>每页 {{ pageSize }} 条</span>
|
||||
<button :disabled="loading || !canNext()" @click="changePage(page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<section class="help-page">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Help</p>
|
||||
<h2>如何连接 MQTT</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-content">
|
||||
<section>
|
||||
<h3>连接地址</h3>
|
||||
<p>将 Meshtastic 设备或客户端连接到本服务提供的 MQTT broker。</p>
|
||||
<ul>
|
||||
<li>默认地址:<strong>localhost</strong></li>
|
||||
<li>默认端口:<strong>1883</strong></li>
|
||||
<li>如果部署在其他机器,请使用服务器 IP 或域名。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>频道加密要求</h3>
|
||||
<p>为了让服务能够解析 Meshtastic MQTT payload,频道需要满足以下任一条件:</p>
|
||||
<ul>
|
||||
<li>频道不加密。</li>
|
||||
<li>使用 Meshtastic 默认 PSK:<strong>AQ==</strong>。</li>
|
||||
</ul>
|
||||
<p>如果使用自定义加密密钥,需要在服务配置中设置对应的 <strong>meshtastic.psk</strong>,否则数据可能会被判定为无法解密并丢弃。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Topic 说明</h3>
|
||||
<p>客户端发布到 Meshtastic MQTT topic 后,服务会先解析 payload。解析成功的数据会被转发并写入数据库;解析失败的数据会被丢弃并记录到后台的丢弃数据页面。</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -343,11 +343,29 @@ h3 {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.detail-page {
|
||||
.detail-page,
|
||||
.help-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.help-content section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-content ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.detail-section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
@@ -605,7 +623,8 @@ h3 {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-status-grid div {
|
||||
.admin-status-grid div,
|
||||
.status-card-link {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -614,15 +633,39 @@ h3 {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.admin-status-grid span {
|
||||
.status-card-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status-card-link:hover {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.admin-status-grid span,
|
||||
.status-card-link span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-status-grid strong {
|
||||
.admin-status-grid strong,
|
||||
.status-card-link strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.discard-raw,
|
||||
.discard-json {
|
||||
max-width: 360px;
|
||||
max-height: 120px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -116,6 +116,22 @@ export interface AdminManagedUserResponse {
|
||||
user: AdminManagedUser
|
||||
}
|
||||
|
||||
export interface DiscardDetails {
|
||||
id: number
|
||||
topic: string
|
||||
error: string
|
||||
payload_len: number
|
||||
raw_base64: string
|
||||
mqtt_client_id: string | null
|
||||
mqtt_username: string | null
|
||||
mqtt_listener: string | null
|
||||
mqtt_remote_addr: string | null
|
||||
mqtt_remote_host: string | null
|
||||
mqtt_remote_port: string | null
|
||||
created_at: string
|
||||
content_json: string
|
||||
}
|
||||
|
||||
export interface AdminLoginLog {
|
||||
id: number
|
||||
username: string
|
||||
|
||||
Reference in New Issue
Block a user