Files
blog/themes/Ying/assets/js/modules/artalk.js
T
zqlit e0c5ac5739 feat(comments): 人机验证改点按式 + 分割线可配置 + 投票契约修复
- 人机验证抽成 human.js 模块,改成 Cloudflare Turnstile 风格的「点一下验证」,
  控件移到「评论一下」发送按钮左侧(不再用图形验证码那一套)
- 留言页 / 文章页两段内联脚本(友链弹窗、人机验证)抽成模块随 page-only.js 加载。
  原先内联 <script> 写在 #pjax-container 外,PJAX 只换容器内容不重新执行它,
  从首页 PJAX 进留言页会出现「申请友链卡片点了没反应」「没有人机验证控件」
- 友链申请弹窗 #flApplyBox 隐藏滚动条(内容仍可滚,避开细滚动条 + 毛玻璃的割裂感)
- 内容分割线抽成 partial divider.html,hugo.toml [params.divider] 可切
  icon(原生 uk-divider-icon)/ image(本地图片),当前用 icon
- 投票契约修复(后端 blog-admin):Artalk 客户端把方向编在 target_name 后缀
  (comment_up / comment_down / page_up / page_down)、路径只有两段,
  而原先只注册了三段 :choice 路由 → 点赞/踩一路 404、界面点了没反应。
  补 POST /votes/:target_name/:target_id,新增 parseVoteTarget() 同时兼容两套契约
  (名字里与路径里方向矛盾时判非法,不静默取其一),错误文案改用实体名
- 无身份点赞不再静默失败:artalk.js 加 __atkVoteGuard,
  未填昵称/邮箱时拦截并提示「填写昵称和邮箱后才能点赞」,
  同时把光标送到昵称框(多个同名输入框逐个试,以 focus 真生效为判据)
2026-10-04 16:26:28 +08:00

1458 lines
73 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 / ...),
// 所以这里把它换成一句人话 + 可展开的技术详情(留着给站长排查)。
// ============================================================================
(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 自己读)。
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 p = origFetch.apply(this, fargs);
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) {
// 某些端点不读 D1(/human/* 走 KV),它们 200 不代表服务恢复;
// 只有真正读 D1 的端点成功才算恢复
if (svcDown && /\/(conf|comments|captcha|votes|pages)/.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-main-editor{opacity:.55!important;filter:grayscale(1)!important}' +
'.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);
// 编辑器全部子元素已 pointer-events:none,点击会落到编辑器容器本身 ——
// 借这个事件弹 toast 告诉用户为什么点不动(幂等:节点换新才重挂)
if (!ed.dataset.svcGuard) {
ed.dataset.svcGuard = '1';
ed.addEventListener('click', function () {
svcToast(downCode === 'd1_quota'
? '评论服务暂时不可用(额度已用尽),预计明早 8:00 自动恢复'
: '评论服务暂时不可用,请稍后再试');
});
}
}
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;
fetch(apiBase + '/api/v2/captcha/status').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 的人」写的,自带一句"评论服务暂时不可用:",
// 放到卡片里会和标题重复,这里去掉冗余前缀
var desc = fresh && fresh.msg
? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '')
: 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
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);
} 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
// 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);
}
// 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++) allBadges[bi].classList.add('atk-cmt-badge');
} 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);
})();