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:
1 parent
db150c55bd
commit
6988a39732
6 files changed
+355
-61
No files matched your search
@@ -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 自己的失败处理(保留输入内容)不受影响
|
||||
});
|
||||
};
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user