邮件模板微信化 + 评论老友自动填充 + CF 后台国内线路一键跳

- 邮件模板重做成微信聊天式:灰底、自己评论绿色气泡靠右、对方白色气泡靠左、
  40px 圆角方头像(weavatar,邮箱 md5 不外泄)、时间居中、#07C160 主按钮;
  布局全嵌套表格(Outlook 稳),表情内联 max-height:28px(修掉「表情太大」)、
  配图限宽 100%;管理员主题从「新的评论待审」改成「您有一个新的评论」
- 老友自动填充:新增 /comments/lookup(只回昵称+网址,绝不含邮箱;IP 限速
  30 次/10 分钟)+ 主题 comment-autofill 模块(填昵称弹提示、填邮箱自动带出
  上次的昵称/网址,事件委托不怕 Artalk 重建编辑器)
- CF 后台新增「国内线路」一键跳:/editor/handoff 用共享令牌对时间戳 HMAC
  签名 → editor-api /admin/handoff 验签发会话 Cookie(2 分钟有效、防重放),
  晚高峰丢包时直达国内机后台免重新登录
This commit is contained in:
zqlit committed 2026-10-05 07:36:26 +08:00
1 parent 18a4882577
commit bcfa229105
10 files changed
+443 -87

No files matched your search

@@ -0,0 +1,141 @@
/**
* comment-autofill —— 老朋友「填邮箱自动带出昵称和网址」
*
* 场景:很多人隔几个月来评论一次,早忘了上次填的昵称/网址,每次都重新想。
* 这个模块做两件事:
* 1. 访客开始在昵称框打字时,弹一次 toast 提示「填邮箱可以自动带回来」;
* 2. 邮箱填完(失焦或格式完整)就问后端:这个邮箱上次评论用的什么昵称/网址,
* 拿到后回填到空着的输入框,让老朋友少打几个字。
*
* 隐私红线:后端 /comments/lookup **只回昵称和网址,永远不回邮箱**;
* 邮箱只作为查询参数发给评论后端(跟提交评论本身一样),不在本地落任何存储,
* 也不打日志。查不到就安静地什么都不做,绝不给访客「你在被查询」的感觉。
*
* 实现说明:用 document 级事件委托(capture),不绑定具体输入框 ——
* Artalk 的编辑器会反复重建(PJAX 切页、点回复、登录态变化),委托一劳永逸。
*/
(function () {
if (window.__atkAutofillReady) return; // PJAX 重复执行防抖
window.__atkAutofillReady = true;
var HINT_KEY = 'atk_friend_hint'; // sessionStorage:提示只弹一次
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) {
var box = el && el.closest ? el.closest('.atk-editor') : null;
return box || 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) {
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; // 已经填了邮箱,不用提示
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 = [];
// 昵称:空着,或者只打了历史昵称的开头(说明 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('昵称'); }
}
}
if (link && !link.value.trim() && url) { setVal(link, url); filled.push('网址'); }
if (filled.length) {
toast('欢迎回来,' + name + '!已帮你填好' + filled.join('和') + ' 👋', 'success', 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)──────────────
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)) {
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);
})();
+5 -2
View File
@@ -141,8 +141,10 @@
{{ $human := resources.Get "js/modules/human.js" }}
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
{{ $reward := resources.Get "js/modules/reward.js" }}
{{/* 老朋友自动填充:填邮箱带出上次的昵称/网址 */}}
{{ $autofill := resources.Get "js/modules/comment-autofill.js" }}
{{ $pageScripts := slice $friendlink $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
{{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
<script defer src="{{ $pageScripts.RelPermalink }}"></script>
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
@@ -154,8 +156,9 @@
{{ $human := resources.Get "js/modules/human.js" }}
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
{{ $reward := resources.Get "js/modules/reward.js" }}
{{ $autofill := resources.Get "js/modules/comment-autofill.js" }}
{{ $pageScripts := slice $friendlink $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
{{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
{{ end }}