From 855a001046df7e1fa34963323d64e9bf32807994 Mon Sep 17 00:00:00 2001 From: zqlit Date: Sun, 4 Oct 2026 00:28:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E6=80=81=EF=BC=9A=E7=BD=AE=E7=81=B0=E4=BD=86=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=AE=8C=E6=95=B4(=E8=A1=A5=E5=8F=91=E9=80=81=E5=AD=97?= =?UTF-8?q?=E6=A0=B7/=E5=8D=A0=E4=BD=8D=E7=AC=A6/=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=9B=BE=E6=A0=87)=EF=BC=8Csvc-down=20?= =?UTF-8?q?=E9=9A=90=E8=97=8F=20Powered=20By=20=E7=89=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- themes/Ying/assets/js/modules/artalk.js | 159 ++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 8 deletions(-) diff --git a/themes/Ying/assets/js/modules/artalk.js b/themes/Ying/assets/js/modules/artalk.js index b2dad17a..80f81aea 100644 --- a/themes/Ying/assets/js/modules/artalk.js +++ b/themes/Ying/assets/js/modules/artalk.js @@ -27,8 +27,11 @@ 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 (String(url).indexOf('/api/v2/') === -1) return p; + 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 { @@ -40,10 +43,15 @@ 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) { @@ -63,8 +71,9 @@ var st = document.createElement('style'); st.id = 'atk-err-css'; st.textContent = - '.atk-error-layer{padding:0!important;margin:14px 0 30px!important;background:transparent!important}' + - '.atk-err-card{display:block;text-align:center;padding:30px 22px;box-sizing:border-box;' + + '.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}' + @@ -77,11 +86,126 @@ '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 24px!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}}'; + '@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; @@ -90,7 +214,6 @@ if (em) raw = (em.innerText || em.textContent || '').trim(); var prev = layer.querySelector('.atk-err-card'); - if (prev && prev.getAttribute('data-raw') === raw) return; // 内容没变,不重复渲染 // 优先用结构化错误码;没有就从 Artalk 原始错误文本里认特征词 // (D1 额度用尽 / 网络失败都有固定措辞)。结构化记录有时效且 Artalk @@ -102,6 +225,10 @@ : /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 的人」写的,自带一句"评论服务暂时不可用:", // 放到卡片里会和标题重复,这里去掉冗余前缀 @@ -109,6 +236,9 @@ ? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '') : info.desc; + // 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅 + if (code !== 'network') setSvcDown(code); + // 原始「重试」节点:Vue 会整层重建,所以每次重新找; // 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件) var retry = prev ? prev.querySelector('.atk-err-retry') : null; @@ -125,7 +255,7 @@ injectCss(); var card = document.createElement('div'); card.className = 'atk-err-card'; - card.setAttribute('data-raw', raw); + card.setAttribute('data-raw', raw + '|' + code); card.innerHTML = '' + info.ic + '' + '
' + esc(info.title) + '
' + @@ -154,13 +284,26 @@ 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 重建也能命中 - new MutationObserver(function () { scan(); }).observe(document.body, { childList: true, subtree: true }); + 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);