操作日志和访问日志
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取访问日志列表
|
||||
export function getAccessLogs(params) {
|
||||
return request({
|
||||
url: '/api/access-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取访问日志详情
|
||||
export function getAccessLogById(id) {
|
||||
return request({
|
||||
url: `/api/access-logs/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户访问统计
|
||||
export function getUserAccessStats(params) {
|
||||
return request({
|
||||
url: '/api/access-logs/user/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧访问日志
|
||||
export function clearOldAccessLogs(keepDays = 90) {
|
||||
return request({
|
||||
url: '/api/access-logs/clear',
|
||||
method: 'post',
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取操作日志列表
|
||||
export function getOperationLogs(params) {
|
||||
return request({
|
||||
url: '/api/operation-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取操作日志详情
|
||||
export function getOperationLogById(id) {
|
||||
return request({
|
||||
url: `/api/operation-logs/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户操作统计
|
||||
export function getUserOperationStats(params) {
|
||||
return request({
|
||||
url: '/api/operation-logs/user/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取租户操作统计
|
||||
export function getTenantOperationStats(params) {
|
||||
return request({
|
||||
url: '/api/operation-logs/tenant/stats',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧日志
|
||||
export function clearOldLogs(keepDays = 90) {
|
||||
return request({
|
||||
url: '/api/operation-logs/clear',
|
||||
method: 'post',
|
||||
data: { keep_days: keepDays }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="access-log-container">
|
||||
<!-- 统计面板 -->
|
||||
<!-- <StatisticsPanel /> -->
|
||||
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" label-width="100px" :inline="true">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="filters.module" placeholder="选择模块" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option
|
||||
v-for="opt in moduleOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源类型">
|
||||
<el-input v-model="filters.resource_type" placeholder="搜索资源类型" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="dateRange" label-width="100px" :inline="true">
|
||||
<el-form-item label="访问时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange.range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button @click="showClearDialog">清空日志</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>访问日志列表</span>
|
||||
<span class="log-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="username" label="用户" align="center" width="120" />
|
||||
<el-table-column prop="module_name" label="模块" align="center" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.module_name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resource_type" label="资源类型" align="center" width="120" />
|
||||
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.request_url" placement="top">
|
||||
<span>{{ row.request_url }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_method" label="方法" align="center" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMethodTag(row.request_method)">
|
||||
{{ row.request_method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
|
||||
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
|
||||
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="访问日志详情" width="70%">
|
||||
<el-descriptions v-if="currentRecord" :column="2" border>
|
||||
<el-descriptions-item label="日志ID">
|
||||
{{ currentRecord.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">
|
||||
{{ currentRecord.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户ID">
|
||||
{{ currentRecord.tenant_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">
|
||||
{{ currentRecord.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">
|
||||
{{ currentRecord.module_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源类型">
|
||||
{{ currentRecord.resource_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源ID">
|
||||
{{ currentRecord.resource_id || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问路径">
|
||||
{{ currentRecord.request_url }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">
|
||||
{{ currentRecord.request_method }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="查询字符串">
|
||||
{{ currentRecord.query_string || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ currentRecord.ip_address }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="User Agent">
|
||||
<div style="word-break: break-all; font-size: 12px">
|
||||
{{ currentRecord.user_agent }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时(ms)">
|
||||
{{ currentRecord.duration }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="访问时间">
|
||||
{{ formatTime(currentRecord.create_time) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 清空对话框 -->
|
||||
<el-dialog v-model="clearDialogVisible" title="清空日志" width="400px">
|
||||
<el-form :model="clearForm" label-width="100px">
|
||||
<el-form-item label="保留天数">
|
||||
<el-input-number v-model="clearForm.keepDays" :min="0" :max="365" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clearDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleClear">确定清空</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getAccessLogs, clearOldAccessLogs } from '@/api/accessLog'
|
||||
import { getAllMenus } from '@/api/menu'
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
resource_type: ''
|
||||
})
|
||||
|
||||
const dateRange = reactive({
|
||||
range: null
|
||||
})
|
||||
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const clearDialogVisible = ref(false)
|
||||
const currentRecord = ref(null)
|
||||
|
||||
const clearForm = reactive({
|
||||
keepDays: 90
|
||||
})
|
||||
|
||||
const moduleOptions = ref([])
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取HTTP方法的标签类型
|
||||
const getMethodTag = (method) => {
|
||||
const tagMap = {
|
||||
GET: 'success',
|
||||
POST: 'warning',
|
||||
PUT: 'info',
|
||||
DELETE: 'danger'
|
||||
}
|
||||
return tagMap[method] || 'info'
|
||||
}
|
||||
|
||||
// 加载菜单数据(用于模块选项)
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenus()
|
||||
if (res.data) {
|
||||
const menus = res.data.list || res.data || []
|
||||
const options = menus.map((m) => ({
|
||||
value: m.path ? m.path.split('/').pop() : m.permission,
|
||||
label: m.name
|
||||
}))
|
||||
moduleOptions.value = options
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load menus:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志列表
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page_num: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
username: filters.username || undefined,
|
||||
module: filters.module || undefined,
|
||||
resource_type: filters.resource_type || undefined
|
||||
}
|
||||
|
||||
if (dateRange.range && dateRange.range.length === 2) {
|
||||
params.start_time = dateRange.range[0].toLocaleString('zh-CN')
|
||||
params.end_time = dateRange.range[1].toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const res = await getAccessLogs(params)
|
||||
if (res.data) {
|
||||
tableData.value = res.data
|
||||
// 补充模块名称(从 moduleOptions 中查找)
|
||||
tableData.value.forEach((row) => {
|
||||
const module = moduleOptions.value.find((m) => m.value === row.module)
|
||||
row.module_name = module ? module.label : row.module
|
||||
})
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载访问日志失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理重置
|
||||
const handleReset = () => {
|
||||
filters.username = ''
|
||||
filters.module = ''
|
||||
filters.resource_type = ''
|
||||
dateRange.range = null
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理日期改变
|
||||
const handleDateChange = () => {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 处理分页改变
|
||||
const handlePageChange = () => {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
const showDetail = (row) => {
|
||||
currentRecord.value = row
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 显示清空对话框
|
||||
const showClearDialog = () => {
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 处理清空日志
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要清空 ${clearForm.keepDays} 天前的日志吗?该操作不可撤销。`,
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
await clearOldAccessLogs(clearForm.keepDays)
|
||||
ElMessage.success('日志清空成功')
|
||||
clearDialogVisible.value = false
|
||||
loadLogs()
|
||||
} catch (e) {
|
||||
// 取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 处理导出
|
||||
const handleExport = () => {
|
||||
// 可以调用后端导出接口或使用前端库导出
|
||||
ElMessage.info('导出功能暂未实现')
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
onMounted(async () => {
|
||||
await loadMenus()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-log-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="操作日志详情"
|
||||
width="80%"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<div v-if="logDetail" class="detail-container">
|
||||
<!-- 基本信息 -->
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="操作ID">{{ logDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">{{ logDetail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">{{ logDetail.module }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作类型">
|
||||
<el-tag :type="getOperationTag(logDetail.operation)">{{ logDetail.operation }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="资源类型">{{ logDetail.resource_type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资源ID">{{ logDetail.resource_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作结果">
|
||||
<el-tag :type="logDetail.status === 1 ? 'success' : 'danger'">
|
||||
{{ logDetail.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作耗时">{{ logDetail.duration_ms }}ms</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">{{ logDetail.request_method }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求IP">{{ logDetail.ip_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作时间" :span="2">{{ formatTime(logDetail.create_time) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 请求信息 -->
|
||||
<el-divider content-position="left">请求信息</el-divider>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="请求URL">
|
||||
<div class="url-text">{{ logDetail.request_url }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="User Agent">
|
||||
<div class="user-agent-text">{{ logDetail.user_agent }}</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 操作说明 -->
|
||||
<el-divider content-position="left">操作说明</el-divider>
|
||||
<div class="description-box">{{ logDetail.description || '-' }}</div>
|
||||
|
||||
<!-- 修改前后值 -->
|
||||
<div v-if="logDetail.old_value || logDetail.new_value">
|
||||
<el-divider content-position="left">数据变更</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12" v-if="logDetail.old_value">
|
||||
<h4>修改前</h4>
|
||||
<pre class="json-box">{{ formatJson(logDetail.old_value) }}</pre>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="logDetail.new_value">
|
||||
<h4>修改后</h4>
|
||||
<pre class="json-box">{{ formatJson(logDetail.new_value) }}</pre>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="logDetail.error_message">
|
||||
<el-divider content-position="left">错误信息</el-divider>
|
||||
<el-alert
|
||||
:title="logDetail.error_message"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="closeDialog">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { getOperationLogById } from '@/api/operationLog'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const logDetail = ref(null)
|
||||
|
||||
const getOperationTag = (operation) => {
|
||||
const map = {
|
||||
'CREATE': 'success',
|
||||
'READ': 'info',
|
||||
'UPDATE': 'warning',
|
||||
'DELETE': 'danger',
|
||||
'LOGIN': 'primary',
|
||||
'LOGOUT': 'info'
|
||||
}
|
||||
return map[operation] || 'info'
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatJson = (jsonStr) => {
|
||||
try {
|
||||
if (typeof jsonStr === 'string') {
|
||||
return JSON.stringify(JSON.parse(jsonStr), null, 2)
|
||||
}
|
||||
return JSON.stringify(jsonStr, null, 2)
|
||||
} catch {
|
||||
return jsonStr || '-'
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = async (id) => {
|
||||
try {
|
||||
const res = await getOperationLogById(id)
|
||||
if (res.success) {
|
||||
logDetail.value = res.data
|
||||
dialogVisible.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载日志详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false
|
||||
logDetail.value = null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openDetail
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.url-text {
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f7fa;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-agent-text {
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f7fa;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.description-box {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
min-height: 60px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
border-left: 3px solid #409eff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 10px 0 5px 0;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<!-- 统计面板 -->
|
||||
<el-card class="statistics-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>操作统计</span>
|
||||
<el-button type="primary" size="small" @click="refreshStats">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20" v-if="statsData">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">总操作数</div>
|
||||
<div class="stat-value">{{ statsData.total_operations || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">活跃用户</div>
|
||||
<div class="stat-value">{{ statsData.total_users || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">查询操作</div>
|
||||
<div class="stat-value" style="color: #409eff">{{ statsData.query_operations || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">修改操作</div>
|
||||
<div class="stat-value" style="color: #e6a23c">{{ statsData.modify_operations || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 用户排行 -->
|
||||
<div v-if="topUsers && topUsers.length > 0" style="margin-top: 20px">
|
||||
<h4>用户操作排行</h4>
|
||||
<el-table :data="topUsers" stripe max-height="300">
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column prop="operation_count" label="操作次数" width="100" align="center" />
|
||||
<el-table-column prop="last_operation_time" label="最后操作" width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getTenantOperationStats } from '@/api/operationLog'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const statsData = ref(null)
|
||||
const topUsers = ref([])
|
||||
const days = ref(7)
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await getTenantOperationStats({ days: days.value })
|
||||
if (res.success) {
|
||||
statsData.value = res.data.statistics
|
||||
topUsers.value = res.data.top_users || []
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载统计数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStats = () => {
|
||||
loadStats()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div class="operation-log-container">
|
||||
<!-- 统计面板 -->
|
||||
<!-- <StatisticsPanel /> -->
|
||||
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" label-width="100px" :inline="true">
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="filters.module" placeholder="选择模块" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option
|
||||
v-for="opt in moduleOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作类型">
|
||||
<el-select v-model="filters.operation" placeholder="选择操作类型" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="新增" value="CREATE" />
|
||||
<el-option label="查询" value="READ" />
|
||||
<el-option label="修改" value="UPDATE" />
|
||||
<el-option label="删除" value="DELETE" />
|
||||
<el-option label="登录" value="LOGIN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作结果">
|
||||
<el-select v-model="filters.status" placeholder="选择操作结果" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="成功" :value="1" />
|
||||
<el-option label="失败" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="dateRange" label-width="100px" :inline="true">
|
||||
<el-form-item label="操作时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange.range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button @click="showClearDialog">清空日志</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>操作日志列表</span>
|
||||
<span class="log-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="username" label="用户" align="center"/>
|
||||
<el-table-column prop="module_name" label="模块" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.module_name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operation" label="操作类型" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getOperationTag(row.operation)">{{ row.operation }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resource_type" label="资源类型" align="center"/>
|
||||
<el-table-column prop="status" label="结果" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP地址" align="center"/>
|
||||
<el-table-column prop="create_time" label="操作时间" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click.stop="handleDetail(row.id)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page_num"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="loadLogs"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<OperationLogDetail ref="detailRef" />
|
||||
|
||||
<!-- 清空日志对话框 -->
|
||||
<el-dialog v-model="clearDialogVisible" title="清空旧日志" width="400px">
|
||||
<el-form :model="clearForm" label-width="150px">
|
||||
<el-form-item label="保留天数">
|
||||
<el-input-number
|
||||
v-model="clearForm.keep_days"
|
||||
:min="1"
|
||||
:max="365"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span style="color: #909399; font-size: 12px; margin-left: 10px">
|
||||
将删除超过 {{ clearForm.keep_days }} 天的日志
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clearDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleClearLogs">确认清空</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getOperationLogs, clearOldLogs } from '@/api/operationLog'
|
||||
import { getAllMenus } from '@/api/menu'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
// import StatisticsPanel from './components/StatisticsPanel.vue'
|
||||
import OperationLogDetail from './components/OperationLogDetail.vue'
|
||||
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const detailRef = ref(null)
|
||||
const clearDialogVisible = ref(false)
|
||||
const moduleOptions = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
page_num: 1,
|
||||
page_size: 20
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
operation: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const dateRange = reactive({
|
||||
range: null
|
||||
})
|
||||
|
||||
const clearForm = reactive({
|
||||
keep_days: 90
|
||||
})
|
||||
|
||||
const getOperationTag = (operation) => {
|
||||
const map = {
|
||||
'CREATE': 'success',
|
||||
'READ': 'info',
|
||||
'UPDATE': 'warning',
|
||||
'DELETE': 'danger',
|
||||
'LOGIN': 'primary',
|
||||
'LOGOUT': 'info'
|
||||
}
|
||||
return map[operation] || 'info'
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载系统菜单并构建模块下拉选项
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenus()
|
||||
if (res && res.success && Array.isArray(res.data)) {
|
||||
const map = new Map()
|
||||
res.data.forEach((m) => {
|
||||
const name = m.name || m.title || ''
|
||||
let code = ''
|
||||
// 优先使用 path 最后一段作为模块标识(例如 /system/dict -> dict),更贴近日志中的 module 字段
|
||||
if (m.path) {
|
||||
const parts = m.path.split('/').filter(Boolean)
|
||||
code = parts.length ? parts[parts.length - 1] : m.path
|
||||
} else if (m.permission) {
|
||||
// 次选使用 permission
|
||||
code = m.permission
|
||||
} else {
|
||||
code = String(m.id)
|
||||
}
|
||||
if (code && !map.has(code)) {
|
||||
map.set(code, name)
|
||||
}
|
||||
})
|
||||
|
||||
moduleOptions.value = Array.from(map.entries()).map(([value, label]) => ({ value, label }))
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略菜单加载错误,不影响日志页面其他功能
|
||||
console.error('加载菜单失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...pagination,
|
||||
...filters
|
||||
}
|
||||
|
||||
// 添加日期范围参数
|
||||
if (dateRange.range && dateRange.range.length === 2) {
|
||||
params.start_time = new Date(dateRange.range[0]).toISOString().split('T')[0]
|
||||
params.end_time = new Date(dateRange.range[1]).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const res = await getOperationLogs(params)
|
||||
if (res.success) {
|
||||
tableData.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page_num = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
filters.username = ''
|
||||
filters.module = ''
|
||||
filters.operation = ''
|
||||
filters.status = ''
|
||||
dateRange.range = null
|
||||
pagination.page_num = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const handleDateChange = () => {
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleRowClick = (row) => {
|
||||
handleDetail(row.id)
|
||||
}
|
||||
|
||||
const handleDetail = (id) => {
|
||||
detailRef.value?.openDetail(id)
|
||||
}
|
||||
|
||||
const showClearDialog = () => {
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
ElMessageBox.confirm(
|
||||
`将删除超过 ${clearForm.keep_days} 天的所有操作日志,此操作无法撤销!`,
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await clearOldLogs(clearForm.keep_days)
|
||||
if (res.success) {
|
||||
ElMessage.success('日志清空成功')
|
||||
clearDialogVisible.value = false
|
||||
loadLogs()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('清空日志失败')
|
||||
}
|
||||
}).catch(() => {
|
||||
// 用户取消
|
||||
})
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
ElMessage.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMenus()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operation-log-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-table__row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.el-table__row:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user