fix(build): 消除构建期远程拉取隐患 + 评论提交反馈 + 友链卡片移动端

构建隐患(本次丢构建的根因)
- footer.html 用 resources.GetRemote 在每个页面渲染时拉 api/links 与 api/feeds,
  构建节点连不上 Cloudflare 时重试撞穿 Hugo partial 30s 超时 → 整站构建失败
- 改为「流水线 curl 快照 + 模板读本地文件」:
  · 新增 scripts/fetch_snapshots.sh,一次拉齐 conf / friend_links / friend_feeds
    (--max-time 30 --retry 2,JSON 校验,失败一律删文件且恒 exit 0)
  · .cnb.yml Hugo 构建 stage 改为调用该脚本
  · footer.html 改读 site.Data.friend_links / site.Data.friend_feeds,缺失渲染空值
  · .gitignore 忽略 /data/friend_links.json 与 /data/friend_feeds.json
- 降级实测:删掉快照后构建仍 EXIT=0,产物 friendLinks 为空数组

评论提交反馈(artalk.js 新增 __atkSubmit 模块)
- 校验:config.beforeSubmit 拦下 内容空 / 昵称空 / 邮箱空 / 邮箱格式错(网址不校验)
- 发送中:editor-submit 常驻 loading,超 7s 补「网络较慢」
- 成功:editor-submitted 按 normal/reply/edit 分文案,is_pending 提示等待审核
- 失败:拦 POST /api/v2/comments,403/429/4xx/5xx/网络异常全部翻译成人话
- 失败时输入内容保留,并清掉 Artalk 自带的重复小字通知
- 无头浏览器实测 19/19 通过(POST 走 mock,未向线上写数据)

友链申请卡片移动端
- max-width:640px 下隐藏 .atk-fl-nick(昵称 + 博主徽章),桌面端不变

改动:6 files, +282 / -61
This commit is contained in:
zqlit committed 2026-10-04 17:38:04 +08:00
1 parent 836743b16d
commit 571d552c9d
6 files changed
+355 -61

No files matched your search

