评论加载失败改为清晰提示:接管 Artalk 错误层,按服务端错误码给读者人话文案+可展开技术详情
Deploy to Production / pre-check (push) Successful in 1m0s
Deploy to Production / build (push) Successful in 4m18s
Deploy to Production / deploy-edgeone (push) Successful in 3m55s
Deploy to Production / finalize (push) Successful in 24s
Deploy to Production / notify-failure (push) Skipped

This commit is contained in:
zqlit committed 2026-10-03 19:49:10 +08:00
1 parent 45afb53580
commit afa1346a0f
1 file changed
+173
+173
View File
@@ -1,3 +1,176 @@
// ============================================================================
// 评论加载失败 —— 给读者看的清晰提示
//
// 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) || '';
var p = origFetch.apply(this, arguments);
if (String(url).indexOf('/api/v2/') === -1) return p;
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()
};
}).catch(function () {
last = { status: res.status, code: '', msg: '', detail: 'HTTP ' + res.status, at: Date.now() };
});
} catch (e) { /* clone 不支持就放弃记录,不影响请求 */ }
}
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{padding:0!important;background:transparent!important}' +
'.atk-err-card{display:flex;gap:12px;align-items:flex-start;padding:16px 18px;box-sizing:border-box;' +
'border:1px solid var(--border-main,#eee);border-radius:10px;background:var(--bg-card,#fff);' +
'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{flex:none;font-size:22px;line-height:1.2}' +
'.atk-err-body{flex:1;min-width:0}' +
'.atk-err-t{font-size:14px;font-weight:600;color:var(--text-main,#333);margin-bottom:4px}' +
'.atk-err-d{font-size:13px;line-height:1.7;color:var(--text-desc,#777);word-break:break-word}' +
'.atk-err-acts{display:flex;align-items:center;gap:10px;margin-top:10px;font-size:13px}' +
'.atk-err-retry{display:inline-flex;align-items:center;padding:5px 14px;border-radius:5px;' +
'background:var(--theme-main-20,rgba(7,193,96,.2));color:var(--theme-main,#07c160);' +
'font-weight:600;cursor:pointer;transition:background .2s,transform .2s;user-select:none}' +
'.atk-err-retry:hover{background:var(--theme-main-30,rgba(7,193,96,.3))}' +
'.atk-err-retry:active{transform:scale(.97)}' +
'.atk-err-det{margin-top:10px;font-size:12px;color:var(--text-muted,#999)}' +
'.atk-err-det>summary{cursor:pointer;outline:none;user-select:none}' +
'.atk-err-det pre{margin:8px 0 0;padding:8px 10px;border-radius:6px;background:rgba(127,127,127,.08);' +
'white-space:pre-wrap;word-break:break-all;font-size:11px;line-height:1.6;max-height:150px;overflow:auto}' +
'[data-theme="dark"] .atk-err-card{border-color:rgba(255,255,255,.14)}' +
'@media (max-width:640px){.atk-err-card{padding:13px 14px;gap:8px}.atk-err-ic{font-size:18px}' +
'.atk-err-t{font-size:13.5px}.atk-err-d{font-size:12.5px}}';
document.head.appendChild(st);
}
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');
if (prev && prev.getAttribute('data-raw') === raw) return; // 内容没变,不重复渲染
// 优先用结构化错误码;没有就按「网络层失败」处理
var fresh = last && (Date.now() - last.at < 30000) ? last : null;
var code = (fresh && fresh.code) || (/failed to fetch|network ?error|load failed/i.test(raw) ? 'network' : 'internal');
var info = INFO[code] || INFO.internal;
// 服务端文案是给「直接调 API 的人」写的,自带一句"评论服务暂时不可用:",
// 放到卡片里会和标题重复,这里去掉冗余前缀
var desc = fresh && fresh.msg
? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '')
: info.desc;
var detail = (fresh && fresh.detail) || raw;
// 原始「重试」节点: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);
card.innerHTML =
'<span class="atk-err-ic">' + info.ic + '</span>' +
'<div class="atk-err-body">' +
'<div class="atk-err-t">' + esc(info.title) + '</div>' +
'<div class="atk-err-d">' + esc(desc) + '</div>' +
'<div class="atk-err-acts"></div>' +
(detail ? '<details class="atk-err-det"><summary>技术详情(给站长排查用)</summary><pre>' +
esc((code ? '[' + code + '] ' : '') + detail) + '</pre></details>' : '') +
'</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]);
}
function boot() {
scan();
if (!document.body) { setTimeout(boot, 300); return; }
// 错误层是 Artalk 异步渲染出来的,用观察器盯着;PJAX 重建也能命中
new MutationObserver(function () { scan(); }).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 切换保留) ──