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:
2026-08-17 05:14:41 +08:00
parent 8d6c3c4a8d
commit a11df22600
10 changed files with 241 additions and 8 deletions

View File

@@ -74,6 +74,7 @@
<script src="js/pages/polls.js"></script>
<script src="js/pages/servers.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/feature-detail.js"></script>
<script src="js/app.js"></script>

View File

@@ -11,6 +11,7 @@ const App = {
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', 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'] },
],
@@ -99,6 +100,7 @@ const App = {
case 'feature': this.renderMain('更新详情', FeatureDetail, param); break;
case 'servers': this.renderMain('服务器管理', ServersPage, param); break;
case 'bans': this.renderMain('封禁列表', BansPage, param); break;
case 'logs': this.renderMain('系统日志', LogsPage, param); break;
default: this.renderMain('控制台', Dashboard, param);
}
},
@@ -178,6 +180,7 @@ window.FeaturesPage = FeaturesPage;
window.FeatureDetail = FeatureDetail;
window.ServersPage = ServersPage;
window.BansPage = BansPage;
window.LogsPage = LogsPage;
window.LoginPage = LoginPage;
window.RegisterPage = RegisterPage;
window.HomePage = HomePage;

86
public/js/pages/logs.js Normal file
View 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>`;
}
}
};