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

165 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* comment-autofill —— 老朋友「填邮箱自动带出昵称和网址」
*
* 场景:很多人隔几个月来评论一次,早忘了上次填的昵称/网址,每次都重新想。
* 这个模块做两件事:
* 1. 昵称框一获得焦点就弹 toast 提示「填邮箱可以自动带回来」(90 秒冷却,
* 防止反复点进点出刷屏);
* 2. 邮箱填完(失焦或格式完整)就问后端:这个邮箱上次评论用的什么昵称/网址,
* 拿到后回填到空着的输入框;如果框里已经有内容就不覆盖,也会弹一条
* 「欢迎回来」让 TA 知道系统认出了 TA。
*
* 隐私红线:后端 /comments/lookup **只回昵称和网址,永远不回邮箱**;
* 邮箱只作为查询参数发给评论后端(跟提交评论本身一样),不在本地落任何存储,
* 也不打日志。查不到就安静地什么都不做,绝不给访客「你在被查询」的感觉。
*
* 实现说明:用 document 级事件委托(capture),不绑定具体输入框 ——
* Artalk 的编辑器会反复重建(PJAX 切页、点回复、登录态变化),委托一劳永逸。
*/
(function () {
if (window.__atkAutofillReady) return; // PJAX 重复执行防抖
window.__atkAutofillReady = true;
var HINT_COOLDOWN = 90000; // 同一编辑器 90 秒内不重复弹提示
var asked = {}; // 查过的邮箱 → 结果,避免重复请求
var timer = null;
function toast(msg, type, duration) {
try {
if (window.Toast && typeof window.Toast[type || 'info'] === 'function') {
window.Toast[type || 'info'](msg, duration);
}
} catch (e) {}
}
function isEmail(s) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(String(s || '').trim()); }
function editorOf(el) {
// 线上 Artalk 的编辑器容器是 .atk-main-editor(旧版/别处可能是 .atk-editor),
// 兜底再往上找整个 .artalk 根 —— 宁可范围大一点,也别让委托匹配不到
return (el && el.closest)
? el.closest('.atk-main-editor, .atk-editor, .artalk, .atk-layer-wrap')
: null;
}
function fieldIn(editor, name) {
return editor ? editor.querySelector('input[name="' + name + '"]') : null;
}
/** Artalk 对受控输入有自己的状态,改完必须补一个 input 事件它才认 */
function setVal(input, value) {
if (!input) return;
input.value = value;
try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
function showHint(editor) {
// 邮箱已经填了就不用提示(提示了也没东西可带出)
var email = fieldIn(editor, 'email');
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);
}
function fillFrom(editor, data) {
if (!data || !data.found) return;
var nick = fieldIn(editor, 'nick');
var link = fieldIn(editor, 'link');
var name = String(data.name || '').trim();
var url = String(data.link || '').trim();
if (!name) return;
var filled = [];
var kept = []; // 查到了,但框里已经有 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 (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);
}
}
function lookup(editor, email) {
var key = email.toLowerCase();
if (asked[key]) { fillFrom(editor, asked[key]); return; }
var server = (window.artalkConfig && window.artalkConfig.server || '').replace(/\/+$/, '');
if (!server) return;
// 超时兜底:评论后端在国内访问偶尔抖,别让访客干等
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
var killer = null;
if (ctrl) killer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 8000);
fetch(server + '/api/v2/comments/lookup?email=' + encodeURIComponent(email), {
method: 'GET',
credentials: 'omit',
signal: ctrl ? ctrl.signal : undefined
}).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
asked[key] = d || { found: false };
fillFrom(editor, asked[key]);
}).catch(function () {
asked[key] = { found: false }; // 失败也记一下,别反复打同一个请求
}).finally(function () { if (killer) clearTimeout(killer); });
}
// ── 事件委托(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 === 'email') {
var v = el.value.trim();
if (isEmail(v)) {
if (timer) clearTimeout(timer);
timer = setTimeout(function () { lookup(editor, v); }, 700); // 停手再查
}
}
}, true);
document.addEventListener('focusout', function (e) {
var el = e.target;
if (!el || el.tagName !== 'INPUT' || el.name !== 'email') return;
var editor = editorOf(el);
if (!editor) return;
var v = el.value.trim();
if (!isEmail(v)) return;
if (timer) clearTimeout(timer);
lookup(editor, v); // 离开邮箱框就立刻查
}, true);
})();