Files
xh-medical-sign-web/clinical-web/src/views/management/audit/index.vue
T

579 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>