feat: system logs - email log + system log with admin UI
- db: email_logs (sent/failed) + system_logs (info/warn/error) tables, auto-migrated for existing installs - backend/logger.js: logEmail/logSystem with try/catch (never breaks flow) - mailer: log send result (SMTP unconfigured/sent/failed + error msg) - webhook: log blocked-internal, non-2xx response, send failure - server: error middleware records 500 errors to system_logs - routes/logs.js: GET /logs/emails + /logs/system (admin/owner, filters) - UI: 系统日志 page (owner/admin) with system/email tabs + filters - API docs updated
This commit is contained in:
@@ -288,6 +288,30 @@ async function initSchema(connection) {
|
|||||||
INDEX idx_created (created_at)
|
INDEX idx_created (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('email_logs', `CREATE TABLE email_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
to_email VARCHAR(100) NOT NULL,
|
||||||
|
template_code VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
subject VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
status ENUM('sent','failed') NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_email (to_email),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('system_logs', `CREATE TABLE system_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
level ENUM('info','warn','error') NOT NULL DEFAULT 'info',
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_level (level),
|
||||||
|
INDEX idx_source (source),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
await seedTemplates(db);
|
await seedTemplates(db);
|
||||||
await migrateAdditions(db);
|
await migrateAdditions(db);
|
||||||
}
|
}
|
||||||
@@ -318,6 +342,32 @@ async function migrateAdditions(db) {
|
|||||||
SELECT id, source, game_name, game_uid FROM users WHERE game_name != ''`);
|
SELECT id, source, game_name, game_uid FROM users WHERE game_name != ''`);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// 日志表(邮件日志 + 系统日志)
|
||||||
|
try {
|
||||||
|
await db.execute(`CREATE TABLE IF NOT EXISTS email_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
to_email VARCHAR(100) NOT NULL,
|
||||||
|
template_code VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
subject VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
status ENUM('sent','failed') NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_email (to_email),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
await db.execute(`CREATE TABLE IF NOT EXISTS system_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
level ENUM('info','warn','error') NOT NULL DEFAULT 'info',
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_level (level),
|
||||||
|
INDEX idx_source (source),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS polls (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, options JSON NOT NULL, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', start_time DATETIME, end_time DATETIME, active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active), INDEX idx_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
try { await db.execute(`CREATE TABLE IF NOT EXISTS polls (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, options JSON NOT NULL, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', start_time DATETIME, end_time DATETIME, active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active), INDEX idx_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS poll_votes (id INT AUTO_INCREMENT PRIMARY KEY, poll_id INT NOT NULL, user_id INT NOT NULL, option_index INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_poll_user (poll_id, user_id), INDEX idx_poll (poll_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
try { await db.execute(`CREATE TABLE IF NOT EXISTS poll_votes (id INT AUTO_INCREMENT PRIMARY KEY, poll_id INT NOT NULL, user_id INT NOT NULL, option_index INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_poll_user (poll_id, user_id), INDEX idx_poll (poll_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_items (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_items (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||||
|
|||||||
28
backend/logger.js
Normal file
28
backend/logger.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// 日志模块: 邮件日志 + 系统日志, 写入数据库供后台查看
|
||||||
|
// 所有写库操作 try/catch 包裹, 日志失败绝不影响主流程
|
||||||
|
const { query } = require('./db');
|
||||||
|
|
||||||
|
// 系统日志: level = info | warn | error
|
||||||
|
async function logSystem(level, source, message, details) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO system_logs(level, source, message, details) VALUES (?,?,?,?)',
|
||||||
|
[level || 'info', String(source || '').slice(0, 50), String(message || '').slice(0, 2000),
|
||||||
|
details ? JSON.stringify(details).slice(0, 4000) : null]
|
||||||
|
);
|
||||||
|
} catch { /* 日志失败忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮件日志: status = sent | failed
|
||||||
|
async function logEmail(to, templateCode, subject, status, error) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO email_logs(to_email, template_code, subject, status, error) VALUES (?,?,?,?,?)',
|
||||||
|
[String(to || '').slice(0, 100), String(templateCode || '').slice(0, 50),
|
||||||
|
String(subject || '').slice(0, 255), status === 'sent' ? 'sent' : 'failed',
|
||||||
|
error ? String(error).slice(0, 2000) : null]
|
||||||
|
);
|
||||||
|
} catch { /* 日志失败忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { logSystem, logEmail };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { getPool, query, getRow, getConfig } = require('./db');
|
const { getPool, query, getRow, getConfig } = require('./db');
|
||||||
|
const { logEmail, logSystem } = require('./logger');
|
||||||
|
|
||||||
async function getSmtpConfig() {
|
async function getSmtpConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -56,16 +57,21 @@ function getTransporter(cfg) {
|
|||||||
async function sendEmail(to, templateCode, vars) {
|
async function sendEmail(to, templateCode, vars) {
|
||||||
try {
|
try {
|
||||||
const cfg = await getSmtpConfig();
|
const cfg = await getSmtpConfig();
|
||||||
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; }
|
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); await logSystem('warn', 'mailer', 'SMTP未配置,邮件未发送', { to, template: templateCode }); return false; }
|
||||||
const template = await getTemplate(templateCode);
|
const template = await getTemplate(templateCode);
|
||||||
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; }
|
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); await logEmail(to, templateCode, '', 'failed', '模板不存在: ' + templateCode); return false; }
|
||||||
const siteName = await getSiteName();
|
const siteName = await getSiteName();
|
||||||
const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() };
|
const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() };
|
||||||
const { subject, body } = renderTemplate(template, allVars);
|
const { subject, body } = renderTemplate(template, allVars);
|
||||||
const transporter = getTransporter(cfg);
|
const transporter = getTransporter(cfg);
|
||||||
await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body });
|
await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body });
|
||||||
|
await logEmail(to, templateCode, subject, 'sent');
|
||||||
return true;
|
return true;
|
||||||
} catch (err) { console.error('[Mailer]', err.message); return false; }
|
} catch (err) {
|
||||||
|
console.error('[Mailer]', err.message);
|
||||||
|
await logEmail(to, templateCode, '', 'failed', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sendEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };
|
module.exports = { sendEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };
|
||||||
|
|||||||
37
backend/routes/logs.js
Normal file
37
backend/routes/logs.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query } = require('../db');
|
||||||
|
const { authenticate, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ---- 邮件日志(admin/owner 可查) ----
|
||||||
|
router.get('/emails', authenticate, requireRole('owner', 'admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, email, limit } = req.query;
|
||||||
|
let q = 'SELECT * FROM email_logs WHERE 1=1';
|
||||||
|
const p = [];
|
||||||
|
if (status === 'sent' || status === 'failed') { q += ' AND status = ?'; p.push(status); }
|
||||||
|
if (email) { q += ' AND to_email LIKE ?'; p.push(`%${email}%`); }
|
||||||
|
q += ' ORDER BY id DESC LIMIT ?';
|
||||||
|
p.push(Math.min(parseInt(limit) || 100, 500));
|
||||||
|
const rows = await query(q, p);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 系统日志(admin/owner 可查) ----
|
||||||
|
router.get('/system', authenticate, requireRole('owner', 'admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { level, source, limit } = req.query;
|
||||||
|
let q = 'SELECT * FROM system_logs WHERE 1=1';
|
||||||
|
const p = [];
|
||||||
|
if (['info', 'warn', 'error'].includes(level)) { q += ' AND level = ?'; p.push(level); }
|
||||||
|
if (source) { q += ' AND source LIKE ?'; p.push(`%${source}%`); }
|
||||||
|
q += ' ORDER BY id DESC LIMIT ?';
|
||||||
|
p.push(Math.min(parseInt(limit) || 100, 500));
|
||||||
|
const rows = await query(q, p);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -128,6 +128,7 @@ function loadBusinessRoutes() {
|
|||||||
businessRouter.use('/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls'));
|
businessRouter.use('/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls'));
|
||||||
businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features'));
|
businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features'));
|
||||||
businessRouter.use('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans'));
|
businessRouter.use('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans'));
|
||||||
|
businessRouter.use('/logs', methodGuard(['GET']), generalLimiter, require('./routes/logs'));
|
||||||
businessRouter.use('/external', require('./routes/external'));
|
businessRouter.use('/external', require('./routes/external'));
|
||||||
|
|
||||||
businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
||||||
@@ -197,6 +198,7 @@ app.use((err, req, res, next) => {
|
|||||||
if (err.message?.includes('不支持的文件类型')) return res.status(400).json({ error: err.message });
|
if (err.message?.includes('不支持的文件类型')) return res.status(400).json({ error: err.message });
|
||||||
if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' });
|
if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' });
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
try { require('./logger').logSystem('error', 'server', err.message || '服务器内部错误', { path: req.path, method: req.method, stack: String(err.stack).slice(0, 1000) }); } catch {}
|
||||||
res.status(500).json({ error: '服务器内部错误' });
|
res.status(500).json({ error: '服务器内部错误' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { query, getRow } = require('./db');
|
const { query, getRow } = require('./db');
|
||||||
|
const { logSystem } = require('./logger');
|
||||||
|
|
||||||
const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' };
|
const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' };
|
||||||
|
|
||||||
@@ -24,14 +25,20 @@ async function sendWebhook(event, data) {
|
|||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
const events = cfg.events || 'all';
|
const events = cfg.events || 'all';
|
||||||
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue;
|
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue;
|
||||||
if (cfg.webhook_url && await isPrivateUrl(cfg.webhook_url)) { console.error('[Webhook] blocked internal URL:', cfg.name); continue; }
|
if (cfg.webhook_url && await isPrivateUrl(cfg.webhook_url)) { console.error('[Webhook] blocked internal URL:', cfg.name); await logSystem('warn', 'webhook', `拦截内网地址: ${cfg.name}`, { url: cfg.webhook_url }); continue; }
|
||||||
const embed = buildEmbed(event, data);
|
const embed = buildEmbed(event, data);
|
||||||
try {
|
try {
|
||||||
const ctrl = new AbortController();
|
const ctl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), 10000);
|
const t = setTimeout(() => ctl.abort(), 10000);
|
||||||
await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctrl.signal });
|
const resp = await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctl.signal });
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
} catch (e) { console.error('[Webhook]', cfg.name, e.message); }
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
|
await logSystem('warn', 'webhook', `Webhook ${cfg.name} 返回 ${resp.status}`, { event, url: cfg.webhook_url });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Webhook]', cfg.name, e.message);
|
||||||
|
await logSystem('error', 'webhook', `Webhook ${cfg.name} 发送失败: ${e.message}`, { event, url: cfg.webhook_url });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
13
docs/API.md
13
docs/API.md
@@ -301,6 +301,19 @@ Base URL: `http://<host>:3100/api`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 九·五、日志 Logs *(admin/owner)*
|
||||||
|
|
||||||
|
系统自动记录邮件发送结果、Webhook 调用、服务器错误,后台「系统日志」页可查,方便排查。
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| GET /logs/emails?status=sent\|failed&email=&limit= | 邮件日志(收件人/模板/主题/状态/错误) |
|
||||||
|
| GET /logs/system?level=info\|warn\|error&source=&limit= | 系统日志(级别/来源/消息/详情) |
|
||||||
|
|
||||||
|
记录来源:mailer(SMTP 未配置/发送成功/失败)、webhook(拦截内网/非2xx/发送失败)、server(500 错误中间件)。日志写库失败不影响主流程,表自动迁移创建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 十、验证码与文件
|
## 十、验证码与文件
|
||||||
|
|
||||||
| 端点 | 说明 |
|
| 端点 | 说明 |
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
<script src="js/pages/polls.js"></script>
|
<script src="js/pages/polls.js"></script>
|
||||||
<script src="js/pages/servers.js"></script>
|
<script src="js/pages/servers.js"></script>
|
||||||
<script src="js/pages/bans.js"></script>
|
<script src="js/pages/bans.js"></script>
|
||||||
|
<script src="js/pages/logs.js"></script>
|
||||||
<script src="js/pages/features.js"></script>
|
<script src="js/pages/features.js"></script>
|
||||||
<script src="js/pages/feature-detail.js"></script>
|
<script src="js/pages/feature-detail.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const App = {
|
|||||||
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
|
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
|
||||||
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
|
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
|
||||||
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
|
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
|
||||||
|
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
|
||||||
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
|
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ const App = {
|
|||||||
case 'feature': this.renderMain('更新详情', FeatureDetail, param); break;
|
case 'feature': this.renderMain('更新详情', FeatureDetail, param); break;
|
||||||
case 'servers': this.renderMain('服务器管理', ServersPage, param); break;
|
case 'servers': this.renderMain('服务器管理', ServersPage, param); break;
|
||||||
case 'bans': this.renderMain('封禁列表', BansPage, param); break;
|
case 'bans': this.renderMain('封禁列表', BansPage, param); break;
|
||||||
|
case 'logs': this.renderMain('系统日志', LogsPage, param); break;
|
||||||
default: this.renderMain('控制台', Dashboard, param);
|
default: this.renderMain('控制台', Dashboard, param);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -178,6 +180,7 @@ window.FeaturesPage = FeaturesPage;
|
|||||||
window.FeatureDetail = FeatureDetail;
|
window.FeatureDetail = FeatureDetail;
|
||||||
window.ServersPage = ServersPage;
|
window.ServersPage = ServersPage;
|
||||||
window.BansPage = BansPage;
|
window.BansPage = BansPage;
|
||||||
|
window.LogsPage = LogsPage;
|
||||||
window.LoginPage = LoginPage;
|
window.LoginPage = LoginPage;
|
||||||
window.RegisterPage = RegisterPage;
|
window.RegisterPage = RegisterPage;
|
||||||
window.HomePage = HomePage;
|
window.HomePage = HomePage;
|
||||||
|
|||||||
86
public/js/pages/logs.js
Normal file
86
public/js/pages/logs.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const LogsPage = {
|
||||||
|
tab: 'system',
|
||||||
|
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '系统日志';
|
||||||
|
document.getElementById('page-actions').innerHTML = `<button class="btn btn-o btn-sm" onclick="LogsPage.refresh()"><i class="fas fa-rotate"></i> 刷新</button>`;
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="tab-nav" id="logs-tabs">
|
||||||
|
<button data-tab="system" class="active"><i class="fas fa-server"></i> 系统日志</button>
|
||||||
|
<button data-tab="email"><i class="fas fa-envelope"></i> 邮件日志</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<select id="lg-level" onchange="LogsPage.applyFilter()">
|
||||||
|
<option value="">全部级别</option><option value="info">信息</option><option value="warn">警告</option><option value="error">错误</option>
|
||||||
|
</select>
|
||||||
|
<input id="lg-source" placeholder="来源关键字" onkeyup="if(event.key==='Enter')LogsPage.applyFilter()">
|
||||||
|
<button class="btn btn-o btn-sm" onclick="LogsPage.applyFilter()"><i class="fas fa-filter"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="logs-list"></div>
|
||||||
|
`;
|
||||||
|
document.querySelectorAll('#logs-tabs button').forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
document.querySelectorAll('#logs-tabs button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
this.tab = b.dataset.tab;
|
||||||
|
this.load();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh() { this.load(); },
|
||||||
|
|
||||||
|
async applyFilter() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const list = document.getElementById('logs-list');
|
||||||
|
U.loading(list);
|
||||||
|
const level = document.getElementById('lg-level')?.value || '';
|
||||||
|
const src = document.getElementById('lg-source')?.value.trim() || '';
|
||||||
|
const isEmail = this.tab === 'email';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (level) params.set('level', level);
|
||||||
|
if (src) params.set(isEmail ? 'email' : 'source', src);
|
||||||
|
const qs = params.toString();
|
||||||
|
const rows = await API.get('/logs/' + (isEmail ? 'emails' : 'system') + (qs ? '?' + qs : ''));
|
||||||
|
this.renderList(list, rows, isEmail);
|
||||||
|
} catch (e) { list.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(ct, rows, isEmail) {
|
||||||
|
if (isEmail) {
|
||||||
|
ct.innerHTML = rows.length === 0
|
||||||
|
? '<div class="empty"><i class="fas fa-envelope-open"></i><p>暂无邮件日志</p></div>'
|
||||||
|
: `<div class="card table-wrap"><table><thead><tr><th>#</th><th>收件人</th><th>模板</th><th>主题</th><th>状态</th><th>错误</th><th>时间</th></tr></thead><tbody>${rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td>${r.id}</td>
|
||||||
|
<td>${U.esc(r.to_email)}</td>
|
||||||
|
<td><span class="badge bg-admin">${U.esc(r.template_code||'-')}</span></td>
|
||||||
|
<td class="ts">${U.esc(r.subject||'')}</td>
|
||||||
|
<td>${r.status==='sent'?'<span class="badge bg-resolved">成功</span>':'<span class="badge bg-rejected">失败</span>'}</td>
|
||||||
|
<td class="ts" style="max-width:260px;word-break:break-all">${U.esc(r.error||'-')}</td>
|
||||||
|
<td class="ts">${U.date(r.created_at)}</td>
|
||||||
|
</tr>`).join('')}</tbody></table></div>`;
|
||||||
|
} else {
|
||||||
|
const lv = { info:'<span class="badge bg-resolved">信息</span>', warn:'<span class="badge bg-medium">警告</span>', error:'<span class="badge bg-rejected">错误</span>' };
|
||||||
|
ct.innerHTML = rows.length === 0
|
||||||
|
? '<div class="empty"><i class="fas fa-clipboard"></i><p>暂无系统日志</p></div>'
|
||||||
|
: `<div class="card table-wrap"><table><thead><tr><th>#</th><th>级别</th><th>来源</th><th>消息</th><th>详情</th><th>时间</th></tr></thead><tbody>${rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td>${r.id}</td>
|
||||||
|
<td>${lv[r.level]||r.level}</td>
|
||||||
|
<td><span class="badge bg-admin">${U.esc(r.source)}</span></td>
|
||||||
|
<td>${U.esc(r.message)}</td>
|
||||||
|
<td class="ts" style="max-width:280px;word-break:break-all">${U.esc(r.details||'-')}</td>
|
||||||
|
<td class="ts">${U.date(r.created_at)}</td>
|
||||||
|
</tr>`).join('')}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user