修复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
|
Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
/logs/
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -1,5 +1,5 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
/logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-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