Files
blog/themes/Ying/assets/js/modules/artalk.js
T
zqlit 6b8fba827b fix(评论): 徽章排序兜底 —— 系统徽章(博主/小编)恒定排在最前
现象:普通用户的 badge_name 徽章(如「小编」)会排在
友链/友圈/官职之后,与「博主」的位置不一致(管理员路径下系统徽章
在 .atk-badge-wrap 里、会被提前,普通用户路径则可能被追加到末尾)。

修法:拆掉 .atk-badge-wrap 后做一次确定性重排,按
系统徽章 > 置顶 > 头衔 > 友链 > 友圈 > 官职 的优先级依序 append 回
昵称(DocumentFragment 移动保序)。不管 Artalk 把系统徽章渲染在哪
都会归位,以后新徽章只要补进 PRIO 数组即可。

本地验证:CDP 注入 fetch 拦截给小十的评论加 badge_name=小编,
完整复现截图场景 → 顺序 小编 友链 友圈 司户参军 ✅
2026-10-05 18:21:00 +08:00

1813 lines
95 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.
// ============================================================================
// 评论加载失败 —— 给读者看的清晰提示
//
// Artalk 自带的错误层只会写「Failed to load comments / TypeError: Failed to fetch」,
// 既没说原因也没给下一步。服务端现在已经会返回结构化的错误码
// (见 artalk-cf/src/lib/errors.ts:d1_quota / d1_unavailable / timeout / ...),
// 所以这里把它换成一句人话 + 可展开的技术详情(留着给站长排查)。
// ============================================================================
// ── 本地调试开关:给 URL 加 ?atk_svc=<code> 强制复现服务故障态 ──────────────
// 例:/comment.html?atk_svc=d1_quota
// 仅 localhost 生效(生产环境这段完全不执行),用来本地看禁用态/错误卡片的观感,
// 不必等真的把 D1 额度跑满。支持的 code 见下面的 INFO 表。
(function () {
var host = location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '') return;
var m = /[?&]atk_svc=([a-z0-9_]+)/.exec(location.search || '');
if (!m) return;
var code = m[1];
var MSG = {
d1_quota: '数据库今日读取额度已用尽,明早 8:00 自动恢复。你可以先看看文章,稍后再来~',
d1_unavailable: '数据库连接异常,我们正在处理,请稍后再试。',
timeout: '评论服务响应超时,请稍后重试。',
unavailable: '服务或网络有点问题,请稍后重试。',
internal: '评论服务出了点小问题,请稍后重试。'
};
if (!MSG[code]) return;
var orig = window.fetch;
window.fetch = function (input, init) {
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
if (/\/api\/v2\/comments(\?|$)/.test(url)) {
return Promise.resolve(new Response(
JSON.stringify({ code: code, msg: MSG[code], detail: '[demo] forced by ?atk_svc=' + code }),
{ status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'application/json; charset=utf-8' } }
));
}
return orig.apply(this, arguments);
};
try { console.info('[artalk] 调试模式:已强制评论接口返回 ' + code); } catch (e) {}
})();
(function () {
if (window.__atkErrGuard) return; // PJAX 会重复执行本文件,只装一次
window.__atkErrGuard = true;
var last = null; // 最近一次 /api/v2 失败:{ status, code, msg, detail, at }
var INFO = {
d1_quota: { ic: '😴', title: '评论暂停中', desc: '数据库今日读取额度已用尽,明早 8:00 自动恢复。你可以先看看文章,稍后再来~' },
d1_unavailable: { ic: '🛠️', title: '评论服务正在维护', desc: '数据库连接异常,我们已经在处理,请稍后再试。' },
timeout: { ic: '🐢', title: '评论服务响应有点慢', desc: '这次请求超时了,稍后再试一次。' },
unavailable: { ic: '📡', title: '评论服务暂时不可用', desc: '服务或网络有点问题,请稍后再试。' },
network: { ic: '📡', title: '网络好像不太稳定', desc: '评论没能加载出来,检查一下网络再点重试。' },
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 自己读)。
// ── 1.5) 瞬断自动重试:PJAX 进文章偶尔「连不上服务器」,手动刷新就好
// —— 跨境链路抖一下(Failed to fetch / 5xx)列表就整块报错。GET/HEAD
// 幂等,网络错误或 5xx 时静默重试两次(700ms / 1800ms 退避);POST
// 绝不重试(重复提交风险);请求已被 PJAX abort 的也不再续命。
var RETRYABLE_STATUS = { 502: 1, 503: 1, 504: 1, 520: 1, 521: 1, 522: 1, 523: 1, 524: 1, 525: 1, 527: 1, 530: 1 };
function retryWait(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function fetchRetry(doFetch) {
var delays = [700, 1800];
function run(i) {
return doFetch().then(function (res) {
if (res && RETRYABLE_STATUS[res.status] && i < delays.length) {
try { if (res.body && res.body.cancel) res.body.cancel(); } catch (e) {}
return retryWait(delays[i]).then(function () { return run(i + 1); });
}
return res;
}, function (err) {
if (i >= delays.length) throw err;
return retryWait(delays[i]).then(function () { return run(i + 1); });
});
}
return run(0);
}
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
url = String(url);
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 self = this;
function doFetch() { return origFetch.apply(self, fargs); }
var canRetry = (method === 'GET' || method === 'HEAD') &&
!(init && init.signal && init.signal.aborted);
var p = canRetry ? fetchRetry(doFetch) : doFetch();
return p.then(function (res) {
if (res && res.ok === false) {
try {
res.clone().json().then(function (d) {
last = {
status: res.status,
code: (d && d.code) || '',
msg: (d && d.msg) || '',
detail: (d && d.detail) || '',
at: Date.now()
};
if (last.code && last.code !== 'network') setSvcDown(last.code);
}).catch(function () {
last = { status: res.status, code: '', msg: '', detail: 'HTTP ' + res.status, at: Date.now() };
});
} catch (e) { /* clone 不支持就放弃记录,不影响请求 */ }
} else if (res && res.ok) {
// 「服务恢复」只认评论列表本身:禁用的直接原因就是列表挂了,
// 列表回来了才算好。votes/pv/captcha 这些端点不读 D1 或只读
// 一行(KV),它们成功≠列表能拉出来 —— 曾据此误判恢复,把
// 置灰和横幅撤了,只剩一张孤零零的错误卡片挂在页面底部。
if (svcDown && /\/comments(\?|$)/.test(url)) setSvcUp();
}
return res;
}).catch(function (e) {
last = { status: 0, code: 'network', msg: '', detail: (e && e.message) || 'network error', at: Date.now() };
throw e; // 原样抛回,Artalk 自己的重试逻辑不受影响
});
};
}
function esc(x) {
return String(x == null ? '' : x)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function injectCss() {
if (document.getElementById('atk-err-css')) return;
var st = document.createElement('style');
st.id = 'atk-err-css';
st.textContent =
'.atk-error-layer{position:relative!important;top:0!important;left:0!important;height:auto!important;' +
'padding:0!important;margin:14px 0 40px!important;background:transparent!important;z-index:auto!important}' +
'.atk-err-card{display:block;width:100%;box-sizing:border-box;text-align:center;padding:30px 22px;' +
'animation:atkErrIn .35s cubic-bezier(.22,1,.36,1)}' +
'@keyframes atkErrIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}' +
'.atk-err-ic{display:block;font-size:30px;line-height:1;margin-bottom:12px}' +
'.atk-err-t{font-size:15px;font-weight:600;color:var(--text-main,#333);margin-bottom:6px}' +
'.atk-err-d{font-size:13px;line-height:1.8;color:var(--text-muted,#999);' +
'max-width:420px;margin:0 auto 16px;word-break:break-word}' +
'.atk-err-acts{display:flex;justify-content:center;align-items:center;gap:10px}' +
'.atk-err-retry{display:inline-flex;align-items:center;padding:6px 20px;border-radius:999px;' +
'background:var(--theme-main-20,rgba(7,193,96,.12));color:var(--theme-main,#07c160);' +
'font-size:13px;font-weight:600;cursor:pointer;transition:background .2s,color .2s,transform .2s;user-select:none}' +
'.atk-err-retry:hover{background:var(--theme-main,#07c160);color:#fff}' +
'.atk-err-retry:active{transform:scale(.96)}' +
'@media (max-width:640px){.atk-error-layer{margin:10px 0 30px!important}.atk-err-card{padding:20px 14px}.atk-err-ic{font-size:26px}' +
'.atk-err-t{font-size:14px}.atk-err-d{font-size:12.5px}}' +
// 禁用态不改编辑器外观(降透明/灰化整块很难看);只禁交互,
// 为什么点不动由上方横幅说明
'.atk-svc-down .atk-copyright{display:none!important}' +
'.atk-svc-down .atk-main-editor *{pointer-events:none}' +
'.atk-svc-down .atk-human-tf{display:none}' +
'.atk-svc-down .atk-list-body{min-height:0!important}' +
'.atk-editor-off-bar{display:flex;align-items:center;gap:8px;margin:12px 0 10px;padding:9px 14px;border-radius:8px;' +
'background:rgba(7,193,96,.06);border:1px solid rgba(7,193,96,.22);font-size:13px;line-height:1.6;color:var(--text-main,#333)}' +
'[data-theme="dark"] .atk-editor-off-bar{background:rgba(7,193,96,.08);border-color:rgba(7,193,96,.3)}' +
'@media (max-width:640px){.atk-editor-off-bar{margin:10px 0 8px;padding:8px 12px;font-size:12.5px}}';
document.head.appendChild(st);
}
// ── 服务不可用时的编辑器禁用态 ─────────────────────────────────────
// 列表挂了编辑器还开着,用户打完字点提交却毫无反馈 —— 最差的体验。
// 这里:编辑器整体置灰(内容原样可见,只是变灰)+ 编辑框上方插窄横幅
// 说明原因和恢复时间,并每 60s 用一次 1 行 D1 成本的探针(/captcha/status,
// 只读 1 行 settings)探测恢复,恢复后自动解除禁用并 toast,不用刷新页面。
// toast 统一走 Message.js(modules/toast.js 适配层),不再自绘浮层。
// conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮
// 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观,
// 这里按官方标记把缺的字和图标补齐(幂等,Artalk 重建编辑器后可重复跑)。
// SVG 与 libs/Artalk.js 里 useBtn() 的原生节点逐字节一致,视觉与正常态无差。
var PLUG_BTNS =
'<span class="atk-plug-btn"><i aria-label="表情"><svg fill="currentColor" aria-hidden="true" height="14" viewBox="0 0 14 14" width="14"><path d="m4.26829 5.29294c0-.94317.45893-1.7074 1.02439-1.7074.56547 0 1.02439.76423 1.02439 1.7074s-.45892 1.7074-1.02439 1.7074c-.56546 0-1.02439-.76423-1.02439-1.7074zm4.43903 1.7074c.56546 0 1.02439-.76423 1.02439-1.7074s-.45893-1.7074-1.02439-1.7074c-.56547 0-1.02439.76423-1.02439 1.7074s.45892 1.7074 1.02439 1.7074zm-1.70732 2.73184c-1.51883 0-2.06312-1.52095-2.08361-1.58173l-1.29551.43231c.03414.10244.868 2.51604 3.3798 2.51604 2.51181 0 3.34502-2.41291 3.37982-2.51604l-1.29484-.43573c-.02254.06488-.56683 1.58583-2.08498 1.58583zm7-2.73252c0 3.86004-3.1401 7.00034-7 7.00034s-7-3.1396-7-6.99966c0-3.86009 3.1401-7.00034 7-7.00034s7 3.14025 7 7.00034zm-1.3659 0c0-3.10679-2.5275-5.63442-5.6341-5.63442-3.10663 0-5.63415 2.52832-5.63415 5.6351 0 3.10676 2.52752 5.63446 5.63415 5.63446 3.1066 0 5.6341-2.5277 5.6341-5.63446z"/></svg></i></span>' +
'<span class="atk-plug-btn"><i aria-label="预览"><svg fill="currentColor" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"></path></svg></i></span>';
function dressEditor(ed) {
var btn = ed.querySelector('.atk-send-btn');
if (btn && !btn.textContent.trim()) btn.textContent = '发送';
var ta = ed.querySelector('.atk-textarea');
if (ta && !ta.getAttribute('placeholder')) ta.setAttribute('placeholder', '输入内容(支持 Markdown)…');
var ph = { 'atk-nick': '昵称', 'atk-email': '邮箱', 'atk-link': '网址' };
for (var cls in ph) {
var inp = ed.querySelector('input.' + cls);
if (inp && !inp.getAttribute('placeholder')) inp.setAttribute('placeholder', ph[cls]);
}
var plugWrap = ed.querySelector('.atk-plug-btn-wrap');
if (plugWrap && !plugWrap.childElementCount) plugWrap.innerHTML = PLUG_BTNS;
}
var svcDown = false;
var downCode = '';
var downAt = 0; // 进入禁用态的时刻(用来判断这次"故障"是不是一闪而过)
var probeTimer = null;
var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址)
function svcToast(msg) {
// 统一通知组件(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) {}
}
}
function ensureBar(code) {
// 状态类无条件加:conf 失败时 Artalk 可能连编辑器都不渲染,
// 但类挂在 body 上无副作用;编辑器晚点出现时 scan() 会补挂横幅
document.body.classList.add('atk-svc-down');
var ed = document.querySelector('.atk-main-editor');
if (!ed || !ed.parentNode) return;
dressEditor(ed); // 幂等:横幅已插好也要补(PJAX 重建编辑器 / 晚渲染场景)
var bar = document.getElementById('atk-editor-off-bar');
if (bar && bar.parentNode === ed.parentNode) return; // 已插好
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
bar = document.createElement('div');
bar.id = 'atk-editor-off-bar';
bar.className = 'atk-editor-off-bar uk-animation-slide-top-small'; // UIKit 入场动画
// 只说一句状态 + 恢复时间:详细解释由错误卡片负责(列表区),这里不重复
bar.innerHTML = code === 'd1_quota'
? '⏸ 评论暂停中,预计明早 8:00 恢复'
: '⏸ 评论暂停中,请稍后再试';
ed.parentNode.insertBefore(bar, ed);
}
function removeBar() {
document.body.classList.remove('atk-svc-down');
var bar = document.getElementById('atk-editor-off-bar');
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
}
function setSvcDown(code) {
downCode = code;
// 评论服务故障:列表不会返回了,别让骨架屏一直转(故障态有自己的横幅/卡片)
listSkeletonDone();
if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案
svcDown = true;
downAt = Date.now();
ensureBar(code);
if (!probeTimer) {
probeTimer = setInterval(function () {
if (!apiBase) return;
// 探针必须真读 D1:/captcha/status 走 KV,额度没恢复它也 200,
// 会把故障态 60s 一轮地误解除。评论列表 limit=1 是最便宜的
// 「真读 D1」请求(rows_read 个位数),它通了才算恢复。
fetch(apiBase + '/api/v2/comments?page_key=' + encodeURIComponent('/') + '&limit=1').then(function (r) {
if (r.ok) setSvcUp();
}).catch(function () { /* 还没恢复 */ });
}, 60000);
}
}
function setSvcUp() {
if (!svcDown) return;
svcDown = false;
if (probeTimer) { clearInterval(probeTimer); probeTimer = null; }
removeBar();
// 禁用态只持续了一瞬间就别报喜:读者眨个眼就"恢复"的故障,弹提示纯属噪音
// (真实故障从出现到恢复不会只有两三秒,所以门槛不会漏报)
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();
var prev = layer.querySelector('.atk-err-card');
// 优先用结构化错误码;没有就从 Artalk 原始错误文本里认特征词
// (D1 额度用尽 / 网络失败都有固定措辞)。结构化记录有时效且 Artalk
// 走某些路径时抓不到,只靠它会出现"有时显示通用文案"的不稳定 ——
// 双保险后,额度用尽这类固定故障任何时候都能显示出对应文案。
var fresh = last && (Date.now() - last.at < 30000) ? last : null;
var code = (fresh && fresh.code)
|| (/数据库连接异常|d1_unavailable/i.test(raw) ? 'd1_unavailable'
: /D1_ERROR|exceeded D1|free tier|额度已用尽|暂时不可用|加载失败|无法获取|出了点小状况/i.test(raw) ? 'd1_quota'
: /failed to fetch|network ?error|load failed/i.test(raw) ? 'network'
: 'internal');
// 幂等:同一份「原文+结论」渲染过就不再动。结论会变(结构化记录晚到时
// internal 升级成 d1_quota),所以 key 里带上 code,变了就重渲染
if (prev && prev.getAttribute('data-raw') === raw + '|' + code) return;
var info = INFO[code] || INFO.internal;
// 服务端文案是给「直接调 API 的人」写的,自带一句"评论服务暂时不可用:",
// 放到卡片里会和标题重复,这里去掉冗余前缀;顺带规整两句别扭的措辞
// (服务端旧文案,下次 Worker 部署后这两条 replace 自然变成空操作)
var desc = fresh && fresh.msg
? fresh.msg
.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::,,]\s*/, '')
.replace('北京时间明早', '明早')
.replace(/给你带来不便,可以先看看文章,稍后再来[。~~]?/, '你可以先看看文章,稍后再来~')
: info.desc;
// 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅。
// 但「读不到任何可归因证据」时不下结论:raw 为空且结构化记录也没命中,
// 说明这是个我们识别不了的错误层,宁可只渲染卡片,也不要误置灰评论区。
if (code !== 'network' && (raw || (fresh && fresh.code))) setSvcDown(code);
// 原始「重试」节点:Vue 会整层重建,所以每次重新找;
// 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件)
var retry = prev ? prev.querySelector('.atk-err-retry') : null;
if (!retry) {
var box = layer.querySelector('.atk-error-text span');
if (box) {
box.querySelectorAll('span').forEach(function (sp) {
if (!retry && /(重试|重新|retry)/i.test(sp.textContent || '')) retry = sp;
});
}
}
var admin = layer.querySelector('[atk-only-admin-show]');
injectCss();
var card = document.createElement('div');
card.className = 'atk-err-card';
card.setAttribute('data-raw', raw + '|' + code);
card.innerHTML =
'<span class="atk-err-ic">' + info.ic + '</span>' +
'<div class="atk-err-t">' + esc(info.title) + '</div>' +
'<div class="atk-err-d">' + esc(desc) + '</div>' +
'<div class="atk-err-acts"></div>';
layer.textContent = '';
layer.appendChild(card);
var acts = card.querySelector('.atk-err-acts');
if (retry) {
retry.textContent = '重新加载';
retry.removeAttribute('style');
retry.className = 'atk-err-retry';
acts.appendChild(retry); // 移动节点 → 原点击事件保留
} else {
var b = document.createElement('span');
b.className = 'atk-err-retry';
b.textContent = '重新加载';
b.onclick = function () { location.reload(); };
acts.appendChild(b);
}
if (admin && admin.offsetParent !== null) acts.appendChild(admin);
}
function scan() {
var layers = document.querySelectorAll('.atk-error-layer');
for (var i = 0; i < layers.length; i++) rewrite(layers[i]);
// PJAX 换页后编辑器是新的 DOM:禁用态还在就把横幅补回去;恢复了就清掉残留
if (svcDown) {
if (downCode) ensureBar(downCode);
} else if (document.getElementById('atk-editor-off-bar')) {
removeBar();
}
}
function boot() {
scan();
if (!document.body) { setTimeout(boot, 300); return; }
// 错误层是 Artalk 异步渲染出来的,用观察器盯着;PJAX 重建也能命中
var scanT = null;
new MutationObserver(function () {
scan();
// 延迟再扫一次:等 fetch 包装层的结构化记录落地(竞态兜底 ——
// 晚到的结论会通过 data-raw 变化触发重渲染)
if (scanT) clearTimeout(scanT);
scanT = setTimeout(scan, 250);
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();
// Global Artalk Initialization Function (Supports PJAX)
// ── 持久化 artalkInitCount (跨 PJAX 切换保留) ──
// PJAX 切换时 artalk.js 会被重新加载,普通变量会重置。
// 使用 sessionStorage 持久化,让缓存机制在 PJAX 切换时继续生效。
if (typeof window.artalkInitCount === 'undefined') {
try {
var stored = sessionStorage.getItem('artalk_init_count');
window.artalkInitCount = stored ? parseInt(stored, 10) : 0;
} catch(e) {
window.artalkInitCount = 0;
}
}
var saveInitCount = function() {
try {
sessionStorage.setItem('artalk_init_count', String(window.artalkInitCount));
} catch(e) {}
};
// ── 增强评论框字段 (PJAX 适配版本) ──
// 在 Artalk 完全加载前就开始监控,确保油猴脚本能识别到字段
window.__artalkEnhancer = window.__artalkEnhancer || {
observer: null,
running: false,
enhance: function() {
var self = this;
var count = 0;
// 昵称输入框
var nickInputs = document.querySelectorAll('input[name="nick"]:not([data-autofill-done])');
nickInputs.forEach(function(input) {
input.setAttribute('autocomplete', 'name');
input.setAttribute('aria-label', '昵称 author nick name user');
input.classList.add('comment-author', 'user-nick', 'user-name', 'user-displayname', '昵称');
input.setAttribute('data-autofill-done', '1');
count++;
});
// 邮箱输入框
var emailInputs = document.querySelectorAll('input[name="email"]:not([data-autofill-done])');
emailInputs.forEach(function(input) {
input.setAttribute('autocomplete', 'email');
input.setAttribute('aria-label', '邮箱 email mail');
input.classList.add('comment-email', 'user-email', 'user-mail', '邮箱');
input.setAttribute('data-autofill-done', '1');
count++;
});
// 网址输入框
var linkInputs = document.querySelectorAll('input[name="link"]:not([data-autofill-done])');
linkInputs.forEach(function(input) {
input.setAttribute('autocomplete', 'url');
input.setAttribute('aria-label', '网址 website url link homepage site web');
input.classList.add('comment-url', 'user-link', 'user-website', 'user-site', 'user-web', '网址');
input.setAttribute('data-autofill-done', '1');
count++;
});
// 内容输入框(避免被误识别为昵称)
var contentInputs = document.querySelectorAll('textarea[name="content"]:not([data-autofill-done])');
contentInputs.forEach(function(input) {
input.setAttribute('autocomplete', 'off');
input.setAttribute('aria-label', '评论内容 content comment message');
input.setAttribute('data-autofill-done', '1');
count++;
});
if (count > 0 && window.console && console.log) {
console.log('[ArtalkEnhancer] 增强了 ' + count + ' 个评论输入框');
}
return count;
},
// 监控 DOM 变化(PJAX 切换、动态加载)
observe: function() {
var self = this;
if (typeof MutationObserver === 'undefined') return;
if (this.observer) return;
this.observer = new MutationObserver(function(mutations) {
var shouldEnhance = false;
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (node.nodeType === 1) {
// 检查是否是 input 或 textarea 元素
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
shouldEnhance = true;
break;
}
// 检查子节点
if (node.querySelectorAll) {
var inputs = node.querySelectorAll('input, textarea');
if (inputs.length > 0) {
shouldEnhance = true;
break;
}
}
}
}
if (shouldEnhance) break;
}
if (shouldEnhance) {
// 稍微延迟,确保 DOM 完全插入
setTimeout(function() { self.enhance(); }, 10);
}
});
this.observer.observe(document.body, { childList: true, subtree: true });
},
// 启动全局监控
start: function() {
if (this.running) return;
this.running = true;
if (window.console && console.log) {
console.log('[ArtalkEnhancer] 启动评论框字段增强');
}
// 立即尝试增强一次
this.enhance();
// 启动 DOM 监控
this.observe();
// 兜底:定时检查(Artalk 加载慢时)
var checkCount = 0;
var maxChecks = 20; // 最多检查 20 次(10 秒)
var interval = setInterval(function() {
checkCount++;
var enhanced = window.__artalkEnhancer.enhance();
if (enhanced > 0 || checkCount >= maxChecks) {
clearInterval(interval);
}
}, 500);
}
};
// 立即启动(不等待 DOMContentLoaded,尽早开始监控)
if (typeof window.__artalkEnhancer !== 'undefined') {
window.__artalkEnhancer.start();
}
// 监听 PJAX 切换:重置并重新增强
document.addEventListener('pjax:send', function() {
if (window.__artalkEnhancer && window.console && console.log) {
console.log('[ArtalkEnhancer] PJAX 切换,准备重新增强');
}
});
document.addEventListener('pjax:success', function() {
if (window.__artalkEnhancer) {
// 立即尝试
window.__artalkEnhancer.enhance();
// 延迟再试(Artalk 重新初始化需要时间)
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 200);
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 500);
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 1000);
}
});
document.addEventListener('pjax:complete', function() {
if (window.__artalkEnhancer) {
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 300);
setTimeout(function() { window.__artalkEnhancer.enhance(); }, 800);
}
});
window.loadScriptOnce = window.loadScriptOnce || function(src, globalName, callback) {
if (globalName && window[globalName]) {
callback();
return;
}
var existing = document.querySelector('script[data-dynamic-src="' + src + '"]');
if (existing) {
existing.addEventListener('load', callback, { once: true });
return;
}
var script = document.createElement('script');
script.src = src;
script.defer = true;
script.dataset.dynamicSrc = src;
script.onload = callback;
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() {
var mount = document.querySelector("#Comments");
if (!mount) return;
// Check if configuration exists
if (!window.artalkConfig) {
console.warn('window.artalkConfig is not defined. Skipping Artalk init.');
return;
}
if (typeof Artalk === 'undefined') {
var artalkUrl = window.optionalAssetUrls && window.optionalAssetUrls.artalk;
if (!artalkUrl) return;
window.loadScriptOnce(artalkUrl, 'Artalk', window.initArtalk);
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
// 2. PJAX: Load from localStorage if available
// Initialize initialization count to track PJAX usage
// (从 sessionStorage 加载,由文件顶部的逻辑处理)
if (typeof window.artalkInitCount === 'undefined') {
window.artalkInitCount = 0;
}
window.artalkInitCount++;
saveInitCount(); // 持久化到 sessionStorage
var config = Object.assign({}, window.artalkConfig); // Clone config
// ── 提交前校验(昵称 / 邮箱必填;网址随意)──────────────────────────
// Artalk 的 editor.submit() 实现是:
// conf.beforeSubmit ? conf.beforeSubmit(editor, next) : trigger('editor-submit')
// 也就是官方就留了拦截点 —— 不调 next() 就不会走提交流程。
// 挂在这份 config 上(而不是内联 <script> 里的 window.artalkConfig)是因为
// 两条 init 路径(带 / 不带 localStorage 缓存)都从它派生,挂这里两个分支
// 必然都带上;何况缓存那份只存简单类型,函数本来就存不下来。
if (window.__atkSubmit && window.__atkSubmit.beforeSubmit) {
config.beforeSubmit = window.__atkSubmit.beforeSubmit;
}
var server = config.server.replace(/\/$/, '');
var confKey = 'artalk_conf_' + btoa(server);
var cachedConf = null;
// Try to read cache
try {
var raw = localStorage.getItem(confKey);
if (raw) {
cachedConf = JSON.parse(raw);
// 剔除页面级字段:缓存来自「上一个页面」的实例 conf,若把它的
// pageKey/pageTitle 合并进当前页就会串页 —— 历史上文章页在后台
// 标题被污染成「留言」(用户从留言页 PJAX 进文章页)就是这个原因。
// pageKey / pageTitle 现已全部由 window.artalkConfig 显式提供,
// 这里删掉属于双保险,防止 config 哪天漏传又被旧值钻空子。
if (cachedConf && typeof cachedConf === 'object') {
['pageKey', 'pageTitle', 'pageThumb'].forEach(function(k) {
delete cachedConf[k];
});
}
}
} catch(e) {
console.warn('Failed to parse Artalk cache:', e);
}
// Decision: Use Cache or Fetch?
// Use cache only if:
// 1. Not the first load (PJAX)
// 2. Cache exists
if (window.artalkInitCount > 1 && cachedConf) {
// Merge cache into config
// CAUTION: cachedConf contains page-specific data (like pageKey) from the previous page.
// We must ensure current window.artalkConfig overwrites it.
try {
// Defensive copy to avoid mutating cached object
var mergedConfig = Object.assign({}, config);
Object.assign(mergedConfig, cachedConf);
Object.assign(mergedConfig, window.artalkConfig); // Re-apply current page config
mergedConfig.useBackendConf = false;
// Attempt to init with cache
window.artalk = Artalk.init(mergedConfig);
} catch (initErr) {
console.warn('Artalk init with cache failed, falling back to default:', initErr);
localStorage.removeItem(confKey); // Clear bad cache
window.artalk = Artalk.init(config); // Fallback to normal init
}
} else {
// First load or no cache: Allow Artalk to fetch (useBackendConf defaults to true)
window.artalk = Artalk.init(config);
}
var artalk = window.artalk; // Local reference
// ── 评论提交反馈(发送中 / 成功;失败由文件末尾的 fetch 拦截兜底)──
// editor-submit = 提交开始(beforeSubmit 放行后才会触发)
// editor-submitted = 提交成功(Artalk 的失败分支直接 return,不触发本事件)
if (window.__atkSubmit) {
artalk.on('editor-submit', window.__atkSubmit.begin);
artalk.on('editor-submitted', window.__atkSubmit.done);
}
// Sync Theme Mode Immediately
var currentTheme = document.documentElement.getAttribute('data-theme');
if (typeof artalk.setDarkMode === '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 准确匹配
var enhanceEditorInputs = function() {
var editor = document.querySelector('.atk-editor');
if (!editor) return 0;
var enhanced = 0;
// 昵称输入框
var nickInput = editor.querySelector('input[name="nick"]');
if (nickInput && !nickInput.getAttribute('data-autofill-done')) {
nickInput.setAttribute('autocomplete', 'name');
nickInput.setAttribute('aria-label', '昵称 author nick name');
nickInput.classList.add('comment-author', 'user-nick');
nickInput.setAttribute('data-autofill-done', '1');
enhanced++;
}
// 邮箱输入框
var emailInput = editor.querySelector('input[name="email"]');
if (emailInput && !emailInput.getAttribute('data-autofill-done')) {
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('aria-label', '邮箱 email mail');
emailInput.classList.add('comment-email', 'user-email');
emailInput.setAttribute('data-autofill-done', '1');
enhanced++;
}
// 网址输入框
var linkInput = editor.querySelector('input[name="link"]');
if (linkInput && !linkInput.getAttribute('data-autofill-done')) {
linkInput.setAttribute('autocomplete', 'url');
linkInput.setAttribute('aria-label', '网址 website url link homepage site web');
linkInput.classList.add('comment-url', 'user-link', 'user-website');
linkInput.setAttribute('data-autofill-done', '1');
enhanced++;
}
// 内容输入框(避免被误识别为昵称)
var contentInput = editor.querySelector('textarea[name="content"]');
if (contentInput && !contentInput.getAttribute('data-autofill-done')) {
contentInput.setAttribute('autocomplete', 'off');
contentInput.setAttribute('aria-label', '评论内容 content comment');
contentInput.setAttribute('data-autofill-done', '1');
enhanced++;
}
// 触发油猴脚本自动填充(延迟到评论框稳定后)
if (enhanced > 0) {
triggerAutoFill(editor);
}
return enhanced;
};
// ── 触发油猴脚本自动填充 ──
// 问题:油猴脚本可能在评论框还没完全加载时就填充了,
// 导致填充的数据在 Artalk 重新渲染后丢失。
// 解决:使用 MutationObserver 监控评论框 DOM 变化,
// 在评论框稳定后(300ms 内没有变化)才触发填充。
var pendingFillTimer = null;
var fillDebounceDelay = 300; // 评论框稳定后 300ms 才触发填充
var triggerAutoFill = function(editor) {
if (pendingFillTimer) {
clearTimeout(pendingFillTimer);
}
// 延迟触发,等待评论框稳定
pendingFillTimer = setTimeout(function() {
pendingFillTimer = null;
// 再次检查评论框是否存在
editor = document.querySelector('.atk-editor');
if (!editor) return;
// 获取评论框中的输入框
var nickInput = editor.querySelector('input[name="nick"]');
var emailInput = editor.querySelector('input[name="email"]');
var linkInput = editor.querySelector('input[name="link"]');
// 保存当前输入框的值到 sessionStorage
var fillData = {
nick: nickInput ? nickInput.value : '',
email: emailInput ? emailInput.value : '',
link: linkInput ? linkInput.value : '',
timestamp: Date.now()
};
// 如果有填充数据,保存到 sessionStorage
if (fillData.nick || fillData.email || fillData.link) {
try {
sessionStorage.setItem('artalk_autofill_data', JSON.stringify(fillData));
console.log('[ArtalkEnhancer] 保存填充数据到 sessionStorage:', fillData);
} catch(e) {}
}
// 手动触发 input 事件,通知可能存在的自动填充脚本
// 一些油猴脚本会监听 input/change 事件来检测新字段
[nickInput, emailInput, linkInput].forEach(function(input) {
if (input && input.value) {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// 尝试调用油猴脚本的公开 API(如果有)
// 例如 Greasemonkey/Tampermonkey 的 GM_setValue 配合 GM_getValue
if (typeof window.GM !== 'undefined' && window.GM.setValue) {
// 通知油猴脚本评论框已加载完成
window.GM_setValue('artalk_editor_ready', true);
}
console.log('[ArtalkEnhancer] 评论框稳定,触发自动填充');
}, fillDebounceDelay);
};
// ── 恢复 sessionStorage 中的填充数据 ──
// 问题:Artalk 评论框可能在填充后又重新渲染,导致填充的数据丢失。
// 解决:在评论框稳定后,检查 sessionStorage 是否有之前保存的填充数据,
// 如果有,自动恢复到输入框中。
var restoreAutoFillData = function() {
var editor = document.querySelector('.atk-editor');
if (!editor) return;
try {
var stored = sessionStorage.getItem('artalk_autofill_data');
if (!stored) return;
var fillData = JSON.parse(stored);
if (!fillData || !fillData.timestamp) return;
// 数据是否在 24 小时内有效
var maxAge = 24 * 60 * 60 * 1000; // 24 小时
if (Date.now() - fillData.timestamp > maxAge) {
sessionStorage.removeItem('artalk_autofill_data');
return;
}
// 恢复到输入框
var nickInput = editor.querySelector('input[name="nick"]');
var emailInput = editor.querySelector('input[name="email"]');
var linkInput = editor.querySelector('input[name="link"]');
if (fillData.nick && nickInput && !nickInput.value) {
nickInput.value = fillData.nick;
nickInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (fillData.email && emailInput && !emailInput.value) {
emailInput.value = fillData.email;
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (fillData.link && linkInput && !linkInput.value) {
linkInput.value = fillData.link;
linkInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (fillData.nick || fillData.email || fillData.link) {
console.log('[ArtalkEnhancer] 从 sessionStorage 恢复填充数据:', fillData);
}
} catch(e) {
console.warn('[ArtalkEnhancer] 恢复填充数据失败:', e);
}
};
// 立即执行一次
enhanceEditorInputs();
// 评论框加载完成(Artalk 内置事件)
artalk.on('mounted', function() {
setTimeout(function() {
enhanceEditorInputs();
restoreAutoFillData();
}, 100);
});
// 评论列表加载完成(PJAX 切换后)
artalk.on('list-loaded', function() {
setTimeout(function() {
enhanceEditorInputs();
restoreAutoFillData();
}, 200);
});
// 评论发送成功后保存填充数据
artalk.on('comment-submit', function() {
try {
var editor = document.querySelector('.atk-editor');
if (editor) {
var fillData = {
nick: editor.querySelector('input[name="nick"]') ? editor.querySelector('input[name="nick"]').value : '',
email: editor.querySelector('input[name="email"]') ? editor.querySelector('input[name="email"]').value : '',
link: editor.querySelector('input[name="link"]') ? editor.querySelector('input[name="link"]').value : '',
timestamp: Date.now()
};
if (fillData.nick || fillData.email || fillData.link) {
sessionStorage.setItem('artalk_autofill_data', JSON.stringify(fillData));
console.log('[ArtalkEnhancer] 评论提交后保存填充数据');
}
}
} catch(e) {}
});
// 兜底:定时检查(防止某些情况下事件未触发)
var enhanceInterval = setInterval(function() {
enhanceEditorInputs();
restoreAutoFillData();
// 找到所有字段都已增强后停止
var editor = document.querySelector('.atk-editor');
if (editor) {
var inputs = editor.querySelectorAll('input[name="nick"], input[name="email"], input[name="link"], textarea[name="content"]');
var allDone = true;
inputs.forEach(function(input) {
if (!input.getAttribute('data-autofill-done')) allDone = false;
});
if (allDone && inputs.length > 0) {
clearInterval(enhanceInterval);
}
}
}, 1000);
// 30秒后停止轮询
setTimeout(function() { clearInterval(enhanceInterval); }, 30000);
// Aggressive Config Saving Strategy
var saveConf = function() {
try {
// Attempt to find the full configuration object
// artalk.conf is the standard property in v2+
// artalk.ctx.conf is for internal context access
var remoteConf = artalk.conf || (artalk.ctx && artalk.ctx.conf);
if (remoteConf) {
// Filter out non-serializable properties (like DOM elements, circular refs)
var safeConf = {};
for (var key in remoteConf) {
var val = remoteConf[key];
// Skip if value is a DOM element
if (val instanceof Element || val instanceof Node) continue;
// Skip if value is window or document
if (val === window || val === document) continue;
// Skip internal private properties starting with $ or _
if (key.startsWith('$') || key.startsWith('_')) continue;
// Only allow simple types or simple objects
var type = typeof val;
if (type === 'string' || type === 'number' || type === 'boolean' || val === null) {
safeConf[key] = val;
} else if (type === 'object') {
// Simple array check
if (Array.isArray(val)) {
safeConf[key] = val;
} else {
// For objects, deep check might be too expensive/complex here.
// We'll try to shallow copy if it looks like a plain object
// and doesn't have circular markers like 'el'
if (val.constructor === Object && !val.el && !val.$el) {
safeConf[key] = val;
}
}
}
}
localStorage.setItem(confKey, JSON.stringify(safeConf));
// console.log('Artalk: Config cached to', confKey);
}
} catch(e) {
console.error('Artalk saveConf error:', e);
}
};
// Hook into multiple events to ensure we capture the config after it's loaded
// 'conf-loaded' is the ideal event if supported
artalk.on('conf-loaded', saveConf);
// 'mounted' is when the widget is ready
artalk.on('mounted', saveConf);
// 'list-loaded' means comments (and likely config) are fetched
artalk.on('list-loaded', saveConf);
// Fallback: Poll check after a few seconds (for slow networks)
setTimeout(saveConf, 2000);
setTimeout(saveConf, 5000);
// ── Badge system: 友链 / 友圈 / 评论等级 ──
var normalizeHost = function(h) {
return (h || '').replace(/^www\./, '');
};
var buildMap = function(data) {
var hosts = new Map();
var names = new Map();
if (data && data.hosts) {
data.hosts.forEach(function(item) {
if (item.host) hosts.set(item.host, item.title || item.host);
});
}
if (data && data.names) {
data.names.forEach(function(n) {
if (n.lower) names.set(n.lower, n.title);
});
}
return { hosts: hosts, names: names };
};
var linkMap = null;
var feedMap = null;
// Comment rank table — Song dynasty 九品十八阶
// Color scale: warm → cool gradient for visual hierarchy
var RANK_TABLE = [
{ min: 2000, title: '正一品·太师', short: '太师', color: '#c5881b', bg: 'rgba(197,136,27,0.16)' },
{ min: 1400, title: '从一品·太尉', short: '太尉', color: '#cc942a', bg: 'rgba(204,148,42,0.13)' },
{ min: 1050, title: '正二品·参知政事', short: '参知政事', color: '#d46d33', bg: 'rgba(212,109,51,0.16)' },
{ min: 800, title: '从二品·节度使', short: '节度使', color: '#d87940', bg: 'rgba(216,121,64,0.13)' },
{ min: 600, title: '正三品·御史中丞', short: '御史中丞', color: '#cf4a58', bg: 'rgba(207,74,88,0.16)' },
{ min: 480, title: '从三品·秘书监', short: '秘书监', color: '#d45d68', bg: 'rgba(212,93,104,0.13)' },
{ min: 380, title: '正四品·谏议大夫', short: '谏议大夫', color: '#b05385', bg: 'rgba(176,83,133,0.16)' },
{ min: 300, title: '从四品·侍读学士', short: '侍读学士', color: '#b8638f', bg: 'rgba(184,99,143,0.13)' },
{ min: 240, title: '正五品·给事中', short: '给事中', color: '#9570d6', bg: 'rgba(149,112,214,0.16)' },
{ min: 190, title: '从五品·知州', short: '知州', color: '#a07bda', bg: 'rgba(160,123,218,0.13)' },
{ min: 150, title: '正六品·侍御史', short: '侍御史', color: '#5f7ace', bg: 'rgba(95,122,206,0.16)' },
{ min: 115, title: '从六品·通判', short: '通判', color: '#6b85d0', bg: 'rgba(107,133,208,0.13)' },
{ min: 85, title: '正七品·知县', short: '知县', color: '#2a9a84', bg: 'rgba(42,154,132,0.16)' },
{ min: 60, title: '从七品·殿中侍御史', short: '殿中侍御史', color: '#34a390', bg: 'rgba(52,163,144,0.13)' },
{ min: 40, title: '正八品·大理评事', short: '大理评事', color: '#628530', bg: 'rgba(98,133,48,0.16)' },
{ min: 25, title: '从八品·录事参军', short: '录事参军', color: '#6e913d', bg: 'rgba(110,145,61,0.13)' },
{ min: 15, title: '正九品·主簿', short: '主簿', color: '#8a7050', bg: 'rgba(138,112,80,0.16)' },
{ min: 5, title: '从九品·司户参军', short: '司户参军', color: '#917a5c', bg: 'rgba(145,122,92,0.13)' },
{ min: 0, title: '庶民', short: '庶民', color: '#8a8e98', bg: 'rgba(138,142,152,0.10)' }
];
var getRank = function(count) {
for (var i = 0; i < RANK_TABLE.length; i++) {
if (count >= RANK_TABLE[i].min) return RANK_TABLE[i];
}
return RANK_TABLE[RANK_TABLE.length - 1];
};
// localStorage-based cross-page user comment counter with page dedup
var USER_COUNT_KEY = 'atk_user_counts_v2';
var loadUserCounts = function() {
try {
var raw = localStorage.getItem(USER_COUNT_KEY);
return raw ? JSON.parse(raw) : { _pages: {} };
} catch(e) { return { _pages: {} }; }
};
var saveUserCounts = function(counts) {
try { localStorage.setItem(USER_COUNT_KEY, JSON.stringify(counts)); } catch(e) {}
};
var getPageKey = function() {
return window.artalkConfig ? (window.artalkConfig.pageKey || window.location.pathname) : window.location.pathname;
};
// ── 评论区徽章统一注入(comment-rendered 每条触发)──
// 优先级:官方系统徽章(博主等,Artalk 自渲染)独立显示;
// 主题徽章互斥取最高一级:友链 > 友圈 > 评论官职。
// 数据源:window.friendLinks/friendFeeds(友链、友圈)+ 后端 rank_* 字段(官职)。
var ensureBadgeMaps = function() {
if (!linkMap && window.friendLinks) linkMap = buildMap(window.friendLinks);
if (!feedMap && window.friendFeeds) feedMap = buildMap(window.friendFeeds);
return !!(linkMap || feedMap);
};
var matchBadge = function(map, host, nickLower) {
if (!map) return null;
if (host && map.hosts.has(host)) return map.hosts.get(host);
if (map.names.has(nickLower)) return map.names.get(nickLower);
return null;
};
var injectBadge = function(nickEl, cls, text, title, color, bg) {
if (nickEl.querySelector('.' + cls)) return;
var b = document.createElement('span');
b.className = cls;
b.textContent = text;
if (title) b.title = title;
if (color) b.style.color = color;
if (bg) b.style.backgroundColor = bg;
nickEl.appendChild(b);
};
artalk.on('comment-rendered', function(node) {
try {
var data = null;
if (node && typeof node.getData === 'function') data = node.getData();
else if (node && node.comment && typeof node.comment.getData === 'function') data = node.comment.getData();
else data = node;
if (!data) return;
var el = null;
if (node && node.$el) el = node.$el;
else if (node && typeof node.getEl === 'function') el = node.getEl();
if (!el || !el.querySelector) return;
var nickEl = el.querySelector('.atk-nick');
if (!nickEl) return;
if (!ensureBadgeMaps()) return;
var nickLower = (data.nick || '').toLowerCase();
var host = null;
try { if (data.link) host = normalizeHost(new URL(data.link).hostname); } catch (e) {}
var linkMatch = matchBadge(linkMap, host, nickLower);
var feedMatch = matchBadge(feedMap, host, nickLower);
// 系统徽章(博主/置顶)由 Artalk 渲染在 .atk-badge-wrap 里,头衔/友链/友圈/官职
// 则是直接插进昵称的 —— 两套 DOM 会导致尺寸与垂直对齐都不一致。这里把包装层
// 拆掉,让所有徽章都成为昵称的直接子元素,视觉顺序:
// 系统徽章 > 头衔 > 友链 > 友圈 > 官职
var badgeWrap = el.querySelector('.atk-badge-wrap');
if (badgeWrap && badgeWrap.parentNode !== nickEl) nickEl.appendChild(badgeWrap);
// 1) 头衔徽章(金色,管理员在后台手动设置,仅次于系统徽章;配色走 .atk-title-badge 类)
if (data.title_name && !nickEl.querySelector('.atk-title-badge')) {
injectBadge(nickEl, 'atk-title-badge', data.title_name, '头衔: ' + data.title_name);
}
// 2) 友链
if (linkMatch) injectBadge(nickEl, 'atk-link-badge', '友链', '友链: ' + linkMatch);
// 3) 友圈
if (feedMatch) injectBadge(nickEl, 'atk-feed-badge', '友圈', '友圈: ' + feedMatch);
// 4) 评论官职(按全站评论数)
if (data.rank_name && !nickEl.querySelector('.atk-rank-badge')) {
var tip = data.rank_title || data.rank_name;
if (data.rank_count != null) tip += ' (' + data.rank_count + '条评论)';
injectBadge(nickEl, 'atk-rank-badge', data.rank_name, tip, data.rank_color || '', data.rank_bg || '');
}
// 5) 拆掉 .atk-badge-wrap 包装层:把 博主/置顶 提成昵称的直接子元素(保持原顺序)
var wrap = nickEl.querySelector('.atk-badge-wrap');
if (wrap && wrap.parentNode) {
var wrapParent = wrap.parentNode;
while (wrap.firstChild) wrapParent.insertBefore(wrap.firstChild, wrap);
wrapParent.removeChild(wrap);
}
// 5.5) 统一排序兜底:不管 Artalk 把系统徽章渲染在哪、什么时候渲染,
// 最终顺序强制为:系统徽章(博主/小编) > 置顶 > 头衔 > 友链 > 友圈 > 官职。
// 实测:管理员的系统徽章在 .atk-badge-wrap 里、走上面的逻辑会排在最前;
// 普通用户的 badge_name 徽章(小编)由 Artalk 稍后直接追加到昵称末尾,
// 会排到友链/友圈/官职后面 —— 这里按优先级重排,两种路径都归位。
var PRIO = ['.atk-badge', '.atk-pinned-badge', '.atk-title-badge', '.atk-link-badge', '.atk-feed-badge', '.atk-rank-badge'];
var badgeFrag = document.createDocumentFragment();
for (var pi = 0; pi < PRIO.length; pi++) {
var priEls = nickEl.querySelectorAll(PRIO[pi]);
for (var pei = 0; pei < priEls.length; pei++) badgeFrag.appendChild(priEls[pei]);
}
nickEl.appendChild(badgeFrag);
// 6) 所有徽章挂同一个类,几何样式(尺寸/内边距/圆角/字重/对齐)只由
// .atk-cmt-badge 一处定义,从此不会有"两种徽章尺寸不一致"的问题
var allBadges = nickEl.querySelectorAll(
'.atk-badge, .atk-pinned-badge, .atk-title-badge, .atk-link-badge, .atk-feed-badge, .atk-rank-badge'
);
for (var bi = 0; bi < allBadges.length; bi++) {
var bdg = allBadges[bi];
bdg.classList.add('atk-cmt-badge');
// 7) 把徽章文字镜像到 data-badge —— CSS 就能按「博主 / 小编 / ...」分别配色。
// 系统徽章(.atk-badge)身上只有 class + 文字可用:badge_color 是 Artalk
// 写在元素内联 style 上的,而主题的 !important 实底规则会盖掉它,
// 所以按文字做一个稳定的选择器钩子(以后加新徽章配色不用再改 JS)。
var btxt = (bdg.textContent || '').trim();
if (btxt && bdg.getAttribute('data-badge') !== btxt) bdg.setAttribute('data-badge', btxt);
}
} catch (e) { /* 徽章注入失败不影响评论 */ }
// ── 友链申请入口卡片:评论内容就是约定 URL(https://usj.cc/link-apply)时,
// 整条评论解析成申请入口卡片(点击打开申请弹窗),其余评论不受影响。
try {
var d2 = null;
if (node && typeof node.getData === 'function') d2 = node.getData();
else if (node && node.comment && typeof node.comment.getData === 'function') d2 = node.comment.getData();
else d2 = node;
var el2 = null;
if (node && node.$el) el2 = node.$el;
else if (node && typeof node.getEl === 'function') el2 = node.getEl();
if (!d2 || !el2) return;
var content = String(d2.content || '').trim().replace(/\/+$/, '').toLowerCase();
if (content !== 'https://usj.cc/link-apply') return;
// 只转换顶层评论:回复里出现同一串 URL 不该被吃掉(也避免出现游离的卡片)
if (d2.rid) return;
// 幂等 + 自愈:class 每次渲染都补一次(CSS 靠它隐藏整条评论),
// 卡片始终挂在评论列表"上方"(.atk-list-body 的第一个子节点),
// 这样发新评论/切换排序都不会把卡片夹到评论中间。
el2.classList.add('atk-fl-apply');
var listBody = document.querySelector('.atk-list-body') || document.querySelector('.atk-list');
var oldHolder = document.querySelector('.atk-fl-card-wrap');
if (oldHolder && listBody && oldHolder.parentNode === listBody && oldHolder.isConnected) return;
if (oldHolder && oldHolder.parentNode) oldHolder.parentNode.removeChild(oldHolder);
el2.style.cursor = 'pointer';
el2.title = '点击申请友链';
// 先取原评论的头像 / 昵称 / 官方徽章(博主),再隐藏常规元素
var avImg = el2.querySelector('.atk-avatar img');
var avatarSrc = avImg ? avImg.getAttribute('src') : '';
var nickEl2 = el2.querySelector('.atk-nick');
// wrap 已在上面拆掉,博主徽章现在是昵称的直接子元素
var sysBadge = (nickEl2 || el2).querySelector('.atk-badge') ||
el2.querySelector('.atk-badge');
var nickText = String(d2.nick || '博主');
// 隐藏评论常规元素(头像列/昵称行/页脚操作),整条变成一张卡片
var hd = el2.querySelector('.atk-header');
var ft = el2.querySelector('.atk-footer');
var av0 = el2.querySelector('.atk-avatar');
var meta = el2.querySelector('.atk-meta');
if (hd) hd.style.display = 'none';
if (ft) ft.style.display = 'none';
if (av0) av0.style.display = 'none';
if (meta) meta.style.display = 'none';
var main = el2.querySelector('.atk-main') || el2;
var contentEl = main.querySelector('.atk-content');
// 卡片挂在 .atk-comment 这一级,而不是塞进 .atk-content 里:
// .atk-main 被主题锁了 margin-left:34px !important(头像缩进),塞在里面
// 卡片永远比评论输入框窄一圈;提到 .atk-comment 这一级就能和输入框同宽对齐。
var commentEl = el2.querySelector('.atk-comment') || main.parentElement || el2;
if (main && main !== el2) main.style.display = 'none'; // 常规内容整块隐藏
var FL_DESC = '想和我交换友链?点击申请,审核结果会邮件通知你';
var card = document.createElement('div');
card.className = 'atk-fl-card'; // 独立类名:容器用的是 atk-fl-apply 做标记
// 跟随评论列表原生观感:白底 + 细边框,只有按钮带主题色
card.style.cssText = 'display:flex;align-items:center;gap:12px;padding:12px 14px;margin:2px 0;' +
'background:var(--bg-card,#fff);border:1px solid var(--border-main,#eeeeee);' +
'border-radius:10px;transition:border-color .2s,background .2s;';
card.innerHTML =
'<img class="atk-fl-avatar uk-animation-scale-up" src="' + avatarSrc + '" alt="" referrerpolicy="no-referrer" ' +
'style="flex:none;width:40px;height:40px;border-radius:6px;object-fit:cover">' +
'<span class="atk-fl-body" style="flex:1;min-width:0">' +
'<span class="atk-fl-nick" style="display:flex;align-items:center;gap:0;font-size:14px;font-weight:600;color:var(--text-main,#333)">' +
'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + nickText.replace(/</g, '&lt;') + '</span>' +
'</span>' +
'<span class="atk-fl-desc" style="display:block;font-size:13px;margin-top:2px;color:var(--text-desc,#777);line-height:1.5">' +
'<span class="atk-fl-typed"></span><i class="atk-fl-caret"></i>' +
'</span>' +
'</span>' +
'<span class="atk-fl-btn" style="flex:none;background:var(--theme-main-20,rgba(7,193,96,.2));color:var(--theme-main,#07c160);' +
'font-size:13px;font-weight:600;padding:6px 14px;border-radius:5px;white-space:nowrap">申请友链</span>';
// 昵称行追加官方徽章(博主),样式与评论区徽章一致
if (sysBadge) {
var bw = sysBadge.cloneNode(true);
bw.style.marginLeft = '0';
var nickRow = card.querySelector('span > span');
nickRow.appendChild(bw);
}
// 卡片包一层容器挂到列表上方(宽度与评论区一致,且不受列表排序/新增影响)
if (contentEl) contentEl.innerHTML = '';
var holder = document.createElement('div');
holder.className = 'atk-fl-card-wrap';
holder.appendChild(card);
if (listBody) {
if (listBody.firstChild) listBody.insertBefore(holder, listBody.firstChild);
else listBody.appendChild(holder);
} else {
commentEl.appendChild(holder);
}
// 点击卡片即打开申请弹窗(原来挂在 el2 上,现在卡片已移出评论节点)
holder.addEventListener('click', function () {
if (window.flApplyOpenModal) window.flApplyOpenModal();
});
// ── 打字机效果(PJAX 安全:卡片被移除/替换时定时器自动停止) ──
// 注意:不要用 prefers-reduced-motion 跳过打字 —— Windows 关闭"动画效果"
// 或部分浏览器默认就会报告 reduce,会导致效果完全不出现。这里始终打字。
try {
var typedEl = card.querySelector('.atk-fl-typed');
var caretEl = card.querySelector('.atk-fl-caret');
var alive = function () { return card.isConnected !== undefined ? card.isConnected : document.body.contains(card); };
if (typedEl) {
var ti = 0;
setTimeout(function () {
if (!alive()) return;
var timer = setInterval(function () {
if (!alive()) { clearInterval(timer); return; } // PJAX 切页后自动停止
ti += 1;
typedEl.textContent = FL_DESC.slice(0, ti);
if (ti >= FL_DESC.length) {
clearInterval(timer);
if (caretEl) caretEl.style.display = 'none';
}
}, 42);
}, 320); // 等入场动画播完再打字,观感更自然
}
} catch (e) {}
el2.addEventListener('click', function () {
if (window.flApplyOpenModal) window.flApplyOpenModal();
});
el2.addEventListener('mouseenter', function () {
card.style.borderColor = 'var(--theme-main,#07c160)';
card.style.background = 'var(--bg-card,#fff)';
});
el2.addEventListener('mouseleave', function () {
card.style.borderColor = 'var(--border-main,#eeeeee)';
});
} catch (e) { /* 卡片化失败不影响评论 */ }
});
artalk.on('list-loaded', function() {
// Add staggered animation to the comment list
var list = document.querySelector('.atk-list');
if (list) {
list.setAttribute('uk-scrollspy', 'target: > .atk-comment-wrap; cls: uk-animation-slide-bottom-small; delay: 50');
}
// Apply granular animations to inner elements
document.querySelectorAll('.atk-comment-wrap').forEach(function(wrap) {
var avatar = wrap.querySelector('.atk-avatar');
if (avatar) avatar.setAttribute('uk-scrollspy', 'cls: uk-animation-scale-up; delay: 200');
var main = wrap.querySelector('.atk-main');
if (main) main.setAttribute('uk-scrollspy', 'cls: uk-animation-slide-right-small; delay: 300');
var nick = wrap.querySelector('.atk-nick');
if (nick) nick.setAttribute('uk-scrollspy', 'cls: uk-animation-slide-top-small; delay: 350');
});
// Trigger lazy load update
if (window.lazyLoadAgain) {
window.lazyLoadAgain();
}
});
} catch (e) {
console.error('Artalk init failed:', e);
// 初始化失败要撤回幂等标记,否则后续入口的调用会被守卫拦掉、评论区再也起不来
mount.__atkInited = false;
listSkelSettled = true;
listSkeletonHide();
}
};
// ============================================================================
// 投票前的身份提示 —— 别让读者点了「赞同」却毫无反应
//
// 服务端(artalk-cf)的投票语义和 Artalk 原生一致:**票必须挂在身份上**,
// 未登录时要求 body 带 name + email,否则返回 400。而客户端只在
// `userInfo.name && userInfo.email` 都齐了才会带上身份(见 Artalk.js 的
// getUserFields)——没填过的读者点下去就是一个静默失败:
// 请求打到服务端、报错、界面上的数字纹丝不动,读者只会觉得「点坏了」。
//
// 这里在 capture 阶段把这类点击拦下来(拦在 Artalk 自己的 handler 之前),
// 给一句人话提示并把光标送到昵称输入框。填过身份的直接放行,不干扰。
// ============================================================================
(function () {
if (window.__atkVoteGuard) return; // PJAX 会重复执行本文件,只装一次
window.__atkVoteGuard = true;
var USER_KEY = 'ArtalkUser'; // Artalk 的用户存储键(nick / email / link / token)
function readUser() {
try {
var raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
// 已登录(有 token)服务端认 Authorization;游客必须昵称 + 邮箱
function hasIdentity(u) {
if (!u) return false;
if (u.token) return true;
return !!(u.nick && u.email);
}
document.addEventListener('click', function (e) {
var el = e.target;
var btn = el && el.closest ? el.closest('.atk-common-action-btn') : null;
if (!btn) return;
if (!/赞同|反对|顶|踩/.test(btn.textContent || '')) return;
if (hasIdentity(readUser())) return;
// 拦在 Artalk 之前:不发那个注定 400 的请求,也不让数字假装动一下
e.preventDefault();
e.stopPropagation();
if (window.showToast) window.showToast('填写昵称和邮箱后才能点赞', 'warning');
else if (window.Toast && window.Toast.warning) window.Toast.warning('填写昵称和邮箱后才能点赞');
// 把光标送到昵称输入框。
// ⚠️ 不能只 querySelector 第一个:Artalk 的 DOM 里存在**不止一个** nick 输入框
// (隐藏模板、未激活的回复框也算),第一个匹配到的往往不可焦点,
// 对它 focus() 会被浏览器静默忽略 —— activeElement 一点不变,很容易误判成"生效了"。
// 所以逐个候选试,并以「focus 真的落到它身上」为唯一判据。
function focusFirstUsable(sel) {
var list = document.querySelectorAll(sel);
for (var i = 0; i < list.length; i++) {
var el = list[i];
if (!(el.offsetWidth || el.offsetHeight || el.getClientRects().length)) continue;
try { el.focus({ preventScroll: true }); } catch (err) { try { el.focus(); } catch (e2) { continue; } }
if (document.activeElement === el) return el;
}
return null;
}
var nick = focusFirstUsable('.atk-header [name="nick"]') || focusFirstUsable('[name="nick"]');
if (nick) {
try { nick.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (err) { /* 老浏览器忽略 */ }
}
}, true);
})();
// ============================================================================
// 评论提交反馈 —— 校验 / 发送中 / 成功 / 失败
//
// 为什么需要:评论接口(Cloudflare)在国内常年丢包,读者点完「发送」可能好几秒
// 没有任何反馈,根本没法判断到底发出去没有;而服务端对「昵称 / 邮箱缺失」是直接
// 返 400 的,客户端这边却只在编辑框里弹一行小字,很容易被忽略。
//
// 四个挂点:
// 1) config.beforeSubmit → 提交前校验(内容 / 昵称 / 邮箱必填,邮箱格式;
// 网址随意不校验)。不调 next() 就不发请求,
// 省掉一次注定失败的跨境往返。
// 2) artalk.on('editor-submit') → 提交开始:常驻的「评论发送中…」loading toast;
// 超过 7 秒仍未结束再补一条「网络较慢」。
// 3) artalk.on('editor-submitted') → 提交成功(Artalk 失败分支直接 return,不会
// 触发本事件):关掉 loading,按评论 / 回复 /
// 编辑三种模式弹成功 toast。
// 4) 拦 fetch 的 POST /api/v2/comments → 失败(HTTP 错误或网络异常)时,把服务端
// 的 msg / 标记翻译成人话弹 error toast,并删掉
// Artalk 自己那行重复的小字通知。
//
// 注意:Artalk 提交失败时**不会**清空输入框(只有成功才 reset),所以失败提示里
// 可以放心说「内容还在」。另外所有失败都是「服务端已拒」——loading toast 必须
// 在同一轮里收干净,不能留一个转圈的 toast 挂在那儿。
// ============================================================================
(function () {
if (window.__atkSubmit) return; // PJAX 会重复执行本文件,只装一次
var P = (window.__atkSubmit = {});
var POST_RE = /\/api\/v2\/comments(\?|$)/;
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var loading = false; // loading toast 是否挂着
var slowTimer = null; // 慢网补充提示的定时器
var editor = null; // beforeSubmit 拿到的编辑器实例(用来判提交模式)
var result = null; // 本轮 POST 的结果,由下面的 fetch 拦截写入
function toast(type, text) {
try {
if (window.Toast && typeof window.Toast[type] === 'function') return window.Toast[type](text);
if (window.showToast) window.showToast(text, type);
} catch (e) {}
return null;
}
// 一次性收干净:不用维护 Qmsg 实例引用,也顺手清掉「网络较慢」那条。
// (Qmsg 的 loading 是 autoClose:false,不主动关就会一直转)
function closeLoading() {
loading = false;
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
try { if (window.Toast && window.Toast.closeAll) window.Toast.closeAll(); } catch (e) {}
}
// ── 校验用的取值 ─────────────────────────────────────────────────────
function val(el) { return el && typeof el.value === 'string' ? el.value.trim() : ''; }
function headerEls() {
// 首选 Artalk 编辑器自己的访问器:返回的是**这一份编辑器**的输入框。
// ⚠️ 不能拿 document.querySelector('[name=nick]') 顶替:Artalk 的 DOM 里
// 存在不止一个 nick 输入框(隐藏模板、未激活的回复框都算),拿错就会
// 把提示弹在一个看不见的框上,focus 也静默失败。
try {
if (editor && typeof editor.getHeaderInputEls === 'function') {
var h = editor.getHeaderInputEls();
if (h && (h.nick || h.email)) return h;
}
} catch (e) {}
var ed = document.querySelector('.atk-main-editor');
if (!ed) return {};
return {
nick: ed.querySelector('.atk-header [name="nick"]'),
email: ed.querySelector('.atk-header [name="email"]'),
link: ed.querySelector('.atk-header [name="link"]')
};
}
function focusEl(el) {
if (!el) return;
try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} }
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {}
}
// ── 1) 提交前校验:返回 false 表示已拦下,不发请求 ─────────────────────
P.beforeSubmit = function (ed, next) {
editor = ed || editor;
var content = '';
try { content = String(ed.getContentFinal() || '').trim(); } catch (e) {}
if (!content) { toast('warning', '评论内容不能为空'); return; }
// 编辑已有评论(管理员)用登录身份提交,没有昵称/邮箱输入框,
// 硬校验会把管理员挡在「编辑」外面 —— 直接放行,内容校验已在上面做过
var st = '';
try { st = (ed && typeof ed.getState === 'function' && ed.getState()) || ''; } catch (e) {}
if (st === 'edit') { next(); return; }
var els = headerEls();
var nick = val(els.nick), email = val(els.email);
if (!nick) { toast('warning', '请先填写昵称'); focusEl(els.nick); return; }
if (!email) { toast('warning', '请先填写邮箱(有人回复你时才能通知到)'); focusEl(els.email); return; }
if (!EMAIL_RE.test(email)) { toast('warning', '邮箱格式好像不对,检查一下'); focusEl(els.email); return; }
// 网址(link)留空或随便填都行 —— 不校验。服务端也只在非空时才查格式
next();
};
// ── 2) 提交开始 ──────────────────────────────────────────────────────
P.begin = function () {
closeLoading(); // 极端情况下上一轮没收干净
loading = true;
result = null;
toast('loading', '评论发送中…');
// 慢网补充提示:CF 丢包时首包可能要好几秒,别让读者以为按钮坏了
slowTimer = setTimeout(function () {
slowTimer = null;
if (loading) toast('info', '网络较慢,评论仍在提交中…');
}, 7000);
};
// ── 3) 提交成功 ──────────────────────────────────────────────────────
P.done = function () {
var r = result;
closeLoading();
if (r && r.pending) { toast('info', '评论已提交,等待审核'); return; }
var st = '';
try { st = (editor && typeof editor.getState === 'function' && editor.getState()) || ''; } catch (e) {}
if (st === 'edit') { toast('success', '修改已保存'); return; }
if (st === 'reply') { toast('success', '回复发送成功'); return; }
toast('success', '评论发送成功');
};
// ── 4) 失败:把服务端响应翻译成人话 ──────────────────────────────────
// 服务端(blog-admin)的文案见 src/routes/comments.ts:
// 400 'Invalid parameters: name / email / content / page_key are required'
// 400 'Invalid Email' / 400 'Invalid Link'
// 429 'Too many requests, please slow down'
// 403 'Human verification required'(带 need_captcha,被自研人机门禁拦下)
// 403 'Admin access required'(带 need_login,昵称/邮箱撞上管理员账号)
// 5xx 'Comment failed' 等
function humanize(r) {
r = r || {};
var d = r.data || {};
var msg = String(r.msg || '').trim();
if (r.network) return '网络不稳定,评论没发出去 —— 内容还在,稍后重试';
if (d.need_login) return '该昵称或邮箱是管理员账号,请先登录后台再评论';
if (d.need_captcha) return '请先点一下「点击验证」,通过后再发送评论';
if (r.status === 429 || /too many/i.test(msg)) return '评论太频繁了,歇一会儿再发';
if (/invalid email/i.test(msg)) return '邮箱格式不对,检查一下再发';
if (/invalid link/i.test(msg)) return '网址格式不对(也可以留空)';
if (/parent comment not found/i.test(msg)) return '要回复的那条评论已经不在了';
if (/required/i.test(msg)) return '昵称、邮箱和内容都要填哦';
if (r.status >= 500) return '服务器开小差了,评论没发出去,稍后重试';
return msg || ('评论发送失败(HTTP ' + r.status + '),稍后重试');
}
function failToast(r) {
closeLoading();
toast('error', humanize(r));
// Artalk 还会在自己编辑框里塞一行 "评论失败: ..." 的小字通知,与 toast 重复。
// 只删文本匹配的那一条,避免误伤「内容已自动恢复」之类其它通知。
setTimeout(function () {
var ns = document.querySelectorAll('.atk-notify');
for (var i = 0; i < ns.length; i++) {
if (/评论失败|Failed to comment/.test(ns[i].textContent || '')) {
if (ns[i].parentNode) ns[i].parentNode.removeChild(ns[i]);
}
}
}, 80);
}
// ── 提交成功后收起还开着的插件面板(表情 / 图片 …)──────────────────────
// Artalk 的插件面板是 toggle:点「表情」展开、再点收起。提交成功后它只会
// reset() 输入内容,不会把面板关掉 —— 于是「选完表情 → 发表」之后表情面板
// 还杵在那里,输入框看起来像没复位。这里补一次收起。
// 必须走「点当前 active 按钮」这条路:按钮 toggle 依赖内部 openedPlug 状态,
// 直接改 style.display='none' 会让状态和 UI 脱节(下次点按钮要点两下才开)。
function closePlugPanels() {
try {
var btns = document.querySelectorAll('.atk-plug-btn-wrap .atk-plug-btn.active');
for (var i = 0; i < btns.length; i++) btns[i].click();
} catch (e) {}
}
// ── fetch 拦截:只认「新建评论」这一个请求 ────────────────────────────
// 之所以要先 await 读完 body 再放行,是因为 Artalk 在 `await fetch(...)` 之后
// 才触发 editor-submitted;如果这里用 res.clone().json() 异步解析而不 await,
// 事件的回调会先跑,读到的是上一轮的 result(成功/审核状态就串了)。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if (method !== 'POST' || !POST_RE.test(url)) return origFetch.apply(this, arguments);
// ★ 把「当前页文章的作者 id」塞进评论提交体 —— 服务端凭它决定这封
// 「有新评论」的通知邮件发给文章作者本人,还是发博主。
// 读 window.artalkConfig 而不是闭包里的 config:PJAX 切页时 artalkConfig
// 会被重新赋值,闭包里那份会过期(把上一页的作者带过去就发错人了)。
var args = arguments;
var pageAuthorId = Number((window.artalkConfig || {}).pageAuthorId || 0);
if (pageAuthorId && init && typeof init.body === 'string') {
try {
var payload = JSON.parse(init.body);
if (payload && typeof payload === 'object' && !payload.author_id) {
payload.author_id = pageAuthorId;
args = [input, Object.assign({}, init, { body: JSON.stringify(payload) })];
}
} catch (e) { /* body 不是 JSON(理论上不会)→ 原样放行,不影响提交 */ }
}
var p = origFetch.apply(this, args);
return p.then(function (res) {
if (res && res.ok) {
closePlugPanels(); // 发表成功 → 顺手把展开的表情/图片面板收起来
// 成功:读一下 body 看是不是落进了审核队列(服务端返回的就是
// 煮熟的评论对象本体,is_pending 是它的字段)
return res.clone().json().then(function (d) {
result = { ok: true, pending: !!(d && d.is_pending) };
return res;
}).catch(function () {
result = { ok: true, pending: false };
return res;
});
}
// 失败:Artalk 拿到 !res.ok 会自己 throw,这里只负责记因 + 弹 toast
return res.clone().json().then(function (d) {
result = { ok: false, status: res.status, msg: (d && d.msg) || '', data: d || {} };
failToast(result);
return res;
}).catch(function () {
result = { ok: false, status: res.status, msg: 'HTTP ' + res.status, data: {} };
failToast(result);
return res;
});
}, function (err) {
result = { ok: false, network: true, msg: (err && err.message) || 'network error' };
failToast(result);
throw err; // 原样抛回:Artalk 自己的失败处理(保留输入内容)不受影响
});
};
}
})();