Files
tamakyi 03cb5ddcf0 fix(ui): 提交防重复覆盖验证码路径、waline 按页加载、懒加载、safe-area、硬编码文案接字典
1) 防重复点击原来漏了最主要的一条路径
   footer 的全局 submit 监听会禁用按钮,但**程序化 form.submit() 不触发 submit 事件** ——
   走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,而默认配置
   captcha_type = recaptcha,也就是主提问流一直没有防重复。登录、注册、找回密码、
   投票、注销账号同样走这个回调。
   → 把「切提交中状态」抽成 tbBeginSubmit(form, trigger) 挂到 window,
     验证码回调先调它再 submit。7 个 data-callback="onSubmit" 的模板全部受益。
     顺带加了 data-tb-restore 的重复覆盖保护,避免 bfcache 复位后按钮卡在转圈。

2) waline.js / waline.css 从全站改按页加载
   只有 home.html 与 item.html 用评论区,但此前挂在 header 里 —— 登录、后台、投票、
   私聊每页都下载解析 waline.js(2.15 未压缩约 200KB)。实测 /login 已无引用。

3) 图片懒加载:explore 列表头像与题目配图加 loading="lazy"(首屏推荐位不加,避免影响 LCP)。

4) 移动端底栏:body 只预留固定 56px,而底栏 padding 含 safe-area-inset-bottom。
   带 Home Indicator 的 iPhone 上 inset 约 34px、底栏实际约 88px,最后约 32px
   (页脚备案、存活计时)被压在栏下,滚到底也看不到。改成 calc(56px + env(...))。

5) 硬编码中文接字典(en/ja 用户此前直接看到中文):
   history-template 的「查看回答 / 加载更多 / 加载中... / 无更多留言」、
   单题页「@xxx 以前回答过的问题」(字典里本来就有 ask.history)、
   投票页「(最多 N 项)」改为带参数的整句(原先前半走字典、后半手写中文)。
   新增 4 个 key × 4 语言。

实测:箱主语言设为 en 后箱主页显示 View answer ×2 / Load more / No more posts,中文零残留。
2026-10-29 16:28:53 +08:00

