Files
MC_Report/public/js/app.js
canglan e232f3b02e fix: auth expiry - validate token before showing protected pages
- Add validateAuth() method: calls /api/me to verify token validity,
  clears Auth state on 401/login error
- route() now calls validateAuth() async for ALL protected pages
  BEFORE showing layout/sidebar/rendering content
- Extract _doRoute() for actual page rendering (runs after validation)
- init() uses API.get for install check (proper auth handling)
- Added reset() to Auth: clears localStorage without redirecting

This fixes: expired token showing login-required pages, and no feedback
when session expires mid-session.
2026-08-21 20:46:09 +08:00

277 lines
13 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() {
// 获取站点名称:先尝试带 token 的请求,失败则降级(安装阶段无需登录)
try {
const r = await API.get('/install/status');
if (!r.installed) { location.hash = '#/install'; return; }
} catch {
// 未安装或 API 不可用,尝试直接路由到安装页
location.hash = '#/install';
return;
}
// 安装完成后获取站点名称(需要 token,若未登录则忽略名称设置)
try {
const nameRes = await fetch('/api/settings/settings');
if (nameRes.ok) {
const settings = await nameRes.json();
if (settings.site_name && settings.site_name.value) window.__SITE_NAME__ = settings.site_name.value;
}
} catch {}
// 确保登录态与服务器同步:若本地有 token 但 site_name 未加载(可能 token 已过期)
if (Auth.logged() && !window.__SITE_NAME__) Auth.reset();
if (window.__SITE_NAME__) {
document.getElementById('site-title').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 (!Auth.isAdmin() || this._footerLoaded) return;
this._footerLoaded = true;
try {
const s = await API.get('/settings/settings');
const cr = document.getElementById('footer-copyright');
const icp = document.getElementById('footer-icp');
if (cr && s.copyright) cr.innerHTML = s.copyright.value;
if (icp && s.icp && s.icp.value) icp.innerHTML = `<a href="https://beian.miit.gov.cn" target="_blank">${s.icp.value}</a>`;
} catch {}
},
afterLogin() { location.hash = '#/dashboard'; },
logout() { Auth.logout(); },
closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); },
navigate(page, param) { location.hash = `#/${page}` + (param ? `/${param}` : ''); },
// 验证 token 有效性(轻量级,失败则清除登录态)
async validateAuth() {
if (!Auth.logged()) return false;
try {
await API.get('/me');
return true;
} catch (e) {
if (e && e.message && e.message.includes('登录')) {
Auth.reset();
return false;
}
throw e;
}
},
route() {
const hash = location.hash || '#/home';
const parts = hash.replace('#/', '').split('/');
const page = parts[0];
const param = parts.slice(1).join('/');
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; }
// 访问受保护页面前先验证 token 有效性
if (Auth.logged()) {
this.validateAuth().then(valid => {
if (!valid) { location.hash = '#/login'; return; }
this._doRoute(page, param);
}).catch(() => {
Auth.reset();
location.hash = '#/login';
});
return;
}
location.hash = '#/login';
},
_doRoute(page, param) {
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 '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();
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.App = App;
window.Dashboard = Dashboard;
window.TicketsPage = TicketsPage;
window.TicketDetail = TicketDetail;
window.TicketCreate = TicketCreate;
window.TrackingPage = TrackingPage;
window.UnclaimedPage = UnclaimedPage;
window.UsersPage = UsersPage;
window.TemplatesPage = TemplatesPage;
window.NotificationsPage = NotificationsPage;
window.ExternalApiPage = ExternalApiPage;
window.SourcesPage = SourcesPage;
window.SettingsPage = SettingsPage;
window.ExportPage = ExportPage;
window.PollsPage = PollsPage;
window.FeaturesPage = FeaturesPage;
window.FeatureDetail = FeatureDetail;
window.ServersPage = ServersPage;
window.BansPage = BansPage;
window.LogsPage = LogsPage;
window.LoginPage = LoginPage;
window.RegisterPage = RegisterPage;
window.HomePage = HomePage;
window.InstallPage = InstallPage;
window.ForgotPage = ForgotPage;
window.ResetPage = ResetPage;
window.VerifyPage = VerifyPage;
window.U = U;
window.Auth = Auth;
window.API = API;
window.App = App;