579 lines
15 KiB
Vue
579 lines
15 KiB
Vue
<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: '修改密码',
|
||
PROFILE_UPDATE: '修改本人联系方式',
|
||
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>
|