From ff789a2fc912fa44b731f74f5531fad5e1092bcb Mon Sep 17 00:00:00 2001 From: zqlit Date: Mon, 5 Oct 2026 10:05:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(comments):=20=E5=8C=BF=E5=90=8D=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E5=8D=87=E7=BA=A7=E4=B8=BA=E3=80=8C=E6=82=84=E6=82=84?= =?UTF-8?q?=E8=AF=9D=E3=80=8D=E2=80=94=E2=80=94=E5=8F=AA=E8=97=8F=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E4=B8=8D=E8=97=8F=E8=BA=AB=E4=BB=BD=EF=BC=9Bfix:=20PJ?= =?UTF-8?q?AX=20=E7=9E=AC=E6=96=AD=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 悄悄话:头像/昵称/头衔/等级/IP归属照常显示,仅内容对非管理员请求剥空 (F12/网络面板拿不到原文),博主后台/登录态/邮件看全文 - 开关挪进底栏 .atk-bottom-left、表情/预览按钮右边,移动端不挤; 回复悄悄话勾不勾随自己(去掉自动勾选) - 前端识别:服务端剥空后 .atk-content 为空即悄悄话(空评论会被后端拒绝、 纯表情评论有 img 不误判),无需解析 API - 邮件页面标题书名号《》保留(按用户要求) - PJAX 偶发「连不上服务器」:GET /api/v2 网络错误或 5xx 静默重试 2 次 (700ms/1800ms 退避),POST 不重试,被 abort 的请求不续命 --- blog-admin/src/lib/cook.ts | 53 +++++----- blog-admin/src/lib/mail.ts | 1 + blog-admin/src/routes/comments.ts | 12 +-- blog-admin/src/types.ts | 2 + themes/Ying/assets/css/main.css | 49 +++++++--- themes/Ying/assets/js/modules/artalk.js | 30 +++++- .../assets/js/modules/comment-anonymous.js | 96 +++++++++++++++---- 7 files changed, 183 insertions(+), 60 deletions(-) diff --git a/blog-admin/src/lib/cook.ts b/blog-admin/src/lib/cook.ts index 69eab837..c2c434b7 100644 --- a/blog-admin/src/lib/cook.ts +++ b/blog-admin/src/lib/cook.ts @@ -183,7 +183,13 @@ export function cookNotify(n: { export async function cookComments( env: Env, rows: CommentRow[], - opts: { ipRegion?: boolean; visibility?: Map; includeMarked?: boolean } = {}, + opts: { + ipRegion?: boolean; + visibility?: Map; + includeMarked?: boolean; + /** 管理员请求:跳过匿名脱敏,昵称/头像/内容全给真的(审核与回复需要) */ + revealAnonymous?: boolean; + } = {}, ): Promise { 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; }); } diff --git a/blog-admin/src/lib/mail.ts b/blog-admin/src/lib/mail.ts index 9d5f2ddd..d1800d5c 100644 --- a/blog-admin/src/lib/mail.ts +++ b/blog-admin/src/lib/mail.ts @@ -424,6 +424,7 @@ export async function notifyByEmail(env: Env, c: NotifyCtx): Promise { 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, diff --git a/blog-admin/src/routes/comments.ts b/blog-admin/src/routes/comments.ts index 0479975a..10bc7ccf 100644 --- a/blog-admin/src/routes/comments.ts +++ b/blog-admin/src/routes/comments.ts @@ -296,6 +296,7 @@ export async function listComments(ctx: Ctx): Promise { ipRegion: await isIPRegionEnabled(env), visibility, includeMarked: admin, // 只有后台要 content_marked,前台不带(省一半流量) + revealAnonymous: admin, // 管理员请求:匿名评论给真实身份和内容 }); const resp: Record = { comments, count, roots_count: rootsCount }; @@ -489,6 +490,7 @@ export async function createComment(ctx: Ctx): Promise { 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 { 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 { 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 { } 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 ?? {}); } diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts index 8497173f..3d68afe5 100644 --- a/blog-admin/src/types.ts +++ b/blog-admin/src/types.ts @@ -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; diff --git a/themes/Ying/assets/css/main.css b/themes/Ying/assets/css/main.css index 8aa9f1b9..af49ddf6 100644 --- a/themes/Ying/assets/css/main.css +++ b/themes/Ying/assets/css/main.css @@ -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 { diff --git a/themes/Ying/assets/js/modules/artalk.js b/themes/Ying/assets/js/modules/artalk.js index 271621eb..355a8c3f 100644 --- a/themes/Ying/assets/js/modules/artalk.js +++ b/themes/Ying/assets/js/modules/artalk.js @@ -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 { diff --git a/themes/Ying/assets/js/modules/comment-anonymous.js b/themes/Ying/assets/js/modules/comment-anonymous.js index c79c9e2d..7d7a8d04 100644 --- a/themes/Ying/assets/js/modules/comment-anonymous.js +++ b/themes/Ying/assets/js/modules/comment-anonymous.js @@ -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; + // 有图/视频/占位的一律不算空(纯表情评论也有 ,不会误伤) + 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]); }; }