Files
H3ConuMS-v2/frontend/src/views/AuditLog.vue
T
v6ole 32b7a2cc6a fix: el-pagination 弃用属性 small → size="small"
消除 Element Plus 3.0 deprecated 控制台警告。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00

250 lines
11 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.
<template>
<div class="audit-page">
<div class="page-header">
<div class="page-title-group">
<h1 class="page-title">审计日志</h1>
<span class="page-subtitle">记录所有用户操作支持追溯与问责</span>
</div>
<button class="export-btn" @click="handleExport" :disabled="exporting">
{{ exporting ? '导出中…' : '导出 CSV' }}
</button>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width: 340px"
@change="onFilter"
/>
<el-input v-model="filters.username" placeholder="用户名" size="small" style="width: 130px" clearable @change="onFilter" />
<el-select v-model="filters.action_type" placeholder="操作类型" size="small" style="width: 120px" clearable @change="onFilter">
<el-option v-for="t in actionTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-select v-model="filters.status" placeholder="状态" size="small" style="width: 100px" clearable @change="onFilter">
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="错误" value="error" />
</el-select>
<button class="reset-btn" @click="resetFilters">重置</button>
</div>
<!-- 表格 -->
<div class="table-wrap">
<el-table :data="logs" size="small" style="width:100%" v-loading="loading" @row-click="openDetail">
<el-table-column label="时间" width="160">
<template #default="{ row }">
<span class="mono-val">{{ fmtTime(row.action_time) }}</span>
</template>
</el-table-column>
<el-table-column label="用户" width="110">
<template #default="{ row }">
<span>{{ row.username }}</span>
<span class="role-tag">{{ row.user_role }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<span :class="['type-tag', `type-${row.action_type}`]">{{ typeLabel(row.action_type) }}</span>
</template>
</el-table-column>
<el-table-column label="子类型" width="80">
<template #default="{ row }">
<span class="muted">{{ row.action_subtype || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="描述" min-width="180" show-overflow-tooltip prop="description" />
<el-table-column label="路径" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono-val small">{{ row.request_method }} {{ row.request_path }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="72" align="center">
<template #default="{ row }">
<span :class="['status-dot', `status-${row.status}`]">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="IP" width="130">
<template #default="{ row }">
<span class="mono-val small">{{ row.ip_address || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
size="small"
@change="fetchLogs"
/>
</div>
</div>
<!-- 详情抽屉 -->
<el-drawer v-model="drawerVisible" title="日志详情" size="480px" direction="rtl">
<div v-if="detail" class="detail-body">
<div class="detail-row" v-for="(v, k) in detailFields" :key="k">
<span class="detail-label">{{ v.label }}</span>
<span class="detail-value" :class="{ mono: v.mono }">{{ v.val }}</span>
</div>
<template v-if="detail.request_params">
<div class="detail-section">请求参数</div>
<pre class="json-block">{{ JSON.stringify(detail.request_params, null, 2) }}</pre>
</template>
<template v-if="detail.error_message">
<div class="detail-section">错误信息</div>
<pre class="json-block error">{{ detail.error_message }}</pre>
</template>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from '../utils/message'
import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit'
import { fmtTime } from '../utils/datetime'
const logs = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const exporting = ref(false)
const drawerVisible = ref(false)
const detail = ref(null)
const dateRange = ref(null)
const filters = ref({ username: '', action_type: '', status: '' })
const actionTypes = [
{ label: '认证', value: 'auth' },
{ label: '设备', value: 'device' },
{ label: 'OLT', value: 'olt' },
{ label: '用户', value: 'user' },
{ label: '系统', value: 'system' },
{ label: '库存', value: 'inventory' },
]
const typeLabel = (t) => actionTypes.find(x => x.value === t)?.label || t
const statusLabel = (s) => ({ success: '成功', failed: '失败', error: '错误' }[s] || s)
// fmtTime 从 utils/datetime 导入,已在文件顶部
const buildParams = () => {
const p = { page: page.value, page_size: pageSize.value }
if (dateRange.value?.[0]) p.start_time = dateRange.value[0].toISOString()
if (dateRange.value?.[1]) p.end_time = dateRange.value[1].toISOString()
if (filters.value.username) p.username = filters.value.username
if (filters.value.action_type) p.action_type = filters.value.action_type
if (filters.value.status) p.status = filters.value.status
return p
}
const fetchLogs = async () => {
loading.value = true
try {
const { data } = await getAuditLogs(buildParams())
logs.value = data.items
total.value = data.total
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const onFilter = () => { page.value = 1; fetchLogs() }
const resetFilters = () => {
filters.value = { username: '', action_type: '', status: '' }
dateRange.value = null
page.value = 1
fetchLogs()
}
const openDetail = async (row) => {
try {
const { data } = await getAuditLogDetail(row.id)
detail.value = data
drawerVisible.value = true
} catch { ElMessage.error('加载详情失败') }
}
const detailFields = computed(() => {
if (!detail.value) return {}
const d = detail.value
return {
time: { label: '时间', val: fmtTime(d.action_time), mono: true },
user: { label: '用户', val: `${d.username} (${d.user_role})` },
type: { label: '操作', val: `${typeLabel(d.action_type)} / ${d.action_subtype || '—'}` },
path: { label: '路径', val: `${d.request_method} ${d.request_path}`, mono: true },
status: { label: '状态', val: `${statusLabel(d.status)} (${d.status_code})` },
ip: { label: 'IP', val: d.ip_address || '—', mono: true },
desc: { label: '描述', val: d.description },
ua: { label: 'UA', val: d.user_agent || '—' },
}
})
const handleExport = async () => {
exporting.value = true
try {
const { data } = await exportAuditLogs(buildParams())
const url = URL.createObjectURL(new Blob([data]))
const a = document.createElement('a')
a.href = url
a.download = `audit_${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('导出失败') } finally { exporting.value = false }
}
onMounted(fetchLogs)
</script>
<style scoped>
.audit-page { padding: 24px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
.page-title-group { display: flex; flex-direction: column; gap: 4px; }
.page-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.page-subtitle { font-size: 12px; color: var(--text-muted); }
.export-btn { padding: 7px 16px; background: var(--bg-elevated); border: 1px solid var(--border-default); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.export-btn:hover { border-color: var(--accent); color: var(--accent); }
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; align-items: center; }
.reset-btn { padding: 6px 12px; background: transparent; border: 1px solid var(--border-default); border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px; cursor: pointer; }
.reset-btn:hover { color: var(--text-primary); }
.table-wrap { background: var(--bg-card); border: 1px solid var(--border-default); border-radius: var(--radius-lg); overflow: hidden; }
.pagination { padding: 12px 16px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border-subtle); }
.mono-val { font-family: var(--font-mono); font-size: 12px; }
.small { font-size: 11px; }
.muted { color: var(--text-muted); font-size: 12px; }
.role-tag { margin-left: 4px; font-size: 10px; color: var(--text-muted); background: var(--bg-elevated); padding: 1px 5px; border-radius: 3px; }
.type-tag { font-size: 11px; padding: 2px 7px; border-radius: 3px; font-weight: 500; }
.type-auth { background: #dbeafe; color: #1d4ed8; }
.type-device { background: #dcfce7; color: #15803d; }
.type-olt { background: #fef9c3; color: #854d0e; }
.type-user { background: #fce7f3; color: #9d174d; }
.type-system { background: #f3e8ff; color: #6b21a8; }
.type-inventory{ background: #ffedd5; color: #9a3412; }
.status-dot { font-size: 11px; padding: 2px 7px; border-radius: 3px; }
.status-success { background: #dcfce7; color: #15803d; }
.status-failed { background: #fef9c3; color: #854d0e; }
.status-error { background: #fee2e2; color: #991b1b; }
.detail-body { padding: 4px 0; }
.detail-row { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: 13px; }
.detail-label { width: 60px; flex-shrink: 0; color: var(--text-muted); }
.detail-value { flex: 1; color: var(--text-primary); word-break: break-all; }
.detail-value.mono { font-family: var(--font-mono); font-size: 12px; }
.detail-section { margin: 16px 0 8px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.json-block { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 12px; font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); margin: 0; }
.json-block.error { color: #dc2626; }
</style>