perf(comments): 评论加载提速 + 骨架屏 + 友链弹窗 pjax 修复 + toast 统一 Message.js

优化:
- initArtalk 加节点级幂等守卫(mount.__atkInited,失败回滚),
  消除 artalk.html/mypjax 多入口导致的 4× comments + 4× pv 重复请求
- fetch 包装层对无 body 的 GET/HEAD 去掉多余 content-type,
  消掉 Artalk 对 GET /comments 的 CORS 预检(按完整 URL 缓存,
  每篇文章都重付一次 RTT)。实测评论列表 +1150~1565ms → +626ms

修复:
- 评论列表骨架屏改注入 .atk-list-body(原骨架在 #Comments 内,
  被 Artalk.init 清空),list-loaded/故障/12s 兜底移除
- 友链申请弹窗抽成 modules/friendlink.js 进 page-only bundle
  (原内联脚本在 #pjax-container 外,pjax 后 flApplyOpenModal 未定义)
- toast 统一走本地化 Message.js/Qmsg 适配层(保留 window.Toast/showToast 旧 API)

文档:
- README / 架构总览 更新;新增 评论加载优化方案.md
This commit is contained in:
zqlit committed 2026-10-04 15:00:52 +08:00
1 parent b60ddc5bf5
commit e3e3cce1a4
14 files changed
+1018 -486

No files matched your search

+218 -19
View File
@@ -21,17 +21,94 @@
internal: { ic: '⚠️', title: '评论暂时加载不出来', desc: '服务出了点小状况,请稍后再试。' }
};
// ── /api/v2/conf 内联快照 ──────────────────────────────────────────
// 构建期把 conf 响应内联进页面(见 partials/artalk.html)。服务端这份 conf
// 只由 settings 表决定,唯一因人而异的字段是 imgUpload(imgUpload || admin),
// 所以匿名访客看到的 conf 人人都一样 —— 匿名请求直接本地合成 Response,
// 不发网络。Artalk 内部 conf 与评论列表是严格串行的,省掉这一跳等于首屏
// 少一整个跨境往返。
// 带 Authorization 的请求来自已登录用户(管理员拿到 imgUpload=true),属个性化
// 响应,放行真实网络。
var CONF_URI = /\/api\/v2\/conf(\?|$)/;
function hasAuthHeader(init, input) {
try {
var h = (init && init.headers) || null;
if (!h && input && typeof input !== 'string') h = input.headers || null;
if (!h) return false;
if (typeof h.get === 'function') return !!h.get('Authorization');
if (Array.isArray(h)) {
for (var i = 0; i < h.length; i++) {
if (String(h[i][0]).toLowerCase() === 'authorization') return !!h[i][1];
}
return false;
}
return !!(h.Authorization || h.authorization);
} catch (e) {
return true; // 判不准就当有凭据 → 放行真实请求(保守,宁可多一跳也不串号)
}
}
// ── 0) 去掉 GET/HEAD 上多余的 content-type:省掉一次 CORS 预检 ──────
// Artalk 给「所有」请求都带上 content-type: application/json,可 GET 根本没有 body。
// content-type 属于「非简单头」→ 浏览器必须先发一个 OPTIONS 预检(Access-Control-Request-Headers: content-type)。
// 预检按「完整 URL」缓存(含 query),而 comments 的 URL 带 page_key、每篇文章都不同,
// 于是每进一篇新文章都要重付一次预检 —— 跨域下这是实打实的整整一个 RTT,
// 而它正好卡在评论列表的关键路径上(预检 → 才发 GET)。
// GET/HEAD 没有 body,删掉这个头不改变任何语义,却能直接把预检砍掉。
function sansContentType(init) {
var out = {}, k;
for (k in init) out[k] = init[k];
var h = out.headers;
try {
if (h && typeof Headers === 'function' && h instanceof Headers) {
var nh = new Headers();
h.forEach(function (v, n) { if (n.toLowerCase() !== 'content-type') nh.append(n, v); });
out.headers = nh;
} else if (Array.isArray(h)) {
out.headers = h.filter(function (p) { return String(p[0]).toLowerCase() !== 'content-type'; });
} else if (h && typeof h === 'object') {
var nh2 = {};
for (k in h) { if (k.toLowerCase() !== 'content-type') nh2[k] = h[k]; }
out.headers = nh2;
}
} catch (e) {
return init; // headers 形态不认识就原样放行(宁可多一次预检,不能出错)
}
return out;
}
// ── 1) 记录 API 失败原因。只读不改:包装层原样返回上游响应,
// 失败时顺带 clone 一份读 body(clone 不影响 Artalk 自己读)。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input) {
window.fetch = function (input, init) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
url = String(url);
var p = origFetch.apply(this, arguments);
if (url.indexOf('/api/v2/') === -1) return p;
// 学习 API 源(探测恢复用)
if (url.indexOf('/api/v2/') === -1) return origFetch.apply(this, arguments);
// 学习 API 源(探测恢复用)—— 拦快照之前先学,别因为少了一次网络请求而丢掉来源
if (!apiBase && url.indexOf('/api/v2/') > 0) apiBase = url.split('/api/v2/')[0];
// conf 快照命中:本地合成响应,完全不发网络
if (window.__artalkConfSnapshot && CONF_URI.test(url)
&& typeof Response === 'function' && !hasAuthHeader(init, input)) {
return Promise.resolve(new Response(
JSON.stringify(window.__artalkConfSnapshot),
{
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}
));
}
// GET/HEAD 且无 body:去掉 content-type,避免不必要的 CORS 预检
var fargs = arguments;
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if ((method === 'GET' || method === 'HEAD') && !(init && init.body)) {
fargs = [input, sansContentType(init || {})];
}
var p = origFetch.apply(this, fargs);
return p.then(function (res) {
if (res && res.ok === false) {
try {
@@ -105,6 +182,7 @@
// 这里:编辑器整体置灰(内容原样可见,只是变灰)+ 编辑框上方插窄横幅
// 说明原因和恢复时间,并每 60s 用一次 1 行 D1 成本的探针(/captcha/status,
// 只读 1 行 settings)探测恢复,恢复后自动解除禁用并 toast,不用刷新页面。
// toast 统一走 Message.js(modules/toast.js 适配层),不再自绘浮层。
// conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮
// 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观,
@@ -130,22 +208,18 @@
var svcDown = false;
var downCode = '';
var downAt = 0; // 进入禁用态的时刻(用来判断这次"故障"是不是一闪而过)
var probeTimer = null;
var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址)
function svcToast(msg) {
var t = document.getElementById('atk-svc-toast');
if (!t) {
t = document.createElement('div');
t.id = 'atk-svc-toast';
t.style.cssText = 'position:fixed;left:50%;bottom:110px;transform:translateX(-50%);z-index:99999;' +
'padding:12px 24px;border-radius:999px;background:var(--theme-main,#07c160);color:#fff;' +
'font-size:14px;box-shadow:0 10px 32px rgba(0,0,0,.35);opacity:0;transition:opacity .35s,transform .35s;pointer-events:none';
document.body.appendChild(t);
// 统一通知组件(Message.js):Toast 在 core bundle 里先于本模块加载,
// 但 PJAX 极端时序下仍兜底判一次,避免 undefined 直接抛错
if (window.Toast && typeof window.Toast.info === 'function') {
window.Toast.info(msg, 3200);
} else {
try { console.log('[artalk]', msg); } catch (e) {}
}
t.textContent = msg;
requestAnimationFrame(function () { t.style.opacity = '1'; });
setTimeout(function () { t.style.opacity = '0'; }, 3200);
}
function ensureBar(code) {
@@ -185,8 +259,11 @@
function setSvcDown(code) {
downCode = code;
// 评论服务故障:列表不会返回了,别让骨架屏一直转(故障态有自己的横幅/卡片)
listSkeletonDone();
if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案
svcDown = true;
downAt = Date.now();
ensureBar(code);
if (!probeTimer) {
probeTimer = setInterval(function () {
@@ -203,12 +280,26 @@
svcDown = false;
if (probeTimer) { clearInterval(probeTimer); probeTimer = null; }
removeBar();
svcToast('评论服务已恢复,可以继续评论了 ✓');
// 禁用态只持续了一瞬间就别报喜:读者眨个眼就"恢复"的故障,弹提示纯属噪音
// (真实故障从出现到恢复不会只有两三秒,所以门槛不会漏报)
if (downAt && Date.now() - downAt >= 3000) {
svcToast('评论服务已恢复,可以继续评论了 ✓');
}
downAt = 0;
}
function rewrite(layer) {
if (!layer) return;
// ── 不是评论列表的错误层,一律不碰 ──────────────────────────────
// Artalk 会把「编辑器插件资源加载失败」(表情包 / 图片等)的错误层也渲染成
// .atk-error-layer,挂在编辑器插件面板(.atk-plug-panel-wrap)里。它既不是
// 评论服务故障,也不该被改写成我们的故障卡片 —— 直接放行走 Artalk 原样式。
// 曾经没这道判断时:这类层的文本不在 .error-message 里 → raw 读成空串 →
// 兜底判成 internal → 因为 internal !== network 就置灰整个评论区,随后
// 数据正常返回又弹「评论服务已恢复」toast(读者视角:页面顿一下 + 莫名提示)。
if (layer.closest('.atk-plug-panel-wrap, .atk-editor-plug-emoticons')) return;
var raw = '';
var em = layer.querySelector('.error-message');
if (em) raw = (em.innerText || em.textContent || '').trim();
@@ -236,8 +327,10 @@
? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '')
: info.desc;
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅
if (code !== 'network') setSvcDown(code);
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅。
// 但「读不到任何可归因证据」时不下结论:raw 为空且结构化记录也没命中,
// 说明这是个我们识别不了的错误层,宁可只渲染卡片,也不要误置灰评论区。
if (code !== 'network' && (raw || (fresh && fresh.code))) setSvcDown(code);
// 原始「重试」节点:Vue 会整层重建,所以每次重新找;
// 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件)
@@ -501,8 +594,91 @@ window.loadScriptOnce = window.loadScriptOnce || function(src, globalName, callb
document.head.appendChild(script);
};
/* ── 评论列表骨架屏 ────────────────────────────────────────────────────
Artalk 初始化时会把 #Comments 整个清空(连服务端渲染出来的骨架屏一起清),
而评论列表要等一次跨境 /api/v2/comments 往返才有内容 —— 这段时间评论区是
一片空白(本地实测空档约 1.0~1.5s,跨境只会更久),读者视角就是"进来啥也没有"。
这里在 Artalk 挂载后往 .atk-list-body 里补一份骨架,list-loaded / 服务故障 /
12s 兜底时移除,把这段空档抹平。
延迟 120ms 才显示:命中缓存、极快返回的情况不会闪一下骨架。 */
var LIST_SKELETON_HTML =
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-80"></div>' +
'<div class="atk-loading-line width-60"></div>' +
'</div>' +
'</div>' +
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-60"></div>' +
'</div>' +
'</div>' +
'<div class="atk-loading-item">' +
'<div class="atk-loading-avatar"></div>' +
'<div class="atk-loading-content">' +
'<div class="atk-loading-line width-name"></div>' +
'<div class="atk-loading-line width-80"></div>' +
'<div class="atk-loading-line width-40"></div>' +
'</div>' +
'</div>';
var listSkelTimer = null; // 延迟布防用的定时器
var listSkelSettled = false; // 本次导航的列表是否已经有结果(含失败)
function listSkeletonShow() {
var body = document.querySelector('#Comments .atk-list-body');
if (!body || body.querySelector('.atk-ys-skel')) return;
var skel = document.createElement('div');
skel.className = 'atk-ys-skel atk-loading-placeholder';
skel.setAttribute('aria-hidden', 'true');
skel.innerHTML = LIST_SKELETON_HTML;
body.insertBefore(skel, body.firstChild);
// 注意:Artalk 渲染评论时若整体重写 .atk-list-body 的 innerHTML,
// 骨架会随之被自然移除 —— 因此这里不做任何"占位"逻辑,移除是幂等的。
}
function listSkeletonHide() {
var skels = document.querySelectorAll('#Comments .atk-ys-skel');
for (var i = 0; i < skels.length; i++) {
if (skels[i].parentNode) skels[i].parentNode.removeChild(skels[i]);
}
}
/* 布防:Artalk 挂载后调用(可重复调用,只生效一次) */
function listSkeletonArm() {
if (listSkelSettled || listSkelTimer) return;
listSkelTimer = setTimeout(function () {
listSkelTimer = null;
if (listSkelSettled) return;
listSkeletonShow();
// 极端时序下 .atk-list-body 可能还没渲染出来:短轮询补一次
if (!document.querySelector('#Comments .atk-ys-skel')) {
var tries = 0;
var spin = setInterval(function () {
if (listSkelSettled || ++tries > 12) { clearInterval(spin); return; }
if (document.querySelector('#Comments .atk-list-body')) {
listSkeletonShow();
clearInterval(spin);
}
}, 100);
}
}, 120);
}
/* 撤防:列表有结果(成功/失败/超时)后调用 */
function listSkeletonDone() {
listSkelSettled = true;
if (listSkelTimer) { clearTimeout(listSkelTimer); listSkelTimer = null; }
listSkeletonHide();
}
window.initArtalk = function() {
if (!document.querySelector("#Comments")) return;
var mount = document.querySelector("#Comments");
if (!mount) return;
// Check if configuration exists
if (!window.artalkConfig) {
@@ -517,6 +693,17 @@ window.initArtalk = function() {
return;
}
// ── 幂等守卫:同一份 #Comments 只初始化一次 ──────────────────────────
// initArtalk 有多个调用入口(artalk.html 内联脚本 / main.js DOMContentLoaded /
// mypjax 的 script.onload 与 requestIdleCallback),不去重就会在同一页上创建
// 多个 Artalk 实例:实测每次 PJAX 进文章页发出 4× /api/v2/comments + 4×
// /pages/pv,列表还要等最慢那条响应才渲染(既拖首屏,又 4 倍消耗 D1 读额度)。
// 用「节点级」标记而不是全局标志:PJAX 换页会换掉 #Comments 节点,新节点无标记
// → 照常重新初始化,切页行为完全不受影响。
// 第二道判断(节点内已有 .atk-list-body)兜底 Artalk 若替换了挂载节点的情况。
if (mount.__atkInited || mount.querySelector('.atk-list-body')) return;
mount.__atkInited = true;
try {
// Logic for caching Artalk configuration to speed up rendering
// 1. Manual Refresh (F5): Request from server and save to localStorage
@@ -578,6 +765,14 @@ window.initArtalk = function() {
artalk.setDarkMode(currentTheme === 'dark');
}
// ── 评论列表骨架屏布防(见文件上方 listSkeleton* 注释) ──
listSkelSettled = false;
listSkeletonArm();
artalk.on('mounted', listSkeletonArm);
artalk.on('list-loaded', listSkeletonDone);
// 兜底:长时间没结果(严重慢网 / 列表接口异常)也别让骨架一直转
setTimeout(listSkeletonDone, 12000);
// ── 增强评论框字段属性 (兼容油猴自动填充脚本) ──
// 为 Artalk 输入框添加额外属性,确保第三方脚本(如博客网站留言评论信息自动填充)
// 能够通过 type/id/name/placeholder/class/aria-label 准确匹配
@@ -1183,5 +1378,9 @@ artalk.on('list-loaded', function() {
} catch (e) {
console.error('Artalk init failed:', e);
// 初始化失败要撤回幂等标记,否则后续入口的调用会被守卫拦掉、评论区再也起不来
mount.__atkInited = false;
listSkelSettled = true;
listSkeletonHide();
}
};
+136
View File
@@ -0,0 +1,136 @@
/**
* 友链自助申请弹窗
*
* 为什么独立成模块(而不是留在 layouts/page/comment.html 的内联 <script>):
* 那段脚本写在 #pjax-container 外面,PJAX 只换容器内容、不会重新执行它 ——
* 用户从首页 PJAX 进留言页时 window.flApplyOpenModal 根本没定义,
* 「申请友链」卡片点了没反应;直接刷新(整页加载)才正常。抽到
* page-only bundle 里随 PJAX 加载,两种进入方式行为一致。
*
* 幂等性:弹窗节点挂 document.body(跨 PJAX 存活),buildModal 二次调用直接复用;
* 事件监听只在首次构建时挂,不会重复注册。
*/
(function () {
if (window.__flApplyInit) return; // PJAX 重复执行防抖
window.__flApplyInit = true;
var BASE = (window.rssApiBase || 'https://api.200181.xyz').replace(/\/$/, '');
function buildModal() {
if (document.getElementById('flApplyMask')) return document.getElementById('flApplyMask');
var mask = null;
var style = document.createElement('style');
style.textContent =
'#flApplyMask{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;padding:16px;' +
'background:rgba(15,23,42,.35);-webkit-backdrop-filter:blur(10px) saturate(1.3);backdrop-filter:blur(10px) saturate(1.3)}' +
'#flApplyMask.on{display:flex}' +
'#flApplyBox{background:var(--bg-card,#fff);color:var(--text-main,#253244);border-radius:12px;max-width:420px;width:100%;' +
'box-sizing:border-box;padding:22px;box-shadow:0 16px 48px rgba(15,23,42,.25);max-height:88vh;overflow:auto}' +
'[data-theme="dark"] #flApplyBox{box-shadow:0 16px 48px rgba(0,0,0,.55)}' +
'#flApplyBox .fl-field{display:block;margin-bottom:10px}' +
'#flApplyBox .fl-label{display:block;font-size:12px;color:var(--text-muted,#8a94a6);margin-bottom:4px}' +
'#flApplyBox input,#flApplyBox textarea{width:100%;box-sizing:border-box;padding:8px 10px;' +
'border:1px solid var(--border-main,#e7e9ee);border-radius:6px;font-size:14px;' +
'background:var(--bg-input,#fff);color:inherit;font-family:inherit}' +
'#flApplyBox input:focus,#flApplyBox textarea:focus{outline:none;border-color:var(--theme-main,#07c160);' +
'box-shadow:0 0 0 3px var(--theme-main-20,rgba(7,193,96,.2))}' +
/* 主题 main.css 有一条全局 button 归一化规则(margin-left:23%; margin-top:10px;
border:1px solid; background:#222),会把提交按钮推右溢出弹窗 → 这里用 id 选择器压过它 */
'#flSubmit{display:block;width:100%;margin:0;background:var(--theme-main,#07c160);border:none;color:#fff;' +
'padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:filter .2s}' +
'#flSubmit:hover{filter:brightness(1.06)}' +
'#flSubmit:disabled{opacity:.6;cursor:default}' +
/* 弹窗滚动条:细一点、跟随主题,别用系统那根粗滚动条 */
'#flApplyBox{overflow-x:hidden;scrollbar-width:thin;scrollbar-color:rgba(140,150,160,.45) transparent}' +
'#flApplyBox::-webkit-scrollbar{width:6px;height:6px}' +
'#flApplyBox::-webkit-scrollbar-track{background:transparent}' +
'#flApplyBox::-webkit-scrollbar-thumb{background:rgba(140,150,160,.45);border-radius:3px}' +
'[data-theme="dark"] #flApplyBox{scrollbar-color:rgba(200,205,212,.35) transparent}' +
'[data-theme="dark"] #flApplyBox::-webkit-scrollbar-thumb{background:rgba(200,205,212,.35)}';
document.head.appendChild(style);
mask = document.createElement('div');
mask.id = 'flApplyMask';
mask.innerHTML =
'<div id="flApplyBox">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">' +
'<b style="font-size:16px;display:flex;align-items:center;gap:8px">' +
'<span style="width:4px;height:16px;border-radius:2px;background:var(--theme-main,#07c160);display:inline-block"></span>友链自助申请</b>' +
'<span id="flApplyClose" style="cursor:pointer;color:var(--text-muted,#8a94a6);font-size:22px;line-height:1" title="关闭">×</span>' +
'</div>' +
'<label class="fl-field"><span class="fl-label">站点名称 *</span>' +
'<input id="flName" placeholder="我的小站"></label>' +
'<label class="fl-field"><span class="fl-label">站点链接 *</span>' +
'<input id="flUrl" placeholder="https://example.com"></label>' +
'<label class="fl-field"><span class="fl-label">图标链接(选填,留空自动为你抓取站点图标)</span>' +
'<input id="flIcon" placeholder="https://example.com/logo.png"></label>' +
'<label class="fl-field"><span class="fl-label">订阅地址(RSS/Atom)*</span>' +
'<input id="flFeed" placeholder="https://example.com/feed"></label>' +
'<label class="fl-field"><span class="fl-label">邮箱 *(接收审核结果)</span>' +
'<input id="flEmail" type="email" placeholder="you@example.com"></label>' +
'<label class="fl-field" style="margin-bottom:14px"><span class="fl-label">站点简介(选填)</span>' +
'<textarea id="flDesc" rows="2" style="resize:vertical"></textarea></label>' +
'<div id="flMsg" hidden style="font-size:13px;margin-bottom:10px"></div>' +
'<button id="flSubmit">提交申请</button>' +
'</div>';
document.body.appendChild(mask);
mask.addEventListener('click', function (e) { if (e.target === mask) close(); });
document.getElementById('flApplyClose').addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
document.getElementById('flSubmit').addEventListener('click', function () {
var btn = this, msg = document.getElementById('flMsg');
var payload = {
name: document.getElementById('flName').value.trim(),
url: document.getElementById('flUrl').value.trim(),
image: document.getElementById('flIcon').value.trim(),
feed: document.getElementById('flFeed').value.trim(),
email: document.getElementById('flEmail').value.trim(),
description: document.getElementById('flDesc').value.trim()
};
if (!payload.name || !payload.url || !payload.feed || !payload.email) {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '请填写全部必填项'; return;
}
btn.disabled = true; btn.textContent = '提交中…';
msg.hidden = true;
fetch(BASE + '/api/link-apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json().then(function (d) { return { code: r.status, d: d }; }); })
.then(function (res) {
if (res.code === 200 && res.d.ok) {
msg.hidden = false; msg.style.color = 'var(--theme-main,#07c160)'; msg.textContent = res.d.message || '申请已提交';
btn.textContent = '已提交 ✓';
setTimeout(close, 2200);
} else {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = res.d.error || '提交失败,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
}
})
.catch(function () {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '网络异常,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
});
});
return mask;
}
function close() {
var m = document.getElementById('flApplyMask');
if (m) m.classList.remove('on');
}
/* 供 artalk.js 的 comment-rendered(置顶评论卡片)调用 */
window.flApplyOpenModal = function () {
buildModal().classList.add('on');
};
/* 兜底:卡片节点被 PJAX/Artalk 重建后自身监听可能丢失,
这里用 document 级事件委托再接一层(打开是幂等的,重复触发无害) */
document.addEventListener('click', function (e) {
var hit = e.target && e.target.closest && e.target.closest('.atk-fl-card, .atk-fl-card-wrap');
if (hit) window.flApplyOpenModal();
});
})();
+41 -49
View File
@@ -1,63 +1,55 @@
/**
* Toast — lightweight notification
* Usage: window.Toast.success('msg'), Toast.error('msg'), Toast.warning('msg'), Toast.info('msg')
* window.Toast.show('msg', { type: 'info', duration: 2500 })
* Toast — 统一通知组件(Message.js / Qmsg)
*
* 全站 toast 一律走 Message.js(https://github.com/snwjas/Message.js),
* 库本体已本地化:js/libs/message.min.js + css/libs/message.min.css(随 style.css 打包)。
* 这里只做一层薄适配,保留旧 API,调用点(utils/floating-tools/artalk…)不用改:
* Toast.show(text, { type, duration })
* Toast.success / error / warning / info(text, duration?)
* showToast(text, type) ← 全局快捷方式
*/
(function() {
var container = null;
var counter = 0;
(function () {
if (window.__yingToastReady) return; // PJAX 重复执行防抖
window.__yingToastReady = true;
function ensureContainer() {
if (container) return;
container = document.createElement('div');
container.className = 'ying-toast-container';
document.body.appendChild(container);
var Q = window.Qmsg;
if (!Q) {
// Message.js 没加载出来时兜底:不打断业务,仅降级为无样式 console
var fallback = function (text) {
try { console.log('[toast]', text); } catch (e) {}
};
fallback.success = fallback.error = fallback.warning = fallback.info = fallback;
window.Toast = fallback;
window.showToast = function (text) { fallback(text); };
return;
}
function remove(el, delay) {
setTimeout(function() {
el.classList.remove('ying-toast--visible');
el.addEventListener('transitionend', function onEnd(e) {
if (e.propertyName !== 'opacity') return;
el.removeEventListener('transitionend', onEnd);
if (el.parentNode) el.parentNode.removeChild(el);
});
}, delay);
// 全站统一默认值:最多同时 3 条,自动关闭
try { Q.config({ maxNums: 3, timeout: 2500 }); } catch (e) {}
function opt(duration) {
return duration ? { timeout: duration } : undefined;
}
window.Toast = {
show: function(text, options) {
show: function (text, options) {
options = options || {};
var type = options.type || 'info';
var duration = options.duration || 2200;
ensureContainer();
var el = document.createElement('div');
el.className = 'ying-toast ying-toast--' + type;
el.textContent = text;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
container.appendChild(el);
/* Force layout then animate in */
void el.offsetWidth;
el.classList.add('ying-toast--visible');
remove(el, duration);
if (typeof Q[type] === 'function') return Q[type](text, opt(options.duration));
return Q.info(text, opt(options.duration));
},
success: function (text, duration) { return Q.success(text, opt(duration)); },
error: function (text, duration) { return Q.error(text, opt(duration)); },
warning: function (text, duration) { return Q.warning(text, opt(duration)); },
info: function (text, duration) { return Q.info(text, opt(duration)); },
loading: function (text) { return Q.loading(text); },
closeAll: function () { try { Q.closeAll(); } catch (e) {} }
};
success: function(text, duration) {
this.show(text, { type: 'success', duration: duration });
},
error: function(text, duration) {
this.show(text, { type: 'error', duration: duration });
},
warning: function(text, duration) {
this.show(text, { type: 'warning', duration: duration });
},
info: function(text, duration) {
this.show(text, { type: 'info', duration: duration });
}
// 全局快捷方式(utils.js 原实现迁到这里,单一来源)
window.showToast = function (text, type) {
var t = type || 'info';
if (window.Toast && typeof window.Toast[t] === 'function') window.Toast[t](text);
else window.Toast.info(text);
};
})();
+2 -11
View File
@@ -20,17 +20,8 @@ function themeSweep(newTheme, callback) {
callback();
}
// Toast Message Helper
window.showToast = function(text, type) {
if (window.Toast) {
var t = type || 'info';
if (typeof window.Toast[t] === 'function') {
window.Toast[t](text);
} else {
window.Toast.info(text);
}
}
};
// Toast 快捷方式已统一迁到 modules/toast.js(Message.js/Qmsg 适配层),
// 这里不再重复定义 window.showToast,避免双实现漂移。
// Theme Toggle Logic
window.initThemeToggle = function() {