更新用户管理相关

This commit is contained in:
2026-06-03 23:58:17 +08:00
parent 9221a53617
commit 63676f7f34
12 changed files with 464 additions and 176 deletions
+30 -3
View File
@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import { adminLogout, 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 NodeListPanel from './components/NodeListPanel.vue'
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
const isAdminPage = window.location.pathname === '/admin'
const adminPath = window.location.pathname
const isAdminPage = adminPath.startsWith('/admin')
const adminUser = ref<AdminUser | null>(null)
const adminChecking = ref(false)
@@ -171,6 +174,14 @@ async function checkAdminSession() {
}
}
async function logoutAdmin() {
try {
await adminLogout()
} finally {
adminUser.value = null
}
}
onMounted(() => {
if (isAdminPage) {
checkAdminSession()
@@ -196,6 +207,11 @@ onBeforeUnmount(() => {
</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>
@@ -211,7 +227,18 @@ onBeforeUnmount(() => {
<template v-if="isAdminPage">
<div v-if="adminChecking" class="panel admin-loading">正在检查登录状态...</div>
<AdminDashboard v-else-if="adminUser" :user="adminUser" @logout="adminUser = null" />
<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>
+5
View File
@@ -1,4 +1,5 @@
import type {
AdminLoginLogsResponse,
AdminLoginResponse,
AdminManagedUserResponse,
AdminMqttStatus,
@@ -95,3 +96,7 @@ export function createAdminUser(username: string, password: string): Promise<Adm
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
}
export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLogsResponse> {
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
}
@@ -1,28 +1,11 @@
<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: []
}>()
import { getAdminMqttStatus } from '../api'
import type { AdminMqttStatus } from '../types'
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 {
@@ -32,10 +15,6 @@ function formatUptime(seconds: number): string {
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 = ''
@@ -48,83 +27,8 @@ async function refreshStatus() {
}
}
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)
})
@@ -144,9 +48,7 @@ onBeforeUnmount(() => {
<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>
@@ -159,81 +61,13 @@ onBeforeUnmount(() => {
<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.messages_sent }}</strong></div>
<div><span>丢弃消息</span><strong>{{ status.messages_dropped }}</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>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getAdminLoginLogs } from '../api'
import type { AdminLoginLog } from '../types'
const logs = ref<AdminLoginLog[]>([])
const loading = ref(false)
const error = ref('')
const page = ref(1)
const pageSize = 100
const canPrev = () => page.value > 1
const canNext = () => logs.value.length === pageSize
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
async function refreshLogs() {
loading.value = true
error.value = ''
try {
const response = await getAdminLoginLogs(pageSize, (page.value - 1) * pageSize)
logs.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)
refreshLogs()
}
onMounted(refreshLogs)
</script>
<template>
<section class="admin-dashboard">
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Login logs</p>
<h2>登录日志</h2>
</div>
<button class="admin-button" @click="refreshLogs" :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>用户名</th>
<th>结果</th>
<th>原因</th>
<th>Remote Addr</th>
<th>Remote Host</th>
<th>User-Agent</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td>{{ formatTime(log.created_at) }}</td>
<td>{{ log.username || '-' }}</td>
<td>
<span class="log-badge" :class="log.success ? 'log-success' : 'log-failure'">
{{ log.success ? '成功' : '失败' }}
</span>
</td>
<td>{{ log.reason || '-' }}</td>
<td>{{ log.remote_addr || '-' }}</td>
<td>{{ log.remote_host || '-' }}</td>
<td>{{ log.user_agent || '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="logs.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,163 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { createAdminUser, getAdminUsers, updateAdminUserPassword } from '../api'
import type { AdminManagedUser, AdminUser } from '../types'
const props = defineProps<{
user: AdminUser
}>()
const users = ref<AdminManagedUser[]>([])
const usersLoading = ref(false)
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>>({})
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
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 }
}
}
onMounted(refreshUsers)
</script>
<template>
<section class="admin-dashboard">
<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="managedUser in users" :key="managedUser.id">
<td>{{ managedUser.id }}</td>
<td>{{ managedUser.username }} <span v-if="managedUser.username === props.user.username" class="badge">当前</span></td>
<td>{{ managedUser.role }}</td>
<td>{{ formatTime(managedUser.created_at) }}</td>
<td>{{ formatTime(managedUser.updated_at) }}</td>
<td>
<input
v-model="passwordEdits[managedUser.id]"
class="admin-table-input"
type="password"
autocomplete="new-password"
placeholder="输入新密码"
/>
</td>
<td>
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="users.length === 0" class="empty">暂无用户</div>
</div>
</div>
</section>
</template>
+50 -1
View File
@@ -348,12 +348,43 @@ h3 {
}
.admin-login,
.admin-dashboard {
.admin-dashboard,
.admin-session-card {
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
.admin-session-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
}
.admin-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.admin-nav a {
border-radius: 999px;
padding: 7px 10px;
color: #334155;
text-decoration: none;
font-size: 13px;
font-weight: 700;
background: #e2e8f0;
}
.admin-nav a.active {
color: #fff;
background: #2563eb;
}
.admin-form {
display: grid;
gap: 14px;
@@ -408,6 +439,24 @@ h3 {
background: #f0fdf4;
}
.log-badge {
display: inline-flex;
border-radius: 999px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
}
.log-success {
color: #166534;
background: #dcfce7;
}
.log-failure {
color: #991b1b;
background: #fee2e2;
}
.admin-dashboard {
display: grid;
gap: 12px;
+18
View File
@@ -106,6 +106,24 @@ export interface AdminManagedUserResponse {
user: AdminManagedUser
}
export interface AdminLoginLog {
id: number
username: string
user_id: number | null
success: boolean
reason: string
remote_addr: string
remote_host: string
user_agent: string
created_at: string
}
export interface AdminLoginLogsResponse {
items: AdminLoginLog[]
limit: number
offset: number
}
export interface AdminMqttClient {
client_id: string
username: string