/* * MC Report System * Copyright (C) 2026 Sea Network Technology Studio * Author: CangLan * * 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 . */ const App = { navItems: [ { id: 'dashboard', label: '控制台', icon: 'fa-chart-simple', roles: ['owner','admin','player'] }, { id: 'tickets', label: '工单列表', icon: 'fa-list', roles: ['owner','admin'] }, { 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', '
Error: ' + String(msg).substring(0,80) + '
'); }; 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 = `${s.icp}`; } 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 在全局词法环境, 用内联 script 挂到 window // (不能用 eval: CSP scriptSrc 无 'unsafe-eval', (0,eval) 会被拦截导致 data-action 按钮失效) const objs = this.PAGE_OBJS[src.split('/').pop()] || []; if (objs.length) { const inline = document.createElement('script'); inline.textContent = objs.map(o => `window.${o} = ${o};`).join('\n'); document.head.appendChild(inline); } 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 = `
页面资源加载失败, 请刷新重试
`; 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 = ''; 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 = '
'; }); }, updateTopAuth() { const el = document.getElementById('top-auth'); if (Auth.logged()) { const u = Auth.user(); el.innerHTML = `${U.esc(u.game_name||u.username)} ${Auth.isAdmin() ? ` 控制台` : ''} ${Auth.isAdmin() ? `待处理` : ''} ${Auth.isOwner() ? `` : ''} `; } else { el.innerHTML = ` 登录 注册`; 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 = `
${U.esc(u.game_name||u.username)}
`; 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 => ``).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 = `
登录已过期,请重新登录。
`; } 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;