- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐 - 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调 - CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403 - Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议 - 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问 - 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表 - 导航栏:用户名链接指向 /space,新增「设置」入口 - 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
339 lines
14 KiB
JavaScript
339 lines
14 KiB
JavaScript
/* ============================================================
|
||
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 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
|
||
// 保存原始 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)
|
||
})();
|