```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user