569 lines
27 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="{{.LangCode}}"{{ if .ThemeSlug }} data-theme-style="{{ .ThemeSlug }}"{{ end }}{{ if .PageScope }} data-page-scope="{{ .PageScope }}"{{ end }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
<meta name="description" content="{{ if .MetaDescription }}{{ .MetaDescription }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}">
<meta name="keywords" content="{{ t .LangCode "site.keywords" }}">
<!-- 主题初始化:必须在任何样式生效前执行,否则深色模式用户会先渲染出一帧亮色(顶栏白闪)。
只读 cookie 并打属性,切换逻辑仍在页面底部(#theme-toggle / #theme-toggle-mobile)。 -->
<script>
(function () {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
if (m && m[1] === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
<!-- 预连接:UIkit 走公共 CDN(cdnjs.snrat.com),其余自托管库仍在本站 R2,均需预连接 -->
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
<!-- UIkit 3.3.3 切到公共 CDN(cdnjs.snrat.com,国内实测全量下载 ~0.25s,原 R2 自建 ~1.4-2.0s) -->
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
<!-- 全站基础样式(由原内联块抽出,可被浏览器缓存):改样式后把 conf.StaticVersion +1 -->
<link rel="stylesheet" href="/static/css/base.css?v={{ .StaticVersion }}"/>
{{ if .ThemeSlug }}<!-- 箱主主题:只覆盖配色/图案/页面背景,不涉及布局;仅提问箱相关页面加载 -->
<link rel="stylesheet" href="/static/css/themes/{{ .ThemeSlug }}.css?v={{ .StaticVersion }}"/>{{ end }}
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
<!-- dayjs/alpinejs/vconsole 已迁 cdnjs.snrat.com(sha256 与官方一致,国内实测快 2-7x) -->
<script defer src="https://cdnjs.snrat.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
{{ if .VConsole }}
<script src="https://cdnjs.snrat.com/ajax/libs/vConsole/3.12.1/vconsole.min.js" type="application/javascript"></script>
<script>new window.VConsole()</script>
{{ end }}
<!-- jQuery 已改为按需加载:仅 easy.qrcode 二维码页(list/question-not-found/profile)自行引入 -->
{{ if eq .CaptchaType "math" }}
<!-- 算数验证码:弹窗模式,不影响原布局 -->
<style>
.captcha-equation-box {
display: inline-flex;
align-items: center;
background: #f0f2f5;
border: 1px solid #dcdcdc;
border-radius: var(--tb-radius-sm);
padding: 8px 8px 8px 16px;
gap: 6px;
transition: border-color 0.2s;
}
.captcha-equation-box:focus-within {
border-color: var(--tb-primary);
box-shadow: 0 0 0 3px var(--tb-primary-weak);
}
.math-captcha-modal-question {
font-size: 1.4rem;
font-family: "Courier New", monospace;
font-weight: 700;
color: #333;
letter-spacing: 1px;
white-space: nowrap;
}
#captcha-modal-answer {
width: 80px;
padding: 6px 10px;
text-align: center;
font-size: 1.2rem;
font-family: "Courier New", monospace;
font-weight: 600;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
background: #fff;
}
#captcha-modal-answer:focus {
border-color: var(--tb-primary);
box-shadow: 0 0 0 2px var(--tb-primary-weak);
}
/* 隐藏数字输入框的上下箭头 */
#captcha-modal-answer::-webkit-inner-spin-button,
#captcha-modal-answer::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
#captcha-modal-answer {
-moz-appearance: textfield;
}
</style>
{{ else if .RecaptchaTurnstileStyle }}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha" async defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.g-recaptcha').forEach(item => {
item.disabled = true;
});
});
function onSubmit() {
document.querySelectorAll('.g-recaptcha').forEach(item => {
item.disabled = false;
})
}</script>
{{ else }}
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script>
<script>
// reCAPTCHA v3 回调:拿到 token 后手动提交表单。
//
// 程序化 submit() **不触发 submit 事件**,所以 footer 里那个全局监听器
// (禁用按钮 + 转圈)对它完全无效 —— 而默认配置 captcha_type = recaptcha 时,
// 这就是主提问流的提交路径,等于一直没有防重复点击。
// 这里先显式切到「提交中」再 submit。tbBeginSubmit 定义在 footer.html,
// 脚本顺序在本文件之后;回调发生在用户点击之后,那时它已就绪,仍做一次判空兜底。
function onSubmit() {
var f = document.getElementById('form');
if (!f) return;
if (window.tbBeginSubmit) { window.tbBeginSubmit(f, null); }
f.submit();
}
</script>
{{ end }}
{{ if ne SentryDSN "" }}
<script
src="https://browser.sentry-cdn.com/7.37.2/bundle.tracing.replay.min.js"
integrity="sha384-o0mU2P+zsvXua0Y6f/J55PKWZt3sZCv3qDqJe6J5lZklyIQkpgjBVqNnPWYWqGhf"
crossorigin="anonymous"
></script>
<script>
Sentry.init({
dsn: "{{ SentryDSN }}",
integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
</script>
{{ end }}
<!-- Commit 6d0a3ad -->
<meta property="og:site_name" content="{{ t .LangCode "site.title" }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.title" }}{{ else }}{{ t .LangCode "site.title" }}{{ end }}" />
<meta property="og:url" content="{{ .ExternalURL }}{{ .CurrentURI }}" />
<meta property="og:description" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.description" }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}" />
{{ if .NoIndex }}<meta name="robots" content="noindex">{{ end }}
{{ if .IsUserPage }}
<meta property="og:image" content="{{ .PageUser.Avatar }}?og-image=1" />
<meta property="og:image:alt" content="{{ .PageUser.Name }}" />
<meta itemprop="image" content="{{ .PageUser.Avatar }}?og-image=1"/>
<meta name="msapplication-TileImage" content="{{ .PageUser.Avatar }}"/>
<link rel="shortcut icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
<link rel="apple-touch-icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
<link rel="icon" sizes="192x192" href="{{ .PageUser.Avatar }}?og-image=1">
<meta name="twitter:image:src" content="{{ .PageUser.Avatar }}?og-image=1" />
{{ else }}
<meta itemprop="image" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<meta name="msapplication-TileImage" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<link rel="shortcut icon" href="https://r2.337845818.best/tamabox/tamabox_low.png" />
<link rel="apple-touch-icon" href="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<link rel="icon" sizes="192x192" href="https://r2.337845818.best/tamabox/tamabox_low.png">
<meta name="twitter:image:src" content="https://r2.337845818.best/tamabox/tamabox_low.png" />
{{ end }}
<!-- Google and Bing 站点验证:token 与域名绑定,
域名迁移(box.tama.guru → box.shiroko.one)后须用新域重新验证并替换下面的 content。
msvalidate.01 只在 head 顶部保留一份,这里不再重复。 -->
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
<!-- 站点统计(umami):非关键资源,window load 后再动态注入,避免 mami.shiroko.one
(Vercel 托管、无国内节点)在高峰/冷访问时阻塞 DOMContentLoaded 与 load -->
<script>
window.addEventListener("load", function () {
var s = document.createElement("script");
s.src = "https://mami.shiroko.one/tamami.js";
s.setAttribute("data-website-id", "c42ebe8b-9281-4da0-83a4-8f18b3e710fe");
document.body.appendChild(s);
});
</script>
</head>
<body>
<script>
// 二维码降级渲染(P0-②):easy.qrcode 的 logo(用户头像)/ 背景图加载失败或极慢时,
// 库内部仅 console.error 且无降级 → 二维码区域会永久空白。
// 策略:未知状态的远程图先不画(基础码立即可扫)→ 后台探针(Image 预载)→
// 判定完成后再重绘带 logo/背景的升级版;失败则保持基础码,绝不空白。
// 同会话记忆:探针结果按 URL 记忆(good/bad),编辑页拖动滑块高频重绘时零等待、不闪降级。
// 使用:window.tbRenderQR(el, function(){ return options; }) // options 含完整 logo/backgroundImage
window.tbRenderQR = function (el, getOptions) {
if (!el || typeof QRCode === 'undefined') return;
var opts = getOptions();
function remoteOf(o, key) {
var v = o[key];
return (typeof v === 'string' && v.indexOf('http') === 0) ? v : '';
}
var good = window._tbQrGood || (window._tbQrGood = {}); // URL -> true(可 CORS 加载)
var bad = window._tbQrBad || (window._tbQrBad = {}); // URL -> true(加载失败)
// 每层状态:''=无需, 1=已知可用, 0=已知失败, null=未知需探针
function layerState(url) { return !url ? '' : (good[url] ? 1 : (bad[url] ? 0 : null)); }
var layers = [
{ url: remoteOf(opts, 'logo'), st: null, present: function (o, on) {
if (on) { /* 保留 */ } else { o.logo = undefined; o.logoWidth = undefined; o.logoHeight = undefined; o.logoBackgroundTransparent = undefined; }
} },
{ url: remoteOf(opts, 'backgroundImage'), st: null, present: function (o, on) {
if (on) { /* 保留 */ } else { o.backgroundImage = undefined; o.backgroundImageAlpha = undefined; }
} }
];
var needProbe = false;
for (var i = 0; i < layers.length; i++) {
layers[i].st = layerState(layers[i].url);
if (layers[i].st === null) needProbe = true;
}
function paint(states) {
var o = {};
for (var k in opts) o[k] = opts[k];
for (var j = 0; j < layers.length; j++) layers[j].present(o, states[j] === 1);
el.innerHTML = '';
new QRCode(el, o);
}
// 已知可用层立即带上;未知层先省略 → 立即绘制(logo 记忆命中时不会闪降级)
var initStates = [];
for (var n = 0; n < layers.length; n++) initStates.push(layers[n].st === 1 ? 1 : 0);
paint(initStates);
if (!needProbe) return;
// 后台探针未知层;全部判定后若有变化再重绘一次升级版
var pending = 0, cross = opts.crossOrigin || 'anonymous';
layers.forEach(function (L) {
if (L.st !== null) return;
pending++;
var im = new Image();
im.onload = function () { good[L.url] = true; L.st = 1; onDone(); };
im.onerror = function () { bad[L.url] = true; L.st = 0; onDone(); };
if (cross) im.crossOrigin = cross;
im.src = L.url;
});
function onDone() {
if (--pending > 0) return;
var finalStates = [];
for (var m = 0; m < layers.length; m++) finalStates.push(layers[m].st === 1 ? 1 : 0);
var changed = false;
for (var q = 0; q < finalStates.length; q++) if (finalStates[q] !== initStates[q]) changed = true;
if (changed) paint(finalStates);
}
// 超时保险:个别挂起连接永不回调时,3s 后按当前已知状态收尾(基础码早已可扫)
setTimeout(function () { if (pending > 0) { pending = 0; onDone(); } }, 3000);
};
</script>
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
<div class="uk-navbar-center">
<div class="uk-navbar-center-left">
<div>
<ul class="uk-navbar-nav">
<!-- 探索链接,始终显示 -->
<li><a href="/explore">{{t .LangCode "nav.explore"}}</a></li>
{{ if .IsLogged }}
<!-- 我的:进入自己的提问箱主页(与右侧「提问/设置」形成左右均衡) -->
<li><a href="/_/{{ .LoggedUser.Domain }}">{{t .LangCode "nav.mine"}}</a></li>
{{ else }}
<!-- 移动端占位:未登录时左栏仅「探索」一项,与右栏「注册/登录」两项不等宽,
导致 logo 整体被拉向左(偏左)。移动端(≤640px)显示一个与「我的」等宽的隐藏占位,
使左右视觉平衡;桌面端由 .tb-mobile-balancer 隐藏,保持原布局完全不变。 -->
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">{{t .LangCode "nav.mine"}}</a></li>
{{ end }}
</ul>
</div>
</div>
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>{{t .LangCode "site.logoSuffix"}}</a>
<div class="uk-navbar-center-right">
<div>
{{ if .IsLogged }}
<ul class="uk-navbar-nav">
<li><a href="/user/questions">{{t .LangCode "nav.ask"}}</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/user/profile">{{t .LangCode "nav.settings"}}</a></li>
</ul>
{{ else}}
<ul class="uk-navbar-nav">
<li><a href="/register">{{t .LangCode "nav.register"}}</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/login">{{t .LangCode "nav.login"}}</a></li>
</ul>
{{ end }}
{{ if not .IsUserPage }}
<!-- 语言切换(紧凑图标,点击展开;移动端隐藏,见底部操作栏) -->
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" aria-label="{{t .LangCode "common.lang"}}" title="{{t .LangCode "common.lang"}}"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M3.2 9h17.6M3.2 15h17.6"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg></a>
<div class="uk-navbar-dropdown" uk-drop="mode: click; pos: bottom-right">
<ul class="uk-nav uk-navbar-dropdown-nav">
{{ range .SupportedLangs }}
<li class="{{ if eq .Code $.LangCode }}uk-active{{ end }}">
<a href="/i18n/{{ .Code }}?to={{ urlquery $.CurrentURI }}">{{ .Name }}</a>
</li>
{{ end }}
</ul>
</div>
</li>
</ul>
{{ end }}
<!-- 深色模式切换(紧凑图标;移动端隐藏,见底部操作栏) -->
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" id="theme-toggle" title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
<script>
document.addEventListener("DOMContentLoaded", function() {
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦‍⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕‍🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
const placeholder = document.querySelector('.emoji-placeholder');
if (placeholder) {
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
}
});
</script>
<!-- 移动端固定底部操作栏:语言切换 + 深色模式(桌面端隐藏) -->
<div id="tb-mobile-utils">
{{ if not .IsUserPage }}
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="{{t .LangCode "common.lang"}}">
{{ range .SupportedLangs }}
<option value="{{ .Code }}" {{ if eq .Code $.LangCode }}selected{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
{{ end }}
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</button>
</div>
<script>
(function() {
// 深色模式:读取 cookie 应用主题,按钮切换并持久化
var THEME_COOKIE = 'theme';
function getTheme() {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
return m ? m[1] : '';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function toggleTheme() {
var next = getTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
}
// 首屏主题已由 <head> 顶部的内联脚本打过属性(避免白闪),这里只负责点击切换。
document.addEventListener('DOMContentLoaded', function() {
// 桌面顶栏按钮与移动端底部按钮都绑定
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
buttons.forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
toggleTheme();
});
});
// 移动端底部语言切换:onchange 跳转到 /i18n/{code},保留当前页面位置
var langSel = document.getElementById('mobile-lang-select');
if (langSel) {
langSel.addEventListener('change', function() {
if (!this.value) return;
var to = window.location.pathname + window.location.search;
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
});
}
});
})();
</script>
{{ with announcement .LangCode }}
<!-- 站内公告:内容与展示时间窗来自 conf/app.ini 的 [announcement] 段。
由服务端按当前时间判定,窗口外整块不渲染,不留空白占位。 -->
<div class="uk-container uk-container-xsmall tb-announce-wrap">
<div class="tb-announce" role="status">
<span class="tb-announce-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 11v2a1 1 0 0 0 1 1h2l5 4V6L6 10H4a1 1 0 0 0-1 1z"/><path d="M16 8.5a5 5 0 0 1 0 7"/><path d="M19 6a9 9 0 0 1 0 12"/></svg></span>
<div class="tb-announce-body">{{ . }}</div>
</div>
</div>
{{ end }}
{{ if .IsPixel }}
<div>
{{ else }}
<div class="uk-container uk-container-xsmall">
{{ end }}
{{ if eq .CaptchaType "math" }}
<!-- 算数验证码弹窗 --><div id="math-captcha-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body uk-text-center">
<h3 class="uk-modal-title">{{t .LangCode "captcha.title"}}</h3>
<p class="uk-text-meta">{{t .LangCode "captcha.hint"}}</p>
<div class="captcha-equation-box">
<span id="captcha-modal-question" class="math-captcha-modal-question">{{t .LangCode "captcha.loading"}}</span>
<input type="number" id="captcha-modal-answer" placeholder="?" autocomplete="off" inputmode="numeric">
</div>
<div style="margin-top:6px;">
<button type="button" class="uk-button uk-button-text uk-text-small" id="captcha-modal-refresh">{{t .LangCode "captcha.refresh"}}</button>
</div>
<div id="captcha-modal-error" class="uk-text-danger uk-text-small" style="display:none;min-height:20px;"></div>
<div class="uk-margin-small-top">
<button type="button" class="uk-button uk-button-primary" id="captcha-modal-submit">{{t .LangCode "captcha.submit"}}</button>
<button type="button" class="uk-button uk-button-default uk-modal-close">{{t .LangCode "common.cancel"}}</button>
</div>
</div>
</div>
<script>
var CAPTCHA_I18N = {
loading: "{{t .LangCode "captcha.loading"}}",
loadFailed: "{{t .LangCode "captcha.loadFailed"}}",
loadRetry: "{{t .LangCode "captcha.loadRetry"}}",
inputAnswer: "{{t .LangCode "captcha.inputAnswer"}}",
wrongAnswer: "{{t .LangCode "captcha.wrongAnswer"}}",
verifyFailed: "{{t .LangCode "captcha.verifyFailed"}}",
needPassword: "{{t .LangCode "ask.needPassword"}}",
needEmail: "{{t .LangCode "ask.pleaseEmail"}}"
};
</script>
<script>
(function() {
var captchaId = '';
var pendingForm = null;
var pendingNeedPassword = false;
var verifying = false;
function fetchCaptcha() {
var qEl = document.getElementById('captcha-modal-question');
var errEl = document.getElementById('captcha-modal-error');
var ansEl = document.getElementById('captcha-modal-answer');
qEl.textContent = CAPTCHA_I18N.loading;
fetch('/api/captcha/generate')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.code === 0 && data.data) {
captchaId = data.data.id;
qEl.textContent = data.data.question;
if (errEl) errEl.style.display = 'none';
if (ansEl) { ansEl.value = ''; ansEl.focus(); }
} else {
qEl.textContent = CAPTCHA_I18N.loadFailed;
}
})
.catch(function() {
qEl.textContent = CAPTCHA_I18N.loadRetry;
});
}
function getCSRFToken() {
var input = document.querySelector('input[name="_csrf"]');
return input ? input.value : '';
}
function doSubmit(form) {
// 将 captcha 答案填入隐藏字段
var ans = document.getElementById('captcha-modal-answer').value.trim();
var hidden = form.querySelector('[name="g-recaptcha-response"]');
if (hidden) hidden.value = captchaId + ':' + (ans || '0');
UIkit.modal('#math-captcha-modal').hide();
// 处理 AskPassword(提问箱密码)
if (pendingNeedPassword) {
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
.then(function(password) {
if (password) {
var pwInput = document.getElementById('ask_password_input');
if (pwInput) pwInput.value = password;
form.submit();
}
});
} else {
form.submit();
}
}
// 验证按钮
document.getElementById('captcha-modal-submit').addEventListener('click', function() {
if (verifying) return;
var ans = document.getElementById('captcha-modal-answer').value.trim();
if (!ans) {
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.inputAnswer;
errEl.style.display = 'block';
return;
}
verifying = true;
fetch('/api/captcha/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({ id: captchaId, answer: parseInt(ans, 10) })
})
.then(function(r) { return r.json(); })
.then(function(data) {
verifying = false;
if (data.correct) {
if (pendingForm) doSubmit(pendingForm);
} else {
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.wrongAnswer;
errEl.style.display = 'block';
fetchCaptcha(); // 自动刷新题目
}
})
.catch(function() {
verifying = false;
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.verifyFailed;
errEl.style.display = 'block';
fetchCaptcha();
});
});
// 回车提交
document.getElementById('captcha-modal-answer').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('captcha-modal-submit').click();
}
});
// 刷新按钮
document.getElementById('captcha-modal-refresh').addEventListener('click', fetchCaptcha);
// 拦截所有带 class="math-captcha-trigger" 的按钮
document.addEventListener('click', function(e) {
var btn = e.target.closest('.math-captcha-trigger');
if (!btn) return;
e.preventDefault();
var form = btn.closest('form');
if (!form) return;
pendingForm = form;
// 检查是否需要提问密码
pendingNeedPassword = !!(form.querySelector('#ask_password_input'));
// 邮箱校验(提问页面)
var notifyCb = document.getElementById('notify-checkbox');
var emailInput = document.getElementById('email-input');
if (notifyCb && notifyCb.checked && emailInput && emailInput.value.trim() === '') {
UIkit.modal.alert(CAPTCHA_I18N.needEmail);
return;
}
fetchCaptcha();
UIkit.modal('#math-captcha-modal').show();
});
})();
</script>
{{ end }}