feat(comments): 匿名评论 + 表情最近使用;fix: 自动填充聚焦提示 & 邮件模板回退原版 & 返回顶部改 CSS 动画

- 匿名评论:comments.is_anonymous 列;对外昵称/头像/网址/徽标/等级/IP归属全部脱敏,
  真实身份留 users 表(回复通知照发、autofill 不受污染);邮件昵称也用「匿名」防泄露
- 表情包最近使用:面板顶部「最近」栏,localStorage 记录 8 个,零侵入复用 Artalk 插入逻辑
- 匿名开关插在 textarea 与底栏之间独立一行,不挤移动端底栏
- 自动填充:提示改 focusin 触发(原 input+sessionStorage 一次性门导致永不触发);
  已填昵称/网址时不覆盖并弹提示
- 邮件模板 UI 回退原版深色气泡设计,保留表情内联尺寸/主题文案等逻辑修复
- 返回顶部:rAF 自绘改 CSS scroll-behavior(滚动期临时挂类),修移动端掉帧
This commit is contained in:
zqlit committed 2026-10-05 09:02:23 +08:00
1 parent 090262d278
commit 215929a67f
11 files changed
+587 -207

No files matched your search

+23 -15
View File
@@ -215,15 +215,24 @@ export async function cookComments(
const user = users.get(c.user_id);
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 ?? '');
const cook: CookedComment = {
id: c.id,
content: c.content,
content_marked: opts.includeMarked ? renderMarkdown(c.content) : '',
user_id: c.user_id,
nick: user?.name ?? '',
email_encrypted: md5Lower(user?.email ?? ''),
link: user?.link ?? '',
ua: c.ua || '',
nick,
email_encrypted: avatarHash,
link: anon ? '' : user?.link ?? '',
ua: anon ? '' : c.ua || '',
date: formatDateCN(c.created_at),
is_collapsed: !!c.is_collapsed,
is_pending: !!c.is_pending,
@@ -231,9 +240,9 @@ export async function cookComments(
is_allow_reply: !c.is_collapsed && !c.is_pending,
is_verified: user?.is_admin ? true : !!c.is_verified,
rid: c.rid,
badge_name: user?.badge_name ?? '',
badge_color: user?.badge_color ?? '',
title_name: user?.title_name || '',
badge_name: anon ? '' : user?.badge_name ?? '',
badge_color: anon ? '' : user?.badge_color ?? '',
title_name: anon ? '' : user?.title_name || '',
visible: opts.visibility?.get(c.id) ?? true,
vote_up: c.vote_up || 0,
vote_down: c.vote_down || 0,
@@ -242,15 +251,14 @@ export async function cookComments(
site_name: c.site_name,
};
if (opts.ipRegion && c.ip_region) cook.ip_region = c.ip_region;
// 官职徽标独立字段:不占用官方 badge 槽(博主的「博主」徽章独一无二,
// 官职接在它后面由主题渲染),配色沿用主题原版(彩字 + 半透明底)
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;
// 匿名评论不显示等级(等级 = 真实评论数,能对上号),也不显示 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;
cook.rank_count = myCount;
cook.rank_color = rank.color;
cook.rank_bg = rank.bg;
cook.rank_color = anon ? '' : rank!.color;
cook.rank_bg = anon ? '' : rank!.bg;
return cook;
});
}
+129 -150
View File
@@ -107,162 +107,20 @@ export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
}
// ============================================================================
// 模板 —— 微信聊天式:灰底 #EDEDED、自己的评论绿色气泡(#95EC69)靠右、
// 对方的白色气泡靠左、圆角方头像、时间居中 —— 用大家天天在看的聊天直觉
// 来传达「谁说了什么」,一眼就懂,不需要解释。
// 模板 —— 以站点的原版模板(notice.html / reply.html)为底,UI 完全保持
// 原设计:#f4f5f8 灰底卡片、居中标题、深色聊天气泡 #373737 + 三角箭头、
// 35px 圆头像(weavatar,邮箱 md5,明文不出现在邮件里)、点气泡跳转评论页。
//
// 以站点的老模板(notice.html / reply.html)为底改:头像(weavatar,邮箱
// md5,明文不出现在邮件里)、点气泡跳转评论页这些老设计都保留。
//
// 相比老模板的两处实质修复:
// 1. 表情包尺寸内联到位:老模板靠 head 里 `.comment_img img{width:100px}`
// 相比原版只动逻辑层,UI 一像素不差之外的三处细节打磨:
// 1. 表情包尺寸内联到位:原版靠 head 里 `.comment_img img{width:100px}`
// 控制大小,而不少邮件客户端会把 head 的 <style> 整段丢掉;一丢,
// 表情图就按**原始像素**铺开(站里有几套是 400px+ 的大图),气泡被
// 撑爆 —— 这就是「表情太大看着奇怪」的真因。现在每个 <img> 都带
// 内联尺寸:表情 max-height 28px(微信小表情的体感),配图限宽 100%。
// 2. 布局全用嵌套 <table>(气泡本身就是一个表格,宽度随内容收缩、
// 上限 360px),不依赖 flex / float / inline-block —— Outlook 与
// 各家 webmail 对表格的支持是最稳的,图片限宽也不会算错。
//
// 另外管理员邮件主题由「新的评论待审」改成一句人话(见 ADMIN_SUBJECT)。
// 内联尺寸:表情 max-height 28px,配图限宽 100%(不撑破气泡)。
// 2. 邮件主题改成一句人话(见 ADMIN_SUBJECT / REPLY_SUBJECT)。
// 3. 字体栈、头像 alt 等无障碍小细节。
// ============================================================================
/** 取首字(码点安全,emoji/生僻字不会被截断) */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 气泡里的正文块(保留老模板的 .comment_img 类名,供 head 样式兜底) */
const BODY_OPEN = '<div class="comment_img">';
const BODY_CLOSE = '</div>';
/** 老模板 head 里的那点样式:整体 reset + 表情尺寸兜底(内联样式为主,这只是保险) */
const HEAD_STYLE = `<style>
*{margin:0;padding:0;box-sizing:border-box}
img[atk-emoticon]{max-height:28px;width:auto;vertical-align:middle}
</style>`;
/** 头像:40px 圆角方(微信样式),weavatar 按 Gravatar 规则用邮箱 md5 取图 */
const AVATAR = (prefix: string, nick: string) =>
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" width="40" height="40" alt="${nick}" style="width:40px;height:40px;border-radius:6px;display:block;border:0">`;
/**
* 气泡:bg 为 #95ec69(自己)/ #ffffff(对方)。
* 气泡本身是一个嵌套 <table>(宽度随内容收缩、上限 360px),比 inline-block
* 在邮件客户端里稳得多 —— 图片的 max-width 百分比也能正确解析。
* align="right" 时气泡贴右(微信里自己说的话)。
*/
const BUBBLE = (bg: string, color: string, inner: string, align?: 'right') =>
`<table role="presentation" cellpadding="0" cellspacing="0" border="0"${align ? ` align="${align}"` : ''} style="max-width:360px">
<tr><td bgcolor="${bg}" style="background-color:${bg};border-radius:8px;padding:10px 12px;font-size:14px;color:${color};line-height:1.7;word-break:break-word;text-align:left">${inner}</td></tr>
</table>`;
/** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */
const REPLY_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
${HEAD_STYLE}
</head>
<body style="margin:0;padding:0;background-color:#ededed">
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>的评论有回复</div>
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{parent_comment.datetime}}</div>
<div style="margin-bottom:18px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" align="right" style="text-align:right;padding-right:10px">
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{parent_comment.nick}}</div>
${BUBBLE('#95ec69', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{parent_comment.content}}${BODY_CLOSE}</a>`, 'right')}
</td>
<td width="44" valign="top" style="width:44px">${AVATAR('parent_comment', '{{parent_comment.nick}}')}</td>
</tr>
</table>
</div>
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:2px 0 14px">{{comment.datetime}}</div>
<div style="margin-bottom:8px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
<td valign="top" style="padding-left:10px">
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
</td>
</tr>
</table>
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding-top:26px">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">查看并回复</a>
</td></tr>
</table>
</td></tr>
</table>
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」
</div>
</div>
</body>
</html>`;
/** 管理员通知:一条新评论(白泡靠左),微信式 */
const NOTICE_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
${HEAD_STYLE}
</head>
<body style="margin:0;padding:0;background-color:#ededed">
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6"><a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>有了新的评论</div>
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{comment.datetime}}</div>
<div style="margin-bottom:8px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
<td valign="top" style="padding-left:10px">
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
</td>
</tr>
</table>
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding-top:26px">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">前往查看</a>
</td></tr>
</table>
</td></tr>
</table>
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>博客,请勿直接回复
</div>
</div>
</body>
</html>`;
/**
* 邮件主题。
* 回复:沿用老配置「新的评论回复」。
@@ -271,6 +129,127 @@ ${HEAD_STYLE}
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
// ------------------------------------------------------------------ 模板本体
/** 取首字(码点安全,emoji/生僻字不会被截断) */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 原版 head 里的样式:整体 reset + 表情尺寸兜底(客户端丢 head 时由内联样式接管) */
const HEAD_STYLE = `<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
.comment_img img{
width: 100px;
}
</style>`;
/** 原版卡片外框(550px 灰底卡片),font-family 属于细节打磨,不影响版式 */
const WRAP_STYLE =
'width: 100%;max-width: 550px;margin:1rem auto;background-color: #f4f5f8;padding: 1rem;box-sizing: border-box;' +
"font-family:-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif";
/** 原版 35px 圆头像(weavatar 按 Gravatar 规则用邮箱 md5 取图,明文不出现在邮件里) */
const AVATAR = (prefix: string, nick: string) =>
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" alt="${nick}" style="width: 35px;height: 35px;border-radius: 50%;">`;
/**
* 原版深色气泡 #373737 + 三角箭头,正文 13px 白字,点气泡跳转评论页。
* dir=left:箭头在左(对方说的话,头像在左);dir=right:镜像,头像在右。
*/
const BUBBLE_ROW = (dir: 'left' | 'right', prefix: string, nick: string) => {
const right = dir === 'right';
const rowStyle = right ? 'display: flex;justify-content: flex-end;margin-bottom: 5px' : 'display: flex;margin-bottom: 5px';
const avatarCell = right ? 'width: auto;flex: none;order: 2' : 'width: auto;flex: none';
const bubbleCell = right ? 'position: relative;margin-right: 20px' : 'position: relative;margin-left: 20px';
const arrow = right
? 'width: 0;height: 0;border-top:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid;border-left-color:#373737;border-right:0;border-right-color:transparent;right: -8px;left: auto;top: 12px;position: absolute'
: 'width: 0;height: 0;border-top: 8px solid transparent;border-bottom: 8px solid transparent;border-right: 8px solid;border-right-color: #373737;left: -8px;right: auto;top: 12px;position: absolute';
const nickStyle = right
? 'text-align: right;line-height: 2rem;font-size: 13px;font-weight: bold;margin-right: 5px;'
: 'line-height: 2rem;font-size: 13px;font-weight: bold;margin-left: 5px;';
return ` <div style="${nickStyle}">{{${prefix}.nick}}</div>
<div style="${rowStyle}">
<div style="${avatarCell}">${AVATAR(prefix, nick)}</div>
<div style="${bubbleCell}"><span
style="${arrow}"></span>
<div
style="background-color: #373737;padding: 10px;border-radius: 9px;margin-bottom: 3px;font-size: 13px;color: #ffffff;word-break: break-all;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="text-decoration: none;color:#ffffff;"><div class="comment_img">{{${prefix}.content}}</div></a></div>
</div>
</div>`;
};
/** 原版结尾:「温馨提示」+ 来源声明 */
const FOOTER = ` <div style="text-align: center;padding: 3rem 0;font-size: 12px;"></div>
<div style="text-align: center;color: #555555;font-size: 12px;">温馨提示:点击聊天气泡跳转评论页</div>
<div style="text-align: center;color: #555555;font-size: 12px;">此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="text-decoration: none;color:#373737;">{{site_name}}</a>博客,请勿直接回复,谢谢合作!</div>`;
/** 回复提醒:原版结构 —— 收信人自己的评论(气泡靠右)在上,新回复(靠左)在下 */
const REPLY_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
${HEAD_STYLE}
</head>
<body>
<div style="${WRAP_STYLE}">
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
<div style="text-align: center;">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>的评论有回复</div>
</div><!--!!!-->
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{parent_comment.datetime}}</div>
<div style="display: flex;flex-direction:column;margin-bottom: 3rem;">
${BUBBLE_ROW('right', 'parent_comment', '{{parent_comment.nick}}')}
</div><!--!!!--><!--!!!-->
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
<div style="display: flex;flex-direction:column;">
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
</div><!--!!!-->
${FOOTER}
</div>
</body>
</html>`;
/** 管理员通知:原版结构 —— 一条新评论(气泡靠左) */
const NOTICE_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
${HEAD_STYLE}
</head>
<body>
<div style="${WRAP_STYLE}">
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
<div style="text-align: center;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>页面有了新的评论</div>
</div><!--!!!-->
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
<div style="display: flex;flex-direction:column;">
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
</div><!--!!!-->
${FOOTER}
</div>
</body>
</html>`;
// ------------------------------------------------------------------ 渲染
const esc = (v: unknown): string =>
+11 -3
View File
@@ -428,6 +428,11 @@ export async function createComment(ctx: Ctx): Promise<Response> {
return fail(403, 'Admin access required', { need_login: true });
}
// 匿名评论:真实身份只留在 users 表(回复通知照发、自动填充不受污染),
// 对外一律显示「匿名」。管理员身份本来就不是匿名的,忽略标志。
const anonymous =
!admin && (body.anonymous === true || body.anonymous === 1 || body.anonymous === '1' || body.anonymous === 'true');
// 匿名用户:按 name+email 找或建
let author = user;
let isVerified = true;
@@ -455,8 +460,8 @@ export async function createComment(ctx: Ctx): Promise<Response> {
const t = now();
const res = await env.DB.prepare(
`INSERT INTO comments
(content, page_key, site_name, user_id, rid, root_id, is_pending, is_verified, ua, ip, ip_region, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(content, page_key, site_name, user_id, rid, root_id, is_pending, is_verified, is_anonymous, ua, ip, ip_region, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
content,
@@ -467,6 +472,7 @@ export async function createComment(ctx: Ctx): Promise<Response> {
rootId,
pending ? 1 : 0,
isVerified ? 1 : 0,
anonymous ? 1 : 0,
ua,
ip,
ipRegion,
@@ -504,7 +510,9 @@ export async function createComment(ctx: Ctx): Promise<Response> {
const siteUrl = await siteFirstUrl(env, siteName);
await notifyByEmail(env, {
newComment: row,
author,
// 匿名评论:邮件里的昵称也用「匿名」——收件人可能是任何第三方,
// 真实昵称不能从邮件漏出去(邮箱本来就是只进不出)
author: anonymous ? { ...author, name: '匿名' } : author,
parent,
parentAuthor,
siteName,
+1
View File
@@ -112,6 +112,7 @@ export interface CommentRow {
is_pending: number;
is_pinned: number;
is_verified: number;
is_anonymous: number;
vote_up: number;
vote_down: number;
ua: string;