Archived
为什么:index.html 单体 4600 行 ES5 已到维护极限,迁移到 Vite 工程 获得热更新、类型检查与模块化能力。迁移策略是保真优先——业务逻辑 不重写为 React 组件(行为漂移风险),而是逐字移植为 legacy.ts/auth.ts 两个 @ts-nocheck 控制器模块;React 只接管静态 骨架与响应式显隐(tab/modal/drawer/toast),动态列表/播放器/歌词 仍走 id + innerHTML 命令式更新。 关键决策: - boot 包装:旧脚本原在 body 末尾可直取 DOM,模块化后顶层事件 绑定会在 import 求值时打在 null 上,全部副作用包进 __legacyBoot()/__authBoot() 由 App useEffect 骨架落盘后调用 - 轻量 store:单一可变 AppState + notify() + useSyncExternalStore, 沿用旧版"改 state 后手动通知"心智模型 - modalBus:弹窗栈 + useSyncExternalStore,Escape 走 closeTopmost, stats/cookie 弹窗经 __legacyApi 调 legacy 内部专用关闭(清定时器/ 复位视图) - vite 固定产物文件名(无内容 hash),保证 index.html 字节级稳定, golden sha256 契约才跨构建成立
1447 lines
79 KiB
TypeScript
1447 lines
79 KiB
TypeScript
// @ts-nocheck
|
||
/* 站点账号系统(由旧版 static/site_auth.js 逐字移植)。
|
||
* 与主逻辑通过 window.* 钩子双向协作——与旧版一致,暂不模块化。
|
||
* 全部副作用包在 __authBoot() 中——React 挂载骨架后由 App 调用。 */
|
||
import { openModal as busOpen, closeModal as busClose, isModalOpen } from '../ui/modalBus'
|
||
export function __authBoot() {
|
||
/* ============================================================
|
||
* 云村小助手 - 站点账号系统前端逻辑(按次计费版)
|
||
* 未经授权不得复制、修改或用于商业用途
|
||
* ============================================================ */
|
||
/* Promise.finally 老内核 polyfill(与 index.html 同一份标准实现——
|
||
index.html 的脚本先加载,这里再兜底一次确保独立引用时也安全) */
|
||
if (!Promise.prototype.finally) {
|
||
Promise.prototype.finally = function (cb) {
|
||
var P = this.constructor || Promise;
|
||
return this.then(
|
||
function (v) { return P.resolve(cb()).then(function () { return v; }); },
|
||
function (e) { return P.resolve(cb()).then(function () { throw e; }); }
|
||
);
|
||
};
|
||
}
|
||
|
||
var SITE_VERSION = '2.4.0'; // 与后端 APP_VERSION、index.html CHANGELOG 对齐
|
||
var CHANGELOG = [
|
||
{ v: '2.4.0', date: '2026-09-21', items: ['修复:老内核浏览器(Chrome 57-62)操作按钮卡加载的问题', '安全:登录失败次数过多临时锁定;公网错误信息不再暴露内部细节', '优化:用户列表不再携带明文网易云 Cookie(按需单独查看)'] },
|
||
{ v: '2.1.0', date: '2026-08-28', items: ['计费改为按次计费:一首歌 1 次,歌单打包按歌曲数量扣减', '免费试用改为 3 次(3 首歌),注册页领取,每设备限一次', '卡密系统升级为次数卡,后台可管理用户次数'] },
|
||
{ v: '2.0.0', date: '2026-08-28', items: ['新增站点账号系统:强制登录、邮箱验证码注册', '新增管理后台:用户管理、全局Cookie、SMTP、日志、卡密、公告、数据库备份', '新增个人中心:剩余次数、卡密充值、公告查看'] },
|
||
{ v: '1.2.0', date: '2026-03-29', items: ['修复部分歌曲解析失败的问题', '优化界面交互体验'] }
|
||
];
|
||
|
||
function $(s, root) { return (root || document).querySelector(s); }
|
||
function $$(s, root) { return Array.prototype.slice.call((root || document).querySelectorAll(s)); }
|
||
|
||
/* ---------------- toast ---------------- */
|
||
var toastWrap = null;
|
||
function toast(msg, type?: any) {
|
||
if (!toastWrap) {
|
||
toastWrap = document.createElement('div');
|
||
toastWrap.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;';
|
||
document.body.appendChild(toastWrap);
|
||
}
|
||
var t = document.createElement('div');
|
||
t.textContent = msg;
|
||
t.style.cssText = 'padding:10px 18px;border-radius:10px;font-size:13px;color:#fff;background:' +
|
||
(type === 'error' ? 'linear-gradient(135deg,#ff5f57,#e0483f)' :
|
||
type === 'success' ? 'linear-gradient(135deg,#34c759,#2aa64c)' : 'rgba(30,30,30,.92)') +
|
||
';box-shadow:0 6px 24px rgba(0,0,0,.25);opacity:0;transform:translateY(-8px);transition:all .25s;max-width:80vw;';
|
||
toastWrap.appendChild(t);
|
||
requestAnimationFrame(function () { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; });
|
||
setTimeout(function () {
|
||
t.style.opacity = '0'; t.style.transform = 'translateY(-8px)';
|
||
setTimeout(function () { t.remove(); }, 300);
|
||
}, 2600);
|
||
}
|
||
|
||
/* ---------------- 请求 ---------------- */
|
||
function api(url, params) {
|
||
var body = Object.keys(params || {}).map(function (k) {
|
||
return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]);
|
||
}).join('&');
|
||
// 30 秒超时:服务端挂起时不再无限等待(登录含省份查询约 5s 上限,留足余量;
|
||
// 无 AbortController 的老内核优雅降级为无超时)
|
||
var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||
var timer = null;
|
||
if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 30000);
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||
body: body,
|
||
credentials: 'same-origin',
|
||
signal: ctl ? ctl.signal : undefined
|
||
}).then(function (r) {
|
||
// 双回调解包(不用 Promise.finally:兼容 Chromium 57+ 老内核)
|
||
return r.json().then(function (d) { if (timer) clearTimeout(timer); return d; },
|
||
function (e) { if (timer) clearTimeout(timer); throw e; });
|
||
}).catch(function () {
|
||
// 默认失败兜底——网络断/超时/非 JSON 响应不再产生 unhandledrejection
|
||
// 噪音(喂 fe_error 上报通道),统一转为标准失败对象走调用方既有的 toast
|
||
if (timer) clearTimeout(timer);
|
||
return { status: 0, success: false, message: '网络异常,请检查连接后重试' };
|
||
});
|
||
}
|
||
function apiGet(url) {
|
||
var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||
var timer = null;
|
||
if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 15000);
|
||
// 注意:apiGet 不做默认失败兜底——/auth/me 的调用方依赖"失败保持原登录态"
|
||
//(拒绝会跳过 then 体,不清空已有 user),转成 resolved 失败对象会让 UI 掉成游客
|
||
return fetch(url, { credentials: 'same-origin', signal: ctl ? ctl.signal : undefined })
|
||
.then(function (r) {
|
||
return r.json().then(function (d) { if (timer) clearTimeout(timer); return d; },
|
||
function (e) { if (timer) clearTimeout(timer); throw e; });
|
||
});
|
||
}
|
||
|
||
/* ---------------- 设备标识 ---------------- */
|
||
function getDeviceId() {
|
||
/* 与 index.html 的 getDeviceId 统一为同一键/同一值——
|
||
注册指纹与下载任务/心跳的设备号不再各走一套;
|
||
内存缓存兜底 localStorage 不可写时同一页面内保持稳定。 */
|
||
if (window.__siteDeviceIdCache) return window.__siteDeviceIdCache;
|
||
var KEY = 'netease_device_id';
|
||
var d = '';
|
||
try { d = localStorage.getItem(KEY) || ''; } catch (e) { }
|
||
if (!d) {
|
||
d = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
||
try { localStorage.setItem(KEY, d); } catch (e) { }
|
||
}
|
||
window.__siteDeviceIdCache = d;
|
||
return d;
|
||
}
|
||
|
||
/* ---------------- 状态 ---------------- */
|
||
var user = null;
|
||
var durationText = '';
|
||
var ann = null;
|
||
var smtpOk = false; // fail-closed——/auth/me 返回前不显示注册入口(防止未配置 SMTP 的窗口期误开)
|
||
var registerCredits = 3; // 注册赠送次数由 /auth/me 下发,文案不再硬编码
|
||
|
||
/* ---------------- 弹窗 ---------------- */
|
||
var BUS_MODALS = ['changelog-modal', 'stats-modal', 'api-modal', 'cookie-modal',
|
||
'login-modal', 'register-modal', 'announce-modal'];
|
||
function openModal(id) {
|
||
if (BUS_MODALS.indexOf(id) !== -1) { busOpen(id); return; }
|
||
var m = $('#' + id); if (m) m.classList.add('show');
|
||
}
|
||
function closeModal(id) {
|
||
if (BUS_MODALS.indexOf(id) !== -1) { busClose(id); return; }
|
||
var m = $('#' + id); if (m) m.classList.remove('show');
|
||
}
|
||
|
||
/* ---------------- 导航栏 ---------------- */
|
||
function renderAuthArea() {
|
||
// 同步全局登录状态,供主脚本 requireLogin() 使用
|
||
window.SITE_LOGGED_IN = !!user;
|
||
// 未开放邮箱注册(SMTP 未配置)时,隐藏所有注册入口,避免用户注册卡死
|
||
var showRegister = !user && smtpOk;
|
||
var regBtn = $('#btn-open-register');
|
||
if (regBtn) regBtn.hidden = !showRegister;
|
||
['goto-register', 'goto-register2'].forEach(function (id) {
|
||
var a = $('#' + id); if (a) a.hidden = !showRegister;
|
||
});
|
||
// 两个弹窗(登录/注册)里的试用提示都要受管——原 querySelector 只命中第一个
|
||
$$('.auth-trial-box').forEach(function (box) { box.hidden = !showRegister; });
|
||
$$('.reg-gift-num').forEach(function (el) { el.textContent = registerCredits; });
|
||
var guest = $('#auth-guest-area'), uarea = $('#auth-user-area');
|
||
if (guest && uarea) {
|
||
if (user) {
|
||
guest.hidden = true; uarea.hidden = false;
|
||
var pill = $('#auth-duration-pill');
|
||
if (pill) pill.textContent = '⏳ ' + (durationText || '0 次');
|
||
var av = $('#user-avatar');
|
||
if (av) {
|
||
av.textContent = (user.username || 'U').charAt(0).toUpperCase();
|
||
av.title = user.username + (user.role === 'admin' ? '(管理员)' : '');
|
||
}
|
||
} else {
|
||
guest.hidden = false; uarea.hidden = true;
|
||
}
|
||
}
|
||
var ucTab = $('#tab-usercenter'), uaTab = $('#tab-useradmin');
|
||
if (ucTab) ucTab.hidden = !user;
|
||
if (uaTab) uaTab.hidden = !(user && user.role === 'admin');
|
||
// 全局账号歌单仅管理员可见
|
||
var gTab = $('#tab-global');
|
||
if (gTab) gTab.hidden = !(user && user.role === 'admin');
|
||
}
|
||
|
||
/* ---------------- 会话 ---------------- */
|
||
function refreshSession() {
|
||
return apiGet('/auth/me').then(function (data) {
|
||
var d = data.data || {};
|
||
user = d.user || null;
|
||
durationText = d.duration_text || '';
|
||
ann = d.announcement || null;
|
||
smtpOk = !!d.smtp_configured;
|
||
// 注册赠送次数动态下发(/auth/me),文案不再硬编码
|
||
if (typeof d.register_credits === 'number') registerCredits = d.register_credits;
|
||
renderAuthArea();
|
||
renderUserCenter();
|
||
maybeShowAnnouncement();
|
||
// 确认登录后再恢复下载任务(游客调任务接口会 401 误弹登录框)
|
||
if (user && typeof window.SITE_TASKS_BOOTSTRAP === 'function') {
|
||
try { window.SITE_TASKS_BOOTSTRAP(); } catch (e) {}
|
||
}
|
||
return user;
|
||
}).catch(function () { return null; });
|
||
}
|
||
|
||
// 全局暴露给主脚本 apiPost 的 401 统一处理(同一提示 5 秒内不重复弹出)
|
||
var _lastAuthToast = 0;
|
||
function authToastOnce(msg, type) {
|
||
var now = Date.now();
|
||
if (now - _lastAuthToast < 5000) return;
|
||
_lastAuthToast = now;
|
||
toast(msg, type || 'error');
|
||
}
|
||
window.handleAuthExpired = function (msg, expired) {
|
||
if (expired) {
|
||
authToastOnce(msg || '下载次数已用尽,请充值卡密');
|
||
showExpiredModal();
|
||
return;
|
||
}
|
||
// 会话失效时清掉内存里的旧用户状态再引导登录
|
||
//(原实现保留旧 user,页面继续显示"已登录"却处处 401)
|
||
user = null;
|
||
durationText = '';
|
||
renderAuthArea();
|
||
renderUserCenter();
|
||
authToastOnce(msg || '登录已失效,请重新登录');
|
||
// 登录弹窗已打开且正处于找回/两步验证视图时,不复位视图——
|
||
// 未登录期间后台任务轮询/心跳会持续吃 401 反复进入本函数,若每次都把
|
||
// 弹窗拽回登录视图,管理员正在输入的验证码会被打断、票据被清空
|
||
var _lm = document.getElementById('login-modal');
|
||
var _fv = document.getElementById('forgot-view');
|
||
var _a2v = document.getElementById('admin2fa-view');
|
||
var _authViewOpen = _lm && _lm.classList.contains('show') &&
|
||
((_fv && !_fv.hidden) || (_a2v && !_a2v.hidden));
|
||
if (!_authViewOpen) {
|
||
hideForgotView();
|
||
hideAdmin2faView();
|
||
}
|
||
// 会话失效弹登录窗前,先关掉可能叠开的注册弹窗(双遮罩错乱),
|
||
// 已 show 则短路——401 风暴(1.5s 轮询)期间不反复重开
|
||
var _reg = document.getElementById('register-modal');
|
||
if (_reg && _reg.classList.contains('show')) closeModal('register-modal');
|
||
if (_lm && _lm.classList.contains('show')) return;
|
||
openModal('login-modal');
|
||
};
|
||
// 暴露给主脚本:扣费/任务结束后刷新顶栏剩余次数与登录态
|
||
window.refreshSITECredits = refreshSession;
|
||
window.renderSITEUserCenter = renderUserCenter;
|
||
// 暴露当前剩余下载次数给主脚本预检(未登录 -1,不限 999999)
|
||
window.getSITECredits = function () {
|
||
if (!user) return -1;
|
||
var c = (typeof user.duration_seconds === 'number') ? user.duration_seconds : 0;
|
||
return c < 0 ? 999999 : c;
|
||
};
|
||
|
||
/* 次数用尽弹窗(下载触发,带「去充值」入口) */
|
||
function showExpiredModal() {
|
||
// 先移除可能残留的旧实例(此前被 Escape 隐藏但未删 DOM 时,旧实现会直接
|
||
// return,导致该弹窗从此再也弹不出来)
|
||
var old = $('#site-expired-modal');
|
||
if (old) old.remove();
|
||
var mask = document.createElement('div');
|
||
mask.className = 'modal-mask show';
|
||
mask.id = 'site-expired-modal';
|
||
mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除
|
||
mask.innerHTML =
|
||
'<div class="modal" style="width:380px;">' +
|
||
' <div class="modal-header"><div class="modal-title">⏳ 下载次数已用尽</div></div>' +
|
||
' <div class="modal-body" style="font-size:13px;line-height:1.9;">' +
|
||
' 您的下载次数已用完。解析与在线试听免费,<b>下载需要消耗次数</b>(一首歌 = 1 次,歌单打包按歌曲数量扣减)。<br>' +
|
||
' <span style="opacity:.7;">请在个人中心充值卡密后继续下载。</span>' +
|
||
' </div>' +
|
||
' <div style="display:flex;gap:10px;padding:0 22px 20px;">' +
|
||
' <button class="btn btn-primary" id="site-expired-go" type="button" style="flex:1;">去充值</button>' +
|
||
' <button class="btn" id="site-expired-close" type="button" style="flex:1;">关闭</button>' +
|
||
' </div>' +
|
||
'</div>';
|
||
document.body.appendChild(mask);
|
||
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
|
||
var go = $('#site-expired-go'), close = $('#site-expired-close');
|
||
if (go) go.addEventListener('click', function () {
|
||
mask.remove();
|
||
var t = $('#tab-usercenter');
|
||
if (t) t.click();
|
||
});
|
||
if (close) close.addEventListener('click', function () { mask.remove(); });
|
||
}
|
||
|
||
function afterAuth(data) {
|
||
var d = data.data || {};
|
||
user = d.user || null;
|
||
durationText = d.duration_text || '';
|
||
renderAuthArea();
|
||
renderUserCenter();
|
||
// 登录成功后立即恢复本设备的进行中任务
|
||
if (user && typeof window.SITE_TASKS_BOOTSTRAP === 'function') {
|
||
try { window.SITE_TASKS_BOOTSTRAP(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
/* ---------------- 登录 / 注册弹窗 ---------------- */
|
||
// 注册唯一入口——导航按钮与登录弹窗两处链接(全站仅有的 3 条打开
|
||
// 路径)统一经此:未同意协议 → cookieGate('register') 弹「隐私与协议设置」
|
||
// 注册模式(16 秒强制阅读计时)并中止;已同意 → 直接进注册弹窗。
|
||
// 提交侧 doRegister 另有 fail-closed 兜底闸,程序化打开也绕不过。
|
||
function openRegisterFlow() {
|
||
closeModal('login-modal'); // 先关登录弹窗,避免协议窗被压在下方
|
||
var gate = window.SITE_COOKIE_GATE;
|
||
if (typeof gate !== 'function') {
|
||
// fail-closed:协议闸未就绪(主脚本异常)时不放行注册
|
||
toast('页面协议组件未就绪,请刷新页面后重试', 'error');
|
||
return;
|
||
}
|
||
if (!gate('register')) return; // 未同意:闸内已弹协议窗并提示
|
||
openModal('register-modal');
|
||
}
|
||
function bindAuthModal() {
|
||
var lo = $('#btn-open-login'), ro = $('#btn-open-register');
|
||
if (lo) lo.addEventListener('click', function () { closeModal('register-modal'); hideForgotView(); hideAdmin2faView(); openModal('login-modal'); });
|
||
if (ro) ro.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); });
|
||
['login-close', 'register-close', 'announce-close'].forEach(function (id) {
|
||
var b = $('#' + id);
|
||
if (b) b.addEventListener('click', function () {
|
||
if (id.indexOf('login') === 0 && window.resetAuthViews) window.resetAuthViews(); // 关闭侧复位
|
||
closeModal(id.indexOf('login') === 0 ? 'login-modal' : id.indexOf('register') === 0 ? 'register-modal' : 'announce-modal');
|
||
});
|
||
});
|
||
[['login-modal', 'login-close'], ['register-modal', 'register-close']].forEach(function (p) {
|
||
var m = $('#' + p[0]);
|
||
if (m) m.addEventListener('click', function (e) {
|
||
if (e.target === m) {
|
||
if (p[0] === 'login-modal' && window.resetAuthViews) window.resetAuthViews(); // 关闭侧复位
|
||
closeModal(p[0]);
|
||
}
|
||
});
|
||
});
|
||
var g1 = $('#goto-register'), g2 = $('#goto-register2');
|
||
if (g1) g1.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); });
|
||
if (g2) g2.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); });
|
||
var gl1 = $('#goto-login');
|
||
if (gl1) gl1.addEventListener('click', function () { closeModal('register-modal'); hideForgotView(); hideAdmin2faView(); openModal('login-modal'); });
|
||
// 找回密码入口/返回(只交付 DOM 漏了绑定)
|
||
var gf = $('#goto-forgot');
|
||
if (gf) gf.addEventListener('click', showForgotView);
|
||
var fbl = $('#forgot-back-login');
|
||
if (fbl) fbl.addEventListener('click', hideForgotView);
|
||
// 管理员两步验证视图的返回链接
|
||
var a2b = $('#a2fa-back');
|
||
if (a2b) a2b.addEventListener('click', hideAdmin2faView);
|
||
|
||
// 注册即送 3 次,无需 Tab 切换
|
||
}
|
||
|
||
/* ---------------- 登录 ---------------- */
|
||
function doLogin() {
|
||
var btn = $('#login-submit');
|
||
var username = ($('#login-username') || {}).value || '';
|
||
var password = ($('#login-password') || {}).value || '';
|
||
if (!username || !password) { toast('请输入用户名/邮箱和密码', 'error'); return; }
|
||
btn.disabled = true; btn.textContent = '登录中...';
|
||
api('/auth/login', { username: username, password: password })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '登录失败');
|
||
// 管理员密码验证通过——转两步验证视图(服务端未建会话)
|
||
if (data.data && data.data.admin_2fa) {
|
||
showAdmin2faView(data.data.ticket, data.data.email_masked);
|
||
toast('密码验证通过,请完成邮箱验证', 'success');
|
||
return;
|
||
}
|
||
afterAuth(data);
|
||
closeModal('login-modal');
|
||
toast('登录成功,欢迎回来,' + (user ? user.username : ''), 'success');
|
||
if (data.data && data.data.redirect === 'admin') {
|
||
var uaTab = $('#tab-useradmin');
|
||
if (uaTab) uaTab.click();
|
||
} else {
|
||
var ucTab = $('#tab-usercenter');
|
||
if (ucTab) ucTab.click();
|
||
}
|
||
})
|
||
.catch(function (e) { toast(e.message || '登录失败', 'error'); })
|
||
.finally(function () { btn.disabled = false; btn.textContent = '登 录'; });
|
||
}
|
||
|
||
/* ---------------- 注册 ---------------- */
|
||
var _REG_EMAIL_RE = /^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$/;
|
||
var _REG_NAME_RE = /^[A-Za-z0-9_\u4e00-\u9fa5]{3,20}$/;
|
||
// 注册弹窗内常驻错误行(长文案 2.6s toast 看不完)
|
||
function regShowError(msg) {
|
||
toast(msg, 'error');
|
||
var line = $('#reg-error-line');
|
||
if (line) { line.textContent = msg; line.hidden = false; }
|
||
}
|
||
function regClearError() {
|
||
var line = $('#reg-error-line');
|
||
if (line) { line.textContent = ''; line.hidden = true; }
|
||
}
|
||
function sendCode() {
|
||
var btn = $('#reg-send-code');
|
||
var email = ($('#reg-email') || {}).value || '';
|
||
if (!_REG_EMAIL_RE.test(email.trim())) { toast('邮箱格式不正确', 'error'); return; }
|
||
regClearError();
|
||
btn.disabled = true; btn.textContent = '发送中...';
|
||
api('/auth/send_code', { email: email, purpose: 'register' })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '发送失败');
|
||
toast('验证码已发送,请查收邮箱', 'success');
|
||
var left = 60;
|
||
window.__regCodeTimer = setInterval(function () {
|
||
left--; btn.textContent = left + 's';
|
||
if (left <= 0) { clearInterval(window.__regCodeTimer); btn.textContent = '发送验证码'; btn.disabled = false; }
|
||
}, 1000);
|
||
})
|
||
.catch(function (e) {
|
||
toast(e.message || '发送失败', 'error');
|
||
btn.disabled = false; btn.textContent = '发送验证码';
|
||
});
|
||
}
|
||
|
||
/* ---------------- 找回密码 ---------------- */
|
||
// 只交付了找回视图 DOM 未实现 JS——此处补齐全链路。
|
||
// 后端 /auth/forgot_password 已就绪:验证码原子消费、防枚举
|
||
// 统一文案、login_seq 踢全会话、5 次/分/IP 限流;发码复用 /auth/send_code。
|
||
function forgotShowError(msg) {
|
||
toast(msg, 'error');
|
||
var line = $('#forgot-error-line');
|
||
if (line) { line.textContent = msg; line.hidden = false; }
|
||
}
|
||
function forgotClearError() {
|
||
var line = $('#forgot-error-line');
|
||
if (line) { line.textContent = ''; line.hidden = true; }
|
||
}
|
||
function forgotStopCodeTimer() {
|
||
clearInterval(window.__forgotCodeTimer || 0);
|
||
var btn = $('#forgot-send-code');
|
||
if (btn && btn.disabled) { btn.disabled = false; btn.textContent = '发送验证码'; }
|
||
}
|
||
function showForgotView() {
|
||
var form = document.querySelector('#login-modal .auth-form');
|
||
var fv = $('#forgot-view');
|
||
if (!form || !fv) return;
|
||
form.classList.add('forgot-mode');
|
||
fv.hidden = false;
|
||
forgotClearError();
|
||
var em = $('#forgot-email');
|
||
if (em) { try { em.focus(); } catch (e) {} }
|
||
}
|
||
function hideForgotView() {
|
||
var form = document.querySelector('#login-modal .auth-form');
|
||
var fv = $('#forgot-view');
|
||
if (form) form.classList.remove('forgot-mode');
|
||
if (fv && !fv.hidden) fv.hidden = true;
|
||
forgotClearError();
|
||
forgotStopCodeTimer();
|
||
}
|
||
function doForgotSendCode() {
|
||
var btn = $('#forgot-send-code');
|
||
var email = ($('#forgot-email') || {}).value || '';
|
||
if (!_REG_EMAIL_RE.test(email.trim())) { forgotShowError('邮箱格式不正确'); return; }
|
||
forgotClearError();
|
||
btn.disabled = true; btn.textContent = '发送中...';
|
||
api('/auth/send_code', { email: email, purpose: 'reset' })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '发送失败');
|
||
toast('验证码已发送,请查收邮箱', 'success');
|
||
var left = 60;
|
||
window.__forgotCodeTimer = setInterval(function () {
|
||
left--; btn.textContent = left + 's';
|
||
if (left <= 0) { clearInterval(window.__forgotCodeTimer); btn.textContent = '发送验证码'; btn.disabled = false; }
|
||
}, 1000);
|
||
})
|
||
.catch(function (e) {
|
||
forgotShowError(e.message || '发送失败');
|
||
btn.disabled = false; btn.textContent = '发送验证码';
|
||
});
|
||
}
|
||
function doForgotReset() {
|
||
var btn = $('#forgot-submit');
|
||
var email = ($('#forgot-email') || {}).value || '';
|
||
var code = ($('#forgot-code') || {}).value || '';
|
||
var password = ($('#forgot-password') || {}).value || '';
|
||
forgotClearError();
|
||
if (!email || !code || !password) { forgotShowError('请填写完整找回信息'); return; }
|
||
if (!_REG_EMAIL_RE.test(email.trim())) { forgotShowError('邮箱格式不正确'); return; }
|
||
if (password.length < 8 || password.length > 128) { forgotShowError('新密码长度需在 8-128 位之间'); return; }
|
||
btn.disabled = true; btn.textContent = '重置中...';
|
||
api('/auth/forgot_password', { email: email, code: code, password: password })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '重置失败');
|
||
var fe = $('#forgot-email'), fc = $('#forgot-code'), fp = $('#forgot-password');
|
||
if (fe) fe.value = ''; if (fc) fc.value = ''; if (fp) fp.value = '';
|
||
forgotStopCodeTimer();
|
||
hideForgotView();
|
||
toast('密码已重置,请用新密码登录', 'success');
|
||
})
|
||
.catch(function (e) { forgotShowError(e.message || '重置失败'); })
|
||
.finally(function () { btn.disabled = false; btn.textContent = '重置密码'; });
|
||
}
|
||
|
||
/* ---------------- 管理员两步验证 ----------------
|
||
* Step1 用户名+密码 → 服务端签发票据(不建会话);本视图完成
|
||
* Step2 绑定邮箱比对(触发发码)与 Step3 验证码登录。普通用户不经过此流程。 */
|
||
var _a2faTicket = '';
|
||
function a2faShowError(msg) {
|
||
toast(msg, 'error');
|
||
var line = $('#a2fa-error-line');
|
||
if (line) { line.textContent = msg; line.hidden = false; }
|
||
}
|
||
function a2faClearError() {
|
||
var line = $('#a2fa-error-line');
|
||
if (line) { line.textContent = ''; line.hidden = true; }
|
||
}
|
||
function a2faStopTimer() {
|
||
clearInterval(window.__a2faTimer || 0);
|
||
var btn = $('#a2fa-send');
|
||
if (btn && btn.disabled) { btn.disabled = false; btn.textContent = '发送验证码'; }
|
||
}
|
||
function showAdmin2faView(ticket, emailMask) {
|
||
var form = document.querySelector('#login-modal .auth-form');
|
||
var v = $('#admin2fa-view');
|
||
if (!form || !v) return;
|
||
_a2faTicket = ticket || '';
|
||
var mask = $('#a2fa-email-mask');
|
||
if (mask) mask.textContent = emailMask || '-';
|
||
// 清掉上次会话残留的输入(错码/旧邮箱不再被原样重复提交)
|
||
var ae = $('#a2fa-email'), ac = $('#a2fa-code');
|
||
if (ae) ae.value = '';
|
||
if (ac) ac.value = '';
|
||
form.classList.add('admin2fa-mode');
|
||
v.hidden = false;
|
||
a2faClearError();
|
||
a2faStopTimer();
|
||
var em = $('#a2fa-email');
|
||
if (em) { try { em.focus(); } catch (e) {} }
|
||
}
|
||
function hideAdmin2faView() {
|
||
var form = document.querySelector('#login-modal .auth-form');
|
||
var v = $('#admin2fa-view');
|
||
if (form) form.classList.remove('admin2fa-mode');
|
||
if (v && !v.hidden) v.hidden = true;
|
||
_a2faTicket = '';
|
||
a2faClearError();
|
||
a2faStopTimer();
|
||
}
|
||
// 关闭侧统一复位——登录弹窗的 X/遮罩/Escape/doLogout 关闭路径都调用,
|
||
// 不再只依赖"打开侧纪律";同时供 index.html 的 Escape 通用分支使用
|
||
window.resetAuthViews = function () { hideForgotView(); hideAdmin2faView(); };
|
||
function doA2faSendCode() {
|
||
var btn = $('#a2fa-send');
|
||
var email = ($('#a2fa-email') || {}).value || '';
|
||
if (!email.trim()) { a2faShowError('请输入管理员绑定邮箱'); return; }
|
||
a2faClearError();
|
||
btn.disabled = true; btn.textContent = '发送中...';
|
||
api('/auth/admin_login/email', { ticket: _a2faTicket, email: email })
|
||
.then(function (data) {
|
||
if (data.status === 401) {
|
||
// 先复位视图再提示——错误行随视图隐藏,此处 toast 是唯一可达提示
|
||
hideAdmin2faView();
|
||
a2faShowError(data.message || '请重新登录');
|
||
return;
|
||
}
|
||
if (data.status !== 200) throw new Error(data.message || '发送失败');
|
||
toast('验证码已发送至管理员邮箱,请查收', 'success');
|
||
var ci = $('#a2fa-code');
|
||
if (ci) { try { ci.focus(); } catch (e) {} }
|
||
var left = 60;
|
||
window.__a2faTimer = setInterval(function () {
|
||
left--; btn.textContent = left + 's';
|
||
if (left <= 0) { clearInterval(window.__a2faTimer); btn.textContent = '发送验证码'; btn.disabled = false; }
|
||
}, 1000);
|
||
})
|
||
.catch(function (e) {
|
||
a2faShowError(e.message || '发送失败');
|
||
btn.disabled = false; btn.textContent = '发送验证码';
|
||
});
|
||
}
|
||
function doA2faVerify() {
|
||
var btn = $('#a2fa-submit');
|
||
var email = ($('#a2fa-email') || {}).value || '';
|
||
var code = ($('#a2fa-code') || {}).value || '';
|
||
a2faClearError();
|
||
if (!email.trim()) { a2faShowError('请输入管理员绑定邮箱'); return; }
|
||
if (!code.trim()) { a2faShowError('请先获取并输入邮箱验证码'); return; }
|
||
btn.disabled = true; btn.textContent = '验证中...';
|
||
api('/auth/admin_login/verify', { ticket: _a2faTicket, code: code })
|
||
.then(function (data) {
|
||
if (data.status === 401) {
|
||
// 先复位视图再提示(同上,错误行随视图隐藏)
|
||
hideAdmin2faView();
|
||
a2faShowError(data.message || '请重新登录');
|
||
return;
|
||
}
|
||
if (data.status !== 200) throw new Error(data.message || '验证失败');
|
||
a2faClearError();
|
||
afterAuth(data);
|
||
closeModal('login-modal');
|
||
toast('登录成功,欢迎回来,' + (user ? user.username : ''), 'success');
|
||
var uaTab = $('#tab-useradmin');
|
||
if (uaTab) uaTab.click();
|
||
})
|
||
.catch(function (e) { a2faShowError(e.message || '验证失败'); })
|
||
.finally(function () { btn.disabled = false; btn.textContent = '验证并登录'; });
|
||
}
|
||
|
||
function doRegister() {
|
||
var btn = $('#reg-submit');
|
||
var username = ($('#reg-username') || {}).value || '';
|
||
var password = ($('#reg-password') || {}).value || '';
|
||
var email = ($('#reg-email') || {}).value || '';
|
||
var code = ($('#reg-code') || {}).value || '';
|
||
regClearError();
|
||
// 提交侧兜底闸(入口已统一走 openRegisterFlow,此处防任何程序化
|
||
// 打开路径绕过)。fail-closed:协议闸缺失视为未同意。注册语境只查协议同意
|
||
// (功能性 Cookie 不拦注册,拦解析/下载时另有提示);未同意时闸内已自动
|
||
// 弹「隐私与协议设置」注册模式并 toast,此处不再重复提示
|
||
if (typeof window.SITE_COOKIE_GATE !== 'function') {
|
||
toast('页面协议组件未就绪,请刷新页面后重试', 'error');
|
||
return;
|
||
}
|
||
if (!window.SITE_COOKIE_GATE('register')) return;
|
||
// 前端即时校验(口径与后端一致,省一次网络往返)
|
||
if (!username || !password || !email || !code) { regShowError('请填写完整注册信息'); return; }
|
||
if (!_REG_NAME_RE.test(username)) { regShowError('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线'); return; }
|
||
if (!_REG_EMAIL_RE.test(email.trim())) { regShowError('邮箱格式不正确'); return; }
|
||
if (password.length < 8 || password.length > 128) { regShowError('密码长度需在 8-128 位之间'); return; }
|
||
btn.disabled = true; btn.textContent = '注册中...';
|
||
api('/auth/register', {
|
||
username: username, password: password, email: email, code: code,
|
||
device_id: getDeviceId(), agree: 1
|
||
})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '注册失败');
|
||
regClearError();
|
||
afterAuth(data);
|
||
closeModal('register-modal');
|
||
toast('注册成功,已自动登录,赠送的下载次数可在个人中心查看', 'success');
|
||
var ucTab = $('#tab-usercenter');
|
||
if (ucTab) ucTab.click();
|
||
})
|
||
.catch(function (e) { regShowError(e.message || '注册失败'); })
|
||
.finally(function () { btn.disabled = false; btn.textContent = '注 册'; });
|
||
}
|
||
|
||
/* ---------------- 退出登录 ---------------- */
|
||
function doLogout() {
|
||
api('/auth/logout', {}).then(function () {
|
||
user = null; durationText = '';
|
||
renderAuthArea();
|
||
renderUserCenter(); // 同步个人中心 gate/content 显隐,与其他登出路径一致
|
||
if (window.resetAuthViews) window.resetAuthViews(); // 登出复位登录弹窗视图
|
||
closeModal('register-modal'); closeModal('login-modal');
|
||
// 退出连带清除个人网易云登录(index.html 注册的钩子:
|
||
// 清 localStorage 的个人 Cookie/解析账号选择并复位账号栏)
|
||
if (typeof window.SITE_ON_LOGOUT === 'function') {
|
||
try { window.SITE_ON_LOGOUT(); } catch (e) {}
|
||
}
|
||
var ex = $('#site-expired-modal'); if (ex) ex.remove();
|
||
// 切回搜索页,避免停留在个人中心 / 管理后台 / 管理员歌单
|
||
var search = document.querySelector('.tab[data-tab="search"]') || $('#tab-search');
|
||
if (search) search.click();
|
||
toast('已退出登录', 'success');
|
||
});
|
||
}
|
||
|
||
/* ---------------- 个人中心 ---------------- */
|
||
function fmtTime(ts) {
|
||
if (!ts) return '-';
|
||
var d = new Date(ts * 1000);
|
||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(d.getDate()).padStart(2, '0');
|
||
}
|
||
|
||
function renderUserCenter() {
|
||
var el;
|
||
// 登录态切换:隐藏 🔒 占位、显示个人中心内容
|
||
var gate = $('#uc-gate'), content = $('#uc-content');
|
||
if (gate && content) {
|
||
gate.hidden = !!user;
|
||
content.hidden = !user;
|
||
}
|
||
if (!user) return;
|
||
if ((el = $('#uc-avatar'))) el.textContent = (user.username || 'U').charAt(0).toUpperCase();
|
||
if ((el = $('#uc-username'))) el.textContent = user ? user.username : '-';
|
||
if ((el = $('#uc-role'))) el.textContent = user ? (user.role === 'admin' ? '管理员 · 不限次数'
|
||
: (typeof user.duration_seconds === 'number' && user.duration_seconds < 0 ? 'VIP · 不限次数' : '普通用户')) : '-';
|
||
if ((el = $('#uc-duration'))) el.textContent = user ? (durationText || '0 次') : '-';
|
||
if ((el = $('#uc-email'))) el.textContent = (user && user.email) || '未绑定';
|
||
if ((el = $('#uc-created'))) el.textContent = user ? fmtTime(user.created_at) : '-';
|
||
var cl = $('#uc-changelog');
|
||
if (cl) {
|
||
cl.innerHTML = CHANGELOG.map(function (v) {
|
||
return '<div style="margin-bottom:10px;"><b>v' + v.v + '</b> <span style="opacity:.6;font-size:12px;">' +
|
||
v.date + '</span><div style="font-size:12px;opacity:.85;line-height:1.7;">' +
|
||
v.items.map(function (i) { return '· ' + i; }).join('<br>') + '</div></div>';
|
||
}).join('') + '<div style="opacity:.6;font-size:12px;">当前版本 v' + SITE_VERSION +
|
||
'</div>';
|
||
}
|
||
}
|
||
|
||
function doRedeem() {
|
||
var input = $('#uc-card-input');
|
||
var code = (input && input.value || '').trim();
|
||
if (!code) { toast('请输入卡密', 'error'); return; }
|
||
var btn = $('#uc-card-redeem');
|
||
btn.disabled = true;
|
||
api('/auth/redeem', { code: code })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '兑换失败');
|
||
durationText = (data.data && data.data.duration_text) || durationText;
|
||
// 同步更新内存余额(批量下载预检读的是 user.duration_seconds,
|
||
// 原实现只改显示文字,预检仍用充值前的旧值)
|
||
if (user && data.data && typeof data.data.duration_seconds === 'number') {
|
||
user.duration_seconds = data.data.duration_seconds;
|
||
return null;
|
||
}
|
||
return refreshSession();
|
||
})
|
||
.then(function () {
|
||
renderAuthArea(); renderUserCenter();
|
||
if (input) input.value = '';
|
||
toast('充值成功', 'success');
|
||
})
|
||
.catch(function (e) { toast(e.message || '兑换失败', 'error'); })
|
||
.finally(function () { btn.disabled = false; });
|
||
}
|
||
|
||
|
||
/* ---------------- 公告 ---------------- */
|
||
function maybeShowAnnouncement(force) {
|
||
// 手动"查看公告"(force=true)无条件展示;
|
||
// 自动弹出仍按 popup_mode 与 once 已读版本判断
|
||
if (!force && (!ann || !ann.content || ann.popup_mode === 'off')) return;
|
||
if (!force && ann && ann.popup_mode === 'once') {
|
||
// once 模式的已读判断:登录用户比对服务端 ann_seen;
|
||
// 游客比对本地 localStorage(原实现只查 user,游客每次刷新都重复弹窗)
|
||
if (user && user.ann_seen && String(user.ann_seen) === String(ann.updated_at)) return;
|
||
if (!user) {
|
||
var guestSeen = '';
|
||
try { guestSeen = localStorage.getItem('netease_ann_once_seen') || ''; } catch (e) {}
|
||
if (guestSeen === String(ann.updated_at)) return;
|
||
}
|
||
}
|
||
if (!ann || !ann.content) { toast('当前没有公告内容', 'info'); return; }
|
||
var t = $('#announce-title'), c = $('#announce-content');
|
||
if (t) t.textContent = ann.title || '系统公告';
|
||
if (c) c.innerHTML = String(ann.content).split('\n').map(function (l) { return escapeHtml(l); }).join('<br>');
|
||
// 首访 Cookie 协议窗显示中不叠加公告弹窗(双遮罩错乱);
|
||
// 跳过时不记已读,下次进入页面再弹
|
||
var _cm = document.getElementById('cookie-modal');
|
||
if (_cm && _cm.classList.contains('show')) return;
|
||
openModal('announce-modal');
|
||
if (user) api('/auth/ann_seen', {});
|
||
else { try { localStorage.setItem('netease_ann_once_seen', String(ann.updated_at)); } catch (e) {} }
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
/* ---------------- 管理后台 ---------------- */
|
||
var uaLoaded = false;
|
||
var uaLogsType = 'success';
|
||
|
||
function uaShowGate() {
|
||
var gate = $('#ua-gate'), content = $('#ua-content');
|
||
if (!gate || !content) return;
|
||
var isAdmin = !!(user && user.role === 'admin');
|
||
gate.hidden = isAdmin;
|
||
content.hidden = !isAdmin;
|
||
if (isAdmin && !uaLoaded) { uaLoaded = true; uaLoadUsers(); uaLoadCookieStatus(); uaLoadSmtp(); uaLoadLogs(); uaLoadCards(); uaLoadAnn(); uaLoadDb(); }
|
||
}
|
||
|
||
function uaBindSide() {
|
||
$$('#admin-side .admin-side-item[data-view]').forEach(function (item) {
|
||
// 侧栏项补键盘语义(同型——tabindex/role + Enter/空格激活)
|
||
item.setAttribute('tabindex', '0');
|
||
item.setAttribute('role', 'button');
|
||
item.addEventListener('click', function () {
|
||
$$('#admin-side .admin-side-item').forEach(function (i) { i.classList.remove('active'); });
|
||
item.classList.add('active');
|
||
$$('.admin-view').forEach(function (v) { v.classList.toggle('active', v.getAttribute('data-aview') === item.getAttribute('data-view')); });
|
||
});
|
||
item.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); }
|
||
});
|
||
});
|
||
var back = $('#ua-back');
|
||
if (back) back.addEventListener('click', function () {
|
||
var t = $('#tab-useradmin'); if (t) t.classList.remove('active');
|
||
var search = document.querySelector('.tab[data-tab="search"]');
|
||
if (search) search.click();
|
||
});
|
||
var lo = $('#ua-logout');
|
||
if (lo) lo.addEventListener('click', doLogout);
|
||
// 返回/退出两个无 data-view 的侧栏项同样补键盘语义
|
||
[back, lo].forEach(function (el) {
|
||
if (!el) return;
|
||
el.setAttribute('tabindex', '0');
|
||
el.setAttribute('role', 'button');
|
||
el.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); }
|
||
});
|
||
});
|
||
/* ---- 播放 API 密钥 ---- */
|
||
var pkShow = $('#ua-playkey-show');
|
||
var pkInput = $('#ua-playkey-value');
|
||
function pkCopyToClipboard(text) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(function () { toast('密钥已复制', 'success'); },
|
||
function () { toast('复制失败,请手动选择文本复制', 'error'); });
|
||
} else {
|
||
toast('复制失败,请点击「显示密钥」后手动复制', 'error');
|
||
}
|
||
}
|
||
if (pkShow) pkShow.addEventListener('click', function () {
|
||
if (pkShow.dataset.state === 'shown') {
|
||
pkInput.value = '';
|
||
pkInput.placeholder = '点击「显示密钥」查看';
|
||
pkShow.textContent = '显示密钥';
|
||
pkShow.dataset.state = '';
|
||
return;
|
||
}
|
||
api('/admin/site/play_key/get', {}).then(function (d) {
|
||
if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; }
|
||
pkInput.value = d.data.key;
|
||
pkShow.textContent = '隐藏';
|
||
pkShow.dataset.state = 'shown';
|
||
});
|
||
});
|
||
var pkCopy = $('#ua-playkey-copy');
|
||
if (pkCopy) pkCopy.addEventListener('click', function () {
|
||
api('/admin/site/play_key/get', {}).then(function (d) {
|
||
if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; }
|
||
pkCopyToClipboard(d.data.key);
|
||
});
|
||
});
|
||
var pkRotate = $('#ua-playkey-rotate');
|
||
if (pkRotate) pkRotate.addEventListener('click', function () {
|
||
if (!confirm('刷新后旧密钥立即全部失效(外部旧地址将打不开),确定继续?')) return;
|
||
api('/admin/site/play_key/rotate', {}).then(function (d) {
|
||
if (d.status !== 200) { toast(d.message || '刷新失败', 'error'); return; }
|
||
pkInput.value = d.data.key;
|
||
pkShow.textContent = '隐藏';
|
||
pkShow.dataset.state = 'shown';
|
||
toast('密钥已刷新,旧密钥已全部失效', 'success');
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ---- 用户管理 ---- */
|
||
function uaInjectUserToolbar() {
|
||
var refresh = $('#ua-users-refresh');
|
||
if (!refresh || $('#ua-user-create')) return;
|
||
var btn = document.createElement('button');
|
||
btn.className = 'btn btn-sm btn-primary';
|
||
btn.id = 'ua-user-create';
|
||
btn.type = 'button';
|
||
btn.textContent = '➕ 添加用户';
|
||
btn.addEventListener('click', uaCreateUserModal);
|
||
refresh.parentNode.insertBefore(btn, refresh);
|
||
}
|
||
|
||
function uaCreateUserModal() {
|
||
var old = $('#site-create-user-modal');
|
||
if (old) old.remove();
|
||
var mask = document.createElement('div');
|
||
mask.className = 'modal-mask show';
|
||
mask.id = 'site-create-user-modal';
|
||
mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除
|
||
mask.innerHTML =
|
||
'<div class="modal" style="width:400px;">' +
|
||
' <div class="modal-header"><div class="modal-title">➕ 添加用户</div></div>' +
|
||
' <div class="modal-body">' +
|
||
' <input class="input" id="cnu-username" placeholder="用户名(3-20位)" style="width:100%;margin-bottom:10px;"/>' +
|
||
' <input class="input" id="cnu-password" placeholder="初始密码(至少8位)" style="width:100%;margin-bottom:10px;"/>' +
|
||
' <input class="input" id="cnu-email" placeholder="邮箱(可选)" style="width:100%;margin-bottom:10px;"/>' +
|
||
' <input class="input" id="cnu-count" type="number" min="0" value="10" placeholder="下载次数" style="width:100%;"/>' +
|
||
' </div>' +
|
||
' <div style="display:flex;gap:10px;padding:0 22px 20px;">' +
|
||
' <button class="btn btn-primary" id="cnu-submit" type="button" style="flex:1;">创 建</button>' +
|
||
' <button class="btn" id="cnu-cancel" type="button" style="flex:1;">取消</button>' +
|
||
' </div>' +
|
||
'</div>';
|
||
document.body.appendChild(mask);
|
||
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
|
||
var cancel = $('#cnu-cancel');
|
||
if (cancel) cancel.addEventListener('click', function () { mask.remove(); });
|
||
var submit = $('#cnu-submit');
|
||
if (submit) submit.addEventListener('click', function () {
|
||
var body = {
|
||
username: ($('#cnu-username') || {}).value || '',
|
||
password: ($('#cnu-password') || {}).value || '',
|
||
email: ($('#cnu-email') || {}).value || '',
|
||
count: ($('#cnu-count') || {}).value || 0
|
||
};
|
||
if (!body.username || body.password.length < 8) { toast('请填写用户名和至少 8 位密码', 'error'); return; }
|
||
submit.disabled = true;
|
||
api('/admin/site/user/create', body).then(function (d) {
|
||
toast(d.message || (d.status === 200 ? '创建成功' : '创建失败'), d.status === 200 ? 'success' : 'error');
|
||
if (d.status === 200) { mask.remove(); uaLoadUsers(); }
|
||
}).finally(function () { submit.disabled = false; });
|
||
});
|
||
}
|
||
|
||
function uaLoadUsers() {
|
||
uaInjectUserToolbar();
|
||
api('/admin/site/users', {}).then(function (data) {
|
||
var body = $('#ua-users-body');
|
||
if (!body) return;
|
||
if (data.status !== 200) { body.innerHTML = '<tr><td colspan="8">' + escapeHtml(data.message || '加载失败') + '</td></tr>'; return; }
|
||
var users = (data.data && data.data.users) || [];
|
||
var cnt = $('#ua-users-count');
|
||
if (cnt) cnt.textContent = users.length;
|
||
if (!users.length) { body.innerHTML = '<tr><td colspan="8">暂无用户</td></tr>'; return; }
|
||
body.innerHTML = users.map(function (u) {
|
||
var isAdmin = u.role === 'admin';
|
||
var disabled = (u.status === 0);
|
||
var banned = (u.status === 2);
|
||
// 数值字段同过转义(纵深防御——后端口径若变为字符串
|
||
// 不再形成管理后台存储型 XSS 面,与同表 username/email 口径对齐)
|
||
var uid = escapeHtml(String(u.id));
|
||
var dur = escapeHtml(String(u.duration_seconds));
|
||
var cred = u.duration_seconds < 0 ? '不限' : (dur + ' 次');
|
||
var dev = [u.register_ip, u.trial_device].filter(Boolean).join(' · ') || '-'; // IP 优先显示
|
||
if (u.last_province) dev += ' · ' + u.last_province;
|
||
return '<tr' + ((disabled || banned) ? ' style="opacity:.55;"' : '') + '>' +
|
||
'<td>' + uid + '</td>' +
|
||
'<td>' + escapeHtml(u.username) +
|
||
(banned ? ' <span class="tag-pill" style="background:#8a1f1f;color:#fff;font-size:11px;">已封禁</span>' : '') +
|
||
(disabled ? ' <span class="tag-pill" style="background:var(--color-error,#e0483f);color:#fff;font-size:11px;">已禁用</span>' : '') + '</td>' +
|
||
'<td>' + escapeHtml(u.email || '-') + '</td>' +
|
||
'<td>' + (isAdmin ? '管理员' : '用户') + '</td>' +
|
||
'<td>' + cred + '</td>' +
|
||
'<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + escapeHtml(dev) + '">' + escapeHtml(dev) + '</td>' +
|
||
'<td><button class="btn btn-sm" data-ucookie="' + escapeHtml(u.username) + '">' + (u.has_netease_cookie ? '查看' : '无') + '</button></td>' +
|
||
'<td>' +
|
||
'<button class="btn btn-sm" data-uedit="' + uid + '" data-cur="' + dur + '">改次数</button> ' +
|
||
(isAdmin ? '<button class="btn btn-sm" data-uself="' + uid + '">改自己密码</button>' :
|
||
'<button class="btn btn-sm" data-upwd="' + uid + '" data-name="' + escapeHtml(u.username) + '">重置密码</button> ' +
|
||
'<button class="btn btn-sm" data-utoggle="' + uid + '">' + (disabled ? '启用' : '禁用') + '</button> ' +
|
||
'<button class="btn btn-sm" data-uban="' + uid + '" data-name="' + escapeHtml(u.username) + '">' + (banned ? '解封' : '封禁') + '</button> ' +
|
||
'<button class="btn btn-sm" data-udel="' + uid + '" data-name="' + escapeHtml(u.username) + '" style="color:var(--color-error);">删除</button>') +
|
||
'</td></tr>';
|
||
}).join('');
|
||
$$('[data-uedit]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
var v = prompt('设置该用户的剩余次数(-1 表示不限):', b.getAttribute('data-cur'));
|
||
if (v === null) return;
|
||
api('/admin/site/user/duration', { id: b.getAttribute('data-uedit'), count: v })
|
||
.then(function (d) { toast(d.message || (d.status === 200 ? '已修改' : '修改失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); });
|
||
});
|
||
});
|
||
// 管理员自助改密(需验证旧密码;改完其他设备全部下线)
|
||
$$('[data-uself]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
var oldpwd = prompt('请输入当前密码:', '');
|
||
if (oldpwd === null) return;
|
||
var newpwd = prompt('请输入新密码(至少 8 位):', '');
|
||
if (newpwd === null) return;
|
||
api('/admin/site/user/reset_password', {
|
||
id: b.getAttribute('data-uself'), password: newpwd, old_password: oldpwd
|
||
}).then(function (d) {
|
||
toast(d.message || (d.status === 200 ? '密码已修改' : '修改失败'), d.status === 200 ? 'success' : 'error');
|
||
});
|
||
});
|
||
});
|
||
$$('[data-upwd]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
var v = prompt('为用户「' + b.getAttribute('data-name') + '」设置新密码(至少 8 位):', '');
|
||
if (v === null) return;
|
||
api('/admin/site/user/reset_password', { id: b.getAttribute('data-upwd'), password: v })
|
||
.then(function (d) { toast(d.message || (d.status === 200 ? '已重置' : '重置失败'), d.status === 200 ? 'success' : 'error'); });
|
||
});
|
||
});
|
||
$$('[data-utoggle]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
api('/admin/site/user/toggle', { id: b.getAttribute('data-utoggle') })
|
||
.then(function (d) { toast(d.message || '操作成功', d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); });
|
||
});
|
||
});
|
||
$$('[data-uban]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
var name = b.getAttribute('data-name');
|
||
if (b.textContent === '封禁') {
|
||
var reason = prompt('封禁用户「' + name + '」\n请输入封禁原因(将展示给用户):', '违规使用,封禁处理');
|
||
if (reason === null) return;
|
||
api('/admin/site/user/ban', { id: b.getAttribute('data-uban'), action: 'ban', reason: reason })
|
||
.then(function (d) { toast(d.message || (d.status === 200 ? '已封禁' : '操作失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); });
|
||
} else {
|
||
if (!window.confirm('解封用户「' + name + '」?解封后可正常登录使用。')) return;
|
||
api('/admin/site/user/ban', { id: b.getAttribute('data-uban'), action: 'unban' })
|
||
.then(function (d) { toast(d.message || (d.status === 200 ? '已解封' : '操作失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); });
|
||
}
|
||
});
|
||
});
|
||
$$('[data-udel]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
if (!window.confirm('确定删除用户「' + b.getAttribute('data-name') + '」?')) return;
|
||
api('/admin/site/user/delete', { id: b.getAttribute('data-udel') })
|
||
.then(function (d) { toast(d.message || '已删除', d.status === 200 ? 'success' : 'error'); uaLoadUsers(); });
|
||
});
|
||
});
|
||
$$('[data-ucookie]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
api('/admin/site/user/cookie', { username: b.getAttribute('data-ucookie') })
|
||
.then(function (d) {
|
||
if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; }
|
||
var ck = (d.data && d.data.cookie) || '';
|
||
if (!ck) { toast('该用户未登录过网易云账号', 'info'); return; }
|
||
prompt('用户网易云 Cookie(Ctrl+C 复制):', ck);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ---- 全局 Cookie ---- */
|
||
var uaQrTimer = null, uaUnikey = '', uaQrGen = 0; // 轮询代次令牌(删除 Cookie 后作废旧轮询)
|
||
function uaLoadCookieStatus() {
|
||
api('/admin/cookie/status', {}).then(function (data) {
|
||
var el = $('#ua-cookie-status');
|
||
if (!el) return;
|
||
if (data.status !== 200) { el.textContent = data.message || '获取失败'; return; }
|
||
var info = data.data || {};
|
||
el.textContent = (info.is_valid ? '✅ 有效' : '❌ 未登录') +
|
||
(info.cookie_count != null ? ' · Cookie ' + info.cookie_count + ' 个' : '');
|
||
});
|
||
}
|
||
function uaQrCreate() {
|
||
api('/admin/qr/create', {}).then(function (data) {
|
||
if (data.status !== 200) { toast(data.message || '生成二维码失败', 'error'); return; }
|
||
var d = data.data || {};
|
||
uaUnikey = d.unikey || '';
|
||
var box = $('#ua-qr-box'), tip = $('#ua-qr-tip');
|
||
if (d.qr_svg) {
|
||
var uri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(d.qr_svg)));
|
||
box.innerHTML = '<img src="' + uri + '" style="width:180px;height:180px;" alt="二维码"/>';
|
||
}
|
||
if (tip) tip.textContent = '请使用网易云音乐手机 APP 扫码';
|
||
if (uaQrTimer) clearInterval(uaQrTimer);
|
||
uaQrGen++; // 新轮询代次
|
||
uaQrTimer = setInterval(uaQrPoll, 3000);
|
||
});
|
||
}
|
||
function uaQrPoll() {
|
||
if (!uaUnikey) { if (uaQrTimer) clearInterval(uaQrTimer); return; }
|
||
var myGen = uaQrGen; // 本次轮询所属代次
|
||
api('/admin/qr/check', { unikey: uaUnikey }).then(function (data) {
|
||
if (myGen !== uaQrGen) return; // 期间已删除 Cookie/重开扫码,丢弃迟到响应
|
||
var tip = $('#ua-qr-tip');
|
||
if (data.status !== 200) {
|
||
if (uaQrTimer) clearInterval(uaQrTimer);
|
||
if (tip) tip.textContent = data.message || '检查失败';
|
||
return;
|
||
}
|
||
var st = data.data && data.data.status;
|
||
if (st === 'waiting') { if (tip) tip.textContent = '等待扫码...'; }
|
||
else if (st === 'scanned') { if (tip) tip.textContent = '✅ 已扫码,请在手机上确认'; }
|
||
else if (st === 'success') {
|
||
if (uaQrTimer) clearInterval(uaQrTimer);
|
||
if (tip) tip.textContent = '🎉 登录成功,Cookie 已保存';
|
||
toast('全局账号登录成功', 'success');
|
||
uaLoadCookieStatus();
|
||
} else {
|
||
if (uaQrTimer) clearInterval(uaQrTimer);
|
||
if (tip) tip.textContent = '二维码已过期,请重新生成';
|
||
}
|
||
});
|
||
}
|
||
function uaCookieDelete() {
|
||
if (!window.confirm('确定删除全局 Cookie?删除后需重新扫码登录。')) return;
|
||
api('/admin/cookie/delete', {}).then(function (data) {
|
||
// 作废在途扫码轮询——迟到的 803 成功响应不得把刚删除的 Cookie 写回
|
||
if (uaQrTimer) clearInterval(uaQrTimer);
|
||
uaUnikey = '';
|
||
uaQrGen++;
|
||
toast(data.message || '已删除', data.status === 200 ? 'success' : 'error');
|
||
var box = $('#ua-qr-box'), tip = $('#ua-qr-tip');
|
||
if (box) box.innerHTML = '';
|
||
if (tip) tip.textContent = '点击下方按钮生成二维码';
|
||
uaLoadCookieStatus();
|
||
});
|
||
}
|
||
|
||
/* ---- SMTP ---- */
|
||
function uaLoadSmtp() {
|
||
api('/admin/site/smtp/get', {}).then(function (data) {
|
||
if (data.status !== 200) return;
|
||
var d = data.data || {};
|
||
if ($('#ua-smtp-host')) $('#ua-smtp-host').value = d.host || '';
|
||
if ($('#ua-smtp-port')) $('#ua-smtp-port').value = d.port || 587;
|
||
if ($('#ua-smtp-sender')) $('#ua-smtp-sender').value = d.sender || '';
|
||
});
|
||
}
|
||
function uaSaveSmtp() {
|
||
api('/admin/site/smtp/save', {
|
||
host: ($('#ua-smtp-host') || {}).value || '',
|
||
port: ($('#ua-smtp-port') || {}).value || 587,
|
||
sender: ($('#ua-smtp-sender') || {}).value || '',
|
||
password: ($('#ua-smtp-pass') || {}).value || ''
|
||
}).then(function (d) {
|
||
toast(d.message || '已保存', d.status === 200 ? 'success' : 'error');
|
||
var p = $('#ua-smtp-pass'); if (p) p.value = '';
|
||
});
|
||
}
|
||
function uaTestSmtp() {
|
||
var to = ($('#ua-smtp-test-to') || {}).value || '';
|
||
if (!to) { toast('请填写测试收件邮箱', 'error'); return; }
|
||
var btn = $('#ua-smtp-test');
|
||
btn.disabled = true;
|
||
api('/admin/site/smtp/test', { to: to }).then(function (d) {
|
||
toast(d.message || '已发送', d.status === 200 ? 'success' : 'error');
|
||
}).finally(function () { btn.disabled = false; });
|
||
}
|
||
|
||
/* ---- 日志 ---- */
|
||
var uaLogPage = 1;
|
||
function uaLoadLogs() {
|
||
api('/admin/site/logs', { type: uaLogsType, page: uaLogPage }).then(function (data) {
|
||
var box = $('#ua-log-list');
|
||
if (!box) return;
|
||
if (data.status !== 200) { box.textContent = data.message || '加载失败'; return; }
|
||
var logs = (data.data && data.data.logs) || [];
|
||
var total = (data.data && data.data.total) || 0;
|
||
var pages = (data.data && data.data.pages) || 1;
|
||
uaLogPage = (data.data && data.data.page) || 1;
|
||
window._uaLogPages = pages; // next 按钮按真实页数封顶
|
||
var el;
|
||
if ((el = $('#ua-log-page'))) el.textContent = uaLogPage;
|
||
if ((el = $('#ua-log-pages'))) el.textContent = pages;
|
||
if ((el = $('#ua-log-total'))) el.textContent = total;
|
||
if (!logs.length) { box.textContent = '暂无日志'; return; }
|
||
box.innerHTML = logs.map(function (l) {
|
||
return '<div>[' + escapeHtml(l.time) + '] ' + escapeHtml(l.username || '-') +
|
||
' · ' + escapeHtml(l.action) + ' · IP ' + escapeHtml(l.ip) +
|
||
(l.detail ? ' · ' + escapeHtml(l.detail) : '') + '</div>';
|
||
}).join('');
|
||
});
|
||
}
|
||
|
||
function uaClearLogs() {
|
||
var name = uaLogsType === 'fail' ? '失败' : '成功';
|
||
if (!window.confirm('确定清除全部「' + name + '」日志?该操作不可恢复。')) return;
|
||
api('/admin/site/logs/clear', { type: uaLogsType }).then(function (d) {
|
||
toast(d.message || (d.status === 200 ? '已清除' : '清除失败'), d.status === 200 ? 'success' : 'error');
|
||
uaLogPage = 1;
|
||
uaLoadLogs();
|
||
});
|
||
}
|
||
|
||
/* ---- 卡密 ---- */
|
||
function uaGenCards() {
|
||
var creditsVal = ($('#ua-card-minutes') || {}).value || '10';
|
||
if (creditsVal === 'custom') {
|
||
creditsVal = ($('#ua-card-custom') || {}).value || '0';
|
||
if (!(parseInt(creditsVal, 10) > 0)) { toast('请填写自定义次数(正整数)', 'error'); return; }
|
||
}
|
||
api('/admin/site/cards/generate', {
|
||
count: ($('#ua-card-count') || {}).value || 1,
|
||
credits: creditsVal,
|
||
prefix: ($('#ua-card-prefix') || {}).value || 'VIP',
|
||
note: ($('#ua-card-note') || {}).value || '',
|
||
valid_days: ($('#ua-card-days') || {}).value || 0
|
||
}).then(function (data) {
|
||
if (data.status !== 200) { toast(data.message || '生成失败', 'error'); return; }
|
||
var box = $('#ua-card-codes');
|
||
if (box) {
|
||
box.hidden = false;
|
||
box.textContent = (data.data.codes || []).join('\n');
|
||
}
|
||
toast('已生成 ' + (data.data.count || 0) + ' 张卡密(' + (data.data.credits || 0) + ' 次/张),已复制到文本框,请及时保存', 'success');
|
||
uaLoadCards(0);
|
||
});
|
||
}
|
||
// 卡密管理操作列(禁用/启用/删除)+ 分页(后端已支持,
|
||
// 不分页则超出最新 100 张的旧卡密在界面上永远管理不到)
|
||
var _uaCardPage = 0;
|
||
var _uaCardTotal = 0;
|
||
var UA_CARD_PAGE_SIZE = 100;
|
||
function uaCardAction(act, code) {
|
||
if (!code) return;
|
||
if (act === 'delete' && !window.confirm('确定删除卡密 ' + code.slice(0, 4) + '**** 吗?删除后不可恢复。')) return;
|
||
var url = act === 'delete' ? '/admin/site/cards/delete' : '/admin/site/cards/disable';
|
||
var body = { code: code, disabled: act === 'enable' ? '0' : '1' };
|
||
api(url, body).then(function (data) {
|
||
toast(data.message || (data.status === 200 ? '操作成功' : '操作失败'),
|
||
data.status === 200 ? 'success' : 'error');
|
||
if (data.status === 200) uaLoadCards();
|
||
});
|
||
}
|
||
function _uaRenderCardPager() {
|
||
var pager = $('#ua-cards-pager');
|
||
if (!pager) return;
|
||
if (_uaCardTotal <= 0) { pager.hidden = true; return; }
|
||
var pages = Math.max(1, Math.ceil(_uaCardTotal / UA_CARD_PAGE_SIZE));
|
||
pager.hidden = false;
|
||
pager.textContent = '';
|
||
var prev = document.createElement('button');
|
||
prev.className = 'btn btn-sm'; prev.type = 'button'; prev.textContent = '上一页';
|
||
prev.disabled = _uaCardPage <= 0;
|
||
prev.addEventListener('click', function () { uaLoadCards(_uaCardPage - 1); });
|
||
var next = document.createElement('button');
|
||
next.className = 'btn btn-sm'; next.type = 'button'; next.textContent = '下一页';
|
||
next.disabled = _uaCardPage >= pages - 1;
|
||
next.addEventListener('click', function () { uaLoadCards(_uaCardPage + 1); });
|
||
var info = document.createElement('span');
|
||
info.className = 'hint';
|
||
info.textContent = '第 ' + (_uaCardPage + 1) + ' / ' + pages + ' 页 · 共 ' + _uaCardTotal + ' 张';
|
||
pager.appendChild(prev);
|
||
pager.appendChild(info);
|
||
pager.appendChild(next);
|
||
}
|
||
function uaLoadCards(page) {
|
||
if (typeof page === 'number') _uaCardPage = Math.max(0, page);
|
||
api('/admin/site/cards/list', { limit: UA_CARD_PAGE_SIZE, offset: _uaCardPage * UA_CARD_PAGE_SIZE })
|
||
.then(function (data) {
|
||
var body = $('#ua-cards-body');
|
||
if (!body) return;
|
||
if (data.status !== 200) { body.innerHTML = '<tr><td colspan="8">加载失败</td></tr>'; return; }
|
||
var cards = (data.data && data.data.cards) || [];
|
||
_uaCardTotal = (data.data && data.data.total) || 0;
|
||
if (!cards.length) {
|
||
body.innerHTML = '<tr><td colspan="8">暂无卡密</td></tr>';
|
||
_uaRenderCardPager();
|
||
return;
|
||
}
|
||
body.innerHTML = cards.map(function (c) {
|
||
var st, act;
|
||
if (c.used_by) {
|
||
st = '已使用';
|
||
act = '<span class="hint">—</span>'; // 兑换凭证保留,不给删除/禁用
|
||
} else if (c.disabled) {
|
||
st = '已禁用';
|
||
act = '<button class="btn btn-sm" data-card-act="enable" data-card-code="' + escapeHtml(c.code) + '" type="button">启用</button> ' +
|
||
'<button class="btn btn-danger btn-sm" data-card-act="delete" data-card-code="' + escapeHtml(c.code) + '" type="button">删除</button>';
|
||
} else {
|
||
// 过期判定用服务端下发的 expired(客户端时钟偏差会误标)
|
||
st = c.expired ? '已过期' : '未使用';
|
||
act = '<button class="btn btn-sm" data-card-act="disable" data-card-code="' + escapeHtml(c.code) + '" type="button">禁用</button> ' +
|
||
'<button class="btn btn-danger btn-sm" data-card-act="delete" data-card-code="' + escapeHtml(c.code) + '" type="button">删除</button>';
|
||
}
|
||
return '<tr><td>' + escapeHtml(c.code) + '</td><td>' + escapeHtml(String(c.minutes)) + ' 次</td><td>' + st + '</td><td>' + escapeHtml(c.used_by || '-') + '</td><td>' +
|
||
escapeHtml(c.note || '-') + '</td><td>' +
|
||
(c.expire_at ? new Date(c.expire_at * 1000).toLocaleDateString() : '永久') + '</td><td>' +
|
||
escapeHtml(c.created_at ? new Date(c.created_at * 1000).toLocaleDateString() : '-') + '</td><td>' + act + '</td></tr>';
|
||
}).join('');
|
||
_uaRenderCardPager();
|
||
Array.prototype.forEach.call(body.querySelectorAll('button[data-card-act]'), function (b) {
|
||
b.addEventListener('click', function () {
|
||
uaCardAction(b.getAttribute('data-card-act'), b.getAttribute('data-card-code'));
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ---- 公告 ---- */
|
||
function uaLoadAnn() {
|
||
api('/admin/site/ann/get', {}).then(function (data) {
|
||
if (data.status !== 200) return;
|
||
var d = data.data || {};
|
||
if ($('#ua-ann-title')) $('#ua-ann-title').value = d.title || '';
|
||
if ($('#ua-ann-content')) $('#ua-ann-content').value = d.content || '';
|
||
if ($('#ua-ann-mode')) $('#ua-ann-mode').value = d.popup_mode || 'off';
|
||
});
|
||
}
|
||
function uaSaveAnn() {
|
||
api('/admin/site/ann/save', {
|
||
title: ($('#ua-ann-title') || {}).value || '',
|
||
content: ($('#ua-ann-content') || {}).value || '',
|
||
popup_mode: ($('#ua-ann-mode') || {}).value || 'off'
|
||
}).then(function (d) { toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); });
|
||
}
|
||
|
||
/* ---- 数据库 ---- */
|
||
function fmtSize(n) {
|
||
if (!n && n !== 0) return '-';
|
||
if (n > 1048576) return (n / 1048576).toFixed(2) + ' MB';
|
||
if (n > 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
return n + ' B';
|
||
}
|
||
function uaLoadDb() {
|
||
api('/admin/site/backup/list', {}).then(function (data) {
|
||
var body = $('#ua-db-body');
|
||
if (!body) return;
|
||
if (data.status !== 200) { body.innerHTML = '<tr><td colspan="4">加载失败</td></tr>'; return; }
|
||
var d = data.data || {};
|
||
var size = $('#ua-db-size'); if (size) size.textContent = fmtSize(d.db_size || 0);
|
||
var hours = $('#ua-db-hours'); if (hours && d.interval_hours != null) hours.value = d.interval_hours;
|
||
var baks = d.backups || [];
|
||
if (!baks.length) { body.innerHTML = '<tr><td colspan="4">暂无备份</td></tr>'; return; }
|
||
body.innerHTML = baks.map(function (b) {
|
||
return '<tr><td>' + escapeHtml(b.name) + '</td><td>' + fmtSize(b.size) + '</td><td>' +
|
||
escapeHtml(b.time) + '</td><td>' +
|
||
'<a class="btn btn-sm" href="/admin/site/backup/download?name=' + encodeURIComponent(b.name) + '">下载</a> ' +
|
||
'<button class="btn btn-sm" data-restore="' + escapeHtml(b.name) + '">恢复</button> ' +
|
||
'<button class="btn btn-sm" data-bdel="' + escapeHtml(b.name) + '" style="color:var(--color-error);">删除</button></td></tr>';
|
||
}).join('');
|
||
$$('[data-restore]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
if (!window.confirm('确定用该备份覆盖当前数据库?')) return;
|
||
api('/admin/site/backup/restore', { name: b.getAttribute('data-restore') })
|
||
.then(function (d2) {
|
||
toast(d2.message || '已恢复', d2.status === 200 ? 'success' : 'error');
|
||
if (d2.status === 200) setTimeout(function () { location.reload(); }, 1200);
|
||
});
|
||
});
|
||
});
|
||
$$('[data-bdel]', body).forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
if (!window.confirm('确定删除该备份?')) return;
|
||
api('/admin/site/backup/delete', { name: b.getAttribute('data-bdel') })
|
||
.then(function (d2) { toast(d2.message || '已删除', d2.status === 200 ? 'success' : 'error'); uaLoadDb(); });
|
||
});
|
||
});
|
||
});
|
||
}
|
||
function uaBackup() {
|
||
api('/admin/site/backup/create', {}).then(function (d) {
|
||
toast(d.message || '备份完成', d.status === 200 ? 'success' : 'error');
|
||
uaLoadDb();
|
||
});
|
||
}
|
||
function uaBackupAuto() {
|
||
api('/admin/site/backup/auto', { hours: ($('#ua-db-hours') || {}).value || 0 })
|
||
.then(function (d) { toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); });
|
||
}
|
||
function uaBackupUpload(file) {
|
||
var fd = new FormData();
|
||
fd.append('file', file);
|
||
fetch('/admin/site/backup/upload', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error('上传失败(HTTP ' + r.status + ')');
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
toast(d.message || '上传完成', d.status === 200 ? 'success' : 'error');
|
||
uaLoadDb();
|
||
})
|
||
.catch(function (err) {
|
||
// 全站唯一绕过统一请求封装的调用此前无失败兜底——
|
||
// 网络失败/非 JSON 响应时管理员毫无感知还制造 unhandledrejection 噪音
|
||
toast((err && err.message) || '备份上传失败,请检查网络后重试', 'error');
|
||
});
|
||
}
|
||
|
||
/* ---------------- 绑定 ---------------- */
|
||
function bind() {
|
||
bindAuthModal();
|
||
var s1 = $('#login-submit'); if (s1) s1.addEventListener('click', doLogin);
|
||
var s2 = $('#login-password'); if (s2) s2.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); });
|
||
// 回车提交覆盖全部输入框(登录用户名框 / 注册四框)
|
||
var s2u = $('#login-username'); if (s2u) s2u.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); });
|
||
var s3 = $('#reg-submit'); if (s3) s3.addEventListener('click', doRegister);
|
||
var s4 = $('#reg-send-code'); if (s4) s4.addEventListener('click', sendCode);
|
||
['#reg-username', '#reg-password', '#reg-email', '#reg-code'].forEach(function (sel) {
|
||
var el = $(sel);
|
||
if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doRegister(); });
|
||
});
|
||
// 密码明文切换(登录/注册/找回)
|
||
[['#login-pwd-eye', '#login-password'], ['#reg-pwd-eye', '#reg-password'], ['#forgot-pwd-eye', '#forgot-password']].forEach(function (pair) {
|
||
var eye = $(pair[0]), input = $(pair[1]);
|
||
if (!eye || !input) return;
|
||
eye.addEventListener('click', function () {
|
||
var show = input.type === 'password';
|
||
input.type = show ? 'text' : 'password';
|
||
eye.textContent = show ? '隐藏' : '显示';
|
||
});
|
||
});
|
||
// 发码后修改邮箱 → 提前终止倒计时并恢复按钮,提示需重发
|
||
var regEmail = $('#reg-email');
|
||
if (regEmail) regEmail.addEventListener('input', function () {
|
||
var btn = $('#reg-send-code');
|
||
if (btn && btn.disabled && /^\d+s$/.test(btn.textContent)) {
|
||
clearInterval(window.__regCodeTimer || 0);
|
||
btn.disabled = false; btn.textContent = '发送验证码';
|
||
toast('邮箱已修改,请重新发送验证码', 'info');
|
||
}
|
||
});
|
||
// 找回视图——发码/提交/回车/改邮箱终止倒计时(对齐注册交互)
|
||
var fsc = $('#forgot-send-code'); if (fsc) fsc.addEventListener('click', doForgotSendCode);
|
||
var fsub = $('#forgot-submit'); if (fsub) fsub.addEventListener('click', doForgotReset);
|
||
['#forgot-email', '#forgot-code', '#forgot-password'].forEach(function (sel) {
|
||
var el = $(sel);
|
||
if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doForgotReset(); });
|
||
});
|
||
var forgotEmail = $('#forgot-email');
|
||
if (forgotEmail) forgotEmail.addEventListener('input', function () {
|
||
var btn = $('#forgot-send-code');
|
||
if (btn && btn.disabled && /^\d+s$/.test(btn.textContent)) {
|
||
clearInterval(window.__forgotCodeTimer || 0);
|
||
btn.disabled = false; btn.textContent = '发送验证码';
|
||
toast('邮箱已修改,请重新发送验证码', 'info');
|
||
}
|
||
});
|
||
// 管理员两步验证——发码/验证提交/回车提交
|
||
var asc = $('#a2fa-send'); if (asc) asc.addEventListener('click', doA2faSendCode);
|
||
var asb = $('#a2fa-submit'); if (asb) asb.addEventListener('click', doA2faVerify);
|
||
['#a2fa-email', '#a2fa-code'].forEach(function (sel) {
|
||
var el = $(sel);
|
||
if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doA2faVerify(); });
|
||
});
|
||
var s6 = $('#announce-ok'); if (s6) s6.addEventListener('click', function () { closeModal('announce-modal'); });
|
||
|
||
var ucTab = $('#tab-usercenter');
|
||
if (ucTab) ucTab.addEventListener('click', renderUserCenter);
|
||
var uaTab = $('#tab-useradmin');
|
||
if (uaTab) uaTab.addEventListener('click', uaShowGate);
|
||
var av = $('#user-avatar');
|
||
if (av) av.addEventListener('click', function () {
|
||
if (user && user.role === 'admin') { if (uaTab) uaTab.click(); }
|
||
else { var t = $('#tab-usercenter'); if (t) t.click(); }
|
||
});
|
||
var ucRedeem = $('#uc-card-redeem'); if (ucRedeem) ucRedeem.addEventListener('click', doRedeem);
|
||
var ucAnn = $('#uc-ann-view'); if (ucAnn) ucAnn.addEventListener('click', function () { maybeShowAnnouncement(true); }); // 手动查看强制展示
|
||
var ucLogout = $('#uc-logout'); if (ucLogout) ucLogout.addEventListener('click', doLogout);
|
||
|
||
uaBindSide();
|
||
var b1 = $('#ua-users-refresh'); if (b1) b1.addEventListener('click', uaLoadUsers);
|
||
var b2 = $('#ua-cookie-refresh'); if (b2) b2.addEventListener('click', uaLoadCookieStatus);
|
||
var b3 = $('#ua-qr-create'); if (b3) b3.addEventListener('click', uaQrCreate);
|
||
var b4 = $('#ua-cookie-delete'); if (b4) b4.addEventListener('click', uaCookieDelete);
|
||
var b5 = $('#ua-smtp-save'); if (b5) b5.addEventListener('click', uaSaveSmtp);
|
||
var b6 = $('#ua-smtp-test'); if (b6) b6.addEventListener('click', uaTestSmtp);
|
||
var b7 = $('#ua-log-refresh'); if (b7) b7.addEventListener('click', function () { uaLoadLogs(); });
|
||
var b8 = $('#ua-log-tab-ok'); if (b8) b8.addEventListener('click', function () { uaLogsType = 'success'; uaLogPage = 1; uaLoadLogs(); });
|
||
var b9 = $('#ua-log-tab-fail'); if (b9) b9.addEventListener('click', function () { uaLogsType = 'fail'; uaLogPage = 1; uaLoadLogs(); });
|
||
var blogPrev = $('#ua-log-prev'); if (blogPrev) blogPrev.addEventListener('click', function () { if (uaLogPage > 1) { uaLogPage--; uaLoadLogs(); } });
|
||
// next 补边界(原实现可翻到空页——前端无 pages 时放宽为 999 防呆)
|
||
var blogNext = $('#ua-log-next'); if (blogNext) blogNext.addEventListener('click', function () { var maxP = window._uaLogPages || 999; if (uaLogPage < maxP) { uaLogPage++; uaLoadLogs(); } });
|
||
var blogClear = $('#ua-log-clear'); if (blogClear) blogClear.addEventListener('click', uaClearLogs);
|
||
var b10 = $('#ua-card-gen'); if (b10) b10.addEventListener('click', uaGenCards);
|
||
var cardType = $('#ua-card-minutes');
|
||
if (cardType) cardType.addEventListener('change', function () {
|
||
var custom = $('#ua-card-custom');
|
||
if (custom) custom.hidden = cardType.value !== 'custom';
|
||
if (cardType.value === 'custom' && custom) custom.focus();
|
||
});
|
||
var b11 = $('#ua-ann-save'); if (b11) b11.addEventListener('click', uaSaveAnn);
|
||
var b12 = $('#ua-db-backup'); if (b12) b12.addEventListener('click', uaBackup);
|
||
var b13 = $('#ua-db-auto'); if (b13) b13.addEventListener('click', uaBackupAuto);
|
||
var b14 = $('#ua-db-upload');
|
||
if (b14) b14.addEventListener('change', function () {
|
||
if (b14.files && b14.files[0]) uaBackupUpload(b14.files[0]);
|
||
b14.value = '';
|
||
});
|
||
|
||
}
|
||
|
||
function init() {
|
||
bind();
|
||
refreshSession();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
}
|