feat(comments): 匿名评论升级为「悄悄话」——只藏内容不藏身份;fix: PJAX 瞬断自动重试

- 悄悄话:头像/昵称/头衔/等级/IP归属照常显示,仅内容对非管理员请求剥空
  (F12/网络面板拿不到原文),博主后台/登录态/邮件看全文
- 开关挪进底栏 .atk-bottom-left、表情/预览按钮右边,移动端不挤;
  回复悄悄话勾不勾随自己(去掉自动勾选)
- 前端识别:服务端剥空后 .atk-content 为空即悄悄话(空评论会被后端拒绝、
  纯表情评论有 img 不误判),无需解析 API
- 邮件页面标题书名号《》保留(按用户要求)
- PJAX 偶发「连不上服务器」:GET /api/v2 网络错误或 5xx 静默重试 2 次
  (700ms/1800ms 退避),POST 不重试,被 abort 的请求不续命
This commit is contained in:
zqlit committed 2026-10-05 10:05:21 +08:00
1 parent 8521c3f385
commit ff789a2fc9
7 files changed
+183 -60

No files matched your search

+29 -1
View File
@@ -112,6 +112,30 @@
// ── 1) 记录 API 失败原因。只读不改:包装层原样返回上游响应,
// 失败时顺带 clone 一份读 body(clone 不影响 Artalk 自己读)。
// ── 1.5) 瞬断自动重试:PJAX 进文章偶尔「连不上服务器」,手动刷新就好
// —— 跨境链路抖一下(Failed to fetch / 5xx)列表就整块报错。GET/HEAD
// 幂等,网络错误或 5xx 时静默重试两次(700ms / 1800ms 退避);POST
// 绝不重试(重复提交风险);请求已被 PJAX abort 的也不再续命。
var RETRYABLE_STATUS = { 502: 1, 503: 1, 504: 1, 520: 1, 521: 1, 522: 1, 523: 1, 524: 1, 525: 1, 527: 1, 530: 1 };
function retryWait(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function fetchRetry(doFetch) {
var delays = [700, 1800];
function run(i) {
return doFetch().then(function (res) {
if (res && RETRYABLE_STATUS[res.status] && i < delays.length) {
try { if (res.body && res.body.cancel) res.body.cancel(); } catch (e) {}
return retryWait(delays[i]).then(function () { return run(i + 1); });
}
return res;
}, function (err) {
if (i >= delays.length) throw err;
return retryWait(delays[i]).then(function () { return run(i + 1); });
});
}
return run(0);
}
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
@@ -141,7 +165,11 @@
fargs = [input, sansContentType(init || {})];
}
var p = origFetch.apply(this, fargs);
var self = this;
function doFetch() { return origFetch.apply(self, fargs); }
var canRetry = (method === 'GET' || method === 'HEAD') &&
!(init && init.signal && init.signal.aborted);
var p = canRetry ? fetchRetry(doFetch) : doFetch();
return p.then(function (res) {
if (res && res.ok === false) {
try {
@@ -1,30 +1,35 @@
/**
* comment-anonymous —— 「匿名评论」开关
* comment-anonymous —— 「悄悄话」开关(内容仅博主可见的评论)
*
* 场景:有人想说话但不想暴露身份(昵称/头像/网址都能对上号)。
* 勾选后提交的评论对外显示为「匿名」:头像换通用图、网址/徽标/等级/IP归属
* 全部隐藏。真实昵称和邮箱仍存在后端 users 表里 —— 有人回复时通知照发、
* 下次来「填邮箱自动带出昵称」不受污染。博主后台能看到真实身份(审核需要)。
* 用户拍板的语义:**只藏内容,不藏身份**。勾选后发表的评论
* - 头像 / 昵称 / 头衔 / 等级 / IP 归属照常显示;
* - 内容被服务端从公开接口剥空(F12 / 网络面板拿不到原文),
* 前端识别空内容后画一条「仅博主可见」占位;
* - 博主自己(后台 / 登录态 / 邮件通知)看得到全文。
* 回复悄悄话时勾不勾都行 —— 想公开回还是悄悄回自己定,不做默认勾选。
*
* 实现要点:
* - 开关插在 textarea 和底栏之间**独立一行**(右对齐小开关),不挤底栏 ——
* 移动端底栏本来就塞了表情/图片/发送按钮,再塞一个就挤爆了。
* - 开关放在底栏左侧(.atk-bottom-left)里、表情/预览按钮**右边**,
* 和它们同一行(用户指定位置);短文案 + 小号 checkbox,移动端不挤。
* - Artalk 是 editorTravel 单实例游走,全站只有一个活的 .atk-main-editor,
* fetch 拦截读它的开关状态即可。
* - 借已有的 fetch 拦截链给 POST /api/v2/comments 的 body 注入 anonymous:true。
* - 借已有的 fetch 拦截链给 POST /api/v2/comments 的 body 注入 anonymous:true
* (字段名沿用 is_anonymous 列,语义已升级为悄悄话)。
* - 占位识别:服务端剥空后 .atk-content 是空的 —— 正常评论内容永不为空
* (后端拒绝空评论),空内容即悄悄话,不需要解析 API 也能对上 DOM。
*/
(function () {
if (window.__atkAnonReady) return; // PJAX 重复执行防抖
window.__atkAnonReady = true;
var TIP = '对外显示为「匿名」,头像和网址会隐藏;邮箱只用于接收回复通知,对外不可见';
var TIP = '勾选后,内容只有博主能看到(头像和昵称照常显示)';
// 取「当前活着的」编辑器里的开关:优先可见的(editorTravel 游走的那个)
function currentToggle() {
var eds = document.querySelectorAll('.atk-main-editor');
var fallback = null;
for (var i = 0; i < eds.length; i++) {
var cb = eds[i].querySelector('.atk-anon-toggle input');
var cb = eds[i].querySelector('.atk-whisper-toggle input');
if (!cb) continue;
if (eds[i].offsetParent !== null) return cb; // 可见编辑器优先
if (!fallback) fallback = cb;
@@ -34,20 +39,76 @@
function inject(editor) {
if (!editor || !editor.querySelector) return;
if (editor.querySelector('.atk-anon-toggle')) return; // 已注入
var bottom = editor.querySelector('.atk-bottom');
if (!bottom) return; // 结构没就绪,等下轮扫描
if (editor.querySelector('.atk-whisper-toggle')) return; // 已注入
var left = editor.querySelector('.atk-bottom-left');
if (!left) return; // 底栏没就绪,等下轮扫描
var label = document.createElement('label');
label.className = 'atk-anon-toggle';
label.className = 'atk-whisper-toggle';
label.title = TIP;
var cb = document.createElement('input');
cb.type = 'checkbox';
var txt = document.createElement('span');
txt.textContent = '匿名评论';
txt.textContent = '悄悄话';
label.appendChild(cb);
label.appendChild(txt);
bottom.parentNode.insertBefore(label, bottom);
// 插在表情/预览按钮组(.atk-plug-btn-wrap)的右边
var plugWrap = left.querySelector('.atk-plug-btn-wrap');
if (plugWrap && plugWrap.nextSibling) {
left.insertBefore(label, plugWrap.nextSibling);
} else {
left.appendChild(label);
}
}
// ── 悄悄话占位:服务端已把内容剥空,识别 = 内容区为空 ────────────────
function contentElOf(comment) {
var main = comment.querySelector('.atk-main');
if (!main) return null;
// 评论正文是 .atk-main 的直接子级;跳过编辑器回复预览里的 .atk-content
var el = main.querySelector(':scope > .atk-content');
if (!el) {
var all = main.querySelectorAll('.atk-content');
for (var i = 0; i < all.length; i++) {
if (!all[i].closest('.atk-reply-to') && !all[i].closest('.atk-main-editor')) { el = all[i]; break; }
}
}
return el;
}
function isEmptyContent(el) {
if (!el) return true;
// 有图/视频/占位的一律不算空(纯表情评论也有 <img>,不会误伤)
if (el.querySelector('img, video, iframe, .atk-whisper-note')) return false;
return el.textContent.trim() === '' && el.children.length === 0;
}
function maskComment(node) {
if (node.__atkWhisperMasked) return;
var content = contentElOf(node);
if (!isEmptyContent(content)) return; // 有内容 → 不是悄悄话
node.__atkWhisperMasked = true;
node.classList.add('atk-whisper-comment');
if (!content) {
content = document.createElement('div');
content.className = 'atk-content';
var main = node.querySelector('.atk-main');
if (main) main.appendChild(content);
}
if (!content.querySelector('.atk-whisper-note')) {
var note = document.createElement('div');
note.className = 'atk-whisper-note';
note.textContent = '悄悄话 · 内容仅博主可见';
while (content.firstChild) content.removeChild(content.firstChild);
content.appendChild(note);
}
}
function fullScan() {
try {
var comments = document.querySelectorAll('.atk-comment');
for (var i = 0; i < comments.length; i++) maskComment(comments[i]);
} catch (e) {}
}
var scanTimer = null;
@@ -58,6 +119,7 @@
try {
var eds = document.querySelectorAll('.atk-main-editor');
for (var i = 0; i < eds.length; i++) inject(eds[i]);
fullScan(); // 悄悄话占位,共用这一个扫描节奏
} catch (e) {}
}, 120);
}
@@ -91,7 +153,7 @@
}
}
}
} catch (e) {} // 解析失败就按原样发,绝不能因为匿名开关挂掉评论
} catch (e) {} // 解析失败就按原样发,绝不能因为悄悄话开关挂掉评论
return origFetch.apply(this, [input, init]);
};
}