- app.js: remove window.Dashboard=... block that crashed the whole file before page scripts load (ReferenceError: Dashboard is not defined). Page objects are top-level const in global lexical scope; PAGE_OBJS map + indirect eval in loadScript onload now expose them to window for inline onclick / data-action lookups - index.html: Font Awesome 6.5.1 localized (css/fontawesome.min.css + 8 webfont files) - no more cdnjs CDN (Edge Tracking Prevention blocked it, CN access unstable)
326 lines
15 KiB
JavaScript
326 lines
15 KiB
JavaScript
/*
|
|
* MC Report System
|
|
* Copyright (C) 2026 Sea Network Technology Studio
|
|
* Author: CangLan <admin@sea-studio.top>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
const App = {
|
|
navItems: [
|
|
{ id: 'dashboard', label: '控制台', icon: 'fa-chart-simple', roles: ['owner','admin','player'] },
|
|
{ id: 'tickets-create', label: '提交工单', icon: 'fa-plus-circle', roles: ['player'] },
|
|
{ id: 'bans', label: '封禁列表', icon: 'fa-ban', roles: ['owner','admin','player'] },
|
|
{ id: 'unclaimed', label: '待处理工单', icon: 'fa-inbox', roles: ['owner','admin'] },
|
|
{ id: 'users', label: '用户管理', icon: 'fa-users', roles: ['owner'] },
|
|
{ id: 'servers', label: '服务器管理', icon: 'fa-server', roles: ['owner'] },
|
|
{ id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner'] },
|
|
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] },
|
|
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
|
|
{ id: 'external-api', label: '外部API', icon: 'fa-key', roles: ['owner'] },
|
|
{ id: 'sources', label: '来源管理', icon: 'fa-tags', 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'] },
|
|
],
|
|
|
|
async init() {
|
|
// 站点名称由服务端注入 window.__SITE_NAME__(server.js getHtmlWithKey),
|
|
// 无需前端再请求(settings 接口需登录,未登录时 fetch 会 401 且无 token)
|
|
try {
|
|
const r = await API.get('/install/status');
|
|
if (!r.installed) { location.hash = '#/install'; return; }
|
|
} catch {
|
|
// 未安装或 API 不可用,尝试直接路由到安装页
|
|
location.hash = '#/install';
|
|
return;
|
|
}
|
|
if (window.__SITE_NAME__) {
|
|
document.getElementById('site-title').textContent = window.__SITE_NAME__;
|
|
document.getElementById('logo-text').textContent = window.__SITE_NAME__;
|
|
document.getElementById('sidebar-title').textContent = window.__SITE_NAME__ || '举报系统';
|
|
}
|
|
this._footerLoaded = false;
|
|
window.addEventListener('hashchange', () => this.route());
|
|
window.onerror = (msg, url, line, col, err) => {
|
|
console.error('JS Error:', msg, url, line, col);
|
|
const el = document.getElementById('sidebar-nav');
|
|
if (el) el.insertAdjacentHTML('beforeend', '<div style="color:red;font-size:10px;padding:8px">Error: ' + String(msg).substring(0,80) + '</div>');
|
|
};
|
|
document.getElementById('modal-close-btn').onclick = () => this.closeModal();
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
const parts = btn.dataset.action.split(':');
|
|
const target = window[parts[0]];
|
|
if (target && target[parts[1]]) target[parts[1]](...parts.slice(2));
|
|
});
|
|
this.route();
|
|
},
|
|
|
|
async loadFooter() {
|
|
if (this._footerLoaded) return;
|
|
this._footerLoaded = true;
|
|
try {
|
|
// 公开接口: 访客与登录用户都能读到页尾(版权/ICP), 不暴露敏感设置
|
|
const s = await API.get('/settings/public');
|
|
const cr = document.getElementById('footer-copyright');
|
|
const icp = document.getElementById('footer-icp');
|
|
if (cr && s.copyright) cr.innerHTML = s.copyright;
|
|
if (icp && s.icp) icp.innerHTML = `<a href="https://beian.miit.gov.cn" target="_blank">${s.icp}</a>`;
|
|
} catch {}
|
|
},
|
|
|
|
afterLogin() { location.hash = '#/dashboard'; },
|
|
logout() {
|
|
// 首页/根路径为公开页: 退出仅清除登录态, 停留当前页刷新顶栏; 其他页面才回登录页
|
|
const page = (location.hash || '#/home').replace('#/', '').split('/')[0];
|
|
if (page === 'home' || page === '') {
|
|
Auth.reset();
|
|
this.updateTopAuth();
|
|
} else {
|
|
Auth.logout();
|
|
}
|
|
},
|
|
closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); },
|
|
navigate(page, param) { location.hash = `#/${page}` + (param ? `/${param}` : ''); },
|
|
|
|
// ===== 页面脚本懒加载: 按路由动态注入, 避免全量下发 =====
|
|
_scriptCache: {},
|
|
// 路由 → 依赖脚本(不带 .js 后缀); home 依赖 ticket-create(首页内嵌提交表单), reset 定义于 forgot.js
|
|
PAGE_SCRIPTS: {
|
|
home: ['home', 'ticket-create'],
|
|
install: ['install'],
|
|
login: ['login'],
|
|
register: ['register'],
|
|
forgot: ['forgot'],
|
|
reset: ['forgot'],
|
|
verify: ['verify'],
|
|
dashboard: ['dashboard'],
|
|
unclaimed: ['unclaimed'],
|
|
tickets: ['tickets', 'ticket-create'],
|
|
ticket: ['ticket-detail'],
|
|
tracking: ['tracking'],
|
|
users: ['users'],
|
|
notifications: ['notifications'],
|
|
'external-api': ['external-api'],
|
|
'external-api-docs': ['external-api-docs'],
|
|
sources: ['sources'],
|
|
templates: ['templates'],
|
|
settings: ['settings-page'],
|
|
export: ['export-page'],
|
|
polls: ['polls'],
|
|
features: ['features'],
|
|
feature: ['feature-detail'],
|
|
servers: ['servers'],
|
|
bans: ['bans'],
|
|
logs: ['logs'],
|
|
},
|
|
// 脚本文件 → 顶层 const 页面对象(懒加载后经间接 eval 挂到 window)
|
|
PAGE_OBJS: {
|
|
'home.js': ['HomePage'],
|
|
'ticket-create.js': ['TicketCreate'],
|
|
'install.js': ['InstallPage'],
|
|
'login.js': ['LoginPage'],
|
|
'register.js': ['RegisterPage'],
|
|
'forgot.js': ['ForgotPage', 'ResetPage'],
|
|
'verify.js': ['VerifyPage'],
|
|
'dashboard.js': ['Dashboard'],
|
|
'unclaimed.js': ['UnclaimedPage'],
|
|
'tickets.js': ['TicketsPage'],
|
|
'ticket-detail.js': ['TicketDetail'],
|
|
'tracking.js': ['TrackingPage'],
|
|
'users.js': ['UsersPage'],
|
|
'notifications.js': ['NotificationsPage'],
|
|
'external-api.js': ['ExternalApiPage'],
|
|
'external-api-docs.js': ['ExternalApiDocsPage'],
|
|
'sources.js': ['SourcesPage'],
|
|
'templates.js': ['TemplatesPage'],
|
|
'settings-page.js': ['SettingsPage'],
|
|
'export-page.js': ['ExportPage'],
|
|
'polls.js': ['PollsPage'],
|
|
'features.js': ['FeaturesPage'],
|
|
'feature-detail.js': ['FeatureDetail'],
|
|
'servers.js': ['ServersPage'],
|
|
'bans.js': ['BansPage'],
|
|
'logs.js': ['LogsPage'],
|
|
},
|
|
loadScript(src) {
|
|
if (this._scriptCache[src]) return this._scriptCache[src];
|
|
const p = new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = src;
|
|
s.onload = () => {
|
|
// 页面脚本顶层 const 在全局词法环境, 用间接 eval 挂到 window,
|
|
// 供 inline onclick / data-action 的 window[obj] 查找
|
|
const objs = this.PAGE_OBJS[src.split('/').pop()] || [];
|
|
for (const o of objs) {
|
|
try { if (typeof window[o] === 'undefined') window[o] = (0, eval)(o); } catch {}
|
|
}
|
|
resolve();
|
|
};
|
|
s.onerror = () => { delete this._scriptCache[src]; reject(new Error('脚本加载失败: ' + src)); };
|
|
document.head.appendChild(s);
|
|
});
|
|
this._scriptCache[src] = p;
|
|
return p;
|
|
},
|
|
async loadPageScripts(page) {
|
|
const files = this.PAGE_SCRIPTS[page] || [page];
|
|
for (const f of files) await this.loadScript(`js/pages/${f}.js`);
|
|
},
|
|
|
|
async route() {
|
|
const hash = location.hash || '#/home';
|
|
const parts = hash.replace('#/', '').split('/');
|
|
const page = parts[0];
|
|
const param = parts.slice(1).join('/');
|
|
|
|
// 先加载当前路由所需页面脚本, 再渲染
|
|
try { await this.loadPageScripts(page); }
|
|
catch (e) {
|
|
console.error('页面脚本加载失败:', e.message);
|
|
document.getElementById('page-content').innerHTML = `<div class="card"><div class="card-b" style="text-align:center;color:var(--d)">页面资源加载失败, 请刷新重试</div></div>`;
|
|
return;
|
|
}
|
|
|
|
if (page === 'verify') {
|
|
this.showPublic('verify');
|
|
return;
|
|
}
|
|
|
|
if (page === 'install') { this.showPublic('install'); return; }
|
|
if (page === 'home') { this.showPublic('home', param); return; }
|
|
if (page === 'login' || page === 'register' || page === 'forgot' || page === 'reset') { this.showPublic(page); return; }
|
|
|
|
// 受保护页面: Auth.logged() 内部解码 JWT exp,过期自动清除登录态(同步,无异步循环)
|
|
if (!Auth.logged()) {
|
|
Auth.reset();
|
|
location.hash = '#/login';
|
|
return;
|
|
}
|
|
|
|
this.showLayout();
|
|
const sidebarPage = (page === 'tickets' && param.startsWith('create')) ? 'tickets-create' : page;
|
|
this.renderSidebar(sidebarPage);
|
|
|
|
switch (page) {
|
|
case 'dashboard': this.renderMain('控制台', Dashboard, param); break;
|
|
case 'unclaimed': this.renderMain('待处理工单', UnclaimedPage, param); break;
|
|
case 'tickets-create': App.navigate('tickets','create'); break;
|
|
case 'tickets':
|
|
if (param.startsWith('create')) {
|
|
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
|
|
document.getElementById('page-title').textContent = '创建工单';
|
|
document.getElementById('page-actions').innerHTML = '<button class="btn btn-o btn-sm" data-action="App:navigate:tickets"><i class="fas fa-arrow-left"></i> 返回</button>';
|
|
TicketCreate.render(document.getElementById('page-content'), { type: q.get('type') || 'report', prefill: { game_name: Auth.user()?.game_name, game_uid: Auth.user()?.game_uid }, backLink: "App.navigate('tickets')" });
|
|
} else this.renderMain('工单列表', TicketsPage, param);
|
|
break;
|
|
case 'ticket': this.renderMain('工单详情', TicketDetail, param); break;
|
|
case 'tracking': this.renderMain('追踪工单', TrackingPage, param); break;
|
|
case 'users': this.renderMain('用户管理', UsersPage, param); break;
|
|
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
|
|
case 'external-api': this.renderMain('外部API', ExternalApiPage, param); break;
|
|
case 'external-api-docs': this.renderMain('外部API文档', ExternalApiDocsPage, param); break;
|
|
case 'sources': this.renderMain('来源管理', SourcesPage, param); break;
|
|
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
|
|
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
|
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
|
case 'polls': this.renderMain('投票', PollsPage, param); break;
|
|
case 'features': this.renderMain('更新内容', FeaturesPage, param); break;
|
|
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);
|
|
}
|
|
},
|
|
|
|
showPublic(page, param) {
|
|
document.getElementById('main-layout').classList.add('hidden');
|
|
const pub = document.getElementById('public-page');
|
|
pub.classList.remove('hidden');
|
|
document.getElementById('top-bar').classList.remove('hidden');
|
|
this.updateTopAuth();
|
|
this.loadFooter();
|
|
const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : page === 'forgot' ? ForgotPage : page === 'reset' ? ResetPage : page === 'verify' ? VerifyPage : HomePage;
|
|
comp.render(param).then(html => { pub.innerHTML = html; if (comp.mount) comp.mount(param); }).catch(() => {
|
|
pub.innerHTML = '<div class="pub-card"><div class="logo"><i class="fas fa-exclamation-triangle" style="color:var(--d)"></i><h2>加载失败</h2><p>请刷新页面重试</p></div></div>';
|
|
});
|
|
},
|
|
|
|
updateTopAuth() {
|
|
const el = document.getElementById('top-auth');
|
|
if (Auth.logged()) {
|
|
const u = Auth.user();
|
|
el.innerHTML = `<span class="tm ts">${U.esc(u.game_name||u.username)}</span>
|
|
${Auth.isAdmin() ? `<a href="#/dashboard" class="btn btn-p btn-sm"><i class="fas fa-chart-simple"></i> 控制台</a>` : ''}
|
|
${Auth.isAdmin() ? `<a href="#/unclaimed" class="btn btn-o btn-sm">待处理</a>` : ''}
|
|
${Auth.isOwner() ? `<a href="#/polls" class="btn btn-o btn-sm"><i class="fas fa-poll"></i></a>` : ''}
|
|
<button class="btn btn-o btn-sm" onclick="App.logout()"><i class="fas fa-sign-out-alt"></i> 退出</button>`;
|
|
} else {
|
|
el.innerHTML = `<a href="#/login" class="btn btn-o btn-sm"><i class="fas fa-sign-in-alt"></i> 登录</a><a href="#/register" class="btn btn-p btn-sm"><i class="fas fa-user-plus"></i> 注册</a>`;
|
|
el.style.display = 'flex';
|
|
}
|
|
},
|
|
|
|
showLayout() {
|
|
document.getElementById('top-bar').classList.add('hidden');
|
|
document.getElementById('public-page').classList.add('hidden');
|
|
document.getElementById('main-layout').classList.remove('hidden');
|
|
const u = Auth.user();
|
|
document.getElementById('user-info').innerHTML = `<div class="dt"><div class="nm">${U.esc(u.game_name||u.username)}</div></div>`;
|
|
document.getElementById('sidebar-logout').onclick = () => App.logout();
|
|
this.loadFooter();
|
|
},
|
|
|
|
renderSidebar(current) {
|
|
const u = Auth.user();
|
|
const nav = document.getElementById('sidebar-nav');
|
|
nav.innerHTML = this.navItems
|
|
.filter(i => i.roles.includes(u.role))
|
|
.map(i => `<div class="nav-item ${current===i.id?'active':''}" data-nav="${i.id}"><i class="fas ${i.icon}"></i><span>${i.label}</span></div>`).join('');
|
|
nav.querySelectorAll('.nav-item').forEach(el => {
|
|
el.addEventListener('click', () => App.navigate(el.dataset.nav));
|
|
});
|
|
},
|
|
|
|
async renderMain(title, page, param) {
|
|
document.getElementById('page-title').textContent = title;
|
|
document.getElementById('page-actions').innerHTML = '';
|
|
const ct = document.getElementById('page-content');
|
|
try {
|
|
ct.innerHTML = await page.render(param);
|
|
if (page.mount) await page.mount(param);
|
|
} catch (e) {
|
|
// 401 / 登录过期: 清除状态, 保留访问目标页 hash 提示重新登录
|
|
if (e && e.message && e.message.includes('登录')) {
|
|
Auth.reset();
|
|
const target = location.hash;
|
|
ct.innerHTML = `<div class="alert alert-e">登录已过期,请重新登录。</div>
|
|
<div class="mt-2"><button class="btn btn-p" onclick="location.hash='#/login';setTimeout(()=>location.hash='${target}',2000)">去登录</button></div>`;
|
|
} else throw e;
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', () => App.init());
|
|
// 页面对象(window.Dashboard 等)由各自脚本以顶层 const 定义, 全局词法环境可访问,
|
|
// 无需在此挂载(懒加载下此处引用会导致 ReferenceError 崩掉整个 app.js)
|
|
window.U = U;
|
|
window.Auth = Auth;
|
|
window.API = API;
|
|
window.App = App;
|