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

161 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* comment-anonymous —— 「悄悄话」开关(内容仅博主可见的评论)
*
* 用户拍板的语义:**只藏内容,不藏身份**。勾选后发表的评论
* - 头像 / 昵称 / 头衔 / 等级 / IP 归属照常显示;
* - 内容被服务端从公开接口剥空(F12 / 网络面板拿不到原文),
* 前端识别空内容后画一条「仅博主可见」占位;
* - 博主自己(后台 / 登录态 / 邮件通知)看得到全文。
* 回复悄悄话时勾不勾都行 —— 想公开回还是悄悄回自己定,不做默认勾选。
*
* 实现要点:
* - 开关放在底栏左侧(.atk-bottom-left)里、表情/预览按钮**右边**,
* 和它们同一行(用户指定位置);短文案 + 小号 checkbox,移动端不挤。
* - Artalk 是 editorTravel 单实例游走,全站只有一个活的 .atk-main-editor,
* fetch 拦截读它的开关状态即可。
* - 借已有的 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 = '勾选后,内容只有博主能看到(头像和昵称照常显示)';
// 取「当前活着的」编辑器里的开关:优先可见的(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-whisper-toggle input');
if (!cb) continue;
if (eds[i].offsetParent !== null) return cb; // 可见编辑器优先
if (!fallback) fallback = cb;
}
return fallback;
}
function inject(editor) {
if (!editor || !editor.querySelector) 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-whisper-toggle';
label.title = TIP;
var cb = document.createElement('input');
cb.type = 'checkbox';
var txt = document.createElement('span');
txt.textContent = '悄悄话';
label.appendChild(cb);
label.appendChild(txt);
// 插在表情/预览按钮组(.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;
function scheduleScan() {
if (scanTimer) return;
scanTimer = setTimeout(function () {
scanTimer = null;
try {
var eds = document.querySelectorAll('.atk-main-editor');
for (var i = 0; i < eds.length; i++) inject(eds[i]);
fullScan(); // 悄悄话占位,共用这一个扫描节奏
} catch (e) {}
}, 120);
}
try {
// 编辑器由 Artalk 动态创建(PJAX / 点回复时游走重建位置),观察兜底
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
} catch (e) {}
// MutationObserver 不监听「隐藏节点变可见」,用点击/聚焦兜底扫一遍
document.addEventListener('click', scheduleScan, true);
document.addEventListener('focusin', scheduleScan, true);
scheduleScan();
// ── fetch 拦截:给新建评论请求注入 anonymous 标志 ──────────────────────
// Artalk 的提交走 fetch + JSON body;链上已有别的拦截器(提交反馈/失败
// 翻译),这里只改 init.body,然后原样放行,互不干扰。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
try {
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if (method === 'POST' && /\/api\/v2\/comments(\?|$)/.test(url) &&
init && typeof init.body === 'string') {
var cb = currentToggle();
if (cb && cb.checked) {
var data = JSON.parse(init.body);
if (data && typeof data === 'object' && !data.anonymous) {
data.anonymous = true;
init = Object.assign({}, init, { body: JSON.stringify(data) });
}
}
}
} catch (e) {} // 解析失败就按原样发,绝不能因为悄悄话开关挂掉评论
return origFetch.apply(this, [input, init]);
};
}
})();