编辑器禁用态:置灰但内容完整(补发送字样/占位符/表情预览图标),svc-down 隐藏 Powered By 版权
Deploy to Production / pre-check (push) Successful in 35s
Deploy to Production / build (push) Successful in 3m40s
Deploy to Production / deploy-edgeone (push) Successful in 3m35s
Deploy to Production / finalize (push) Successful in 22s
Deploy to Production / notify-failure (push) Skipped

This commit is contained in:
zqlit committed 2026-10-04 00:28:12 +08:00
1 parent 298f10e6bc
commit 855a001046
1 file changed
+151 -8
+151 -8
View File
@@ -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 =
'<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 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 =
'<span class="atk-err-ic">' + info.ic + '</span>' +
'<div class="atk-err-t">' + esc(info.title) + '</div>' +
@@ -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);