feat: 管理端登录日志与操作审计 (#18)

This commit is contained in:
ila
2026-09-10 20:37:46 +08:00
parent 1e77f1026b
commit 850c78e2aa
22 changed files with 904 additions and 41 deletions
+56
View File
@@ -0,0 +1,56 @@
export const actionLabels = { create: '创建账号', enable: '启用账号', disable: '停用账号', reset_password: '重置密码', update: '更新账号' }
export const reasonLabels = { ok: '完成', invalid_input: '输入无效', invalid_credentials: '账号或密码不正确', rate_limited: '请求过于频繁', forbidden: '无操作权限', not_found: '账号不存在', conflict: '账号冲突', internal_error: '服务异常' }
export function auditQuery(kind, filters = {}) {
if (!['login', 'operation'].includes(kind)) throw new Error('日志类型无效')
const { page = 1, limit = 20, result, action, from, to } = filters
if (!Number.isInteger(page) || page < 1 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('分页参数无效')
const params = new URLSearchParams({ page, limit })
const username = (filters.username || '').trim().toLowerCase()
if (username) params.set('username', username)
if (result) {
if (!['success', 'failure'].includes(result)) throw new Error('结果筛选无效')
params.set('result', result)
}
if (action) {
if (kind !== 'operation' || !Object.hasOwn(actionLabels, action)) throw new Error('操作筛选无效')
params.set('action', action)
}
for (const [key, value] of [['from', from], ['to', to]]) {
if (!value) continue
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('请选择有效时间')
params.set(key, date.toISOString())
}
if (from && to && new Date(from) > new Date(to)) throw new Error('开始时间不能晚于结束时间')
return params.toString()
}
// Every page/filter change and session invalidation retires pending responses.
export function createAuditLogLoader(session, state) {
let sequence = 0
function invalidate() {
sequence++
Object.assign(state, { items: [], total: 0, loading: false, error: '' })
}
return {
invalidate,
async load(kind, filters) {
// Keep the page count stable while fetching another page; resetting total
// makes Element Plus clamp the current page to one and issue a new query.
sequence++
Object.assign(state, { items: [], loading: true, error: '' })
const current = sequence
const generation = session.state.generation
const isCurrent = () => current === sequence && generation === session.state.generation
try {
const data = await session.queryAuditLogs(kind, filters)
if (isCurrent()) Object.assign(state, { items: data.items, total: data.total })
} catch (error) {
if (isCurrent()) state.error = error.message
} finally {
if (current === sequence) state.loading = false
}
}
}
}
+4 -2
View File
@@ -3,14 +3,16 @@
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<aside class="sidebar-container">
<div class="brand">{{ sidebar.opened ? 'LexGo 管理' : 'LG' }}</div>
<el-menu default-active="/accounts" :collapse="!sidebar.opened" router>
<el-menu :default-active="$route.path" :collapse="!sidebar.opened" router>
<el-menu-item index="/accounts"><span>账号管理</span></el-menu-item>
<el-menu-item index="/login-logs"><span>登录日志</span></el-menu-item>
<el-menu-item index="/operation-logs"><span>操作日志</span></el-menu-item>
</el-menu>
</aside>
<div class="main-container">
<header class="navbar">
<button class="menu-toggle" aria-label="切换导航" @click="$store.dispatch('app/toggleSideBar')"><hamburger :is-active="sidebar.opened" /></button>
<span>账号管理</span>
<span>{{ $route.meta.title }}</span>
<div class="right-menu"><span>{{ $store.state.user?.username }}</span><el-button @click="logout">退出登录</el-button></div>
</header>
<app-main />
+7 -1
View File
@@ -3,9 +3,15 @@ import { session } from '../store'
import Layout from '../layout/index.vue'
import Login from '../views/Login.vue'
import Accounts from '../views/Accounts.vue'
import AuditLogs from '../views/AuditLogs.vue'
const router = createRouter({ history: createWebHashHistory(), routes: [
{ path: '/login', component: Login },
{ path: '/', component: Layout, children: [{ path: '', redirect: '/accounts' }, { path: 'accounts', component: Accounts }] },
{ path: '/', component: Layout, children: [
{ path: '', redirect: '/accounts' },
{ path: 'accounts', component: Accounts, meta: { title: '账号管理' } },
{ path: 'login-logs', component: AuditLogs, props: { kind: 'login' }, meta: { title: '登录日志' } },
{ path: 'operation-logs', component: AuditLogs, props: { kind: 'operation' }, meta: { title: '操作日志' } }
] },
{ path: '/:pathMatch(.*)*', redirect: '/accounts' }
] })
router.beforeEach(async to => {
+9
View File
@@ -1,3 +1,4 @@
import { auditQuery } from './audit-logs.mjs'
export function normalizeUsername(value) { return value.trim().toLowerCase() }
export function validPassword(value) {
const bytes = new TextEncoder().encode(value).length
@@ -83,6 +84,14 @@ export function createSession({ fetch, storage, changed = () => {} }) {
} catch (error) { if (generation === state.generation) clear(); throw error }
},
async logout() { const token = state.token; clear(); await revoke(token) },
async queryAuditLogs(kind, filters) {
authorized()
const generation = state.generation
const query = auditQuery(kind, filters)
const data = await request('/' + kind + '-logs?' + query)
assertCurrent(generation)
return data
},
async loadAccounts() {
authorized()
const generation = state.generation
+64
View File
@@ -0,0 +1,64 @@
<template>
<basic-layout><template #wrapper><el-card>
<div class="toolbar"><h1>{{ kind === 'login' ? '登录日志' : '操作日志' }}</h1><span class="retention">保留最近 90 天记录</span></div>
<el-form :model="filters" class="filters" label-position="top" @submit.prevent="search">
<el-form-item label="账号"><el-input v-model="filters.username" :placeholder="kind === 'login' ? '精确匹配账号' : '操作人或目标账号'" clearable /></el-form-item>
<el-form-item label="结果"><el-select v-model="filters.result"><el-option label="全部" value="" /><el-option label="成功" value="success" /><el-option label="失败" value="failure" /></el-select></el-form-item>
<el-form-item v-if="kind === 'operation'" label="操作"><el-select v-model="filters.action"><el-option label="全部" value="" /><el-option v-for="(label, action) in actionLabels" :key="action" :label="label" :value="action" /></el-select></el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="filters.from" type="datetime" placeholder="选择开始时间" /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="filters.to" type="datetime" placeholder="选择结束时间" /></el-form-item>
<div class="filter-actions"><el-button type="primary" native-type="submit" :loading="logs.loading">查询</el-button><el-button @click="reset">重置</el-button></div>
</el-form>
<el-alert v-if="logs.error" :title="logs.error" type="error" :closable="false" show-icon />
<el-table v-loading="logs.loading" :data="logs.items" border :empty-text="logs.error ? '加载失败,请重试' : '暂无符合条件的记录'">
<el-table-column label="时间" min-width="180"><template #default="scope">{{ formatDate(scope.row.createdAt) }}</template></el-table-column>
<el-table-column v-if="kind === 'login'" label="账号" prop="username" min-width="150" show-overflow-tooltip />
<template v-else>
<el-table-column label="操作人" prop="actorUsername" min-width="150" show-overflow-tooltip />
<el-table-column label="目标账号" min-width="150" show-overflow-tooltip><template #default="scope">{{ scope.row.targetUsername || '—' }}</template></el-table-column>
<el-table-column label="操作" min-width="120"><template #default="scope">{{ actionLabels[scope.row.action] || '未知操作' }}</template></el-table-column>
</template>
<el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.result === 'success' ? 'success' : 'danger'">{{ scope.row.result === 'success' ? '成功' : '失败' }}</el-tag></template></el-table-column>
<el-table-column label="说明" min-width="170"><template #default="scope">{{ reasonLabels[scope.row.reason] || '未知原因' }}</template></el-table-column>
<el-table-column v-if="kind === 'login'" label="IP 地址" prop="ip" min-width="150" show-overflow-tooltip />
</el-table>
<pagination v-show="logs.total" v-model:page="page" v-model:limit="limit" :total="logs.total" :page-sizes="[20, 50, 100]" :auto-scroll="false" layout="total, sizes, prev, pager, next" @pagination="paginate" />
</el-card></template></basic-layout>
</template>
<script>
import BasicLayout from '../layout/BasicLayout.vue'
import Pagination from '../components/Pagination/index.vue'
import { session } from '../store'
import { actionLabels, reasonLabels, createAuditLogLoader } from '../audit-logs.mjs'
const emptyFilters = () => ({ username: '', result: '', action: '', from: null, to: null })
export default {
name: 'AuditLogs', components: { BasicLayout, Pagination },
props: { kind: { type: String, required: true } },
data: () => ({ filters: emptyFilters(), applied: emptyFilters(), page: 1, limit: 20, logs: { items: [], total: 0, loading: false, error: '' }, actionLabels, reasonLabels }),
watch: {
kind() { this.loader.invalidate(); this.reset() },
'$store.state.generation': { flush: 'sync', handler() { this.loader.invalidate(); this.filters = emptyFilters(); this.applied = emptyFilters() } }
},
created() { this.loader = createAuditLogLoader(session, this.logs) },
mounted() { this.load() },
beforeUnmount() { this.loader.invalidate() },
methods: {
load() { return this.loader.load(this.kind, { ...this.applied, page: this.page, limit: this.limit }) },
search() { this.page = 1; this.applied = { ...this.filters }; this.load() },
reset() { this.filters = emptyFilters(); this.search() },
paginate({ page, limit }) { this.page = page; this.limit = limit; this.load() },
formatDate(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false }) }
}
}
</script>
<style scoped>
.toolbar { display: flex; align-items: baseline; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
h1 { font-size: 20px; margin: 0; }
.retention { color: #909399; font-size: 13px; }
.filters { display: flex; align-items: flex-end; gap: 0 16px; flex-wrap: wrap; }
.filters .el-form-item { width: 200px; }
.filters :deep(.el-date-editor) { width: 100%; }
.filter-actions { display: flex; margin-bottom: 18px; }
.el-alert { margin-bottom: 16px; }
@media (max-width: 600px) { .filters .el-form-item { width: 100%; } }
</style>