// ============================================================================ // 评论加载失败 —— 给读者看的清晰提示 // // 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, '&').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-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 = '' + ''; 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 = '' + info.ic + '' + '