feat: 管理端登录日志与操作审计 (#18)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user