+224
View File
@@ -718,6 +718,18 @@ window.initArtalk = function() {
saveInitCount(); // 持久化到 sessionStorage
var config = Object.assign({}, window.artalkConfig); // Clone config
// ── 提交前校验(昵称 / 邮箱必填;网址随意)──────────────────────────
// Artalk 的 editor.submit() 实现是:
// conf.beforeSubmit ? conf.beforeSubmit(editor, next) : trigger('editor-submit')
// 也就是官方就留了拦截点 —— 不调 next() 就不会走提交流程。
// 挂在这份 config 上(而不是内联 <script> 里的 window.artalkConfig)是因为
// 两条 init 路径(带 / 不带 localStorage 缓存)都从它派生,挂这里两个分支
// 必然都带上;何况缓存那份只存简单类型,函数本来就存不下来。
if (window.__atkSubmit && window.__atkSubmit.beforeSubmit) {
config.beforeSubmit = window.__atkSubmit.beforeSubmit;
}
var server = config.server.replace(/\/$/, '');
var confKey = 'artalk_conf_' + btoa(server);
var cachedConf = null;
@@ -759,6 +771,14 @@ window.initArtalk = function() {
var artalk = window.artalk; // Local reference
// ── 评论提交反馈(发送中 / 成功;失败由文件末尾的 fetch 拦截兜底)──
// editor-submit = 提交开始(beforeSubmit 放行后才会触发)
// editor-submitted = 提交成功(Artalk 的失败分支直接 return,不触发本事件)
if (window.__atkSubmit) {
artalk.on('editor-submit', window.__atkSubmit.begin);
artalk.on('editor-submitted', window.__atkSubmit.done);
}
// Sync Theme Mode Immediately
var currentTheme = document.documentElement.getAttribute('data-theme');
if (typeof artalk.setDarkMode === 'function') {
@@ -1455,3 +1475,207 @@ artalk.on('list-loaded', function() {
}
}, true);
})();
// ============================================================================
// 评论提交反馈 —— 校验 / 发送中 / 成功 / 失败
//
// 为什么需要:评论接口(Cloudflare)在国内常年丢包,读者点完「发送」可能好几秒
// 没有任何反馈,根本没法判断到底发出去没有;而服务端对「昵称 / 邮箱缺失」是直接
// 返 400 的,客户端这边却只在编辑框里弹一行小字,很容易被忽略。
//
// 四个挂点:
// 1) config.beforeSubmit → 提交前校验(内容 / 昵称 / 邮箱必填,邮箱格式;
// 网址随意不校验)。不调 next() 就不发请求,
// 省掉一次注定失败的跨境往返。
// 2) artalk.on('editor-submit') → 提交开始:常驻的「评论发送中…」loading toast;
// 超过 7 秒仍未结束再补一条「网络较慢」。
// 3) artalk.on('editor-submitted') → 提交成功(Artalk 失败分支直接 return,不会
// 触发本事件):关掉 loading,按评论 / 回复 /
// 编辑三种模式弹成功 toast。
// 4) 拦 fetch 的 POST /api/v2/comments → 失败(HTTP 错误或网络异常)时,把服务端
// 的 msg / 标记翻译成人话弹 error toast,并删掉
// Artalk 自己那行重复的小字通知。
//
// 注意:Artalk 提交失败时**不会**清空输入框(只有成功才 reset),所以失败提示里
// 可以放心说「内容还在」。另外所有失败都是「服务端已拒」——loading toast 必须
// 在同一轮里收干净,不能留一个转圈的 toast 挂在那儿。
// ============================================================================
(function () {
if (window.__atkSubmit) return; // PJAX 会重复执行本文件,只装一次
var P = (window.__atkSubmit = {});
var POST_RE = /\/api\/v2\/comments(\?|$)/;
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var loading = false; // loading toast 是否挂着
var slowTimer = null; // 慢网补充提示的定时器
var editor = null; // beforeSubmit 拿到的编辑器实例(用来判提交模式)
var result = null; // 本轮 POST 的结果,由下面的 fetch 拦截写入
function toast(type, text) {
try {
if (window.Toast && typeof window.Toast[type] === 'function') return window.Toast[type](text);
if (window.showToast) window.showToast(text, type);
} catch (e) {}
return null;
}
// 一次性收干净:不用维护 Qmsg 实例引用,也顺手清掉「网络较慢」那条。
// (Qmsg 的 loading 是 autoClose:false,不主动关就会一直转)
function closeLoading() {
loading = false;
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
try { if (window.Toast && window.Toast.closeAll) window.Toast.closeAll(); } catch (e) {}
}
// ── 校验用的取值 ─────────────────────────────────────────────────────
function val(el) { return el && typeof el.value === 'string' ? el.value.trim() : ''; }
function headerEls() {
// 首选 Artalk 编辑器自己的访问器:返回的是**这一份编辑器**的输入框。
// ⚠️ 不能拿 document.querySelector('[name=nick]') 顶替:Artalk 的 DOM 里
// 存在不止一个 nick 输入框(隐藏模板、未激活的回复框都算),拿错就会
// 把提示弹在一个看不见的框上,focus 也静默失败。
try {
if (editor && typeof editor.getHeaderInputEls === 'function') {
var h = editor.getHeaderInputEls();
if (h && (h.nick || h.email)) return h;
}
} catch (e) {}
var ed = document.querySelector('.atk-main-editor');
if (!ed) return {};
return {
nick: ed.querySelector('.atk-header [name="nick"]'),
email: ed.querySelector('.atk-header [name="email"]'),
link: ed.querySelector('.atk-header [name="link"]')
};
}
function focusEl(el) {
if (!el) return;
try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} }
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {}
}
// ── 1) 提交前校验:返回 false 表示已拦下,不发请求 ─────────────────────
P.beforeSubmit = function (ed, next) {
editor = ed || editor;
var content = '';
try { content = String(ed.getContentFinal() || '').trim(); } catch (e) {}
var els = headerEls();
if (!content) { toast('warning', '评论内容不能为空'); return; }
var nick = val(els.nick), email = val(els.email);
if (!nick) { toast('warning', '请先填写昵称'); focusEl(els.nick); return; }
if (!email) { toast('warning', '请先填写邮箱(有人回复你时才能通知到)'); focusEl(els.email); return; }
if (!EMAIL_RE.test(email)) { toast('warning', '邮箱格式好像不对,检查一下'); focusEl(els.email); return; }
// 网址(link)留空或随便填都行 —— 不校验。服务端也只在非空时才查格式
next();
};
// ── 2) 提交开始 ──────────────────────────────────────────────────────
P.begin = function () {
closeLoading(); // 极端情况下上一轮没收干净
loading = true;
result = null;
toast('loading', '评论发送中…');
// 慢网补充提示:CF 丢包时首包可能要好几秒,别让读者以为按钮坏了
slowTimer = setTimeout(function () {
slowTimer = null;
if (loading) toast('info', '网络较慢,评论仍在提交中…');
}, 7000);
};
// ── 3) 提交成功 ──────────────────────────────────────────────────────
P.done = function () {
var r = result;
closeLoading();
if (r && r.pending) { toast('info', '评论已提交,等待审核'); return; }
var st = '';
try { st = (editor && typeof editor.getState === 'function' && editor.getState()) || ''; } catch (e) {}
if (st === 'edit') { toast('success', '修改已保存'); return; }
if (st === 'reply') { toast('success', '回复发送成功'); return; }
toast('success', '评论发送成功');
};
// ── 4) 失败:把服务端响应翻译成人话 ──────────────────────────────────
// 服务端(blog-admin)的文案见 src/routes/comments.ts:
// 400 'Invalid parameters: name / email / content / page_key are required'
// 400 'Invalid Email' / 400 'Invalid Link'
// 429 'Too many requests, please slow down'
// 403 'Human verification required'(带 need_captcha,被自研人机门禁拦下)
// 403 'Admin access required'(带 need_login,昵称/邮箱撞上管理员账号)
// 5xx 'Comment failed' 等
function humanize(r) {
r = r || {};
var d = r.data || {};
var msg = String(r.msg || '').trim();
if (r.network) return '网络不稳定,评论没发出去 —— 内容还在,稍后重试';
if (d.need_login) return '该昵称或邮箱是管理员账号,请先登录后台再评论';
if (d.need_captcha) return '请先点一下「点击验证」,通过后再发送评论';
if (r.status === 429 || /too many/i.test(msg)) return '评论太频繁了,歇一会儿再发';
if (/invalid email/i.test(msg)) return '邮箱格式不对,检查一下再发';
if (/invalid link/i.test(msg)) return '网址格式不对(也可以留空)';
if (/parent comment not found/i.test(msg)) return '要回复的那条评论已经不在了';
if (/required/i.test(msg)) return '昵称、邮箱和内容都要填哦';
if (r.status >= 500) return '服务器开小差了,评论没发出去,稍后重试';
return msg || ('评论发送失败(HTTP ' + r.status + '),稍后重试');
}
function failToast(r) {
closeLoading();
toast('error', humanize(r));
// Artalk 还会在自己编辑框里塞一行 "评论失败: ..." 的小字通知,与 toast 重复。
// 只删文本匹配的那一条,避免误伤「内容已自动恢复」之类其它通知。
setTimeout(function () {
var ns = document.querySelectorAll('.atk-notify');
for (var i = 0; i < ns.length; i++) {
if (/评论失败|Failed to comment/.test(ns[i].textContent || '')) {
if (ns[i].parentNode) ns[i].parentNode.removeChild(ns[i]);
}
}
}, 80);
}
// ── fetch 拦截:只认「新建评论」这一个请求 ────────────────────────────
// 之所以要先 await 读完 body 再放行,是因为 Artalk 在 `await fetch(...)` 之后
// 才触发 editor-submitted;如果这里用 res.clone().json() 异步解析而不 await,
// 事件的回调会先跑,读到的是上一轮的 result(成功/审核状态就串了)。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if (method !== 'POST' || !POST_RE.test(url)) return origFetch.apply(this, arguments);
var p = origFetch.apply(this, arguments);
return p.then(function (res) {
if (res && res.ok) {
// 成功:读一下 body 看是不是落进了审核队列(服务端返回的就是
// 煮熟的评论对象本体,is_pending 是它的字段)
return res.clone().json().then(function (d) {
result = { ok: true, pending: !!(d && d.is_pending) };
return res;
}).catch(function () {
result = { ok: true, pending: false };
return res;
});
}
// 失败:Artalk 拿到 !res.ok 会自己 throw,这里只负责记因 + 弹 toast
return res.clone().json().then(function (d) {
result = { ok: false, status: res.status, msg: (d && d.msg) || '', data: d || {} };
failToast(result);
return res;
}).catch(function () {
result = { ok: false, status: res.status, msg: 'HTTP ' + res.status, data: {} };
failToast(result);
return res;
});
}, function (err) {
result = { ok: false, network: true, msg: (err && err.message) || 'network error' };
failToast(result);
throw err; // 原样抛回:Artalk 自己的失败处理(保留输入内容)不受影响
});
};
}
})();