feat(audit): 完成审计日志页面筛选与导出

This commit is contained in:
xy
2026-09-28 16:38:27 +08:00
parent 536632e104
commit a25716c861
5 changed files with 618 additions and 7 deletions
@@ -17,6 +17,7 @@ const componentRegistry: Record<string, Component> = {
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
'report/index': defineAsyncComponent(() => import('@/views/management/reports/index.vue')),
'monitor/audit/index': defineAsyncComponent(() => import('@/views/management/audit/index.vue')),
'report/sign/overview/index': defineAsyncComponent(
() => import('@/views/reports/overview/index.vue'),
),
@@ -0,0 +1,577 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { exportAuditLogs, getAuditLogs } from '@/api/management/audit'
import type { AuditLogQuery, AuditLogRecord } from '@/api/management/types'
import { useLoginStore } from '@/stores/login'
type AuditPeriod = '7d' | '30d' | '90d' | 'all'
type AuditModule = NonNullable<AuditLogQuery['module']>
const loginStore = useLoginStore()
const period = ref<AuditPeriod>('7d')
const operatorKeyword = ref('')
const moduleFilter = ref<AuditModule>('')
const records = ref<AuditLogRecord[]>([])
const loading = ref(false)
const exporting = ref(false)
const loadError = ref(false)
const page = ref(1)
const pageSize = 10
const total = ref(0)
let requestSequence = 0
let operatorSearchTimer: ReturnType<typeof setTimeout> | undefined
const canExport = computed(() => loginStore.hasPermission('audit:log:export'))
const periodOptions: { label: string; value: AuditPeriod }[] = [
{ label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' },
{ label: '近90天', value: '90d' },
{ label: '全部时间', value: 'all' },
]
const moduleOptions: { label: string; value: AuditModule }[] = [
{ label: '全部模块', value: '' },
{ label: '组织架构', value: 'ORGANIZATION' },
{ label: '签署任务', value: 'SIGNING' },
{ label: '文档库', value: 'DOCUMENT' },
{ label: '系统设置', value: 'SYSTEM' },
{ label: '报表', value: 'REPORT' },
{ label: '其他', value: 'OTHER' },
]
const actionLabels: Record<string, string> = {
CREATE: '新增',
UPDATE: '修改',
DELETE: '删除',
DISABLE: '停用',
ENABLE: '启用',
PASSWORD_CHANGE: '修改密码',
PASSWORD_RESET: '重置密码',
PASSWORD_RESET_REJECTED: '密码重置被拒绝',
PASSWORD_RESET_RATE_LIMITED: '密码重置触发限流',
ROLE_ASSIGNMENT_UPDATE: '调整角色',
ROLE_PERMISSION_REPLACE: '调整角色权限',
PERMISSION_UPDATE: '调整角色权限',
DATA_SCOPE_UPDATE: '调整数据范围',
ROLE_GRANT_DENIED: '角色调整被拦截',
USER_IMPORT_PREVIEW: '预览用户导入',
USER_IMPORT_COMMIT: '导入用户',
CREATE_REPORT_EXPORT: '创建报表导出',
QUERY_REPORT_EXPORT: '查看报表导出',
CREATE_VERSION: '创建文档版本',
TEMPLATE_VERSION_APPROVED: '审核通过',
TEMPLATE_VERSION_DISABLED: '停用文档版本',
TEMPLATE_VERSION_PENDING_REVIEW: '提交审核',
TEMPLATE_VERSION_PUBLISHED: '发布文档版本',
TEMPLATE_VERSION_REJECTED: '审核驳回',
UPDATE_TEMPLATE_PERMISSION_DEPARTMENT_POLICY: '调整科室文档权限',
UPDATE_TEMPLATE_PERMISSION_ROLE_POLICY: '调整角色文档权限',
}
const detailLabels: Record<string, string> = {
code: '编码',
name: '名称',
username: '账号',
role: '角色',
roleCode: '角色编码',
scope: '范围',
dataScope: '数据范围',
campusId: '院区',
departmentId: '科室',
permissionCount: '权限数',
permissionLevel: '权限级别',
resultCount: '记录数',
format: '格式',
status: '状态',
fromStatus: '原状态',
toStatus: '新状态',
from: '原值',
to: '新值',
reason: '原因',
version: '版本',
versionNo: '版本号',
templateCode: '文档编码',
reviewComment: '审核意见',
forceChangeOnNextLogin: '下次登录强制改密',
cascade: '级联处理',
changedFields: '变更字段',
type: '类型',
}
function dateRange() {
if (period.value === 'all') return {}
const days = period.value === '7d' ? 7 : period.value === '30d' ? 30 : 90
const to = new Date()
const from = new Date(to)
from.setDate(from.getDate() - days)
return { createdFrom: from.toISOString(), createdTo: to.toISOString() }
}
function currentQuery(currentPage = page.value, size = pageSize): AuditLogQuery {
return {
...dateRange(),
page: currentPage,
pageSize: size,
operator: operatorKeyword.value.trim() || undefined,
module: moduleFilter.value || undefined,
}
}
async function loadLogs(nextPage = 1) {
const sequence = ++requestSequence
page.value = nextPage
loading.value = true
loadError.value = false
try {
const result = await getAuditLogs(currentQuery(nextPage))
if (sequence !== requestSequence) return
records.value = result.records
total.value = result.total
page.value = result.page
} catch {
if (sequence !== requestSequence) return
records.value = []
total.value = 0
loadError.value = true
ElMessage.error('审计日志加载失败,请检查当前账号权限或稍后重试')
} finally {
if (sequence === requestSequence) loading.value = false
}
}
function handlePeriodChange() {
void loadLogs(1)
}
function handleModuleChange() {
void loadLogs(1)
}
function handleOperatorInput() {
if (operatorSearchTimer) clearTimeout(operatorSearchTimer)
operatorSearchTimer = setTimeout(() => void loadLogs(1), 350)
}
function handleOperatorEnter() {
if (operatorSearchTimer) clearTimeout(operatorSearchTimer)
void loadLogs(1)
}
function moduleLabel(resourceType: string) {
if (['USER', 'CAMPUS', 'DEPARTMENT'].includes(resourceType)) return '组织架构'
if (
[
'SIGN_TASK',
'SIGN_DELIVERY',
'SIGN_ARTIFACT',
'SIGN_RECORD',
'SIGNATURE',
'SMS_DELIVERY',
'PAD_DELIVERY',
].includes(resourceType)
) {
return '签署任务'
}
if (
[
'DOC_TEMPLATE',
'DOC_TEMPLATE_VERSION',
'TEMPLATE_PERMISSION_DEPARTMENT_POLICY',
'TEMPLATE_PERMISSION_ROLE_POLICY',
].includes(resourceType)
) {
return '文档库'
}
if (['ROLE', 'RBAC_RELATION', 'MENU_RESOURCE'].includes(resourceType)) return '系统设置'
if (resourceType === 'REPORT_EXPORT_JOB') return '报表'
return '其他'
}
function resultLabel(record: AuditLogRecord) {
try {
const result = JSON.parse(record.detailsJson) as { result?: string }
if (result.result === 'SUCCESS') return '成功'
if (result.result === 'DENIED') return '失败/拦截'
if (result.result) return result.result
} catch {
// Older audit rows may not contain normalized JSON details.
}
return '未知'
}
function resultClass(record: AuditLogRecord) {
return resultLabel(record) === '成功' ? 'is-success' : 'is-failed'
}
function displayTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function detailValue(value: unknown) {
if (Array.isArray(value)) return `${value.length} 项`
if (typeof value === 'boolean') return value ? '是' : '否'
if (value && typeof value === 'object') {
return `${Object.keys(value).length} 项`
}
return String(value)
}
function operationContent(record: AuditLogRecord) {
let details: Record<string, unknown> = {}
try {
const parsed: unknown = JSON.parse(record.detailsJson)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
details = parsed as Record<string, unknown>
}
} catch {
// Show the action even if a legacy detail payload is malformed.
}
const description = Object.entries(details)
.filter(([key]) => !['result', 'traceId', 'operatorId', 'columns'].includes(key))
.slice(0, 3)
.map(([key, value]) => `${detailLabels[key] ?? key}:${detailValue(value)}`)
.join(',')
return [actionLabels[record.action] ?? record.action, description].filter(Boolean).join(';')
}
async function exportLogs() {
if (!canExport.value || exporting.value) return
exporting.value = true
try {
const file = await exportAuditLogs(currentQuery(1, 200))
const objectUrl = URL.createObjectURL(file)
const link = document.createElement('a')
link.href = objectUrl
link.download = `审计日志_${new Date().toISOString().slice(0, 10)}.csv`
link.click()
setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
ElMessage.success('审计日志已导出')
} catch {
ElMessage.error('审计日志导出失败,请稍后重试')
} finally {
exporting.value = false
}
}
onMounted(() => void loadLogs())
onUnmounted(() => {
requestSequence += 1
if (operatorSearchTimer) clearTimeout(operatorSearchTimer)
})
</script>
<template>
<section class="audit-page">
<section class="filter-card">
<div class="filter-bar">
<label class="filter-field">
<span>时间</span>
<select v-model="period" aria-label="审计时间范围" @change="handlePeriodChange">
<option v-for="option in periodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field operator-field">
<span>操作人</span>
<input
v-model="operatorKeyword"
type="search"
maxlength="128"
placeholder="姓名 / ID / 工号"
aria-label="搜索操作人姓名、ID 或工号"
@input="handleOperatorInput"
@keydown.enter.prevent="handleOperatorEnter"
/>
</label>
<label class="filter-field">
<span>模块</span>
<select v-model="moduleFilter" aria-label="审计模块" @change="handleModuleChange">
<option v-for="option in moduleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<span class="filter-spacer" />
<button
v-if="canExport"
class="export-button"
type="button"
:disabled="exporting || loading"
@click="exportLogs"
>
{{ exporting ? '导出中…' : '导出' }}
</button>
</div>
</section>
<section class="audit-card">
<header class="audit-heading">
<h2>操作留痕明细</h2>
<span>所有敏感操作(发起 / 签署 / 作废 / 权限调整 / 密码重置)自动记录,不可删除</span>
</header>
<div class="table-scroll">
<table class="audit-table">
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>角色</th>
<th>模块</th>
<th>操作内容</th>
<th>对象</th>
<th>来源 IP</th>
<th>结果</th>
</tr>
</thead>
<tbody>
<tr v-for="record in records" :key="record.id">
<td class="time-cell">{{ displayTime(record.createdAt) }}</td>
<td class="operator-cell">{{ record.operatorName }}</td>
<td class="role-cell">{{ record.operatorRoles }}</td>
<td>
<span class="module-tag">{{ moduleLabel(record.resourceType) }}</span>
</td>
<td class="content-cell" :title="operationContent(record)">
{{ operationContent(record) }}
</td>
<td class="object-cell" :title="record.resourceId">{{ record.resourceId || '—' }}</td>
<td class="ip-cell">{{ record.clientIp }}</td>
<td>
<span class="result-tag" :class="resultClass(record)">{{
resultLabel(record)
}}</span>
</td>
</tr>
<tr v-if="loading">
<td colspan="8" class="empty-cell">正在加载审计日志…</td>
</tr>
<tr v-else-if="loadError">
<td colspan="8" class="empty-cell">审计日志加载失败,请刷新重试</td>
</tr>
<tr v-else-if="!records.length">
<td colspan="8" class="empty-cell">当前筛选条件下暂无审计记录</td>
</tr>
</tbody>
</table>
</div>
<el-pagination
v-if="total > pageSize"
class="audit-pagination"
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="loadLogs"
/>
</section>
</section>
</template>
<style scoped>
.audit-page {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.filter-card,
.audit-card {
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.filter-card {
padding: 18px 22px;
margin-bottom: 18px;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
}
.filter-field {
display: inline-flex;
gap: 9px;
align-items: center;
color: var(--mut);
font-size: 13px;
white-space: nowrap;
}
.filter-field select,
.filter-field input {
min-width: 112px;
height: 38px;
padding: 0 11px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 7px;
outline-color: var(--brand);
}
.operator-field input {
width: 190px;
}
.filter-spacer {
flex: 1;
}
.export-button {
min-width: 76px;
height: 38px;
padding: 0 15px;
color: var(--brand);
font-weight: 700;
background: #fff;
border: 1px solid var(--brand);
border-radius: 7px;
}
.export-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.audit-card {
padding: 20px 22px 16px;
}
.audit-heading {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.audit-heading h2 {
margin: 0;
color: var(--ink);
font-size: 15px;
}
.audit-heading span {
color: var(--mut);
font-size: 12px;
}
.table-scroll {
overflow-x: auto;
}
.audit-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
color: var(--ink);
font-size: 13px;
}
.audit-table th {
padding: 12px 10px;
color: var(--mut);
font-weight: 600;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.audit-table td {
height: 46px;
padding: 8px 10px;
border-bottom: 1px solid #edf2f4;
}
.time-cell,
.role-cell,
.ip-cell {
color: #6b8490;
white-space: nowrap;
}
.operator-cell {
font-weight: 700;
white-space: nowrap;
}
.module-tag,
.result-tag {
display: inline-block;
padding: 4px 11px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
border-radius: 14px;
}
.module-tag {
color: var(--brand);
background: #e8f4f7;
}
.content-cell {
max-width: 600px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.object-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-tag.is-success {
color: #159b67;
background: #e4f5ed;
}
.result-tag.is-failed {
color: #d64e4e;
background: #fceaea;
}
.empty-cell {
height: 140px !important;
color: var(--mut);
text-align: center;
}
.audit-pagination {
justify-content: flex-end;
margin-top: 15px;
}
@media (max-width: 720px) {
.filter-card,
.audit-card {
padding: 14px;
}
.filter-spacer {
display: none;
}
.audit-heading {
align-items: flex-start;
flex-direction: column;
}
}
</style>