diff --git a/blog-admin/schema.sql b/blog-admin/schema.sql index 254916d7..ddd92a34 100644 --- a/blog-admin/schema.sql +++ b/blog-admin/schema.sql @@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS comments ( is_pending INTEGER NOT NULL DEFAULT 0, is_pinned INTEGER NOT NULL DEFAULT 0, is_verified INTEGER NOT NULL DEFAULT 0, + is_anonymous INTEGER NOT NULL DEFAULT 0, -- 匿名评论:对外显示「匿名」,真实身份只留后台 vote_up INTEGER NOT NULL DEFAULT 0, vote_down INTEGER NOT NULL DEFAULT 0, ua TEXT NOT NULL DEFAULT '', diff --git a/blog-admin/src/lib/cook.ts b/blog-admin/src/lib/cook.ts index 38ce46cf..69eab837 100644 --- a/blog-admin/src/lib/cook.ts +++ b/blog-admin/src/lib/cook.ts @@ -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; }); } diff --git a/blog-admin/src/lib/mail.ts b/blog-admin/src/lib/mail.ts index 2df21b57..9d5f2ddd 100644 --- a/blog-admin/src/lib/mail.ts +++ b/blog-admin/src/lib/mail.ts @@ -107,162 +107,20 @@ export async function mailQuotaOk(env: Env, limit = 90): Promise { } // ============================================================================ -// 模板 —— 微信聊天式:灰底 #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 的 `; - -/** 头像:40px 圆角方(微信样式),weavatar 按 Gravatar 规则用邮箱 md5 取图 */ -const AVATAR = (prefix: string, nick: string) => - `${nick}`; - -/** - * 气泡:bg 为 #95ec69(自己)/ #ffffff(对方)。 - * 气泡本身是一个嵌套 (宽度随内容收缩、上限 360px),比 inline-block - * 在邮件客户端里稳得多 —— 图片的 max-width 百分比也能正确解析。 - * align="right" 时气泡贴右(微信里自己说的话)。 - */ -const BUBBLE = (bg: string, color: string, inner: string, align?: 'right') => - `
- -
${inner}
`; - -/** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */ -const REPLY_TPL = ` - - - - - - -${HEAD_STYLE} - - -
- -
你在《{{page_title}}》的评论有回复
-
{{parent_comment.datetime}}
- -
- - - - - -
-
{{parent_comment.nick}}
- ${BUBBLE('#95ec69', '#111111', `${BODY_OPEN}{{parent_comment.content}}${BODY_CLOSE}`, 'right')} -
${AVATAR('parent_comment', '{{parent_comment.nick}}')}
-
- -
{{comment.datetime}}
- -
- - - - - -
${AVATAR('comment', '{{comment.nick}}')} -
{{comment.nick}}
- ${BUBBLE('#ffffff', '#111111', `${BODY_OPEN}{{comment.content}}${BODY_CLOSE}`)} -
-
- - - -
- - -
- 查看并回复 -
-
- -
- 此邮件来自{{site_name}},请勿直接回复
- 不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」 -
-
- -`; - -/** 管理员通知:一条新评论(白泡靠左),微信式 */ -const NOTICE_TPL = ` - - - - - - -${HEAD_STYLE} - - -
- -
《{{page_title}}》有了新的评论
-
{{comment.datetime}}
- -
- - - - - -
${AVATAR('comment', '{{comment.nick}}')} -
{{comment.nick}}
- ${BUBBLE('#ffffff', '#111111', `${BODY_OPEN}{{comment.content}}${BODY_CLOSE}`)} -
-
- - - -
- - -
- 前往查看 -
-
- -
- 此邮件来自{{site_name}}博客,请勿直接回复 -
-
- -`; - /** * 邮件主题。 * 回复:沿用老配置「新的评论回复」。 @@ -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 = ``; + +/** 原版卡片外框(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) => + `${nick}`; + +/** + * 原版深色气泡 #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 `
{{${prefix}.nick}}
+
+
${AVATAR(prefix, nick)}
+
+
{{${prefix}.content}}
+
+
`; +}; + +/** 原版结尾:「温馨提示」+ 来源声明 */ +const FOOTER = `
+
温馨提示:点击聊天气泡跳转评论页
+
此邮件来自{{site_name}}博客,请勿直接回复,谢谢合作!
`; + +/** 回复提醒:原版结构 —— 收信人自己的评论(气泡靠右)在上,新回复(靠左)在下 */ +const REPLY_TPL = ` + + + + + + + +${HEAD_STYLE} + + + +
+
+
你在《{{page_title}}》的评论有回复
+
+
{{parent_comment.datetime}}
+
+${BUBBLE_ROW('right', 'parent_comment', '{{parent_comment.nick}}')} +
+
{{comment.datetime}}
+
+${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')} +
+${FOOTER} +
+ + +`; + +/** 管理员通知:原版结构 —— 一条新评论(气泡靠左) */ +const NOTICE_TPL = ` + + + + + + + +${HEAD_STYLE} + + + +
+
+
《{{page_title}}》页面有了新的评论
+
+
{{comment.datetime}}
+
+${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')} +
+${FOOTER} +
+ + +`; + // ------------------------------------------------------------------ 渲染 const esc = (v: unknown): string => diff --git a/blog-admin/src/routes/comments.ts b/blog-admin/src/routes/comments.ts index f6a1d455..0479975a 100644 --- a/blog-admin/src/routes/comments.ts +++ b/blog-admin/src/routes/comments.ts @@ -428,6 +428,11 @@ export async function createComment(ctx: Ctx): Promise { 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 { 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 { rootId, pending ? 1 : 0, isVerified ? 1 : 0, + anonymous ? 1 : 0, ua, ip, ipRegion, @@ -504,7 +510,9 @@ export async function createComment(ctx: Ctx): Promise { const siteUrl = await siteFirstUrl(env, siteName); await notifyByEmail(env, { newComment: row, - author, + // 匿名评论:邮件里的昵称也用「匿名」——收件人可能是任何第三方, + // 真实昵称不能从邮件漏出去(邮箱本来就是只进不出) + author: anonymous ? { ...author, name: '匿名' } : author, parent, parentAuthor, siteName, diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts index 785acab3..8497173f 100644 --- a/blog-admin/src/types.ts +++ b/blog-admin/src/types.ts @@ -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; diff --git a/themes/Ying/assets/css/main.css b/themes/Ying/assets/css/main.css index 885c690c..8aa9f1b9 100644 --- a/themes/Ying/assets/css/main.css +++ b/themes/Ying/assets/css/main.css @@ -4201,6 +4201,100 @@ input[type=submit] { } /* Back to Top Button */ +/* 匿名评论开关:插在 textarea 与底栏之间的独立一行(右对齐),不挤底栏 */ +.atk-anon-toggle { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + padding: 0 10px 6px; + font-size: 12px; + color: #9b9b9b; + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} +.atk-anon-toggle input { + margin: 0; + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #373737; +} +.atk-anon-toggle:hover { + color: #555; +} +[data-theme="dark"] .atk-anon-toggle { + color: #8a8f98; +} +[data-theme="dark"] .atk-anon-toggle:hover { + color: #c3c7cd; +} +[data-theme="dark"] .atk-anon-toggle input { + accent-color: #d1d5db; +} + +/* 表情面板「最近使用」栏:面板顶部横向一行,移动端可横滑 */ +.atk-recent-emoticons { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + margin-bottom: 2px; + border-bottom: 1px dashed rgba(128, 128, 128, 0.28); + overflow-x: auto; + scrollbar-width: thin; +} +.atk-recent-emoticons .atk-recent-label { + flex: none; + font-size: 12px; + color: #9b9b9b; + white-space: nowrap; +} +.atk-recent-emoticons ul { + display: flex; + align-items: center; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} +.atk-recent-emoticons li { + flex: none; + display: flex; +} +.atk-recent-emoticons img { + max-height: 32px; + width: auto; + vertical-align: middle; + cursor: pointer; + padding: 2px; + border-radius: 4px; +} +.atk-recent-emoticons img:hover { + background: rgba(128, 128, 128, 0.16); +} +[data-theme="dark"] .atk-recent-emoticons { + border-bottom-color: rgba(255, 255, 255, 0.12); +} +[data-theme="dark"] .atk-recent-emoticons .atk-recent-label { + color: #8a8f98; +} +[data-theme="dark"] .atk-recent-emoticons img:hover { + background: rgba(255, 255, 255, 0.12); +} + +/* CSS 平滑滚动:只在程序触发的滚动期间由 JS 挂到 上,滚完即摘。 + 滚动交给浏览器合成器处理,不占 JS 主线程 —— 移动端不再像 rAF 自绘那样掉帧。 */ +html.css-smooth-scroll { + scroll-behavior: smooth; +} +@media (prefers-reduced-motion: reduce) { + html.css-smooth-scroll { + scroll-behavior: auto; + } +} + #back-to-top { opacity: 0; visibility: hidden; diff --git a/themes/Ying/assets/js/modules/comment-anonymous.js b/themes/Ying/assets/js/modules/comment-anonymous.js new file mode 100644 index 00000000..c79c9e2d --- /dev/null +++ b/themes/Ying/assets/js/modules/comment-anonymous.js @@ -0,0 +1,98 @@ +/** + * comment-anonymous —— 「匿名评论」开关 + * + * 场景:有人想说话但不想暴露身份(昵称/头像/网址都能对上号)。 + * 勾选后提交的评论对外显示为「匿名」:头像换通用图、网址/徽标/等级/IP归属 + * 全部隐藏。真实昵称和邮箱仍存在后端 users 表里 —— 有人回复时通知照发、 + * 下次来「填邮箱自动带出昵称」不受污染。博主后台能看到真实身份(审核需要)。 + * + * 实现要点: + * - 开关插在 textarea 和底栏之间**独立一行**(右对齐小开关),不挤底栏 —— + * 移动端底栏本来就塞了表情/图片/发送按钮,再塞一个就挤爆了。 + * - Artalk 是 editorTravel 单实例游走,全站只有一个活的 .atk-main-editor, + * fetch 拦截读它的开关状态即可。 + * - 借已有的 fetch 拦截链给 POST /api/v2/comments 的 body 注入 anonymous:true。 + */ +(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-anon-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-anon-toggle')) return; // 已注入 + var bottom = editor.querySelector('.atk-bottom'); + if (!bottom) return; // 结构没就绪,等下轮扫描 + + var label = document.createElement('label'); + label.className = 'atk-anon-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); + bottom.parentNode.insertBefore(label, bottom); + } + + 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]); + } 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]); + }; + } +})(); diff --git a/themes/Ying/assets/js/modules/comment-autofill.js b/themes/Ying/assets/js/modules/comment-autofill.js index 4f24f46c..ccb47b10 100644 --- a/themes/Ying/assets/js/modules/comment-autofill.js +++ b/themes/Ying/assets/js/modules/comment-autofill.js @@ -3,9 +3,11 @@ * * 场景:很多人隔几个月来评论一次,早忘了上次填的昵称/网址,每次都重新想。 * 这个模块做两件事: - * 1. 访客开始在昵称框打字时,弹一次 toast 提示「填邮箱可以自动带回来」; + * 1. 昵称框一获得焦点就弹 toast 提示「填邮箱可以自动带回来」(90 秒冷却, + * 防止反复点进点出刷屏); * 2. 邮箱填完(失焦或格式完整)就问后端:这个邮箱上次评论用的什么昵称/网址, - * 拿到后回填到空着的输入框,让老朋友少打几个字。 + * 拿到后回填到空着的输入框;如果框里已经有内容就不覆盖,也会弹一条 + * 「欢迎回来」让 TA 知道系统认出了 TA。 * * 隐私红线:后端 /comments/lookup **只回昵称和网址,永远不回邮箱**; * 邮箱只作为查询参数发给评论后端(跟提交评论本身一样),不在本地落任何存储, @@ -18,7 +20,7 @@ if (window.__atkAutofillReady) return; // PJAX 重复执行防抖 window.__atkAutofillReady = true; - var HINT_KEY = 'atk_friend_hint'; // sessionStorage:提示只弹一次 + var HINT_COOLDOWN = 90000; // 同一编辑器 90 秒内不重复弹提示 var asked = {}; // 查过的邮箱 → 结果,避免重复请求 var timer = null; @@ -52,12 +54,16 @@ } function showHint(editor) { - try { - if (sessionStorage.getItem(HINT_KEY)) return; - sessionStorage.setItem(HINT_KEY, '1'); - } catch (e) { return; } // 存储被禁用就别弹了,宁缺毋滥 + // 邮箱已经填了就不用提示(提示了也没东西可带出) var email = fieldIn(editor, 'email'); - if (email && email.value.trim()) return; // 已经填了邮箱,不用提示 + if (email && email.value.trim()) return; + // 冷却门:同一编辑器 90 秒只弹一次,反复点进点出不刷屏。 + // (曾经用 sessionStorage 做「整个会话只弹一次」,结果很多人再也见不到——弃用) + try { + var last = editor.__atkHintAt || 0; + if (Date.now() - last < HINT_COOLDOWN) return; + editor.__atkHintAt = Date.now(); + } catch (e) { return; } toast('老朋友?填一下邮箱,昵称和网址会自动帮你带回来 ✨', 'info', 4500); } @@ -70,20 +76,28 @@ if (!name) return; var filled = []; + var kept = []; // 查到了,但框里已经有 TA 自己填的内容 → 不抢,口头告知 // 昵称:空着,或者只打了历史昵称的开头(说明 TA 正在凭印象敲同一个名字) - // 才替 TA 补全 —— 已经敲了一个完全不同的名字就别抢,那是故意的。 + // 才替 TA 补全 —— 已经敲了一个完全不同的名字就别动,那是故意的。 if (nick) { var cur = nick.value.trim(); var lower = cur.toLowerCase(); var lowerName = name.toLowerCase(); if (!cur || lowerName.indexOf(lower) === 0) { if (cur !== name) { setVal(nick, name); filled.push('昵称'); } + } else { + kept.push('昵称'); } } - if (link && !link.value.trim() && url) { setVal(link, url); filled.push('网址'); } + if (url) { + if (link && !link.value.trim()) { setVal(link, url); filled.push('网址'); } + else if (link && link.value.trim() && link.value.trim() !== url) kept.push('网址'); + } if (filled.length) { toast('欢迎回来,' + name + '!已帮你填好' + filled.join('和') + ' 👋', 'success', 4000); + } else if (kept.length) { + toast('欢迎回来,' + name + '!你已填好' + kept.join('和') + ',就不帮你改动啦', 'info', 4000); } } @@ -112,16 +126,22 @@ } // ── 事件委托(capture:Artalk 内部可能 stopPropagation)────────────── + + // 昵称框获得焦点就提示(focusin 比 focus 冒泡,靠委托才能一把抓住反复重建的编辑器) + document.addEventListener('focusin', function (e) { + var el = e.target; + if (!el || el.tagName !== 'INPUT' || el.name !== 'nick') return; + var editor = editorOf(el); + if (!editor) return; // 只管 Artalk 的评论框 + showHint(editor); + }, true); + document.addEventListener('input', function (e) { var el = e.target; if (!el || el.tagName !== 'INPUT' || !el.name) return; var editor = editorOf(el); if (!editor) return; // 只管 Artalk 的评论框 - if (el.name === 'nick') { - if (el.value.trim()) showHint(editor); // 打了第一个字就开始提示 - return; - } if (el.name === 'email') { var v = el.value.trim(); if (isEmail(v)) { diff --git a/themes/Ying/assets/js/modules/emoticon-recent.js b/themes/Ying/assets/js/modules/emoticon-recent.js new file mode 100644 index 00000000..4da82e2d --- /dev/null +++ b/themes/Ying/assets/js/modules/emoticon-recent.js @@ -0,0 +1,153 @@ +/** + * emoticon-recent —— 表情包「最近使用」 + * + * 场景:表情面板几十套图翻页找常用的太累,用过的排最前,一次点击直达。 + * + * 零侵入实现(不碰 Artalk 源码,靠 DOM 契约): + * - Artalk 表情面板里每张原生图是 `key`,点击后插入 `:[key]` 令牌; + * - 这里用事件委托记录点击(localStorage,最多 8 个,去重置顶); + * - 面板顶部注入「最近」一栏;点最近里的图 = 找到面板里原生的同一张 + * (按 alt 匹配)然后 programmatic click(),完全复用 Artalk 自己的插入逻辑。 + * - 面板由 Artalk 动态创建/重建(PJAX、点回复),MutationObserver 兜底重注入。 + */ +(function () { + if (window.__atkEmoticonRecentReady) return; // PJAX 重复执行防抖 + window.__atkEmoticonRecentReady = true; + + var STORE_KEY = 'atk_recent_emoticons'; + var MAX = 8; + + function load() { + try { + var arr = JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); + if (!Array.isArray(arr)) return []; + return arr.filter(function (it) { return it && it.k && it.s; }); + } catch (e) { return []; } + } + + function save(list) { + try { localStorage.setItem(STORE_KEY, JSON.stringify(list.slice(0, MAX))); } catch (e) {} + } + + function record(key, src) { + if (!key || !src) return; + var list = load().filter(function (it) { return it.k !== key; }); + list.unshift({ k: key, s: src }); + save(list); + } + + // 最近栏当前展示的键序(用于和存储比对,判断要不要重建) + function blockKeys(panel) { + var imgs = panel.querySelectorAll('.atk-recent-emoticons img'); + var keys = []; + for (var i = 0; i < imgs.length; i++) keys.push(imgs[i].getAttribute('alt')); + return keys; + } + + // ── 记录:点的是面板里的原生表情图(capture,先于 Artalk 的 onclick)──── + document.addEventListener('click', function (e) { + var el = e.target; + if (!el || el.tagName !== 'IMG' || !el.closest) return; + if (!el.closest('.atk-editor-plug-emoticons')) return; + if (el.closest('.atk-recent-emoticons')) return; // 最近栏自己的点击单独处理 + var key = el.getAttribute('alt'); + if (key) { + record(key, el.getAttribute('src') || ''); + scheduleScan(); // 顺序变了,刷新最近栏 + } + }, true); + + // ── 在面板里找原生表情图(排除最近栏自己的),按 alt=key 匹配 ────────── + function findNativeImg(panel, key) { + var imgs = panel.querySelectorAll('img'); + for (var i = 0; i < imgs.length; i++) { + var im = imgs[i]; + if (im.closest('.atk-recent-emoticons')) continue; + if (im.getAttribute('alt') === key) return im; + } + return null; + } + + function buildBlock() { + var list = load(); + if (!list.length) return null; + var wrap = document.createElement('div'); + wrap.className = 'atk-recent-emoticons'; + + var label = document.createElement('span'); + label.className = 'atk-recent-label'; + label.textContent = '最近'; + wrap.appendChild(label); + + var ul = document.createElement('ul'); + list.forEach(function (it) { + var li = document.createElement('li'); + var img = document.createElement('img'); + img.src = it.s; + img.alt = it.k; + img.title = it.k; + img.loading = 'lazy'; + img.onclick = function () { + var panel = img.closest('.atk-editor-plug-emoticons'); + var orig = panel ? findNativeImg(panel, it.k) : null; + if (orig) { + orig.click(); // 复用 Artalk 的插入逻辑(插 :[key] 令牌) + record(it.k, it.s); // 再点一次最近栏的图,顺序也要更新 + scheduleScan(); + } else { + // 表情包配置变了,这张已经不存在 → 从最近记录里淘汰 + save(load().filter(function (x) { return x.k !== it.k; })); + scheduleScan(); + } + }; + li.appendChild(img); + ul.appendChild(li); + }); + wrap.appendChild(ul); + return wrap; + } + + function inject(panel) { + if (!panel || !panel.querySelector) return; + var existing = panel.querySelector('.atk-recent-emoticons'); + var want = load(); + if (existing) { + // 内容比对:键序和存储一致就不动(避免无谓重建打断 hover), + // 不一致(点了新表情 / 记录被淘汰)就整块重建 + var have = blockKeys(panel); + var same = have.length === want.length; + if (same) { + for (var i = 0; i < want.length; i++) { + if (want[i].k !== have[i]) { same = false; break; } + } + } + if (same) return; + var fresh = buildBlock(); + if (fresh) panel.replaceChild(fresh, existing); + else existing.parentNode.removeChild(existing); + return; + } + // 原生分组还没渲染出来时先不注入(找原生图会找不到),等下轮扫描 + if (!panel.querySelector('img')) return; + var block = buildBlock(); + if (block) panel.insertBefore(block, panel.firstChild); + } + + var scanTimer = null; + function scheduleScan() { + if (scanTimer) return; + scanTimer = setTimeout(function () { + scanTimer = null; + try { + var panels = document.querySelectorAll('.atk-editor-plug-emoticons'); + for (var i = 0; i < panels.length; i++) inject(panels[i]); + } catch (e) {} + }, 120); + } + + try { + new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true }); + } catch (e) {} + document.addEventListener('focusin', scheduleScan, true); + scheduleScan(); +})(); diff --git a/themes/Ying/assets/js/modules/floating-tools.js b/themes/Ying/assets/js/modules/floating-tools.js index 24594f57..e6e4ba2a 100644 --- a/themes/Ying/assets/js/modules/floating-tools.js +++ b/themes/Ying/assets/js/modules/floating-tools.js @@ -1,27 +1,40 @@ -// 自绘平滑滚动:浏览器原生 behavior:'smooth' 又快又硬(约 300ms 且不可调), -// 还会被系统的「减少动态效果」降级成瞬移。用 rAF + easeInOutCubic 自绘, -// 时长随距离自适应,滚动一旦被用户滚轮/触摸打断立即让位。 -window.smoothScrollTo = function(targetY, duration) { - var startY = window.scrollY; - var diff = targetY - startY; - if (Math.abs(diff) < 2) return; - // 短距离快、长距离慢一些,但都 clamp 在 420~950ms,保证观感一致 - var dur = duration || Math.min(950, Math.max(420, Math.abs(diff) * 0.38)); - if (window.__smoothScrollRAF) cancelAnimationFrame(window.__smoothScrollRAF); - var t0 = null, canceled = false; - var cancel = function() { canceled = true; }; - window.addEventListener('wheel', cancel, { once: true, passive: true }); - window.addEventListener('touchstart', cancel, { once: true, passive: true }); - var ease = function(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }; - var step = function(ts) { - if (canceled) return; - if (t0 === null) t0 = ts; - var p = Math.min(1, (ts - t0) / dur); - window.scrollTo(0, startY + diff * ease(p)); - if (p < 1) window.__smoothScrollRAF = requestAnimationFrame(step); - else window.__smoothScrollRAF = null; +// 平滑滚动 —— 纯 CSS 实现(scroll-behavior: smooth)。 +// 之前用 rAF + window.scrollTo 自绘动画:每一帧都在主线程上强制重排重绘, +// 移动端长页面(尤其带 backdrop-filter / 大图的)掉帧明显,非常卡。 +// CSS 平滑滚动由浏览器合成器接管,不占 JS 主线程,移动端丝滑得多。 +// 做法:只在滚动期间临时给 挂 .css-smooth-scroll 类,滚完就摘 —— +// 避免全局常开平滑滚动干扰 PJAX 切页回顶、锚点跳转这些场景。 +window.smoothScrollTo = function(targetY) { + targetY = Math.max(0, Math.floor(targetY || 0)); + if (Math.abs(window.scrollY - targetY) < 2) return; + + var root = document.documentElement; + // 上一次滚动还没收尾就直接再来一次:先摘干净旧状态 + if (window.__smoothScrollCleanup) window.__smoothScrollCleanup(); + + var reduced = false; + try { reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {} + if (reduced) { window.scrollTo(0, targetY); return; } // 用户要求减少动态效果:瞬移 + + root.classList.add('css-smooth-scroll'); + window.scrollTo(0, targetY); // 类在位,浏览器自动走平滑滚动 + + var done = function () { + if (window.__smoothScrollTimer) { clearTimeout(window.__smoothScrollTimer); window.__smoothScrollTimer = null; } + window.removeEventListener('scrollend', done); + window.removeEventListener('wheel', done, { passive: true } ); + window.removeEventListener('touchstart', done, { passive: true }); + root.classList.remove('css-smooth-scroll'); + window.__smoothScrollCleanup = null; }; - window.__smoothScrollRAF = requestAnimationFrame(step); + window.__smoothScrollCleanup = done; + // scrollend(Chrome 114+/FF 109+)在滚动真正停下的瞬间触发; + // 不支持的浏览器用超时兜底摘类。滚轮/触摸会打断 CSS 平滑滚动, + // 提前收尾把类摘掉,不影响用户接着手动滚。 + window.addEventListener('scrollend', done, { once: true }); + window.addEventListener('wheel', done, { once: true, passive: true }); + window.addEventListener('touchstart', done, { once: true, passive: true }); + window.__smoothScrollTimer = setTimeout(done, 1000); }; window.refreshFloatingTools = function() { diff --git a/themes/Ying/layouts/partials/footer.html b/themes/Ying/layouts/partials/footer.html index 8b10b675..b7243f11 100644 --- a/themes/Ying/layouts/partials/footer.html +++ b/themes/Ying/layouts/partials/footer.html @@ -143,8 +143,11 @@ {{ $reward := resources.Get "js/modules/reward.js" }} {{/* 老朋友自动填充:填邮箱带出上次的昵称/网址 */}} {{ $autofill := resources.Get "js/modules/comment-autofill.js" }} + {{/* 匿名评论开关 + 表情包最近使用 */}} + {{ $anon := resources.Get "js/modules/comment-anonymous.js" }} + {{ $emoRecent := resources.Get "js/modules/emoticon-recent.js" }} - {{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} + {{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} @@ -157,8 +160,10 @@ {{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }} {{ $reward := resources.Get "js/modules/reward.js" }} {{ $autofill := resources.Get "js/modules/comment-autofill.js" }} + {{ $anon := resources.Get "js/modules/comment-anonymous.js" }} + {{ $emoRecent := resources.Get "js/modules/emoticon-recent.js" }} - {{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} + {{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} {{ end }}