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,中文零残留。
569 lines
27 KiB
HTML
569 lines
27 KiB
HTML
<!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 }} |