f1f8518985
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
249 lines
11 KiB
Vue
249 lines
11 KiB
Vue
<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"
|
||
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'
|
||
|
||
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)
|
||
const fmtTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||
|
||
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>
|