Files
blog/themes/Ying/layouts/page/comment.html
T
zqlit bff48517c0
Deploy to Production / pre-check (push) Successful in 59s
Deploy to Production / build (push) Successful in 4m42s
Deploy to Production / deploy-edgeone (push) Successful in 4m0s
Deploy to Production / finalize (push) Successful in 31s
Deploy to Production / notify-failure (push) Skipped
评论验证码取不到时降级为可重试提示(不留破图)
2026-10-03 20:37:46 +08:00

395 lines
19 KiB
HTML
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.
{{ define "main" }}
<div id="pjax-container" uk-scrollspy="target: > #szh,hr,h2,p; cls: uk-animation-slide-bottom-small; delay: 0">
<!-- 用于 Pjax 获取当前文章 ID -->
<div id="data-pid" style="display:none;">{{ .RelPermalink }}</div>
<div class="post-content" itemprop="articleBody" uk-scrollspy="target: > #szh; cls: uk-animation-slide-bottom-small; delay: 0">
{{ .Content }}
<div id="szh" onclick="copySzhContent()" style="cursor: pointer;" title="点击复制">
- 名称:{{ .Site.Params.message_info.name | default .Site.Params.artalk.site }}<br>
- 简介:{{ .Site.Params.message_info.description | default .Site.Params.author.description }}<br>
- 链接:{{ .Site.Params.message_info.url | default .Site.BaseURL }}<br>
- 图标:{{ .Site.Params.message_info.icon | default .Site.Params.author.avatar }}
</div>
</div>
<hr class="uk-divider-icon">
<div class="comments-area">
<!-- 评论区内容 -->
{{ partial "artalk.html" . }}
<!-- 评论区内容结束 -->
</div>
</div>
<script>
/* 友链自助申请:弹窗动态挂到 document.body(评论容器带 transform 动画,
会把 position:fixed 劫持成相对定位,导致遮罩/表单跟着容器变形——
挂 body 彻底规避),遮罩用 backdrop-filter 毛玻璃(GPU 合成,不卡页面)。
显隐用 class 而非 hidden 属性(内联 display 会覆盖 hidden 导致关不掉);
盒子必须 box-sizing:border-box(否则 width:100% + padding 溢出出横向滚动条)。 */
(function () {
var BASE = '{{ $rssBase := site.Params.rssapi.base | default "https://api.200181.xyz" }}{{ $rssBase }}';
function buildModal() {
if (document.getElementById('flApplyMask')) return document.getElementById('flApplyMask');
var style = document.createElement('style');
style.textContent =
'#flApplyMask{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;padding:16px;' +
'background:rgba(15,23,42,.35);-webkit-backdrop-filter:blur(10px) saturate(1.3);backdrop-filter:blur(10px) saturate(1.3)}' +
'#flApplyMask.on{display:flex}' +
'#flApplyBox{background:var(--bg-card,#fff);color:var(--text-main,#253244);border-radius:12px;max-width:420px;width:100%;' +
'box-sizing:border-box;padding:22px;box-shadow:0 16px 48px rgba(15,23,42,.25);max-height:88vh;overflow:auto}' +
'[data-theme="dark"] #flApplyBox{box-shadow:0 16px 48px rgba(0,0,0,.55)}' +
'#flApplyBox .fl-field{display:block;margin-bottom:10px}' +
'#flApplyBox .fl-label{display:block;font-size:12px;color:var(--text-muted,#8a94a6);margin-bottom:4px}' +
'#flApplyBox input,#flApplyBox textarea{width:100%;box-sizing:border-box;padding:8px 10px;' +
'border:1px solid var(--border-main,#e7e9ee);border-radius:6px;font-size:14px;' +
'background:var(--bg-input,#fff);color:inherit;font-family:inherit}' +
'#flApplyBox input:focus,#flApplyBox textarea:focus{outline:none;border-color:var(--theme-main,#07c160);' +
'box-shadow:0 0 0 3px var(--theme-main-20,rgba(7,193,96,.2))}' +
/* 主题 main.css 有一条全局 button 归一化规则(margin-left:23%; margin-top:10px;
border:1px solid; background:#222),会把提交按钮推右溢出弹窗 → 这里用 id 选择器压过它 */
'#flSubmit{display:block;width:100%;margin:0;background:var(--theme-main,#07c160);border:none;color:#fff;' +
'padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:filter .2s}' +
'#flSubmit:hover{filter:brightness(1.06)}' +
'#flSubmit:disabled{opacity:.6;cursor:default}' +
/* 弹窗滚动条:细一点、跟随主题色,别用系统那根粗滚动条 */
'#flApplyBox{overflow-x:hidden;scrollbar-width:thin;scrollbar-color:rgba(140,150,160,.45) transparent}' +
'#flApplyBox::-webkit-scrollbar{width:6px;height:6px}' +
'#flApplyBox::-webkit-scrollbar-track{background:transparent}' +
'#flApplyBox::-webkit-scrollbar-thumb{background:rgba(140,150,160,.45);border-radius:3px}' +
'[data-theme="dark"] #flApplyBox{scrollbar-color:rgba(200,205,212,.35) transparent}' +
'[data-theme="dark"] #flApplyBox::-webkit-scrollbar-thumb{background:rgba(200,205,212,.35)}';
document.head.appendChild(style);
mask = document.createElement('div');
mask.id = 'flApplyMask';
mask.innerHTML =
'<div id="flApplyBox">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">' +
'<b style="font-size:16px;display:flex;align-items:center;gap:8px">' +
'<span style="width:4px;height:16px;border-radius:2px;background:var(--theme-main,#07c160);display:inline-block"></span>友链自助申请</b>' +
'<span id="flApplyClose" style="cursor:pointer;color:var(--text-muted,#8a94a6);font-size:22px;line-height:1" title="关闭">×</span>' +
'</div>' +
'<label class="fl-field"><span class="fl-label">站点名称 *</span>' +
'<input id="flName" placeholder="我的小站"></label>' +
'<label class="fl-field"><span class="fl-label">站点链接 *</span>' +
'<input id="flUrl" placeholder="https://example.com"></label>' +
'<label class="fl-field"><span class="fl-label">图标链接(选填,留空自动为你抓取站点图标)</span>' +
'<input id="flIcon" placeholder="https://example.com/logo.png"></label>' +
'<label class="fl-field"><span class="fl-label">订阅地址(RSS/Atom)*</span>' +
'<input id="flFeed" placeholder="https://example.com/feed"></label>' +
'<label class="fl-field"><span class="fl-label">邮箱 *(接收审核结果)</span>' +
'<input id="flEmail" type="email" placeholder="you@example.com"></label>' +
'<label class="fl-field" style="margin-bottom:14px"><span class="fl-label">站点简介(选填)</span>' +
'<textarea id="flDesc" rows="2" style="resize:vertical"></textarea></label>' +
'<div id="flMsg" hidden style="font-size:13px;margin-bottom:10px"></div>' +
'<button id="flSubmit">提交申请</button>' +
'</div>';
document.body.appendChild(mask);
mask.addEventListener('click', function (e) { if (e.target === mask) close(); });
document.getElementById('flApplyClose').addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
document.getElementById('flSubmit').addEventListener('click', function () {
var btn = this, msg = document.getElementById('flMsg');
var payload = {
name: document.getElementById('flName').value.trim(),
url: document.getElementById('flUrl').value.trim(),
image: document.getElementById('flIcon').value.trim(),
feed: document.getElementById('flFeed').value.trim(),
email: document.getElementById('flEmail').value.trim(),
description: document.getElementById('flDesc').value.trim()
};
if (!payload.name || !payload.url || !payload.feed || !payload.email) {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '请填写全部必填项'; return;
}
btn.disabled = true; btn.textContent = '提交中…';
msg.hidden = true;
fetch(BASE + '/api/link-apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json().then(function (d) { return { code: r.status, d: d }; }); })
.then(function (res) {
if (res.code === 200 && res.d.ok) {
msg.hidden = false; msg.style.color = 'var(--theme-main,#07c160)'; msg.textContent = res.d.message || '申请已提交';
btn.textContent = '已提交 ✓';
setTimeout(close, 2200);
} else {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = res.d.error || '提交失败,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
}
})
.catch(function () {
msg.hidden = false; msg.style.color = '#d75a4a'; msg.textContent = '网络异常,请稍后再试';
btn.disabled = false; btn.textContent = '提交申请';
});
});
return mask;
}
function close() {
var m = document.getElementById('flApplyMask');
if (m) m.classList.remove('on');
}
/* 供置顶评论卡片调用(artalk.js 的 comment-rendered 里触发) */
window.flApplyOpenModal = function () {
buildModal().classList.add('on');
};
})();
</script>
<script>
/* 人机验证(自研「一键验证」)—— 服务端见 artalk-cf/src/lib/human.ts
读者体验:默认什么都不用做(浏览器静默算一个 PoW,约 0.1~2 秒,后台完成);
信号不足时才出现「点一下验证」小方块;再可疑才弹图形验证码。
通行证按 IP 记在服务端(KV,30 分钟),前端不传任何 token,所以不需要改 Artalk 客户端。 */
(function () {
var BASE = '{{ $rssBase := site.Params.rssapi.base | default "https://api.200181.xyz" }}{{ $rssBase }}';
var API = BASE + '/api/v2';
var DIFFICULTY = 4;
var PREFIX = new Array(DIFFICULTY + 1).join('0');
var started = Date.now();
var events = 0, honey = '', hasPass = false, busy = false, widget = null, tries = 0;
// ── 行为信号:人类随手动一下就会有,脚本环境常常一个都没有
['mousemove', 'pointerdown', 'keydown', 'keyup', 'input', 'focusin', 'scroll', 'wheel',
'touchstart', 'click'].forEach(function (ev) {
document.addEventListener(ev, function () { events++; }, { passive: true });
});
function injectCss() {
if (document.getElementById('atk-human-css')) return;
var st = document.createElement('style');
st.id = 'atk-human-css';
st.textContent =
'.atk-human-box{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:8px 12px;' +
'border:1px solid var(--border-main,#eee);border-radius:8px;background:var(--bg-card,#fff);' +
'font-size:13px;color:var(--text-main,#333);transition:border-color .2s,background .2s}' +
'.atk-human-box.is-click{cursor:pointer}' +
'.atk-human-box.is-click:hover{border-color:var(--theme-main,#07c160)}' +
'.atk-human-box.is-busy{opacity:.7;cursor:default}' +
'.atk-human-box-ic{flex:none;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;' +
'justify-content:center;font-size:12px;color:#fff;background:var(--theme-main,#07c160)}' +
'.atk-human-box.is-captcha .atk-human-box-ic{background:#d9534f}' +
'.atk-human-box-tx{flex:1;min-width:0}' +
'.atk-human-img{flex:none;height:34px;width:102px;border-radius:4px;cursor:pointer}' +
'.atk-human-inp{flex:none;width:78px;padding:4px 6px;border:1px solid var(--border-main,#ddd);' +
'border-radius:4px;font-size:13px;box-sizing:border-box;outline:none;background:transparent;color:inherit}' +
'.atk-human-btn{flex:none;padding:4px 10px;border:none;border-radius:4px;cursor:pointer;' +
'background:var(--theme-main,#07c160);color:#fff;font-size:12px}' +
'[data-theme="dark"] .atk-human-box{border-color:rgba(255,255,255,.14)}';
document.head.appendChild(st);
}
function hex(buf) {
var b = new Uint8Array(buf), out = '';
for (var i = 0; i < b.length; i++) out += ('0' + b[i].toString(16)).slice(-2);
return out;
}
function sha256(str) {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)).then(hex);
}
/* 找 nonce 让 sha256(challenge+nonce) 以 N 个 0 开头。
crypto.subtle 是异步的,逐次 await 的 promise 开销远大于哈希本身:
所以按 CHUNK 串一条链、每 CHUNK*YIELD_EVERY 次让出一次主线程,
页面保持流畅,总耗时压到 1 秒上下(全程后台,读者无感)。 */
var CHUNK = 512, YIELD_EVERY = 8;
function solve(challenge) {
var i = 0;
function step() {
var chain = Promise.resolve(null);
var found0 = null;
var limit = i + CHUNK * YIELD_EVERY;
while (i < limit && i < 3000000) {
(function (n) {
chain = chain.then(function () {
if (found0 !== null) return found0; // 已找到 → 后面的直接短路
return sha256(challenge + n).then(function (h) {
if (h.slice(0, DIFFICULTY) === PREFIX) { found0 = n; return n; }
return null;
});
});
})(i++);
}
return chain.then(function (found) {
if (found !== null) return found;
if (i >= 4000000) return null;
return new Promise(function (r) { setTimeout(r, 0); }).then(step);
});
}
return step();
}
function api(path, body) {
return fetch(API + path, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(function (r) { return r.json().catch(function () { return {}; }); });
}
// ── 控件
function editorEl() {
return document.querySelector('.atk-main-editor') || document.querySelector('.atk-comment-wrap');
}
function ensureWidget() {
if (widget && widget.isConnected) return widget;
var ed = editorEl();
if (!ed || !ed.parentNode) return null;
widget = document.createElement('div');
widget.className = 'atk-human-box';
ed.parentNode.insertBefore(widget, ed);
injectCss();
return widget;
}
function dropWidget() {
if (widget && widget.parentNode) widget.parentNode.removeChild(widget);
widget = null;
}
function ensureHoneypot() {
if (document.getElementById('atk-human-hp')) return;
var ed = editorEl();
if (!ed) return;
var i = document.createElement('input');
i.id = 'atk-human-hp'; i.type = 'text'; i.tabIndex = -1; i.autocomplete = 'off';
i.setAttribute('aria-hidden', 'true');
i.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0';
i.addEventListener('input', function () { honey = i.value; });
ed.appendChild(i);
}
function done() {
var w = ensureWidget();
if (!w) { hasPass = true; return; }
w.className = 'atk-human-box';
w.onclick = null;
w.innerHTML = '<span class="atk-human-box-ic">✓</span><span class="atk-human-box-tx">已通过人机验证</span>';
setTimeout(dropWidget, 1600);
}
function showCaptcha() {
var w = ensureWidget();
if (!w) { if (tries++ < 20) setTimeout(showCaptcha, 800); return; }
w.className = 'atk-human-box is-captcha';
w.onclick = null;
w.innerHTML = '<span class="atk-human-box-ic">!</span>' +
'<span class="atk-human-box-tx">请输入图中的字符(点图片可换一张)</span>' +
'<img class="atk-human-img" alt="验证码"><input class="atk-human-inp" maxlength="6" autocomplete="off">' +
'<button class="atk-human-btn" type="button">确认</button>';
var img = w.querySelector('.atk-human-img');
var inp = w.querySelector('.atk-human-inp');
function refresh() {
img.style.display = '';
api('/captcha').then(function (c) {
if (c && c.img_data) { img.src = c.img_data; }
else { fail('验证码暂时取不到'); }
}).catch(function () { fail('验证码暂时取不到'); });
}
// 图形验证码取不到(接口抖动/数据库不可用)时给个可重试的提示,别留一张破图
function fail(msg) {
img.style.display = 'none';
var t = w.querySelector('.atk-human-box-tx');
if (t) { t.innerHTML = msg + ',<b style="cursor:pointer;text-decoration:underline">点这里重试</b>'; }
}
w.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'B') refresh();
});
refresh();
img.onclick = refresh;
function submit() {
var v = (inp.value || '').trim();
if (!v) return;
api('/captcha/verify', { value: v }).then(function (r) {
if (r && r.msg === 'Success') { hasPass = true; done(); }
else { inp.value = ''; refresh(); }
});
}
w.querySelector('.atk-human-btn').onclick = submit;
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
inp.focus();
}
function showClick() {
var w = ensureWidget();
if (!w) { if (tries++ < 20) setTimeout(showClick, 800); return; }
w.className = 'atk-human-box is-click';
w.innerHTML = '<span class="atk-human-box-ic">✓</span>' +
'<span class="atk-human-box-tx">点一下完成验证,然后就能发评论了</span>';
w.onclick = function () {
if (busy) return;
w.className = 'atk-human-box is-busy';
w.querySelector('.atk-human-box-tx').textContent = '验证中…';
run(true);
};
}
// ── 主流程
// 证明只算一次并缓存:点「点一下验证」复用同一个 nonce 重发(补上 clicked 信号),
// 所以点击是**瞬间完成**的,不会让访客再等一次算力。
var proof = null;
function ensureProof() {
if (proof && proof.exp > Date.now() + 5000) return Promise.resolve(proof);
return api('/human/challenge').then(function (ch) {
if (!ch || ch.enabled === false || ch.pass) return null;
return solve(ch.challenge).then(function (nonce) {
if (nonce === null) return null;
proof = { challenge: ch.challenge, nonce: nonce, exp: ch.exp, sig: ch.sig };
return proof;
});
});
}
function run(clicked) {
if (busy) return Promise.resolve();
if (hasPass) return Promise.resolve();
busy = true;
return api('/human/status').then(function (st) {
if (!st || st.enabled === false || st.pass) { hasPass = true; dropWidget(); return null; }
if (!window.crypto || !crypto.subtle) { showCaptcha(); return null; }
return ensureProof().then(function (pr) {
if (!pr) { showCaptcha(); return null; }
return api('/human/verify', {
challenge: pr.challenge, nonce: pr.nonce, exp: pr.exp, sig: pr.sig,
elapsedMs: Date.now() - started, events: events,
webdriver: navigator.webdriver === true, honeypot: honey, clicked: !!clicked,
}).then(function (res) {
if (res && res.pass) { hasPass = true; done(); return; }
if (res && res.need_captcha) { showCaptcha(); return; }
showClick();
});
});
}).catch(function () { /* 网络异常就不打扰读者,服务端仍有图形验证码兜底 */ })
.then(function () { busy = false; });
}
function init() {
if (!document.querySelector('.comments-area')) return; // 非评论页不打扰
started = Date.now(); events = 0; hasPass = false; busy = false; tries = 0; proof = null;
dropWidget();
ensureHoneypot();
run(false);
// 编辑器获得焦点时再确认一次(通行证 30 分钟,长了就续)
setTimeout(function () {
var ed = editorEl();
if (ed) ed.addEventListener('focusin', function () { if (!hasPass) run(false); });
}, 1500);
}
/* 调试/自测钩子(线上留着也无害,方便需要时手动触发两种控件看一眼) */
window.__humanCheck = {
state: function () { return { hasPass: hasPass, busy: busy, events: events, honey: honey }; },
run: run, showClick: showClick, showCaptcha: showCaptcha, drop: dropWidget,
};
document.addEventListener('pjax:complete', function () { setTimeout(init, 300); });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script>
{{ end }}