Files
tamabox/templates/base/header.html
T
tamakyi ddee221de2 feat(theme): 主题支持按页面分别配装饰,生成器改用真实页面画布
原来的生成器只能配「背景 + 人物 + 道具」三件套,预览还是自己画的仿制页面 ——
跟真实页面不像,也没法给不同页面配不同内容。

机制层:
- internal/context.pageScope() 按路径归类,输出 <html data-page-scope>:
  box / question / admin / profile / vote(首页与 /explore 不加载主题,不列)
- /user/questions 单独归为 admin:题目列表信息密,和设置表单的装饰诉求不同
- base.css 装饰槽位 6 -> 10,背景 8 层 -> 12 层;主题按作用域覆盖时同样写满 10 个
- 新增 TestDecoSlotCount:逐文件逐声明检查 10 个值。少写一个不报错,
  只会让图层与 size/position 错位(整页重复同一张图 / 人物跑到左上角),注释守不住

生成器:
- 画板改成 iframe 加载 outputs/preview-*.html(真实模板渲染),配 serve.py 起本地服务
  解决 file:// 下父页面读不到 iframe DOM 的问题
- 八个页面作用域(去掉不生效的首页/探索),每个可独立配背景与最多 10 层装饰
- 图层类型:图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移,可在画板上直接拖
- 位置统一写 calc(0%|50%|100% ± Npx):百分比是相对 (容器-图片),
  不用 left/bottom 关键字混搭,避免三值语法非法导致整条声明作废
- 配色只填 6 个基础色,其余 4 个必需令牌按方向派生(surface-2 往 text 靠,
  浅色变深、深色变亮,方向自动正确)

配套:
- zz_preview_test.go 增加单题页 / 题目列表 / 投票页预览,作为生成器画板
- docs/theme-page-scope.md 记录作用域取值、选择器特异性与 10 槽位约束
- StaticVersion 20261020e(base.css 变了)

验证:生成器导出的 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount;
真实箱主页计算样式为 12 层对齐;临时主题验证「question 覆盖为 none」生效、
未覆盖时回退全局。
2026-10-20 10:53:44 +08:00

547 lines
26 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.
<!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 }}
<!-- Waline 评论区样式:非阻塞加载,不阻塞首屏渲染 -->
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<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/waline.js 已迁 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)自行引入 -->
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
{{ if eq .CaptchaType "math" }}
<!-- 算数验证码:弹窗模式,不影响原布局 -->
<style>
.captcha-equation-box {
display: inline-flex;
align-items: center;
background: #f0f2f5;
border: 1px solid #dcdcdc;
border-radius: 8px;
padding: 8px 8px 8px 16px;
gap: 6px;
transition: border-color 0.2s;
}
.captcha-equation-box:focus-within {
border-color: #1e87f0;
box-shadow: 0 0 0 3px rgba(30,135,240,0.12);
}
.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 #ccc;
border-radius: 5px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
background: #fff;
}
#captcha-modal-answer:focus {
border-color: #1e87f0;
box-shadow: 0 0 0 2px rgba(30,135,240,0.2);
}
/* 隐藏数字输入框的上下箭头 */
#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>function onSubmit() {document.getElementById('form').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>
{{ 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 }}