mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 10:17:17 +08:00
feat: 添加操作日志记录功能和前端日志查看页面 (#1227)
* Initial plan * feat: 添加操作日志记录功能和前端日志查看页面 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: 修复 HttpLog 中 IP 字符串处理的类型安全问题 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: lint * feat: 完善面板内日志记录,添加网站/数据库/项目/计划任务/备份CRUD日志 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 完善更多模块的日志记录(证书/SSH/Webhook/用户/设置/安全) Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: 改进cert_dns删除方法,在删除前获取实体信息用于日志记录 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: lint * feat: 从session获取操作员ID并在前端显示用户名 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: lint --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
7
web/src/api/panel/log/index.ts
Normal file
7
web/src/api/panel/log/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { http } from '@/utils'
|
||||
|
||||
export default {
|
||||
// 获取日志列表
|
||||
list: (type: 'app' | 'db' | 'http', limit: number = 100): any =>
|
||||
http.Get('/log/list', { params: { type, limit } })
|
||||
}
|
||||
121
web/src/views/log/DatabaseLog.vue
Normal file
121
web/src/views/log/DatabaseLog.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'database-log'
|
||||
})
|
||||
|
||||
import { NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import log from '@/api/panel/log'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 日志条目类型定义
|
||||
interface LogEntry {
|
||||
time: string
|
||||
level: string
|
||||
msg: string
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
// 数据加载
|
||||
const limit = ref(200)
|
||||
const { loading, data, send: refresh } = useRequest(
|
||||
() => log.list('db', limit.value),
|
||||
{ initialData: [] }
|
||||
)
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: $gettext('Time'),
|
||||
key: 'time',
|
||||
width: 180,
|
||||
render: (row: LogEntry) => {
|
||||
const date = new Date(row.time)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Level'),
|
||||
key: 'level',
|
||||
width: 80,
|
||||
render: (row: LogEntry) => {
|
||||
const typeMap: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
|
||||
INFO: 'success',
|
||||
WARN: 'warning',
|
||||
ERROR: 'error',
|
||||
DEBUG: 'info'
|
||||
}
|
||||
return h(NTag, { type: typeMap[row.level] || 'default', size: 'small' }, () => row.level)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Query'),
|
||||
key: 'query',
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
},
|
||||
render: (row: LogEntry) => {
|
||||
return row.extra?.query || row.msg || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Duration'),
|
||||
key: 'duration',
|
||||
width: 120,
|
||||
render: (row: LogEntry) => {
|
||||
if (row.extra?.duration) {
|
||||
// 纳秒转毫秒
|
||||
const ms = Number(row.extra.duration) / 1000000
|
||||
return `${ms.toFixed(2)} ms`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Rows'),
|
||||
key: 'rows',
|
||||
width: 80,
|
||||
render: (row: LogEntry) => {
|
||||
return row.extra?.rows !== undefined ? row.extra.rows : '-'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-4 flex gap-4 items-center">
|
||||
<span>{{ $gettext('Show entries') }}:</span>
|
||||
<n-select
|
||||
v-model:value="limit"
|
||||
:options="[
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '200', value: 200 },
|
||||
{ label: '500', value: 500 },
|
||||
{ label: '1000', value: 1000 }
|
||||
]"
|
||||
class="w-100px"
|
||||
@update:value="handleRefresh"
|
||||
/>
|
||||
<n-button type="primary" @click="handleRefresh">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:max-height="600"
|
||||
:scroll-x="800"
|
||||
virtual-scroll
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
146
web/src/views/log/HttpLog.vue
Normal file
146
web/src/views/log/HttpLog.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'http-log'
|
||||
})
|
||||
|
||||
import { NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import log from '@/api/panel/log'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 日志条目类型定义
|
||||
interface LogEntry {
|
||||
time: string
|
||||
level: string
|
||||
msg: string
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
// 数据加载
|
||||
const limit = ref(200)
|
||||
const { loading, data, send: refresh } = useRequest(
|
||||
() => log.list('http', limit.value),
|
||||
{ initialData: [] }
|
||||
)
|
||||
|
||||
// 获取状态码颜色
|
||||
const getStatusType = (code: number): 'success' | 'warning' | 'error' | 'info' => {
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 300 && code < 400) return 'info'
|
||||
if (code >= 400 && code < 500) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: $gettext('Time'),
|
||||
key: 'time',
|
||||
width: 180,
|
||||
render: (row: LogEntry) => {
|
||||
const date = new Date(row.time)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Method'),
|
||||
key: 'method',
|
||||
width: 80,
|
||||
render: (row: LogEntry) => {
|
||||
const method = row.extra?.['http.request.method'] || '-'
|
||||
const colorMap: Record<string, string> = {
|
||||
GET: '#52c41a',
|
||||
POST: '#1890ff',
|
||||
PUT: '#faad14',
|
||||
DELETE: '#ff4d4f',
|
||||
PATCH: '#722ed1'
|
||||
}
|
||||
return h('span', { style: { color: colorMap[method] || 'inherit', fontWeight: 'bold' } }, method)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Path'),
|
||||
key: 'path',
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
},
|
||||
render: (row: LogEntry) => {
|
||||
return row.extra?.['url.path'] || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Status'),
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (row: LogEntry) => {
|
||||
const status = row.extra?.['http.response.status_code']
|
||||
if (status) {
|
||||
return h(NTag, { type: getStatusType(status), size: 'small' }, () => status)
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Duration'),
|
||||
key: 'duration',
|
||||
width: 120,
|
||||
render: (row: LogEntry) => {
|
||||
const duration = row.extra?.['event.duration']
|
||||
if (duration) {
|
||||
// 纳秒转毫秒
|
||||
const ms = Number(duration) / 1000000
|
||||
return `${ms.toFixed(2)} ms`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Client IP'),
|
||||
key: 'client_ip',
|
||||
width: 150,
|
||||
render: (row: LogEntry) => {
|
||||
const ip = row.extra?.['client.ip'] || '-'
|
||||
// 移除端口号
|
||||
return typeof ip === 'string' && ip.includes(':') ? ip.split(':')[0] : ip
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-4 flex gap-4 items-center">
|
||||
<span>{{ $gettext('Show entries') }}:</span>
|
||||
<n-select
|
||||
v-model:value="limit"
|
||||
:options="[
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '200', value: 200 },
|
||||
{ label: '500', value: 500 },
|
||||
{ label: '1000', value: 1000 }
|
||||
]"
|
||||
class="w-100px"
|
||||
@update:value="handleRefresh"
|
||||
/>
|
||||
<n-button type="primary" @click="handleRefresh">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:max-height="600"
|
||||
:scroll-x="800"
|
||||
virtual-scroll
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
34
web/src/views/log/IndexView.vue
Normal file
34
web/src/views/log/IndexView.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'log-index'
|
||||
})
|
||||
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import DatabaseLog from './DatabaseLog.vue'
|
||||
import HttpLog from './HttpLog.vue'
|
||||
import OperationLog from './OperationLog.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 当前激活的标签
|
||||
const activeTab = ref('operation')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-header show-footer>
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="operation" :tab="$gettext('Operation Log')">
|
||||
<OperationLog />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="database" :tab="$gettext('Database Log')">
|
||||
<DatabaseLog />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="http" :tab="$gettext('HTTP Log')">
|
||||
<HttpLog />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</common-page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
119
web/src/views/log/OperationLog.vue
Normal file
119
web/src/views/log/OperationLog.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'operation-log'
|
||||
})
|
||||
|
||||
import { NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import log from '@/api/panel/log'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 日志条目类型定义
|
||||
interface LogEntry {
|
||||
time: string
|
||||
level: string
|
||||
msg: string
|
||||
type?: string
|
||||
operator_id?: number
|
||||
operator_name?: string
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
// 数据加载
|
||||
const limit = ref(200)
|
||||
const { loading, data, send: refresh } = useRequest(
|
||||
() => log.list('app', limit.value),
|
||||
{ initialData: [] }
|
||||
)
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: $gettext('Time'),
|
||||
key: 'time',
|
||||
width: 180,
|
||||
render: (row: LogEntry) => {
|
||||
const date = new Date(row.time)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Level'),
|
||||
key: 'level',
|
||||
width: 80,
|
||||
render: (row: LogEntry) => {
|
||||
const typeMap: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
|
||||
INFO: 'success',
|
||||
WARN: 'warning',
|
||||
ERROR: 'error',
|
||||
DEBUG: 'info'
|
||||
}
|
||||
return h(NTag, { type: typeMap[row.level] || 'default', size: 'small' }, () => row.level)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Type'),
|
||||
key: 'type',
|
||||
width: 120,
|
||||
render: (row: LogEntry) => {
|
||||
return row.type || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Operator'),
|
||||
key: 'operator_name',
|
||||
width: 120,
|
||||
render: (row: LogEntry) => {
|
||||
if (row.operator_id === 0 || row.operator_id === undefined) {
|
||||
return $gettext('System')
|
||||
}
|
||||
return row.operator_name || `#${row.operator_id}`
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Message'),
|
||||
key: 'msg',
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-4 flex gap-4 items-center">
|
||||
<span>{{ $gettext('Show entries') }}:</span>
|
||||
<n-select
|
||||
v-model:value="limit"
|
||||
:options="[
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '200', value: 200 },
|
||||
{ label: '500', value: 500 },
|
||||
{ label: '1000', value: 1000 }
|
||||
]"
|
||||
class="w-100px"
|
||||
@update:value="handleRefresh"
|
||||
/>
|
||||
<n-button type="primary" @click="handleRefresh">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:max-height="600"
|
||||
:scroll-x="800"
|
||||
virtual-scroll
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
25
web/src/views/log/route.ts
Normal file
25
web/src/views/log/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { RouteType } from '~/types/router'
|
||||
|
||||
const Layout = () => import('@/layout/IndexView.vue')
|
||||
|
||||
export default {
|
||||
name: 'log',
|
||||
path: '/log',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 35
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'log-index',
|
||||
path: '',
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: 'Logs',
|
||||
icon: 'mdi:file-document-outline',
|
||||
role: ['admin'],
|
||||
requireAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
} as RouteType
|
||||
Reference in New Issue
Block a user