feat: 日志持久化存储到文件+管理员后台查看/清空日志页面

This commit is contained in:
2026-05-28 00:56:19 +08:00
parent 8319b4b7ed
commit 65965c409f
12 changed files with 473 additions and 224 deletions
+4
View File
@@ -180,4 +180,8 @@ export const adminApi = {
updatePaymentChannel: (id: number, data: any) => api.put(`/admin/payment-channels/${id}`, data),
deletePaymentChannel: (id: number) => api.delete(`/admin/payment-channels/${id}`),
togglePaymentChannel: (id: number) => api.put(`/admin/payment-channels/${id}/toggle`),
getLogs: (lines?: number) => api.get('/admin/logs', { params: { lines: lines || 500 } }),
clearLogs: () => api.delete('/admin/logs'),
getLogInfo: () => api.get('/admin/logs/info'),
}
+6 -1
View File
@@ -61,6 +61,10 @@
<el-icon><Wallet /></el-icon>
<span class="nav-text">{{ $t('admin.paymentManagement') }}</span>
</router-link>
<router-link to="/admin/logs" class="nav-link">
<el-icon><Notebook /></el-icon>
<span class="nav-text">日志管理</span>
</router-link>
<router-link to="/admin/settings" class="nav-link">
<el-icon><Setting /></el-icon>
<span class="nav-text">{{ $t('admin.systemSettings') }}</span>
@@ -160,7 +164,7 @@
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { DataAnalysis, User, Folder, Stamp, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight, Wallet } from '@element-plus/icons-vue'
import { DataAnalysis, User, Folder, Stamp, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight, Wallet, Notebook } from '@element-plus/icons-vue'
import { useUserStore } from '../store/user'
import { useCartStore } from '../store/cart'
@@ -187,6 +191,7 @@ const pageTitles: Record<string, string> = {
'/admin/articles': '资讯管理',
'/admin/banners': '轮播图管理',
'/admin/payment': '支付管理',
'/admin/logs': '日志管理',
'/admin/settings': '系统设置'
}
+1
View File
@@ -51,6 +51,7 @@ const routes = [
{ path: 'articles', name: 'AdminArticles', component: () => import('../views/admin/Articles.vue') },
{ path: 'banners', name: 'AdminBanners', component: () => import('../views/admin/Banners.vue') },
{ path: 'payment', name: 'AdminPayment', component: () => import('../views/admin/PaymentChannels.vue') },
{ path: 'logs', name: 'AdminLogs', component: () => import('../views/admin/Logs.vue') },
],
},
{
-2
View File
@@ -35,12 +35,10 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { systemApi } from '../api'
import { useUserStore } from '../store/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
+261
View File
@@ -0,0 +1,261 @@
<template>
<div class="logs-page">
<div class="page-header">
<div class="log-info">
<span v-if="logInfo" class="info-item">
<el-icon><Document /></el-icon>
{{ formatSize(logInfo.size) }}
</span>
<span v-if="logInfo" class="info-item">
<el-icon><Clock /></el-icon>
{{ formatTime(logInfo.mod_time) }}
</span>
</div>
<div class="header-actions">
<el-select v-model="lines" style="width: 120px" @change="fetchLogs">
<el-option :value="100" label="100 行" />
<el-option :value="500" label="500 行" />
<el-option :value="1000" label="1000 行" />
<el-option :value="2000" label="2000 行" />
</el-select>
<el-button @click="fetchLogs" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-popconfirm title="确定清空所有日志?" confirm-button-text="确定" cancel-button-text="取消" @confirm="clearAllLogs">
<template #reference>
<el-button type="danger" :loading="clearing">
<el-icon><Delete /></el-icon>
清空
</el-button>
</template>
</el-popconfirm>
</div>
</div>
<div class="log-card">
<div class="log-content-wrapper">
<pre ref="logContainer" class="log-content" v-html="highlightedContent"></pre>
<div v-if="loading" class="log-loading">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, Clock, Refresh, Delete, Loading } from '@element-plus/icons-vue'
import { adminApi } from '../../api'
const lines = ref(500)
const content = ref('')
const logInfo = ref<{ size: number; mod_time: string } | null>(null)
const loading = ref(false)
const clearing = ref(false)
const logContainer = ref<HTMLPreElement | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
function highlightLine(line: string): string {
const escaped = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (/\bERROR\b/i.test(line)) {
return `<span class="log-error">${escaped}</span>`
}
if (/\bWARN\b/i.test(line)) {
return `<span class="log-warn">${escaped}</span>`
}
if (/BepUsdt/i.test(line)) {
return `<span class="log-bepusdt">${escaped}</span>`
}
return escaped
}
const highlightedContent = computed(() => {
if (!content.value) return ''
return content.value.split('\n').map(highlightLine).join('\n')
})
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
function formatTime(t: string): string {
if (!t) return '-'
const d = new Date(t)
return d.toLocaleString('zh-CN')
}
async function fetchLogs() {
loading.value = true
try {
const res: any = await adminApi.getLogs(lines.value)
content.value = res.data?.content || ''
await nextTick()
scrollToBottom()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '获取日志失败')
} finally {
loading.value = false
}
}
async function fetchLogInfo() {
try {
const res: any = await adminApi.getLogInfo()
logInfo.value = res.data || null
} catch {}
}
async function clearAllLogs() {
clearing.value = true
try {
await adminApi.clearLogs()
ElMessage.success('日志已清空')
content.value = ''
await fetchLogInfo()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '清空失败')
} finally {
clearing.value = false
}
}
function scrollToBottom() {
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
}
onMounted(() => {
fetchLogs()
fetchLogInfo()
timer = setInterval(() => {
fetchLogs()
fetchLogInfo()
}, 10000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style scoped lang="scss">
.logs-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.log-info {
display: flex;
align-items: center;
gap: 16px;
}
.info-item {
display: inline-flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.log-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden;
}
.log-content-wrapper {
position: relative;
}
.log-content {
background: #1a1a2e;
color: rgba(255, 255, 255, 0.85);
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.6;
padding: 16px;
margin: 0;
max-height: calc(100vh - 200px);
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
}
.log-loading {
position: absolute;
top: 12px;
right: 12px;
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
}
.log-error {
color: #f56c6c;
}
.log-warn {
color: #e6a23c;
}
.log-bepusdt {
color: #409eff;
}
:deep(.el-select .el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: none;
&:hover,
&:focus {
border-color: #4e6ef2;
}
}
:deep(.el-select .el-input__inner) {
color: #fff;
}
</style>
+1 -1
View File
@@ -37,7 +37,7 @@ function formatDate(date: string) {
async function fetchLotteries() {
loading.value = true
const res: any = await lotteryApi.list({ page: page.value, page_size: pageSize })
const res: any = await lotteryApi.list()
lotteries.value = res.data || []
total.value = res.pagination?.total || res.data?.length || 0
loading.value = false
+1 -1
View File
@@ -74,7 +74,7 @@ const quantity = ref(1)
const productImages = computed(() => {
if (!product.value?.images) return []
return product.value.images.split(',').map((s: string) => s.trim()).filter(Boolean).map(url => getImageUrl(url))
return product.value.images.split(',').map((s: string) => s.trim()).filter(Boolean).map((url: string) => getImageUrl(url))
})
onMounted(async () => {