361 lines
18 KiB
JavaScript
361 lines
18 KiB
JavaScript
/* =====================================================================
|
||
* 全局会员登录 / 注册 弹窗组件(UserAuth)
|
||
* ---------------------------------------------------------------------
|
||
* 自包含:自动注入弹层 DOM 与样式,不依赖任何页面原生 HTML / 外部 CSS。
|
||
* 任意页面只需引入一段脚本即可调用,无需再写重复的弹层结构:
|
||
*
|
||
* <script src="/static/haonav/js/auth.js"></script>
|
||
*
|
||
* 可选配置(须在引入本脚本【之前】声明):
|
||
* <script>
|
||
* window.__USERAUTH_CONFIG__ = {
|
||
* apiBase: '/haonav/account', // 接口前缀(默认 haonav 会员接口)
|
||
* autoReload: true, // 登录/注册/退出后是否刷新当前页(默认 true)
|
||
* floating: false, // 页面没有 #userArea 时是否自动创建浮动入口(默认 false)
|
||
* afterLogin: function(user){}, // 登录成功回调(autoReload=false 时生效)
|
||
* afterLogout: function(){} // 退出成功回调
|
||
* };
|
||
* </script>
|
||
*
|
||
* 调用方式:
|
||
* 全局函数:openLogin() / openRegister()
|
||
* 命名空间:UserAuth.open(mode) / .close() / .refresh() / .render(user) / .on(evt, cb)
|
||
* 事件: 'login' / 'logout'(UserAuth.on('login', fn))
|
||
* ===================================================================== */
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ---------- 配置 ---------- */
|
||
var cfg = (window.__USERAUTH_CONFIG__ && typeof window.__USERAUTH_CONFIG__ === 'object') ? window.__USERAUTH_CONFIG__ : {};
|
||
cfg.apiBase = cfg.apiBase || '/haonav/account';
|
||
cfg.autoReload = cfg.autoReload !== false; // 默认刷新(适配依赖登录态的页面,如导航收藏)
|
||
cfg.floating = !!cfg.floating;
|
||
cfg.mount = cfg.mount || 'body';
|
||
|
||
var STYLE_ID = 'userauth-style';
|
||
var MASK_ID = 'authMask';
|
||
|
||
/* ---------- 工具 ---------- */
|
||
function $(s, c) { return (c || document).querySelector(s); }
|
||
function $all(s, c) { return Array.prototype.slice.call((c || document).querySelectorAll(s)); }
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
function defaultAvatar(name) {
|
||
var ch = (name || '?').trim().charAt(0).toUpperCase() || '?';
|
||
return 'data:image/svg+xml;utf8,' + encodeURIComponent(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="32" fill="#667eea"/><text x="50%" y="54%" font-size="30" fill="#fff" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif">' + ch + '</text></svg>'
|
||
);
|
||
}
|
||
|
||
/* ---------- 组件自带样式(单一来源,全站通用,不依赖外部 CSS 变量) ---------- */
|
||
var STYLE = "\
|
||
:root{\
|
||
--ua-primary:#667eea;--ua-primary2:#764ba2;--ua-card:rgba(255,255,255,.97);\
|
||
--ua-card-text:#333;--ua-muted:#999;--ua-border:#f0f0f0;--ua-tag-bg:#f0f0f0;}\
|
||
[data-theme=\"dark\"]{\
|
||
--ua-primary:#7c8df0;--ua-primary2:#8e6fd0;--ua-card:rgba(40,42,58,.96);\
|
||
--ua-card-text:#e8e8ef;--ua-muted:#9a9ab0;--ua-border:#3a3c52;--ua-tag-bg:#33354a;}\
|
||
.qr-mask{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;z-index:999;}\
|
||
.qr-mask.show{display:flex;}\
|
||
.auth-box{background:var(--ua-card);border-radius:14px;padding:24px 26px 26px;width:340px;max-width:92%;position:relative;box-shadow:0 12px 40px rgba(0,0,0,.25);color:var(--ua-card-text);}\
|
||
.auth-box .close{position:absolute;top:12px;right:14px;font-size:13px;color:var(--ua-muted);cursor:pointer;}\
|
||
.auth-tabs{display:flex;gap:22px;margin-bottom:18px;border-bottom:1px solid var(--ua-border);}\
|
||
.auth-tabs a{padding:8px 2px;font-size:16px;color:var(--ua-muted);cursor:pointer;position:relative;}\
|
||
.auth-tabs a.active{color:var(--ua-primary);font-weight:700;}\
|
||
.auth-tabs a.active::after{content:'';position:absolute;left:0;right:0;bottom:-1px;height:3px;background:var(--ua-primary);border-radius:3px;}\
|
||
.auth-form input{width:100%;padding:11px 14px;margin-bottom:12px;border:2px solid var(--ua-border);border-radius:8px;outline:none;background:transparent;color:var(--ua-card-text);font-size:14px;box-sizing:border-box;}\
|
||
.auth-form input:focus{border-color:var(--ua-primary);}\
|
||
.auth-msg{font-size:13px;min-height:18px;margin-bottom:8px;}\
|
||
.auth-msg.ok{color:#2ecc71;}\
|
||
.auth-msg.err{color:#e74c3c;}\
|
||
.auth-submit{width:100%;padding:11px;border:none;border-radius:8px;background:var(--ua-primary);color:#fff;font-size:15px;cursor:pointer;transition:filter .2s;}\
|
||
.auth-submit:hover{filter:brightness(1.08);}\
|
||
.user-area{display:flex;gap:8px;align-items:center;}\
|
||
.user-area-float{position:fixed;top:14px;right:14px;z-index:1001;background:var(--ua-card);padding:6px 10px;border-radius:22px;box-shadow:0 4px 16px rgba(0,0,0,.18);}\
|
||
.nav-btn{border:2px solid var(--ua-border);background:transparent;color:var(--ua-card-text);padding:7px 16px;border-radius:20px;cursor:pointer;font-size:14px;font-weight:500;transition:all .25s;}\
|
||
.nav-btn:hover{border-color:var(--ua-primary);color:var(--ua-primary);}\
|
||
.nav-btn-primary{background:var(--ua-primary);border-color:var(--ua-primary);color:#fff;}\
|
||
.nav-btn-primary:hover{filter:brightness(1.08);color:#fff;}\
|
||
.user-menu{position:relative;display:flex;align-items:center;gap:8px;cursor:pointer;padding:2px 6px;border-radius:20px;transition:background .2s;}\
|
||
.user-menu:hover{background:var(--ua-tag-bg);}\
|
||
.user-avatar{width:34px;height:34px;border-radius:50%;object-fit:cover;background:var(--ua-tag-bg);}\
|
||
.user-name{font-size:14px;color:var(--ua-card-text);max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\
|
||
.user-dropdown{position:absolute;top:46px;right:0;min-width:150px;background:var(--ua-card);border-radius:10px;box-shadow:0 8px 26px rgba(0,0,0,.18);padding:6px;display:none;z-index:1000;color:var(--ua-card-text);}\
|
||
.user-dropdown.show{display:block;}\
|
||
.user-dropdown .ud-name{font-size:13px;color:var(--ua-muted);padding:8px 12px;border-bottom:1px solid var(--ua-border);margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\
|
||
.user-dropdown a{display:block;padding:9px 12px;color:var(--ua-card-text);text-decoration:none;border-radius:6px;font-size:14px;}\
|
||
.user-dropdown a:hover{background:var(--ua-primary);color:#fff;}\
|
||
";
|
||
|
||
function injectStyle() {
|
||
if (document.getElementById(STYLE_ID)) { return; }
|
||
var s = document.createElement('style');
|
||
s.id = STYLE_ID;
|
||
s.textContent = STYLE;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
/* ---------- 弹层 DOM(自动注入,无需页面书写) ---------- */
|
||
function ensureMask() {
|
||
var mask = document.getElementById(MASK_ID);
|
||
if (mask) { return mask; }
|
||
mask = document.createElement('div');
|
||
mask.className = 'qr-mask';
|
||
mask.id = MASK_ID;
|
||
mask.innerHTML =
|
||
'<div class="auth-box">' +
|
||
' <span class="close" id="authClose">关闭</span>' +
|
||
' <div class="auth-tabs">' +
|
||
' <a data-tab="login" class="active">登录</a>' +
|
||
' <a data-tab="register">注册</a>' +
|
||
' </div>' +
|
||
' <form class="auth-form" id="loginForm">' +
|
||
' <input type="text" name="account" placeholder="账号 / 手机号 / 邮箱" autocomplete="username" required>' +
|
||
' <input type="password" name="password" placeholder="密码" autocomplete="current-password" required>' +
|
||
' <div class="auth-msg" id="loginMsg"></div>' +
|
||
' <button type="submit" class="auth-submit">登 录</button>' +
|
||
' </form>' +
|
||
' <form class="auth-form" id="registerForm" style="display:none;">' +
|
||
' <input type="text" name="account" placeholder="设置账号(字母/数字/中文)" autocomplete="username" required>' +
|
||
' <input type="text" name="nickname" placeholder="昵称(可选)" autocomplete="nickname">' +
|
||
' <input type="password" name="password" placeholder="设置密码(至少 6 位)" autocomplete="new-password" required>' +
|
||
' <div class="auth-msg" id="registerMsg"></div>' +
|
||
' <button type="submit" class="auth-submit">注 册</button>' +
|
||
' </form>' +
|
||
'</div>';
|
||
document.body.appendChild(mask);
|
||
return mask;
|
||
}
|
||
|
||
/* ---------- 会员区容器 ---------- */
|
||
function getUserArea() {
|
||
var area = document.getElementById('userArea');
|
||
if (area) { return area; }
|
||
if (!cfg.floating) { return null; }
|
||
area = document.getElementById('globalUserArea');
|
||
if (area) { return area; }
|
||
area = document.createElement('div');
|
||
area.id = 'globalUserArea';
|
||
area.className = 'user-area user-area-float';
|
||
document.body.appendChild(area);
|
||
return area;
|
||
}
|
||
|
||
/* ---------- 事件系统 ---------- */
|
||
var listeners = {};
|
||
function on(evt, cb) { (listeners[evt] = listeners[evt] || []).push(cb); }
|
||
function dispatch(evt, data) {
|
||
(listeners[evt] || []).forEach(function (cb) { try { cb(data); } catch (e) {} });
|
||
}
|
||
|
||
/* ---------- 渲染会员区 ---------- */
|
||
function renderUser(user) {
|
||
var area = getUserArea();
|
||
if (!area) { return; }
|
||
if (user && user.uid) {
|
||
var av = (user.avatar && user.avatar.indexOf('http') === 0) ? user.avatar : (user.avatar || defaultAvatar(user.nickname || user.account));
|
||
var name = user.nickname || user.account || '会员';
|
||
var hasMyNav = !!document.getElementById('myNavList');
|
||
area.innerHTML =
|
||
'<div class="user-menu" id="userMenu">' +
|
||
' <img class="user-avatar" src="' + esc(av) + '" alt="">' +
|
||
' <span class="user-name">' + esc(name) + '</span>' +
|
||
' <div class="user-dropdown" id="userDropdown">' +
|
||
' <div class="ud-name">' + esc(name) + '</div>' +
|
||
(hasMyNav ? ' <a href="javascript:;" id="scrollFav">⭐ 我的导航</a>' : '') +
|
||
' <a href="javascript:;" id="btnLogout">🚪 退出登录</a>' +
|
||
' </div>' +
|
||
'</div>';
|
||
bindUserMenu();
|
||
} else {
|
||
area.innerHTML =
|
||
'<button class="nav-btn" id="btnLogin">登录</button>' +
|
||
'<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>';
|
||
bindOpenButtons();
|
||
}
|
||
}
|
||
|
||
function bindUserMenu() {
|
||
var menu = $('#userMenu');
|
||
var dropdown = $('#userDropdown');
|
||
if (menu) {
|
||
menu.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (dropdown) { dropdown.classList.toggle('show'); }
|
||
});
|
||
}
|
||
document.addEventListener('click', function () {
|
||
if (dropdown) { dropdown.classList.remove('show'); }
|
||
});
|
||
var sf = $('#scrollFav');
|
||
if (sf) {
|
||
sf.addEventListener('click', function () {
|
||
var el = $('#myNavList');
|
||
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
||
if (dropdown) { dropdown.classList.remove('show'); }
|
||
});
|
||
}
|
||
var lo = $('#btnLogout');
|
||
if (lo) {
|
||
lo.addEventListener('click', function () {
|
||
fetch(cfg.apiBase + '/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function () { afterLogout(); })
|
||
.catch(function () { afterLogout(); });
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ---------- 弹层控制 ---------- */
|
||
var modalBound = false;
|
||
function openAuth(tab, opts) {
|
||
ensureMask();
|
||
bindModalOnce();
|
||
if (opts && typeof opts.onSuccess === 'function') { pendingSuccess = opts.onSuccess; }
|
||
switchTab(tab || 'login');
|
||
$('#' + MASK_ID).classList.add('show');
|
||
}
|
||
function closeAuth() {
|
||
var mask = $('#' + MASK_ID);
|
||
if (mask) { mask.classList.remove('show'); }
|
||
pendingSuccess = null;
|
||
}
|
||
function switchTab(tab) {
|
||
$all('.auth-tabs a').forEach(function (a) {
|
||
a.classList.toggle('active', a.getAttribute('data-tab') === tab);
|
||
});
|
||
var lf = $('#loginForm'), rf = $('#registerForm');
|
||
if (lf) { lf.style.display = (tab === 'login') ? '' : 'none'; }
|
||
if (rf) { rf.style.display = (tab === 'register') ? '' : 'none'; }
|
||
var lm = $('#loginMsg'); if (lm) { lm.textContent = ''; }
|
||
var rm = $('#registerMsg'); if (rm) { rm.textContent = ''; }
|
||
}
|
||
function bindOpenButtons() {
|
||
var bl = $('#btnLogin'), br = $('#btnRegister');
|
||
if (bl) { bl.addEventListener('click', function () { openAuth('login'); }); }
|
||
if (br) { br.addEventListener('click', function () { openAuth('register'); }); }
|
||
}
|
||
|
||
var pendingSuccess = null;
|
||
function bindModalOnce() {
|
||
if (modalBound) { return; }
|
||
modalBound = true;
|
||
var mask = $('#' + MASK_ID);
|
||
var close = $('#authClose');
|
||
if (close) { close.addEventListener('click', closeAuth); }
|
||
if (mask) {
|
||
mask.addEventListener('click', function (e) { if (e.target === mask) { closeAuth(); } });
|
||
}
|
||
$all('.auth-tabs a').forEach(function (a) {
|
||
a.addEventListener('click', function () { switchTab(a.getAttribute('data-tab')); });
|
||
});
|
||
|
||
var lf = $('#loginForm');
|
||
if (lf) {
|
||
lf.addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
var fd = new FormData(lf);
|
||
var msg = $('#loginMsg');
|
||
msg.className = 'auth-msg'; msg.textContent = '登录中…';
|
||
fetch(cfg.apiBase + '/login', {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ account: fd.get('account'), password: fd.get('password') })
|
||
}).then(function (r) { return r.json(); }).then(function (res) {
|
||
if (res.code === 0) {
|
||
msg.className = 'auth-msg ok'; msg.textContent = '登录成功';
|
||
afterLogin(res.data);
|
||
} else {
|
||
msg.className = 'auth-msg err'; msg.textContent = res.message || '登录失败';
|
||
}
|
||
}).catch(function () {
|
||
msg.className = 'auth-msg err'; msg.textContent = '网络错误';
|
||
});
|
||
});
|
||
}
|
||
|
||
var rf = $('#registerForm');
|
||
if (rf) {
|
||
rf.addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
var fd = new FormData(rf);
|
||
var msg = $('#registerMsg');
|
||
msg.className = 'auth-msg'; msg.textContent = '注册中…';
|
||
fetch(cfg.apiBase + '/register', {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ account: fd.get('account'), password: fd.get('password'), nickname: fd.get('nickname') })
|
||
}).then(function (r) { return r.json(); }).then(function (res) {
|
||
if (res.code === 0) {
|
||
msg.className = 'auth-msg ok'; msg.textContent = '注册成功,已自动登录';
|
||
afterLogin(res.data);
|
||
} else {
|
||
msg.className = 'auth-msg err'; msg.textContent = res.message || '注册失败';
|
||
}
|
||
}).catch(function () {
|
||
msg.className = 'auth-msg err'; msg.textContent = '网络错误';
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ---------- 登录 / 退出 后续处理 ---------- */
|
||
function afterLogin(user) {
|
||
var area = getUserArea();
|
||
if (area) { renderUser(user); }
|
||
dispatch('login', user);
|
||
var cb = pendingSuccess; pendingSuccess = null;
|
||
setTimeout(closeAuth, 400);
|
||
if (typeof cb === 'function') { cb(user); return; }
|
||
if (cfg.autoReload) { setTimeout(function () { location.reload(); }, 450); }
|
||
else if (typeof cfg.afterLogin === 'function') { cfg.afterLogin(user); }
|
||
}
|
||
function afterLogout() {
|
||
renderUser(null);
|
||
dispatch('logout');
|
||
if (cfg.autoReload) { location.reload(); }
|
||
else if (typeof cfg.afterLogout === 'function') { cfg.afterLogout(); }
|
||
}
|
||
|
||
function refresh() {
|
||
fetch(cfg.apiBase + '/profile', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (res) {
|
||
if (res.code === 0 && res.data && res.data.uid) { renderUser(res.data); }
|
||
else { renderUser(null); }
|
||
}).catch(function () { renderUser(null); });
|
||
}
|
||
|
||
/* ---------- 初始化 ---------- */
|
||
function init() {
|
||
injectStyle();
|
||
ensureMask();
|
||
bindModalOnce();
|
||
renderUser(null); // 默认显示登录/注册,避免闪烁
|
||
refresh(); // 探测登录态
|
||
}
|
||
|
||
/* ---------- 全局导出 ---------- */
|
||
var UserAuth = {
|
||
version: '2.0.0',
|
||
open: function (mode, opts) { openAuth(mode || 'login', opts); },
|
||
close: closeAuth,
|
||
refresh: refresh,
|
||
render: renderUser,
|
||
on: on,
|
||
config: cfg
|
||
};
|
||
window.UserAuth = UserAuth;
|
||
window.openLogin = function (opts) { UserAuth.open('login', opts); };
|
||
window.openRegister = function (opts) { UserAuth.open('register', opts); };
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|