feat: API 访问日志系统(拦截器 + 统计分析 + 管理页面)
- V6: api_access_logs 表(method/path/email/IP/status/耗时/错误) - ApiLogInterceptor: 记录所有 /api/v1/** 请求,order(0) 最优先 - ApiLogController: 分页查询 + 按邮箱/路径统计(24h 窗口) - 前端 ApiLogs.vue: 筛选/统计面板/日志列表/分页 - 导航新增「访问日志」菜单项
This commit is contained in:
@@ -61,5 +61,10 @@ export default {
|
||||
add: (data) => api.post('/admin/psks', data),
|
||||
toggle: (id) => api.post(`/admin/psks/${id}/toggle`)
|
||||
},
|
||||
logs: {
|
||||
list: (params) => api.get('/admin/logs', { params }),
|
||||
emailStats: () => api.get('/admin/logs/stats/email'),
|
||||
pathStats: () => api.get('/admin/logs/stats/path'),
|
||||
},
|
||||
health: () => api.get('/health', { baseURL: '' })
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ const routes = [
|
||||
name: 'Psks',
|
||||
component: () => import('../views/Psks.vue'),
|
||||
meta: { title: 'PSK 管理' }
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'ApiLogs',
|
||||
component: () => import('../views/ApiLogs.vue'),
|
||||
meta: { title: '访问日志' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
95
web/src/views/ApiLogs.vue
Normal file
95
web/src/views/ApiLogs.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>API 访问日志</h2>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<el-card style="margin-top:16px">
|
||||
<el-form :inline="true" :model="filter" size="small">
|
||||
<el-form-item label="邮箱"><el-input v-model="filter.email" placeholder="支持模糊搜索" clearable /></el-form-item>
|
||||
<el-form-item label="路径"><el-input v-model="filter.path" placeholder="/api/v1/..." clearable /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="filter.success" clearable placeholder="全部"><el-option label="成功" :value="true" /><el-option label="失败" :value="false" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="fetchLogs">查询</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计 -->
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="12">
|
||||
<el-card header="按调用方统计(24h)">
|
||||
<el-table :data="emailStats" size="small">
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="cnt" label="总次数" width="80" />
|
||||
<el-table-column prop="ok" label="成功" width="60" />
|
||||
<el-table-column prop="err" label="失败" width="60" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card header="按接口统计(24h)">
|
||||
<el-table :data="pathStats" size="small">
|
||||
<el-table-column prop="path" label="路径" />
|
||||
<el-table-column prop="cnt" label="次数" width="80" />
|
||||
<el-table-column prop="avg_ms" label="平均耗时" width="80">
|
||||
<template #default="scope">{{ Math.round(scope.row.avg_ms) }}ms</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-card style="margin-top:16px">
|
||||
<el-table :data="logs" v-loading="loading" size="small" stripe>
|
||||
<el-table-column prop="method" label="方法" width="60" />
|
||||
<el-table-column prop="path" label="路径" min-width="160" />
|
||||
<el-table-column prop="email" label="邮箱" width="160" />
|
||||
<el-table-column prop="clientIp" label="IP" width="130" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="scope.row.success ? 'success' : 'danger'">{{ scope.row.statusCode }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="70">
|
||||
<template #default="scope">{{ scope.row.responseMs }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误" min-width="150">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.errorMessage" style="color:#f56c6c;font-size:12px">{{ scope.row.errorMessage }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="时间" width="160" />
|
||||
</el-table>
|
||||
<div style="margin-top:12px;text-align:center">
|
||||
<el-pagination small background layout="prev,next" :total="total" :page-size="filter.size" v-model:current-page="filter.page" @current-change="fetchLogs" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import api from '../api'
|
||||
|
||||
const logs = ref([]), loading = ref(false), total = ref(0)
|
||||
const emailStats = ref([]), pathStats = ref([])
|
||||
const filter = reactive({ page: 1, size: 50, email: '', path: '', success: null })
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.logs.list(filter)
|
||||
logs.value = r.data.records || []
|
||||
total.value = r.data.total || 0
|
||||
} catch { ElMessage.error('获取日志失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [e, p] = await Promise.all([api.logs.emailStats(), api.logs.pathStats()])
|
||||
emailStats.value = e.data || []
|
||||
pathStats.value = p.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchLogs(); fetchStats() })
|
||||
</script>
|
||||
@@ -34,6 +34,10 @@
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>PSK 管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>访问日志</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
|
||||
Reference in New Issue
Block a user