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 215929a67f
commit 4fa592c11c
7 files changed
+183 -60

No files matched your search

+29 -24
View File
@@ -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;
});
}
+1
View File
@@ -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,
+6 -6
View File
@@ -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 ?? {});
}
+2
View File
@@ -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;