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:
1 parent
215929a67f
commit
4fa592c11c
7 files changed
+183
-60
No files matched your search
+29
-24
@@ -183,7 +183,13 @@ export function cookNotify(n: {
|
||||
export async function cookComments(
|
||||
env: Env,
|
||||
rows: CommentRow[],
|
||||
opts: { ipRegion?: boolean; visibility?: Map<number, boolean>; includeMarked?: boolean } = {},
|
||||
opts: {
|
||||
ipRegion?: boolean;
|
||||
visibility?: Map<number, boolean>;
|
||||
includeMarked?: boolean;
|
||||
/** 管理员请求:跳过匿名脱敏,昵称/头像/内容全给真的(审核与回复需要) */
|
||||
revealAnonymous?: boolean;
|
||||
} = {},
|
||||
): Promise<CookedComment[]> {
|
||||
if (!rows.length) return [];
|
||||
|
||||
@@ -216,33 +222,33 @@ export async function cookComments(
|
||||
const siteUrl = siteUrlMap.get(c.site_name) || '';
|
||||
const page = pageMap.get(`${c.site_name}\u0000${c.page_key}`);
|
||||
|
||||
// ── 匿名评论 ─────────────────────────────────────────────────────────
|
||||
// 真实身份(昵称/邮箱/网址)只留在 users 表里:回复邮件通知照发、
|
||||
// 老友自动填充不受污染;但**对外**一切能对上号的东西都抹掉 ——
|
||||
// 昵称、头像(换通用图)、网址、徽标、等级(评论数会暴露是谁)、IP 归属。
|
||||
const anon = !!c.is_anonymous;
|
||||
const nick = anon ? '匿名' : user?.name ?? '';
|
||||
const avatarHash = anon ? md5Lower('anonymous@comment.local') : md5Lower(user?.email ?? '');
|
||||
// ── 悄悄话评论(is_anonymous 列沿用,语义 = 内容仅博主可见)──────────
|
||||
// 身份完全不藏(用户拍板):头像/昵称/网址/徽标/等级/IP 归属照常显示。
|
||||
// 只有**内容**对非管理员请求剥空 —— F12 / 网络面板拿不到原文,前端识别
|
||||
// 空内容后画「仅博主可见」占位条;博主(管理员请求 / 后台 / 邮件)看全文。
|
||||
const isAnon = !!c.is_anonymous;
|
||||
const hideContent = isAnon && !opts.revealAnonymous;
|
||||
|
||||
const cook: CookedComment = {
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
content_marked: opts.includeMarked ? renderMarkdown(c.content) : '',
|
||||
content: hideContent ? '' : c.content,
|
||||
content_marked: opts.includeMarked && !hideContent ? renderMarkdown(c.content) : '',
|
||||
user_id: c.user_id,
|
||||
nick,
|
||||
email_encrypted: avatarHash,
|
||||
link: anon ? '' : user?.link ?? '',
|
||||
ua: anon ? '' : c.ua || '',
|
||||
nick: user?.name ?? '',
|
||||
email_encrypted: md5Lower(user?.email ?? ''),
|
||||
link: user?.link ?? '',
|
||||
ua: c.ua || '',
|
||||
date: formatDateCN(c.created_at),
|
||||
is_collapsed: !!c.is_collapsed,
|
||||
is_pending: !!c.is_pending,
|
||||
is_pinned: !!c.is_pinned,
|
||||
is_allow_reply: !c.is_collapsed && !c.is_pending,
|
||||
is_verified: user?.is_admin ? true : !!c.is_verified,
|
||||
is_anonymous: isAnon,
|
||||
rid: c.rid,
|
||||
badge_name: anon ? '' : user?.badge_name ?? '',
|
||||
badge_color: anon ? '' : user?.badge_color ?? '',
|
||||
title_name: anon ? '' : user?.title_name || '',
|
||||
badge_name: user?.badge_name ?? '',
|
||||
badge_color: user?.badge_color ?? '',
|
||||
title_name: user?.title_name || '',
|
||||
visible: opts.visibility?.get(c.id) ?? true,
|
||||
vote_up: c.vote_up || 0,
|
||||
vote_down: c.vote_down || 0,
|
||||
@@ -251,14 +257,13 @@ export async function cookComments(
|
||||
site_name: c.site_name,
|
||||
};
|
||||
if (opts.ipRegion && c.ip_region) cook.ip_region = c.ip_region;
|
||||
// 匿名评论不显示等级(等级 = 真实评论数,能对上号),也不显示 IP 归属
|
||||
const rank = anon ? null : getRank(commentCounts.get(c.user_id) ?? 0);
|
||||
const myCount = anon ? 0 : commentCounts.get(c.user_id) ?? 0;
|
||||
cook.rank_name = anon ? '' : rank!.short;
|
||||
cook.rank_title = anon ? '' : rank!.title;
|
||||
const rank = getRank(commentCounts.get(c.user_id) ?? 0);
|
||||
const myCount = commentCounts.get(c.user_id) ?? 0;
|
||||
cook.rank_name = rank.short;
|
||||
cook.rank_title = rank.title;
|
||||
cook.rank_count = myCount;
|
||||
cook.rank_color = anon ? '' : rank!.color;
|
||||
cook.rank_bg = anon ? '' : rank!.bg;
|
||||
cook.rank_color = rank.color;
|
||||
cook.rank_bg = rank.bg;
|
||||
return cook;
|
||||
});
|
||||
}
|
||||
@@ -424,6 +424,7 @@ export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
|
||||
const pa = c.parentAuthor;
|
||||
const selfReply = pa.id === c.author.id;
|
||||
if (!selfReply && pa.receive_email && pa.email && pa.email.includes('@')) {
|
||||
// 悄悄话评论身份是公开的(只藏内容),邮件里昵称/头像照常真实
|
||||
const input: ReplyMailInput = {
|
||||
siteName: c.siteName,
|
||||
siteUrl: c.siteUrl,
|
||||
|
||||
@@ -296,6 +296,7 @@ export async function listComments(ctx: Ctx): Promise<Response> {
|
||||
ipRegion: await isIPRegionEnabled(env),
|
||||
visibility,
|
||||
includeMarked: admin, // 只有后台要 content_marked,前台不带(省一半流量)
|
||||
revealAnonymous: admin, // 管理员请求:匿名评论给真实身份和内容
|
||||
});
|
||||
|
||||
const resp: Record<string, unknown> = { comments, count, roots_count: rootsCount };
|
||||
@@ -489,6 +490,7 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
const [cooked] = await cookComments(env, [row], {
|
||||
ipRegion: await isIPRegionEnabled(env),
|
||||
includeMarked: !!author.is_admin,
|
||||
revealAnonymous: !!author.is_admin,
|
||||
});
|
||||
|
||||
// 通知(站内):回复时给父评论作者写一条
|
||||
@@ -510,9 +512,7 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
const siteUrl = await siteFirstUrl(env, siteName);
|
||||
await notifyByEmail(env, {
|
||||
newComment: row,
|
||||
// 匿名评论:邮件里的昵称也用「匿名」——收件人可能是任何第三方,
|
||||
// 真实昵称不能从邮件漏出去(邮箱本来就是只进不出)
|
||||
author: anonymous ? { ...author, name: '匿名' } : author,
|
||||
author,
|
||||
parent,
|
||||
parentAuthor,
|
||||
siteName,
|
||||
@@ -542,12 +542,12 @@ export async function getComment(ctx: Ctx): Promise<Response> {
|
||||
const admin = await isAdminRequest(ctx.env, ctx.req, ctx.user);
|
||||
if (row.is_pending && !admin) return fail(404, 'Comment not found');
|
||||
|
||||
const [cooked] = await cookComments(ctx.env, [row], { includeMarked: admin });
|
||||
const [cooked] = await cookComments(ctx.env, [row], { includeMarked: admin, revealAnonymous: admin });
|
||||
let reply: CookedComment | undefined;
|
||||
if (row.rid) {
|
||||
const parentRow = await findComment(ctx.env, row.rid);
|
||||
if (parentRow) {
|
||||
[reply] = await cookComments(ctx.env, [parentRow], { includeMarked: admin });
|
||||
[reply] = await cookComments(ctx.env, [parentRow], { includeMarked: admin, revealAnonymous: admin });
|
||||
}
|
||||
}
|
||||
return ok({ comment: cooked, reply_comment: reply });
|
||||
@@ -606,7 +606,7 @@ export async function updateComment(ctx: Ctx): Promise<Response> {
|
||||
}
|
||||
|
||||
const updated = await findComment(env, id);
|
||||
const [cooked] = await cookComments(env, updated ? [updated] : [], { includeMarked: admin });
|
||||
const [cooked] = await cookComments(env, updated ? [updated] : [], { includeMarked: admin, revealAnonymous: admin });
|
||||
return ok(cooked ?? {});
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ export interface CookedComment {
|
||||
is_pinned: boolean;
|
||||
is_allow_reply: boolean;
|
||||
is_verified: boolean;
|
||||
/** 该评论是否匿名发表(管理员请求时字段原样保留,内容是否脱敏看服务端) */
|
||||
is_anonymous: boolean;
|
||||
rid: number;
|
||||
badge_name: string;
|
||||
badge_color: string;
|
||||
|
||||
@@ -4202,37 +4202,62 @@ input[type=submit] {
|
||||
|
||||
/* Back to Top Button */
|
||||
/* 匿名评论开关:插在 textarea 与底栏之间的独立一行(右对齐),不挤底栏 */
|
||||
.atk-anon-toggle {
|
||||
display: flex;
|
||||
/* 悄悄话开关:底栏左侧(.atk-bottom-left)内、表情/预览按钮右边;
|
||||
短文案 + 小 checkbox,与图标按钮同一行,移动端窄屏也不挤不换行 */
|
||||
.atk-whisper-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
padding: 0 10px 6px;
|
||||
gap: 4px;
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #9b9b9b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.atk-anon-toggle input {
|
||||
.atk-whisper-toggle input {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
cursor: pointer;
|
||||
accent-color: #373737;
|
||||
}
|
||||
.atk-anon-toggle:hover {
|
||||
.atk-whisper-toggle:hover {
|
||||
color: #555;
|
||||
}
|
||||
[data-theme="dark"] .atk-anon-toggle {
|
||||
[data-theme="dark"] .atk-whisper-toggle {
|
||||
color: #8a8f98;
|
||||
}
|
||||
[data-theme="dark"] .atk-anon-toggle:hover {
|
||||
[data-theme="dark"] .atk-whisper-toggle:hover {
|
||||
color: #c3c7cd;
|
||||
}
|
||||
[data-theme="dark"] .atk-anon-toggle input {
|
||||
[data-theme="dark"] .atk-whisper-toggle input {
|
||||
accent-color: #d1d5db;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.atk-whisper-toggle { margin-left: 8px; gap: 3px; }
|
||||
}
|
||||
|
||||
/* 悄悄话评论:服务端已把内容从公开接口剥空,这里只画观感占位(浅灰小圆角条) */
|
||||
.atk-whisper-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
margin: 2px 0 4px;
|
||||
border-radius: 9px;
|
||||
background: rgba(134, 142, 150, 0.09);
|
||||
color: #8a8e98;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
[data-theme="dark"] .atk-whisper-note {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #8a8f98;
|
||||
}
|
||||
|
||||
/* 表情面板「最近使用」栏:面板顶部横向一行,移动端可横滑 */
|
||||
.atk-recent-emoticons {
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user