feat(audit): 完成审计日志页面筛选与导出
This commit is contained in:
@@ -12,8 +12,7 @@ import type {
|
|||||||
BackendPage,
|
BackendPage,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
// 审计日志当前只用于签署留痕,跟随签署工作台的开关;后续有独立页面时再拆出来。
|
const useMockData = mockFlags.audit
|
||||||
const useMockData = mockFlags.signing
|
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -36,8 +35,10 @@ function mapAuditLog(dto: AuditLogResponseDto): AuditLogRecord {
|
|||||||
id: dto.id,
|
id: dto.id,
|
||||||
action: dto.action,
|
action: dto.action,
|
||||||
resourceType: dto.resourceType,
|
resourceType: dto.resourceType,
|
||||||
resourceId: dto.resourceId,
|
resourceId: dto.resourceId ?? '',
|
||||||
operatorId: dto.operatorId,
|
operatorId: dto.operatorId ?? '',
|
||||||
|
operatorName: dto.operatorName ?? (dto.operatorId ? '未知' : '系统'),
|
||||||
|
operatorRoles: dto.operatorRoles ?? '—',
|
||||||
clientIp: dto.clientIp ?? '—',
|
clientIp: dto.clientIp ?? '—',
|
||||||
userAgent: dto.userAgent ?? '—',
|
userAgent: dto.userAgent ?? '—',
|
||||||
detailsJson: dto.detailsJson ?? '',
|
detailsJson: dto.detailsJson ?? '',
|
||||||
@@ -74,6 +75,11 @@ export function getAuditLogs(query: AuditLogQuery): Promise<AuditLogListResponse
|
|||||||
params.keyword = query.keyword.trim()
|
params.keyword = query.keyword.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.createdFrom) params.createdFrom = query.createdFrom
|
||||||
|
if (query.createdTo) params.createdTo = query.createdTo
|
||||||
|
if (query.operator?.trim()) params.operator = query.operator.trim()
|
||||||
|
if (query.module) params.module = query.module
|
||||||
|
|
||||||
return request
|
return request
|
||||||
.get<ApiResponse<BackendCollection<AuditLogResponseDto>>>('/v1/audit-logs', { params })
|
.get<ApiResponse<BackendCollection<AuditLogResponseDto>>>('/v1/audit-logs', { params })
|
||||||
.then(unwrapApiResponse)
|
.then(unwrapApiResponse)
|
||||||
@@ -82,3 +88,20 @@ export function getAuditLogs(query: AuditLogQuery): Promise<AuditLogListResponse
|
|||||||
return { ...page, records: page.records.map(mapAuditLog) }
|
return { ...page, records: page.records.map(mapAuditLog) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportAuditLogs(query: AuditLogQuery): Promise<Blob> {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: Math.max(query.page, 1),
|
||||||
|
size: Math.min(Math.max(query.pageSize, 1), 200),
|
||||||
|
}
|
||||||
|
if (query.keyword?.trim()) params.keyword = query.keyword.trim()
|
||||||
|
if (query.createdFrom) params.createdFrom = query.createdFrom
|
||||||
|
if (query.createdTo) params.createdTo = query.createdTo
|
||||||
|
if (query.operator?.trim()) params.operator = query.operator.trim()
|
||||||
|
if (query.module) params.module = query.module
|
||||||
|
|
||||||
|
return request.get<Blob>('/v1/audit-logs/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -886,14 +886,20 @@ export type TemplatePermissionMatrixResponse = PageResult<TemplatePermissionReco
|
|||||||
|
|
||||||
export interface AuditLogQuery extends PageQuery {
|
export interface AuditLogQuery extends PageQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
|
createdFrom?: string
|
||||||
|
createdTo?: string
|
||||||
|
operator?: string
|
||||||
|
module?: 'ORGANIZATION' | 'SIGNING' | 'DOCUMENT' | 'SYSTEM' | 'REPORT' | 'OTHER' | ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogResponseDto {
|
export interface AuditLogResponseDto {
|
||||||
id: string
|
id: string
|
||||||
action: string
|
action: string
|
||||||
resourceType: string
|
resourceType: string
|
||||||
resourceId: string
|
resourceId?: string | null
|
||||||
operatorId: string
|
operatorId?: string | null
|
||||||
|
operatorName?: string | null
|
||||||
|
operatorRoles?: string | null
|
||||||
clientIp?: string | null
|
clientIp?: string | null
|
||||||
userAgent?: string | null
|
userAgent?: string | null
|
||||||
detailsJson?: string | null
|
detailsJson?: string | null
|
||||||
@@ -906,6 +912,8 @@ export interface AuditLogRecord {
|
|||||||
resourceType: string
|
resourceType: string
|
||||||
resourceId: string
|
resourceId: string
|
||||||
operatorId: string
|
operatorId: string
|
||||||
|
operatorName: string
|
||||||
|
operatorRoles: string
|
||||||
clientIp: string
|
clientIp: string
|
||||||
userAgent: string
|
userAgent: string
|
||||||
detailsJson: string
|
detailsJson: string
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function resolveMockFlag(raw: string | undefined, fallback: boolean): boolean {
|
|||||||
export const mockFlags = {
|
export const mockFlags = {
|
||||||
/** 首页:工作台概览、待办与趋势 */
|
/** 首页:工作台概览、待办与趋势 */
|
||||||
workbenchHome: resolveMockFlag(import.meta.env.VITE_MOCK_WORKBENCH_HOME, false),
|
workbenchHome: resolveMockFlag(import.meta.env.VITE_MOCK_WORKBENCH_HOME, false),
|
||||||
/** 签署工作台:签署任务、签署产物,以及仅用于签署留痕的审计日志 */
|
/** 签署工作台:签署任务和签署产物 */
|
||||||
signing: resolveMockFlag(import.meta.env.VITE_MOCK_SIGNING, true),
|
signing: resolveMockFlag(import.meta.env.VITE_MOCK_SIGNING, true),
|
||||||
/**
|
/**
|
||||||
* 签署记录查询:历史签署记录检索。
|
* 签署记录查询:历史签署记录检索。
|
||||||
@@ -43,6 +43,8 @@ export const mockFlags = {
|
|||||||
documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false),
|
documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false),
|
||||||
/** 报表分析 */
|
/** 报表分析 */
|
||||||
reports: resolveMockFlag(import.meta.env.VITE_MOCK_REPORTS, false),
|
reports: resolveMockFlag(import.meta.env.VITE_MOCK_REPORTS, false),
|
||||||
|
/** 审计日志:已有只读后端查询接口,直接读取数据库记录 */
|
||||||
|
audit: resolveMockFlag(import.meta.env.VITE_MOCK_AUDIT, false),
|
||||||
/**
|
/**
|
||||||
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。
|
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const componentRegistry: Record<string, Component> = {
|
|||||||
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
||||||
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
||||||
'report/index': defineAsyncComponent(() => import('@/views/management/reports/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(
|
'report/sign/overview/index': defineAsyncComponent(
|
||||||
() => import('@/views/reports/overview/index.vue'),
|
() => 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