// ============================================================================ // 评论加载失败 —— 给读者看的清晰提示 // // 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: '服务出了点小状况,请稍后再试。' } }; // ── 1) 记录 API 失败原因。只读不改:包装层原样返回上游响应, // 失败时顺带 clone 一份读 body(clone 不影响 Artalk 自己读)。 var origFetch = window.fetch; if (typeof origFetch === 'function') { window.fetch = function (input) { var url = typeof input === 'string' ? input : (input && input.url) || ''; url = String(url); var p = origFetch.apply(this, arguments); if (url.indexOf('/api/v2/') === -1) return p; // 学习 API 源(探测恢复用) if (!apiBase && url.indexOf('/api/v2/') > 0) apiBase = url.split('/api/v2/')[0]; 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, '&').replace(//g, '>'); } 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-box{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,不用刷新页面。 // conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮 // 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观, // 这里按官方标记把缺的字和图标补齐(幂等,Artalk 重建编辑器后可重复跑)。 // SVG 与 libs/Artalk.js 里 useBtn() 的原生节点逐字节一致,视觉与正常态无差。 var PLUG_BTNS = '' + ''; 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 probeTimer = null; var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址) function svcToast(msg) { var t = document.getElementById('atk-svc-toast'); if (!t) { t = document.createElement('div'); t.id = 'atk-svc-toast'; t.style.cssText = 'position:fixed;left:50%;bottom:110px;transform:translateX(-50%);z-index:99999;' + 'padding:12px 24px;border-radius:999px;background:var(--theme-main,#07c160);color:#fff;' + 'font-size:14px;box-shadow:0 10px 32px rgba(0,0,0,.35);opacity:0;transition:opacity .35s,transform .35s;pointer-events:none'; document.body.appendChild(t); } t.textContent = msg; requestAnimationFrame(function () { t.style.opacity = '1'; }); setTimeout(function () { t.style.opacity = '0'; }, 3200); } 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; if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案 svcDown = true; 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(); svcToast('评论服务已恢复,可以继续评论了 ✓'); } function rewrite(layer) { if (!layer) 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; // 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅 if (code !== 'network') 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 = '' + info.ic + '' + '
' + esc(info.title) + '
' + '
' + esc(desc) + '
' + '
'; 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); }; window.initArtalk = function() { if (!document.querySelector("#Comments")) 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; } 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'); } // ── 增强评论框字段属性 (兼容油猴自动填充脚本) ── // 为 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 = '' + '' + '' + '' + nickText.replace(/' + '' + '' + '' + '' + '' + '申请友链'; // 昵称行追加官方徽章(博主),样式与评论区徽章一致 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); } };