feat(audit): 完成审计日志页面筛选与导出
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user