修复gitignore规则并添加系统日志页面
- 修复.gitignore中logs规则匹配范围过大的问题 - 添加系统日志管理页面 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f6b292bebf
commit
fe91f88350
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,7 +31,7 @@ build/
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -1,5 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
504
frontend/src/views/system/logs/Index.vue
Normal file
504
frontend/src/views/system/logs/Index.vue
Normal file
@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="mb-4">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="日志总数"
|
||||
:value="statistics.totalCount"
|
||||
:loading="statsLoading"
|
||||
>
|
||||
<template #prefix>
|
||||
<FileTextOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
:title="`近${statistics.days}天日志`"
|
||||
:value="statistics.recentCount"
|
||||
:loading="statsLoading"
|
||||
>
|
||||
<template #prefix>
|
||||
<ClockCircleOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="操作类型数"
|
||||
:value="statistics.actionStats.length"
|
||||
:loading="statsLoading"
|
||||
>
|
||||
<template #prefix>
|
||||
<TagOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="今日日志"
|
||||
:value="todayCount"
|
||||
:loading="statsLoading"
|
||||
>
|
||||
<template #prefix>
|
||||
<CalendarOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<a-card class="mb-4">
|
||||
<a-form layout="inline" :model="searchForm" class="search-form">
|
||||
<a-form-item label="关键字">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="搜索操作/内容"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="操作类型">
|
||||
<a-input
|
||||
v-model:value="searchForm.action"
|
||||
placeholder="如: GET /api/users"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="IP地址">
|
||||
<a-input
|
||||
v-model:value="searchForm.ip"
|
||||
placeholder="搜索IP"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="时间范围">
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 操作栏和表格 -->
|
||||
<a-card>
|
||||
<template #title>日志列表</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button
|
||||
v-permission="'log:delete'"
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
danger
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
批量删除 {{ selectedRowKeys.length > 0 ? `(${selectedRowKeys.length})` : '' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-permission="'log:delete'"
|
||||
type="primary"
|
||||
danger
|
||||
ghost
|
||||
@click="handleCleanOldLogs"
|
||||
>
|
||||
<template #icon><ClearOutlined /></template>
|
||||
清理过期日志
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||||
:row-key="(record) => record.id"
|
||||
@change="handleTableChange"
|
||||
:scroll="{ x: 1200 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'user'">
|
||||
<span v-if="record.user">
|
||||
{{ record.user.nickname || record.user.username }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-tag :color="getActionColor(record.action)">
|
||||
{{ record.action }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'content'">
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{ rows: 1, expandable: false }"
|
||||
:content="record.content || '-'"
|
||||
style="margin: 0; max-width: 300px"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'ip'">
|
||||
<span>{{ record.ip || '-' }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span>{{ formatDate(record.createTime) }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'operations'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 日志详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="detailModalVisible"
|
||||
title="日志详情"
|
||||
:footer="null"
|
||||
width="700px"
|
||||
>
|
||||
<a-descriptions :column="2" bordered v-if="currentLog">
|
||||
<a-descriptions-item label="日志ID">{{ currentLog.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作时间">{{ formatDate(currentLog.createTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作用户">
|
||||
{{ currentLog.user?.nickname || currentLog.user?.username || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户ID">{{ currentLog.userId || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作类型" :span="2">
|
||||
<a-tag :color="getActionColor(currentLog.action)">{{ currentLog.action }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址">{{ currentLog.ip || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="User-Agent" :span="2">
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{ rows: 2, expandable: true }"
|
||||
:content="currentLog.userAgent || '-'"
|
||||
style="margin: 0"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="操作内容" :span="2">
|
||||
<div class="log-content-wrapper">
|
||||
<pre class="log-content">{{ formatContent(currentLog.content) }}</pre>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-modal>
|
||||
|
||||
<!-- 清理过期日志弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="cleanModalVisible"
|
||||
title="清理过期日志"
|
||||
:confirm-loading="cleanLoading"
|
||||
@ok="handleConfirmClean"
|
||||
>
|
||||
<a-form :label-col="{ span: 8 }" :wrapper-col="{ span: 14 }">
|
||||
<a-form-item label="保留天数">
|
||||
<a-input-number
|
||||
v-model:value="cleanDays"
|
||||
:min="7"
|
||||
:max="365"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<span class="ml-2">天</span>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ offset: 8 }">
|
||||
<a-alert
|
||||
type="warning"
|
||||
:message="`将删除 ${cleanDays} 天前的所有日志,此操作不可恢复!`"
|
||||
show-icon
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import {
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
TagOutlined,
|
||||
CalendarOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
ClearOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
logsApi,
|
||||
type Log,
|
||||
type LogQueryParams,
|
||||
type LogStatistics,
|
||||
} from '@/api/logs'
|
||||
import { useListRequest } from '@/composables/useListRequest'
|
||||
|
||||
// 统计数据
|
||||
const statsLoading = ref(false)
|
||||
const statistics = ref<LogStatistics>({
|
||||
totalCount: 0,
|
||||
recentCount: 0,
|
||||
days: 7,
|
||||
actionStats: [],
|
||||
dailyStats: [],
|
||||
})
|
||||
|
||||
// 计算今日日志数
|
||||
const todayCount = computed(() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const todayStat = statistics.value.dailyStats.find((s) => {
|
||||
const dateStr = typeof s.date === 'string' ? s.date.split('T')[0] : new Date(s.date).toISOString().split('T')[0]
|
||||
return dateStr === today
|
||||
})
|
||||
return todayStat?.count || 0
|
||||
})
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive<LogQueryParams>({
|
||||
keyword: '',
|
||||
action: '',
|
||||
ip: '',
|
||||
})
|
||||
const dateRange = ref<[Dayjs | string, Dayjs | string] | null>(null)
|
||||
|
||||
// 选中行
|
||||
const selectedRowKeys = ref<number[]>([])
|
||||
|
||||
// 详情弹窗
|
||||
const detailModalVisible = ref(false)
|
||||
const currentLog = ref<Log | null>(null)
|
||||
|
||||
// 清理弹窗
|
||||
const cleanModalVisible = ref(false)
|
||||
const cleanLoading = ref(false)
|
||||
const cleanDays = ref(90)
|
||||
|
||||
// 使用列表请求组合函数
|
||||
const {
|
||||
loading,
|
||||
dataSource,
|
||||
pagination,
|
||||
handleTableChange,
|
||||
refresh: refreshList,
|
||||
search,
|
||||
resetSearch,
|
||||
} = useListRequest<Log>({
|
||||
requestFn: (params) => logsApi.getList(params),
|
||||
errorMessage: '获取日志列表失败',
|
||||
defaultPageSize: 20,
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns: TableColumnsType = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '操作用户', dataIndex: 'user', key: 'user', width: 120 },
|
||||
{ title: '操作类型', dataIndex: 'action', key: 'action', width: 200 },
|
||||
{ title: '操作内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: '操作时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
|
||||
{ title: '操作', key: 'operations', width: 80, fixed: 'right' },
|
||||
]
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化内容
|
||||
const formatContent = (content?: string) => {
|
||||
if (!content) return '-'
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// 根据操作类型返回颜色
|
||||
const getActionColor = (action: string) => {
|
||||
if (action.startsWith('GET')) return 'blue'
|
||||
if (action.startsWith('POST')) return 'green'
|
||||
if (action.startsWith('PUT') || action.startsWith('PATCH')) return 'orange'
|
||||
if (action.startsWith('DELETE')) return 'red'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const fetchStatistics = async () => {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
statistics.value = await logsApi.getStatistics(7)
|
||||
} catch (error) {
|
||||
console.error('获取日志统计失败:', error)
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
const params: LogQueryParams = { ...searchForm }
|
||||
if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
|
||||
params.startTime = typeof dateRange.value[0] === 'string' ? dateRange.value[0] : dateRange.value[0].format('YYYY-MM-DD')
|
||||
params.endTime = typeof dateRange.value[1] === 'string' ? dateRange.value[1] : dateRange.value[1].format('YYYY-MM-DD')
|
||||
}
|
||||
search(params)
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.action = ''
|
||||
searchForm.ip = ''
|
||||
dateRange.value = null
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
// 选择行变化
|
||||
const onSelectChange = (keys: number[]) => {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: Log) => {
|
||||
currentLog.value = record
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请选择要删除的日志')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条日志吗?此操作不可恢复。`,
|
||||
okText: '确定',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await logsApi.delete(selectedRowKeys.value)
|
||||
message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
refreshList()
|
||||
fetchStatistics()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 清理过期日志
|
||||
const handleCleanOldLogs = () => {
|
||||
cleanDays.value = 90
|
||||
cleanModalVisible.value = true
|
||||
}
|
||||
|
||||
// 确认清理
|
||||
const handleConfirmClean = async () => {
|
||||
cleanLoading.value = true
|
||||
try {
|
||||
const result = await logsApi.clean(cleanDays.value)
|
||||
message.success(`成功清理 ${result.deleted} 条过期日志`)
|
||||
cleanModalVisible.value = false
|
||||
refreshList()
|
||||
fetchStatistics()
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '清理失败')
|
||||
} finally {
|
||||
cleanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-content-wrapper {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user