/* ============================================================ * 云村小助手 - 站点账号系统前端逻辑(按次计费版) * 未经授权不得复制、修改或用于商业用途 * ============================================================ */ (function () { 'use strict'; /* 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) { 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 下发,文案不再硬编码 /* ---------------- 弹窗 ---------------- */ function openModal(id) { var m = $('#' + id); if (m) m.classList.add('show'); } function closeModal(id) { 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 = ''; 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 '
v' + v.v + ' ' + v.date + '
' + v.items.map(function (i) { return '· ' + i; }).join('
') + '
'; }).join('') + '
当前版本 v' + SITE_VERSION + '
'; } } 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('
'); // 首访 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 = ''; 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 = '' + escapeHtml(data.message || '加载失败') + ''; return; } var users = (data.data && data.data.users) || []; var cnt = $('#ua-users-count'); if (cnt) cnt.textContent = users.length; if (!users.length) { body.innerHTML = '暂无用户'; 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 '' + '' + uid + '' + '' + escapeHtml(u.username) + (banned ? ' 已封禁' : '') + (disabled ? ' 已禁用' : '') + '' + '' + escapeHtml(u.email || '-') + '' + '' + (isAdmin ? '管理员' : '用户') + '' + '' + cred + '' + '' + escapeHtml(dev) + '' + '' + '' + ' ' + (isAdmin ? '' : ' ' + ' ' + ' ' + '') + ''; }).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 = '二维码'; } 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 '
[' + escapeHtml(l.time) + '] ' + escapeHtml(l.username || '-') + ' · ' + escapeHtml(l.action) + ' · IP ' + escapeHtml(l.ip) + (l.detail ? ' · ' + escapeHtml(l.detail) : '') + '
'; }).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 = '加载失败'; return; } var cards = (data.data && data.data.cards) || []; _uaCardTotal = (data.data && data.data.total) || 0; if (!cards.length) { body.innerHTML = '暂无卡密'; _uaRenderCardPager(); return; } body.innerHTML = cards.map(function (c) { var st, act; if (c.used_by) { st = '已使用'; act = '—'; // 兑换凭证保留,不给删除/禁用 } else if (c.disabled) { st = '已禁用'; act = ' ' + ''; } else { // 过期判定用服务端下发的 expired(客户端时钟偏差会误标) st = c.expired ? '已过期' : '未使用'; act = ' ' + ''; } return '' + escapeHtml(c.code) + '' + escapeHtml(String(c.minutes)) + ' 次' + st + '' + escapeHtml(c.used_by || '-') + '' + escapeHtml(c.note || '-') + '' + (c.expire_at ? new Date(c.expire_at * 1000).toLocaleDateString() : '永久') + '' + escapeHtml(c.created_at ? new Date(c.created_at * 1000).toLocaleDateString() : '-') + '' + act + ''; }).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 = '加载失败'; 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 = '暂无备份'; return; } body.innerHTML = baks.map(function (b) { return '' + escapeHtml(b.name) + '' + fmtSize(b.size) + '' + escapeHtml(b.time) + '' + '下载 ' + ' ' + ''; }).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(); } })();