新增后台管理

This commit is contained in:
2026-06-03 23:29:21 +08:00
parent b1548baccf
commit 9221a53617
15 changed files with 1299 additions and 57 deletions
+72 -36
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import { getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import AdminDashboard from './components/AdminDashboard.vue'
import AdminLogin from './components/AdminLogin.vue'
import ChatPanel from './components/ChatPanel.vue'
import MeshMap from './components/MeshMap.vue'
import NodeListPanel from './components/NodeListPanel.vue'
import type { HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
const isAdminPage = window.location.pathname === '/admin'
const adminUser = ref<AdminUser | null>(null)
const adminChecking = ref(false)
const loading = ref(true)
const nodePageLoading = ref(false)
@@ -153,7 +159,23 @@ async function refresh(showLoading = true) {
}
}
async function checkAdminSession() {
adminChecking.value = true
try {
const response = await getAdminMe()
adminUser.value = response.user
} catch {
adminUser.value = null
} finally {
adminChecking.value = false
}
}
onMounted(() => {
if (isAdminPage) {
checkAdminSession()
return
}
refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000)
})
@@ -170,46 +192,60 @@ onBeforeUnmount(() => {
<header class="topbar">
<div>
<p class="eyebrow">Meshtastic MQTT Server</p>
<h1>MeshMap</h1>
<h1>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
</div>
<div class="topbar-actions">
<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>
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
<template v-if="isAdminPage">
<a class="topbar-link" href="/">返回地图</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>
<p v-if="error" class="error">{{ error }}</p>
<template v-if="isAdminPage">
<div v-if="adminChecking" class="panel admin-loading">正在检查登录状态...</div>
<AdminDashboard v-else-if="adminUser" :user="adminUser" @logout="adminUser = null" />
<AdminLogin v-else @login="adminUser = $event" />
</template>
<section class="workspace">
<ChatPanel
:messages="messages"
:nodes-by-id="nodesById"
:selected-node-id="selectedNodeId"
:loading-older="chatLoadingOlder"
:has-more-messages="chatHasMore"
@select-node="selectedNodeId = $event"
@load-older="loadOlderMessages"
/>
<MeshMap
:nodes="mapNodes"
:selected-node-id="selectedNodeId"
@select-node="selectedNodeId = $event"
@clear-node="selectedNodeId = null"
/>
</section>
<template v-else>
<p v-if="error" class="error">{{ error }}</p>
<NodeListPanel
:nodes="pagedNodeInfo"
:selected-node-id="selectedNodeId"
:page="nodePage"
:page-size="nodePageSize"
:total="nodeTotal"
:loading="nodePageLoading || loading"
@select-node="selectedNodeId = $event"
@page-change="loadNodePage"
/>
<section class="workspace">
<ChatPanel
:messages="messages"
:nodes-by-id="nodesById"
:selected-node-id="selectedNodeId"
:loading-older="chatLoadingOlder"
:has-more-messages="chatHasMore"
@select-node="selectedNodeId = $event"
@load-older="loadOlderMessages"
/>
<MeshMap
:nodes="mapNodes"
:selected-node-id="selectedNodeId"
@select-node="selectedNodeId = $event"
@clear-node="selectedNodeId = null"
/>
</section>
<NodeListPanel
:nodes="pagedNodeInfo"
:selected-node-id="selectedNodeId"
:page="nodePage"
:page-size="nodePageSize"
:total="nodeTotal"
:loading="nodePageLoading || loading"
@select-node="selectedNodeId = $event"
@page-change="loadNodePage"
/>
</template>
</main>
</template>
+72 -4
View File
@@ -1,13 +1,53 @@
import type { HealthStatus, ListResponse, MapReport, NodeInfo, PositionRecord, TextMessage } from './types'
import type {
AdminLoginResponse,
AdminManagedUserResponse,
AdminMqttStatus,
AdminUsersResponse,
HealthStatus,
ListResponse,
MapReport,
NodeInfo,
PositionRecord,
TextMessage,
} from './types'
async function getJSON<T>(path: string): Promise<T> {
const response = await fetch(path)
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, { credentials: 'same-origin', ...init })
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`)
let message = `${response.status} ${response.statusText}`
try {
const data = (await response.json()) as { error?: string }
if (data.error) {
message = data.error
}
} catch {
// Keep the HTTP status message when the response is not JSON.
}
throw new Error(message)
}
return response.json() as Promise<T>
}
function getJSON<T>(path: string): Promise<T> {
return requestJSON<T>(path)
}
function postJSON<T>(path: string, body?: unknown): Promise<T> {
return requestJSON<T>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body == null ? undefined : JSON.stringify(body),
})
}
function putJSON<T>(path: string, body?: unknown): Promise<T> {
return requestJSON<T>(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: body == null ? undefined : JSON.stringify(body),
})
}
export function getHealth(): Promise<HealthStatus> {
return getJSON<HealthStatus>('/api/health')
}
@@ -27,3 +67,31 @@ export function getTextMessages(limit = 100, offset = 0): Promise<ListResponse<T
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`)
}
export function adminLogin(username: string, password: string): Promise<AdminLoginResponse> {
return postJSON<AdminLoginResponse>('/api/admin/login', { username, password })
}
export function adminLogout(): Promise<{ status: string }> {
return postJSON<{ status: string }>('/api/admin/logout')
}
export function getAdminMe(): Promise<AdminLoginResponse> {
return getJSON<AdminLoginResponse>('/api/admin/me')
}
export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
return getJSON<AdminMqttStatus>('/api/admin/mqtt/status')
}
export function getAdminUsers(): Promise<AdminUsersResponse> {
return getJSON<AdminUsersResponse>('/api/admin/users')
}
export function createAdminUser(username: string, password: string): Promise<AdminManagedUserResponse> {
return postJSON<AdminManagedUserResponse>('/api/admin/users', { username, password })
}
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
}
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { adminLogout, createAdminUser, getAdminMqttStatus, getAdminUsers, updateAdminUserPassword } from '../api'
import type { AdminManagedUser, AdminMqttStatus, AdminUser } from '../types'
const props = defineProps<{
user: AdminUser
}>()
const emit = defineEmits<{
logout: []
}>()
const status = ref<AdminMqttStatus | null>(null)
const users = ref<AdminManagedUser[]>([])
const loading = ref(false)
const usersLoading = ref(false)
const error = ref('')
const userError = ref('')
const userMessage = ref('')
const newUsername = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordEdits = ref<Record<number, string>>({})
const passwordSaving = ref<Record<number, boolean>>({})
let timer: number | undefined
function formatUptime(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours}h ${minutes}m ${secs}s`
}
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
async function refreshStatus() {
loading.value = true
error.value = ''
try {
status.value = await getAdminMqttStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function refreshUsers() {
usersLoading.value = true
userError.value = ''
try {
const response = await getAdminUsers()
users.value = response.items
} catch (err) {
userError.value = err instanceof Error ? err.message : String(err)
} finally {
usersLoading.value = false
}
}
async function createUser() {
userError.value = ''
userMessage.value = ''
if (!newUsername.value.trim()) {
userError.value = '用户名不能为空'
return
}
if (!newPassword.value) {
userError.value = '密码不能为空'
return
}
if (newPassword.value !== confirmPassword.value) {
userError.value = '两次输入的密码不一致'
return
}
usersLoading.value = true
try {
await createAdminUser(newUsername.value.trim(), newPassword.value)
newUsername.value = ''
newPassword.value = ''
confirmPassword.value = ''
userMessage.value = '用户已创建'
await refreshUsers()
} catch (err) {
userError.value = err instanceof Error ? err.message : String(err)
} finally {
usersLoading.value = false
}
}
async function updatePassword(user: AdminManagedUser) {
const password = passwordEdits.value[user.id] || ''
userError.value = ''
userMessage.value = ''
if (!password) {
userError.value = '新密码不能为空'
return
}
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
try {
await updateAdminUserPassword(user.id, password)
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
userMessage.value = `${user.username} 的密码已修改`
await refreshUsers()
} catch (err) {
userError.value = err instanceof Error ? err.message : String(err)
} finally {
passwordSaving.value = { ...passwordSaving.value, [user.id]: false }
}
}
async function logout() {
try {
await adminLogout()
} finally {
emit('logout')
}
}
onMounted(() => {
refreshStatus()
refreshUsers()
timer = window.setInterval(refreshStatus, 5000)
})
onBeforeUnmount(() => {
if (timer !== undefined) {
window.clearInterval(timer)
}
})
</script>
<template>
<section class="admin-dashboard">
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Admin</p>
<h2>MQTT 服务状态</h2>
</div>
<div class="admin-actions">
<span class="badge">{{ props.user.username }}</span>
<button @click="refreshStatus" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
<button @click="logout">退出</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="!status" class="empty">正在加载 MQTT 状态...</div>
<div v-else class="admin-status-grid">
<div><span>运行状态</span><strong>{{ status.running ? '运行中' : '未运行' }}</strong></div>
<div><span>监听地址</span><strong>{{ status.address || '-' }}</strong></div>
<div><span>TLS</span><strong>{{ status.tls ? '启用' : '未启用' }}</strong></div>
<div><span>Uptime</span><strong>{{ formatUptime(status.uptime || 0) }}</strong></div>
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
<div><span>收到消息</span><strong>{{ status.messages_received }}</strong></div>
<div><span>发送消息</span><strong>{{ status.messages_sent }}</strong></div>
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Users</p>
<h2>用户管理</h2>
</div>
<button class="admin-button" @click="refreshUsers" :disabled="usersLoading">{{ usersLoading ? '刷新中...' : '刷新用户' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createUser">
<label>
<span>用户名</span>
<input v-model="newUsername" autocomplete="off" placeholder="new-admin" />
</label>
<label>
<span>密码</span>
<input v-model="newPassword" type="password" autocomplete="new-password" />
</label>
<label>
<span>确认密码</span>
<input v-model="confirmPassword" type="password" autocomplete="new-password" />
</label>
<button class="admin-button" :disabled="usersLoading" type="submit">新增用户</button>
</form>
<p v-if="userError" class="error">{{ userError }}</p>
<p v-if="userMessage" class="success">{{ userMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th>更新时间</th>
<th>新密码</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }} <span v-if="user.username === props.user.username" class="badge">当前</span></td>
<td>{{ user.role }}</td>
<td>{{ formatTime(user.created_at) }}</td>
<td>{{ formatTime(user.updated_at) }}</td>
<td>
<input
v-model="passwordEdits[user.id]"
class="admin-table-input"
type="password"
autocomplete="new-password"
placeholder="输入新密码"
/>
</td>
<td>
<button class="admin-button" :disabled="passwordSaving[user.id]" @click="updatePassword(user)">
{{ passwordSaving[user.id] ? '保存中...' : '修改密码' }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="users.length === 0" class="empty">暂无用户</div>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Clients</p>
<h2>MQTT 客户端</h2>
</div>
<span class="badge">{{ status?.clients?.length ?? 0 }}</span>
</div>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>Client ID</th>
<th>Username</th>
<th>Listener</th>
<th>Remote Addr</th>
<th>Remote Host</th>
<th>Remote Port</th>
</tr>
</thead>
<tbody>
<tr v-for="client in status?.clients || []" :key="client.client_id">
<td>{{ client.client_id || '-' }}</td>
<td>{{ client.username || '-' }}</td>
<td>{{ client.listener || '-' }}</td>
<td>{{ client.remote_addr || '-' }}</td>
<td>{{ client.remote_host || '-' }}</td>
<td>{{ client.remote_port || '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from 'vue'
import { adminLogin } from '../api'
import type { AdminUser } from '../types'
const emit = defineEmits<{
login: [user: AdminUser]
}>()
const username = ref('admin')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function submitLogin() {
loading.value = true
error.value = ''
try {
const response = await adminLogin(username.value, password.value)
emit('login', response.user)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
</script>
<template>
<section class="admin-login panel">
<div class="panel-header">
<div>
<p class="eyebrow">Admin</p>
<h2>管理员登录</h2>
</div>
</div>
<form class="admin-form" @submit.prevent="submitLogin">
<label>
<span>用户名</span>
<input v-model="username" autocomplete="username" required />
</label>
<label>
<span>密码</span>
<input v-model="password" type="password" autocomplete="current-password" required />
</label>
<p v-if="error" class="error">{{ error }}</p>
<button :disabled="loading" type="submit">{{ loading ? '登录中...' : '登录' }}</button>
</form>
</section>
</template>
+110 -1
View File
@@ -78,7 +78,8 @@ h3 {
color: #475569;
}
.topbar button {
.topbar button,
.topbar-link {
border: 0;
border-radius: 10px;
padding: 9px 16px;
@@ -87,6 +88,10 @@ h3 {
font-weight: 700;
}
.topbar-link {
text-decoration: none;
}
.topbar button:disabled {
opacity: 0.6;
}
@@ -337,6 +342,110 @@ h3 {
background: #eff6ff;
}
.admin-loading {
padding: 24px;
color: #64748b;
}
.admin-login,
.admin-dashboard {
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
.admin-form {
display: grid;
gap: 14px;
padding: 18px;
}
.admin-user-form {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.admin-form label {
display: grid;
gap: 6px;
color: #334155;
font-size: 14px;
font-weight: 700;
}
.admin-form input {
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 10px 12px;
font: inherit;
}
.admin-form button,
.admin-actions button,
.admin-button {
border: 0;
border-radius: 10px;
padding: 9px 16px;
color: #fff;
font-weight: 700;
background: #2563eb;
}
.admin-table-input {
min-width: 160px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 8px 10px;
font: inherit;
}
.success {
margin: 0 16px 12px;
border: 1px solid #bbf7d0;
border-radius: 14px;
padding: 10px 12px;
color: #166534;
background: #f0fdf4;
}
.admin-dashboard {
display: grid;
gap: 12px;
}
.admin-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.admin-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
padding: 16px;
}
.admin-status-grid div {
display: grid;
gap: 5px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
background: #f8fafc;
}
.admin-status-grid span {
color: #64748b;
font-size: 13px;
}
.admin-status-grid strong {
color: #0f172a;
}
.pagination {
display: flex;
align-items: center;
+59
View File
@@ -80,3 +80,62 @@ export interface MapNode {
}
export type NodeInfoById = Record<string, NodeInfo>
export interface AdminUser {
username: string
role: string
}
export interface AdminLoginResponse {
user: AdminUser
}
export interface AdminManagedUser {
id: number
username: string
role: string
created_at: string
updated_at: string
}
export interface AdminUsersResponse {
items: AdminManagedUser[]
}
export interface AdminManagedUserResponse {
user: AdminManagedUser
}
export interface AdminMqttClient {
client_id: string
username: string
listener: string
remote_addr: string
remote_host: string
remote_port: string
}
export interface AdminMqttStatus {
running: boolean
address: string
tls: boolean
version: string
started: number
uptime: number
bytes_received: number
bytes_sent: number
clients_connected: number
clients_disconnected: number
clients_maximum: number
clients_total: number
messages_received: number
messages_sent: number
messages_dropped: number
retained: number
inflight: number
inflight_dropped: number
subscriptions: number
packets_received: number
packets_sent: number
clients: AdminMqttClient[]
}