Files
mce/templates/MetaLab-2026/static/js/common.js
Victor_Jay 69ba2fb01b refactor: ByteMD 迁移至 Tiptap WYSIWYG 编辑器 + Token 刷新优化
- 移除 ByteMD CDN 资源和 goldmark 依赖,替换为 Tiptap ESM 模块
- 编辑器从源码编辑模式改为 WYSIWYG 所见即所得
- 移除 /api/posts/preview 预览接口(Tiptap 直接输出 HTML 无需后端渲染)
- 保留 UploadImage 图片上传接口和 bluemonday HTML 消毒
- 工具栏支持加粗、斜体、删除线、代码、标题、列表、引用、代码块、分割线、撤销/重做
- 支持图片粘贴/拖拽上传,Ctrl+S 快捷提交,F11 全屏,字数统计
- 优化 common.js:自动 401 拦截 Token 刷新,修复刷新死循环
- 草稿 localStorage 键名更新为 draft_post_body_html
2026-05-28 11:21:53 +08:00

332 lines
14 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 = getCSRFToken;
// ---- 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 = [];
function resolveCSRFToken() {
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 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
while (pendingRetries.length) {
var r = pendingRetries.shift();
r.reject(new Error('refresh_failed'));
}
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);
}
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
var origOnReady = xhr.onreadystatechange;
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)
})();