修复gitignore规则并添加系统日志页面

- 修复.gitignore中logs规则匹配范围过大的问题
- 添加系统日志管理页面

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zhangxiaohua 2026-01-25 14:56:25 +08:00
parent f6b292bebf
commit fe91f88350
3 changed files with 506 additions and 2 deletions

2
.gitignore vendored
View File

@ -31,7 +31,7 @@ build/
Thumbs.db
# Logs
logs/
/logs/
*.log
npm-debug.log*
yarn-debug.log*

2
frontend/.gitignore vendored
View File

@ -1,5 +1,5 @@
# Logs
logs
/logs
*.log
npm-debug.log*
yarn-debug.log*

View 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>