diff --git a/client/src/api/logs.ts b/client/src/api/logs.ts new file mode 100644 index 0000000..cdcbe2d --- /dev/null +++ b/client/src/api/logs.ts @@ -0,0 +1,46 @@ +import { request } from '@/utils/request' + +/** 日志文件信息 */ +export interface LogFileInfo { + name: string + date: string + size: number + modified: string +} + +/** 日志统计 */ +export interface LogStats { + today: { + total: number + errors: number + slow: number + warns: number + } + daily: Array<{ + date: string + total: number + errors: number + slow: number + }> +} + +/** 获取日志文件列表 */ +export function getLogFiles(): Promise { + return request({ url: '/logs/files' }) +} + +/** 获取日志统计 */ +export function getLogStats(): Promise { + return request({ url: '/logs/stats' }) +} + +/** 查询日志 */ +export function queryLogs(params: { + date: string + level?: string + keyword?: string + page?: number + pageSize?: number +}): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> { + return request({ url: `/logs/${params.date}`, data: { ...params, date: undefined } }) +} diff --git a/client/src/pages.json b/client/src/pages.json index c6ec7cb..814c8c8 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -131,6 +131,14 @@ "navigationBarTitleText": "系统配置", "enablePullDownRefresh": true } + }, + { + "path": "pages/admin/logs", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "系统日志", + "enablePullDownRefresh": true + } } ], "globalStyle": { diff --git a/client/src/pages/admin/index.vue b/client/src/pages/admin/index.vue index 4e2690b..cf06f88 100644 --- a/client/src/pages/admin/index.vue +++ b/client/src/pages/admin/index.vue @@ -82,6 +82,11 @@ 系统配置 + + + 系统日志 + + @@ -147,6 +152,7 @@ function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) } function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) } function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) } function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) } +function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) } diff --git a/server/src/index.ts b/server/src/index.ts index 69fb9fa..da92fa2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,7 @@ import adminRoutes from './routes/admin' import feedbackRoutes from './routes/feedback' import configRoutes from './routes/config' import trackRoutes from './routes/track' +import logsRoutes from './routes/logs' import { backupDatabase } from './utils/backup' // Warn if token secret is using default fallback @@ -159,6 +160,7 @@ app.use('/api/admin', apiLimiter, adminRoutes) app.use('/api/feedback', apiLimiter, feedbackRoutes) app.use('/api/config', apiLimiter, configRoutes) app.use('/api/track', trackRoutes) +app.use('/api/logs', apiLimiter, logsRoutes) app.get('/api/health', async (_req, res) => { try { diff --git a/server/src/routes/logs.ts b/server/src/routes/logs.ts new file mode 100644 index 0000000..5a95414 --- /dev/null +++ b/server/src/routes/logs.ts @@ -0,0 +1,145 @@ +import { Router } from 'express' +import fs from 'fs' +import path from 'path' +import { requireAdmin } from '../middleware/requireAdmin' + +const router = Router() +const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs') + +/** 获取日志列表(最近7天) */ +router.get('/files', requireAdmin, (_req, res) => { + try { + if (!fs.existsSync(LOG_DIR)) { + return res.json({ code: 0, data: [] }) + } + + const files = fs.readdirSync(LOG_DIR) + .filter(f => f.endsWith('.log')) + .sort() + .reverse() + .slice(0, 7) // 最多返回7天 + + const fileList = files.map(f => { + const filePath = path.join(LOG_DIR, f) + const stat = fs.statSync(filePath) + return { + name: f, + date: f.replace('.log', ''), + size: stat.size, + modified: stat.mtime.toISOString() + } + }) + + res.json({ code: 0, data: fileList }) + } catch (err) { + console.error('[Logs] List error:', err) + res.status(500).json({ code: 50000, message: '获取日志列表失败' }) + } +}) + +/** 获取日志统计 */ +router.get('/stats', requireAdmin, (_req, res) => { + try { + if (!fs.existsSync(LOG_DIR)) { + return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } }) + } + + const today = new Date() + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + const todayFile = path.join(LOG_DIR, `${dateStr}.log`) + + let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 } + + if (fs.existsSync(todayFile)) { + const content = fs.readFileSync(todayFile, 'utf-8') + const lines = content.split('\n').filter(l => l.trim()) + todayStats.total = lines.length + todayStats.errors = lines.filter(l => l.includes(' ERROR ')).length + todayStats.slow = lines.filter(l => l.includes('Slow request')).length + todayStats.warns = lines.filter(l => l.includes(' WARN ')).length + } + + // 最近7天统计 + const files = fs.readdirSync(LOG_DIR) + .filter(f => f.endsWith('.log')) + .sort() + .reverse() + .slice(0, 7) + + const dailyStats = files.map(f => { + const filePath = path.join(LOG_DIR, f) + const content = fs.readFileSync(filePath, 'utf-8') + const lines = content.split('\n').filter(l => l.trim()) + return { + date: f.replace('.log', ''), + total: lines.length, + errors: lines.filter(l => l.includes(' ERROR ')).length, + slow: lines.filter(l => l.includes('Slow request')).length + } + }) + + res.json({ + code: 0, + data: { + today: todayStats, + daily: dailyStats + } + }) + } catch (err) { + console.error('[Logs] Stats error:', err) + res.status(500).json({ code: 50000, message: '获取日志统计失败' }) + } +}) + +/** 查询指定日期的日志 */ +router.get('/:date', requireAdmin, (req, res) => { + try { + const { date } = req.params + const { level, keyword, page = '1', pageSize = '100' } = req.query + + // 日期格式校验 + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return res.status(400).json({ code: 40001, message: '日期格式无效' }) + } + + const logFile = path.join(LOG_DIR, `${date}.log`) + if (!fs.existsSync(logFile)) { + return res.json({ code: 0, data: { lines: [], total: 0 } }) + } + + const content = fs.readFileSync(logFile, 'utf-8') + let lines = content.split('\n').filter(l => l.trim()) + + // 按级别过滤 + if (level && level !== 'all') { + lines = lines.filter(l => l.includes(` ${level.toUpperCase()} `)) + } + + // 按关键词过滤 + if (keyword && typeof keyword === 'string') { + const kw = keyword.toLowerCase() + lines = lines.filter(l => l.toLowerCase().includes(kw)) + } + + const total = lines.length + const pNum = Math.max(1, parseInt(page as string) || 1) + const pSize = Math.min(500, Math.max(1, parseInt(pageSize as string) || 100)) + const offset = (pNum - 1) * pSize + const paged = lines.slice(offset, offset + pSize) + + res.json({ + code: 0, + data: { + lines: paged, + total, + page: pNum, + pageSize: pSize + } + }) + } catch (err) { + console.error('[Logs] Query error:', err) + res.status(500).json({ code: 50000, message: '查询日志失败' }) + } +}) + +export default router