This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/templates/MetaLab-2026/static/js/common.js
Victor_Jay 8902af420e fix: 登录过期改为跳转登录页而非返回 JSON
- Required 中间件区分页面请求(/api/ 前缀)与 SSR 页面请求,页面请求 302 跳转登录页携带 return URL
- 前端 doRefresh token 刷新失败时跳转登录页,避免静默失败
- 认证页面禁止重定向避免无限循环
2026-06-01 12:19:18 +08:00

348 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
MetaLab Common JS — 全站共享脚本
============================================================ */
(function () {
'use strict';
// ---- Shared utils (exposed to other scripts via window.MetaLab) ----
window.MetaLab = window.MetaLab || {};
var toastTimer = null;
window.MetaLab.toast = function (el, msg, isError) {
if (!el) return;
clearTimeout(toastTimer);
el.textContent = msg;
el.className = 'toast' + (isError ? ' error' : '');
void el.offsetWidth;
el.classList.add('show');
toastTimer = setTimeout(function () {
el.classList.remove('show');
}, 3000);
};
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
window.MetaLab.csrfToken = typeof getCSRFToken === 'function' ? getCSRFToken : function () { return ''; };
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
// Also intercept 401 responses to auto-refresh token
(function () {
var SAME_ORIGIN = /^\/(?!\/)/;
var isRefreshing = false;
var refreshPromise = null;
var pendingRetries = [];
// 优先从 cookie 读取 CSRF token与服务端验证源一致meta 标签作为后备
function resolveCSRFToken() {
var cookieMatch = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
if (cookieMatch && cookieMatch[1]) return cookieMatch[1];
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function getCSRFTokenHeader() {
var t = resolveCSRFToken();
return t || window.MetaLab.csrfToken();
}
// ---- Token refresh logic ----
function redirectToLogin() {
// 避免在认证页面上造成无限跳转循环
if (/^\/auth\//.test(window.location.pathname)) return;
var returnURL = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = '/auth/login?redirect=' + returnURL;
}
function doRefresh() {
if (isRefreshing) return refreshPromise;
isRefreshing = true;
refreshPromise = fetch('/api/auth/refresh', { method: 'POST' })
.then(function (res) {
isRefreshing = false;
refreshPromise = null;
if (!res.ok) {
// Refresh failed — clear all pending retries and redirect to login
while (pendingRetries.length) {
var r = pendingRetries.shift();
r.reject(new Error('refresh_failed'));
}
redirectToLogin();
return Promise.reject(new Error('refresh_failed'));
}
// Retry all pending requests
while (pendingRetries.length) {
var retry = pendingRetries.shift();
retry.retry();
}
return true;
})
.catch(function (err) {
isRefreshing = false;
refreshPromise = null;
while (pendingRetries.length) {
var r2 = pendingRetries.shift();
r2.reject(err);
}
redirectToLogin();
return Promise.reject(err);
});
return refreshPromise;
}
// ---- Patch XMLHttpRequest ----
var origXHROpen = XMLHttpRequest.prototype.open;
var origXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._method = method;
this._url = url;
return origXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
if (SAME_ORIGIN.test(xhr._url) &&
xhr._method && !/^(GET|HEAD|OPTIONS)$/i.test(xhr._method)) {
xhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
}
// 401 interception for XHR
// 保存原始 onreadystatechange 后立即清除,防止原生属性重复触发
// 否则浏览器原生 onreadystatechange 和 addEventListener 监听器会各触发一次
// 导致 Vditor 图片上传等场景下图片被插入两次
var origOnReady = xhr.onreadystatechange;
xhr.onreadystatechange = null;
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState === 4 && xhr.status === 401 &&
xhr._url !== '/api/auth/refresh' &&
!xhr._retried) {
xhr._retried = true;
// Abort the original response handling
xhr._blocked = true;
doRefresh().then(function () {
// Re-create and re-send the request
var newXhr = new XMLHttpRequest();
newXhr.open(xhr._method, xhr._url, true);
// Copy relevant properties
newXhr.onreadystatechange = origOnReady;
newXhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
newXhr.send(xhr._body);
}).catch(function () {
// Refresh failed, trigger original error handler
xhr._blocked = false;
if (origOnReady) origOnReady.call(xhr);
});
}
if (!xhr._blocked && origOnReady) {
origOnReady.call(xhr);
}
});
// Store body for potential retry
if (xhr._method && !/^(GET|HEAD)$/i.test(xhr._method)) {
xhr._body = arguments[0];
}
return origXHRSend.apply(xhr, arguments);
};
// ---- Patch fetch with 401 interceptor ----
var origFetch = window.fetch;
window.fetch = function (url, options) {
options = options || {};
var headers = options.headers || {};
var method = (options.method || 'GET').toUpperCase();
var urlStr = (typeof url === 'string') ? url : '';
if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) {
if (headers instanceof Headers) {
headers.set('X-CSRF-Token', getCSRFTokenHeader());
} else {
headers['X-CSRF-Token'] = getCSRFTokenHeader();
}
options.headers = headers;
}
// Don't intercept the refresh request itself
if (urlStr === '/api/auth/refresh') {
return origFetch(url, options);
}
return origFetch(url, options).then(function (res) {
if (res.status === 401) {
// Clone the response so we can read it once for logging
var cloned = res.clone();
// Queue this request for retry after refresh
return new Promise(function (resolve, reject) {
pendingRetries.push({
retry: function () {
// Re-fetch with updated CSRF token
var retryOpts = Object.assign({}, options);
var retryHeaders = retryOpts.headers || {};
if (retryHeaders instanceof Headers) {
retryHeaders.set('X-CSRF-Token', getCSRFTokenHeader());
} else {
retryHeaders['X-CSRF-Token'] = getCSRFTokenHeader();
}
retryOpts.headers = retryHeaders;
resolve(origFetch(url, retryOpts));
},
reject: function (err) {
reject(err);
}
});
doRefresh();
});
}
return res;
});
};
})();
var menuBtn = document.getElementById('mobileMenuBtn');
var navLinks = document.getElementById('navLinks');
var scrollBtn = document.getElementById('scrollTopBtn');
var menuIcon = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
var closeIcon = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
// ---- Mobile menu toggle ----
if (menuBtn && navLinks) {
menuBtn.addEventListener('click', function () {
navLinks.classList.toggle('active');
menuBtn.innerHTML = navLinks.classList.contains('active') ? closeIcon : menuIcon;
});
}
// ---- Close mobile menu on nav click ----
var links = document.querySelectorAll('.nav-links a');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', function () {
if (navLinks) {
navLinks.classList.remove('active');
}
if (menuBtn) {
menuBtn.innerHTML = menuIcon;
}
});
}
// ---- Smooth scroll for anchor links ----
var anchors = document.querySelectorAll('a[href^="#"]');
for (var j = 0; j < anchors.length; j++) {
anchors[j].addEventListener('click', function (e) {
var href = this.getAttribute('href');
if (href === '#') return;
var target = document.querySelector(href);
if (target) {
e.preventDefault();
window.scrollTo({ top: target.offsetTop - 55, behavior: 'smooth' });
}
});
}
// ---- Scroll-to-top button ----
if (scrollBtn) {
window.addEventListener('scroll', function () {
scrollBtn.classList.toggle('visible', window.scrollY > window.innerHeight * 0.5);
});
scrollBtn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// ---- Dev greeting ----
console.log('%c🔨 嘿,开发者朋友!', 'color:#2ecc71;font-size:18px;font-weight:bold');
console.log('%c欢迎来到 MetaLab 的蓝图页。', 'color:#34495e');
console.log('%c我们正在搭建一个纯净、友好的下一代开发者社区。', 'color:#34495e');
console.log('%c有任何想法或愿意参与早期测试欢迎联系我们metazone@foxmail.com', 'color:#e74c3c;font-style:italic');
// ---- Logout ----
var logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function () {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
window.location.href = '/';
}
};
xhr.send();
});
}
// ---- Unread message badge polling ----
var msgBadge = document.getElementById('msgBadge');
if (msgBadge) {
var POLL_INTERVAL = 60 * 1000; // 1 minute
function pollUnread() {
fetch('/api/messages/unread')
.then(function (res) {
if (!res.ok) return;
return res.json();
})
.then(function (data) {
if (!data || !data.success) return;
var count = data.data && data.data.unread ? data.data.unread : 0;
msgBadge.textContent = count > 99 ? '99+' : count;
msgBadge.style.display = count > 0 ? 'flex' : 'none';
})
.catch(function () {});
}
pollUnread();
setInterval(pollUnread, POLL_INTERVAL);
}
// ---- Auto-refresh token (silent) ----
// 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期)
// 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效
// → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态
// → 仅当页面是未登录状态(无 logoutBtn时才触发 reload
// 场景 C编辑器等页面发 API 请求时遇到 401 → 由 fetch/XHR patch 自动刷新后重试
// 不在认证页面执行刷新(登录/注册页无需续期)
var onAuthPage = /^\/auth\//.test(window.location.pathname);
if (!onAuthPage) {
var REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes
var refreshTimer = null;
function tryRefresh(callback) {
fetch('/api/auth/refresh', { method: 'POST' })
.then(function (res) {
if (res.ok) {
if (callback) callback();
} else {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
})
.catch(function () {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
});
}
// 启动定时刷新:只要用户已登录(有 logoutBtn 元素),就定期刷新
if (document.getElementById('logoutBtn')) {
// 页面已经是登录态,启动定时刷新即可,无需 reload
tryRefresh(function () {
refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL);
});
} else {
// 场景 B页面渲染为未登录无 logoutBtn但 refresh token 可能仍有效
// 尝试刷新一次,成功后 reload 页面获取登录态
// 使用 sessionStorage 防止死循环reload 后再次进入此分支不会再 reload
if (!sessionStorage.getItem('mlb_refreshed')) {
sessionStorage.setItem('mlb_refreshed', '1');
tryRefresh(function () {
window.location.reload();
});
}
}
} // end if (!onAuthPage)
})();