feat: 日志持久化存储到文件+管理员后台查看/清空日志页面
This commit is contained in:
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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': '系统设置'
|
||||
}
|
||||
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user