This commit is contained in:
tamakyi
2026-10-19 16:46:42 +08:00
parent 3202ef9821
commit 38cd41afde
25 changed files with 8663 additions and 41 deletions
+28
View File
@@ -406,3 +406,31 @@ P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
## 投票页 + 标签主色(部署复核后第二轮)
- `/vote/{token}` 主题:`route/vote/vote.go` 的 `Page` 里本来就会 `db.Users.GetByID(vote.UserID)` 取 owner,
直接在同一分支补 `ctx.Data["ThemeSlug"] = theme.Resolve(owner.Theme)`;box 页 / /user 后台 / 投票页三处
全部由发布者配置决定。GetByID 失败(箱主已删)保持站点默认外观。
- **`uk-label` 默认色就是 UIkit 主色 #1e87f0**:主题下提问管理的「未回答 / 未读」两枚胶囊还是蓝的。
base.css 追加
`.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary)`
→ `--tb-primary`。**必须用 :not() 排除语义色**:同特异性且 base.css 在 UIkit 之后,不排除会把
danger/warning/success 一起刷成主色。默认主题实测仍是 (30,135,240),零回归。
- StaticVersion → `20261019g`。
- 线上复核(admin6 专属账号,curl 走完整登录:GET /login 取 _csrf → /api/captcha/generate →
/api/captcha/verify(**answer 必须是数字,传字符串会 400 请求格式错误**)→ POST /login):
`/user/profile`、`/user/questions` 均已输出 `data-theme-style="sakura"` 与 themes link(v=20261019f)。
投票页在 f 版本仍无主题 → 需再部署一次。
## 投票页语言跟随发布者(第三轮)
- 抽出 `applyOwnerLocale(data map[string]interface{}, owner *db.User)`(route/vote/vote.go 顶部):
同时写 `Owner` / `Lang` / `LangCode` / `ThemeSlug`。
签名故意收 Data 而不是 `context.Context`:`Context` 内部字段不可在包外构造,收 map 才能单测
(`route/vote/locale_test.go`,纯内存,不碰 DB)。
- `Page`:owner 查询提到 `FeatureStateOf` **之前**——否则「投票功能已关闭」这类提示先按访客语言生成。
- `Submit`:同样先取 owner 再取 `lang`,保证提交后回跳页的 flash 与页面语言一致;
顺带删掉尾部重复的 `GetByID(owner)`(表单类型通知邮件复用同一个 owner),少一次查询。
- 四语言 `profile.theme.hint` 与 changelog 范围说明补上「你发布的投票页」。
- 验证:vote/i18n/context/theme/templates 全绿;线上 `lang="ja"` + 中文内容的不一致待部署后复测。
+10 -3
View File
@@ -156,14 +156,21 @@
## 主题(箱主个性化)
- 两个注入点,别再漏:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;
`/user/*` → `internal/context` 的 Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`)。
公开页(`/`、`/explore`、`/vote/{token}`)不跟随登录用户主题。
- 三个注入点,别再漏:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;
`/user/*` → `internal/context` 的 Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);
`/vote/{token}` → `route/vote.Page` 按发布者 `owner.Theme`。都看「箱主/发布者」的配置,不看访客。
全站页(`/`、`/explore`、`/login`)不跟随任何人的主题。
- 投票页**语言**也跟发布者(`applyOwnerLocale(ctx.Data, owner)`,与主题同一个函数):
必须在任何 i18n 取词之前调用,否则先渲染出的提示会按访客语言。箱主未配语言时才回退访客/站点默认。
- 主题 = 一个 css 文件只覆盖令牌;新增主题不用改 Go/模板,放 `static/css/themes/<slug>.css` +
`[theme] available` 登记即可(`static/fs.go` 已嵌整个目录)。底稿 `_scaffold.css`。
- **必须同时写浅色块与深色块**(`html[data-theme-style=x]` / `html[data-theme="dark"][data-theme-style=x]`),
`TestThemeCSSComplete` 会查;色块预览由浅色块的 `--tb-bg` + `--tb-primary` 自动生成(色值过正则防注入)。
- 令牌 `--tb-navbar`:顶栏底色,默认 `var(--tb-surface)`。页面底色偏彩时主题应覆盖它,
否则顶栏是纯白带、压在彩底上割裂(用户实测反馈过)。
- UIkit 主色是编译期常量,主题要走通必须逐个组件映射(见 base.css 末尾那段):a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`,
以及 **`.uk-label:not(danger|warning|success|secondary)`**——`uk-label` 默认色就是主色,
但必须用 :not() 排除语义色(同特异性 + base.css 后加载,不排除会连带刷掉 danger/success)。
- 主题底色用**纯色**,别用整页渐变——会被读成「底色分布不均匀」;图案建议 ≥24px、透明度 ≤0.06。
- 改了 base.css / 主题 css 必须 +1 `conf.StaticVersion`(两者共用同一个 v)。
+1 -1
View File
@@ -10,7 +10,7 @@ var (
BuildCommit = "dev"
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
StaticVersion = "20261019f"
StaticVersion = "20261019g"
)
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
+1 -1
View File
@@ -933,7 +933,7 @@ var enDict = map[string]string{
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "Theme",
"profile.theme.hint": "Applies to your question box pages and your dashboard; public pages (home, explore) keep the default look.",
"profile.theme.hint": "Applies to your box pages, the votes you publish, and your dashboard; site pages such as home and explore keep the default look.",
"profile.theme.followDefault": "Follow site default",
"profile.theme.save": "Save theme",
"profile.theme.saved": "Theme saved",
+1 -1
View File
@@ -933,7 +933,7 @@ var jaDict = map[string]string{
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "テーマ",
"profile.theme.hint": "質問箱のページと管理画面に適用されます。ホーム・探索などの公開ページは既定の外観のままです。",
"profile.theme.hint": "質問箱のページ、あなたが作成した投票、管理画面に適用されます。ホーム・探索などのサイト共通ページは既定の外観のままです。",
"profile.theme.followDefault": "サイト既定に従う",
"profile.theme.save": "テーマを保存",
"profile.theme.saved": "テーマを保存しました",
+1 -1
View File
@@ -874,7 +874,7 @@ var zhCNDict = map[string]string{
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "主题外观",
"profile.theme.hint": "对提问箱页面和你的后台页面生效,首页/探索等公开页保持默认外观。",
"profile.theme.hint": "对提问箱页面、你发布的投票和你自己的后台生效;首页/探索等站方页面保持默认外观。",
"profile.theme.followDefault": "跟随站点默认",
"profile.theme.save": "保存主题",
"profile.theme.saved": "主题已保存",
+1 -1
View File
@@ -933,7 +933,7 @@ var zhTWDict = map[string]string{
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "主題外觀",
"profile.theme.hint": "對提問箱頁面與你的後台頁面生效,首頁/探索等公開頁維持預設外觀。",
"profile.theme.hint": "對提問箱頁面、你發起的投票與你自己的後台生效;首頁/探索等站方頁面維持預設外觀。",
"profile.theme.followDefault": "跟隨站點預設",
"profile.theme.save": "儲存主題",
"profile.theme.saved": "主題已儲存",
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+886
View File
@@ -0,0 +1,886 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>测试</title>
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
<meta name="description" content="狼の質問箱は登録不要で使える匿名質問箱。誰でも匿名で質問でき、安心してやり取りできる場所です。">
<meta name="keywords" content="匿名質問箱,質問箱,匿名質問,狼の質問箱,匿名で質問,匿名Q&amp;A">
<script>
(function () {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
if (m && m[1] === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
<link rel="stylesheet" href="https://box-dev-api.337845818.best/static/css/base.css?v=20261019f"/>
<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>
<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>
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
<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>
<meta property="og:site_name" content="狼の質問箱 | 匿名質問箱" />
<meta property="og:type" content="website" />
<meta property="og:title" content="狼の質問箱 | 匿名質問箱" />
<meta property="og:url" content="https://box-dev-api.337845818.best/vote/7kcP5kR8YnJC" />
<meta property="og:description" content="狼の質問箱は登録不要で使える匿名質問箱。誰でも匿名で質問でき、安心してやり取りできる場所です。" />
<meta name="robots" content="noindex">
<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" />
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
<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>
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 = {});
var bad = window._tbQrBad || (window._tbQrBad = {});
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);
}
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);
}
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">探索</a></li>
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">マイ</a></li>
</ul>
</div>
</div>
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>の質問箱</a>
<div class="uk-navbar-center-right">
<div>
<ul class="uk-navbar-nav">
<li><a href="/register">登録</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/login">ログイン</a></li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" aria-label="言語" title="言語"><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">
<li class="">
<a href="/i18n/zh-CN?to=%2Fvote%2F7kcP5kR8YnJC">简体中文</a>
</li>
<li class="">
<a href="/i18n/zh-TW?to=%2Fvote%2F7kcP5kR8YnJC">繁體中文</a>
</li>
<li class="">
<a href="/i18n/en?to=%2Fvote%2F7kcP5kR8YnJC">English</a>
</li>
<li class="uk-active">
<a href="/i18n/ja?to=%2Fvote%2F7kcP5kR8YnJC">日本語</a>
</li>
</ul>
</div>
</li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" id="theme-toggle" title="ダーク/ライトモード切替" aria-label="ダーク/ライトモード切替">
<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">
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="言語">
<option value="zh-CN" >简体中文</option>
<option value="zh-TW" >繁體中文</option>
<option value="en" >English</option>
<option value="ja" selected>日本語</option>
</select>
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
title="ダーク/ライトモード切替" aria-label="ダーク/ライトモード切替">
<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() {
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';
}
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();
});
});
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>
<div class="uk-container uk-container-xsmall">
<div id="math-captcha-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body uk-text-center">
<h3 class="uk-modal-title">人機認証</h3>
<p class="uk-text-meta">以下の計算問題を解いて、ロボットでないことを証明してください</p>
<div class="captcha-equation-box">
<span id="captcha-modal-question" class="math-captcha-modal-question">読み込み中...</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">別の問題</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">送信</button>
<button type="button" class="uk-button uk-button-default uk-modal-close">キャンセル</button>
</div>
</div>
</div>
<script>
var CAPTCHA_I18N = {
loading: "読み込み中...",
loadFailed: "読み込み失敗",
loadRetry: "読み込み失敗、再試行してください",
inputAnswer: "答えを入力してください",
wrongAnswer: "答えが違います。もう一度計算してください",
verifyFailed: "認証失敗、再試行してください",
needPassword: "この質問箱にはパスワードが必要です",
needEmail: "通知を受け取るメールアドレスを入力してください"
};
</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) {
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();
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);
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>
<style>
.tb-field-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-bottom: 10px;
}
.tb-field-head .tb-field-label {
flex-basis: 100%;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
.tb-choice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--tb-border);
border-radius: 8px;
background: var(--tb-surface);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.tb-choice:last-child { margin-bottom: 0; }
.tb-choice input[type="radio"], .tb-choice input[type="checkbox"] {
margin-top: 3px;
}
.tb-choice span {
flex: 1;
min-width: 0;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
.tb-choice:hover {
border-color: var(--tb-primary);
background: var(--tb-surface-hover);
}
.tb-choice:has(input:checked) {
border-color: var(--tb-primary);
background: var(--tb-surface-active);
}
html[data-theme="dark"] .tb-choice {
border-color: var(--tb-border);
background: var(--tb-surface);
}
html[data-theme="dark"] .tb-choice:hover {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.08);
}
html[data-theme="dark"] .tb-choice:has(input:checked) {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.14);
}
.tb-custom-input {
margin-top: 2px;
}
.tb-text-counter {
margin-top: 2px;
text-align: right;
}
@media (max-width: 480px) {
.tb-choice { padding: 10px 12px; }
}
</style>
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 10px;">
<img class="uk-border-circle" src="https://r2.337845818.best/tamabox/picture/2026/08/19/9af3e38222d972ff9141e9cfdb0475" style="width: 40px; height: 40px;">
<div>
<a class="uk-text-bold" href="/_admin6">admin6</a>
<div class="uk-text-meta uk-text-small">の投票</div>
</div>
<div class="uk-margin-auto-left">
<span class="uk-label uk-label-success">進行中</span>
</div>
</div>
<h3 class="uk-margin-remove-top uk-margin-small-bottom">测试</h3>
<p class="uk-margin-remove-top">测试</p>
</div>
<div class="uk-alert uk-alert-primary uk-text-small">匿名での参加が可能で、あなたの身元は記録されません(同一端末で最大1回)。</div>
<form method="post" action="/vote/7kcP5kR8YnJC/submit" id="vote-form" onsubmit="return validateVoteForm()">
<input type="hidden" name="_csrf" value="7PjXuvNsGzsh5kA_4djVOR-5ZSY6MTc5MjM5ODMwMTkzNzA4Nzg0MA">
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-margin">
<div class="tb-field-head">
<span class="tb-chip">1</span>
<span class="tb-chip">一行テキスト</span>
<div class="tb-field-label">测试 <span class="uk-text-danger">*</span></div>
</div>
<input class="uk-input" type="text" name="field_21" data-vote-required data-vote-label="测试">
</div>
</div>
<div class="uk-margin">
<button type="button" class="uk-button uk-button-primary math-captcha-trigger" onclick="return voteValidate(event)">投票する</button>
</div>
</form>
<script>
var MAX_CUSTOM_LEN = 200 ;
function countChars(s) { return Array.from(String(s || '')).length; }
function setupTextCounters() {
var form = document.getElementById('vote-form');
if (!form) { return; }
form.querySelectorAll('[data-tb-text-counter]').forEach(function (input) {
var max = parseInt(input.getAttribute('data-tb-text-max'), 10);
if (!max || max < 1) { max = MAX_CUSTOM_LEN; }
var counter = document.createElement('div');
counter.className = 'uk-text-meta uk-text-small tb-text-counter';
if (input.parentNode) { input.parentNode.insertBefore(counter, input.nextSibling); }
var update = function () {
var n = countChars(input.value);
counter.textContent = n + ' / ' + max;
if (n > max) {
counter.classList.add('uk-text-danger');
input.setAttribute('data-tb-too-long', '1');
} else {
counter.classList.remove('uk-text-danger');
input.removeAttribute('data-tb-too-long');
}
};
input.addEventListener('input', update);
update();
});
}
function validateVoteForm() {
var form = document.getElementById('vote-form');
if (!form) { return true; }
var tooLong = form.querySelector('[data-tb-text-counter][data-tb-too-long]');
if (tooLong) {
alert('自由記入オプションは 200 字以内にしてください');
tooLong.focus();
return false;
}
var groups = {};
form.querySelectorAll('input[data-vote-required][type="radio"], input[data-vote-required][type="checkbox"]').forEach(function (el) {
(groups[el.name] = groups[el.name] || []).push(el);
});
for (var name in groups) {
var any = groups[name].some(function (el) { return el.checked; });
if (!any) {
alert('入力してください「' + groups[name][0].getAttribute('data-vote-label') + '」');
return false;
}
}
var els = form.querySelectorAll('[data-vote-required]:not(input[type="radio"]):not(input[type="checkbox"])');
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.disabled) { continue; }
if ((el.value || '').trim() === '') {
alert('入力してください「' + el.getAttribute('data-vote-label') + '」');
return false;
}
}
return true;
}
function voteValidate(e) {
if (!validateVoteForm()) {
if (e) { e.stopPropagation(); e.preventDefault(); }
return false;
}
return true;
}
function onSubmit() {
if (validateVoteForm()) {
document.getElementById('vote-form').submit();
}
}
(function () {
var form = document.getElementById('vote-form');
if (!form) { return; }
setupTextCounters();
var maxChoices = 0;
if (maxChoices > 0) {
var boxes = form.querySelectorAll('input[type="checkbox"][name="option_ids"]');
boxes.forEach(function (box) {
box.addEventListener('change', function () {
var checked = form.querySelectorAll('input[type="checkbox"][name="option_ids"]:checked').length;
if (checked > maxChoices) { this.checked = false; }
});
});
}
var CUSTOM_MARKER = "__tb_custom_option__";
form.querySelectorAll('[data-vote-custom-input]').forEach(function (input) {
var fid = input.getAttribute('data-vote-custom-input');
var group = form.querySelectorAll('input[name="field_' + fid + '"]');
var select = form.querySelector('select[name="field_' + fid + '"]');
var sync = function () {
var on = false;
if (select) {
on = (select.value === CUSTOM_MARKER);
} else {
group.forEach(function (r) { if (r.value === CUSTOM_MARKER && r.checked) { on = true; } });
}
input.disabled = !on;
if (!on) { input.value = ''; }
};
if (select) { select.addEventListener('change', sync); } else { group.forEach(function (r) { r.addEventListener('change', sync); }); }
sync();
});
})();
</script>
<p class=" uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">ルール</a> |
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">管理者へ</a>
<br>
<a href="https://share.shiroko.one" target="_blank">ドライブ</a>
| <a href="https://blog.shiroko.one" target="_blank">お知らせ</a>
| <a href="https://chat.shiroko.one" target="_blank">チャット</a>
| <a href="https://status.shiroko.one" target="_blank">ステータス</a>
| <a href="https://shiroko.one" target="_blank">リンク</a>
<br>Powered by NekoBox | 狼ICP备 20230101
<br>質問箱は稼働中:<span id="box_runtime"></span>
</p>
<script>
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000);
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create;
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
var SUBMIT_I18N = {
submitting: '送信中...',
uploading: 'アップロード中...'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
if (form.getAttribute('target') === '_blank') return;
var trigger = e.submitter || form.querySelector('button[type="submit"]');
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
});
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>
+6
View File
@@ -410,6 +410,12 @@ a:hover, .uk-link:hover {
background-color: var(--tb-primary);
border-color: transparent;
}
/* 胶囊按钮/状态标签(提问管理里的「置顶」「未回答」,投票的「已参与」)也走主色。
danger / warning / success / secondary 是语义色,必须用 :not() 排除——
同特异性下 base.css 在 UIkit 之后加载,不排除会把它们一并刷成主色。 */
.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary) {
background-color: var(--tb-primary);
}
.uk-input:focus,
.uk-select:focus,
.uk-textarea:focus {
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+25
View File
@@ -0,0 +1,25 @@
package vote
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tamakyi/TamaBox/internal/db"
)
// 投票链接是发布者对外分享的内容:语言必须跟着发布者走,否则中文箱主分享出去的
// 投票页会变成站点默认语言,访客看到的语言和内容对不上。
func TestApplyOwnerLocale(t *testing.T) {
data := map[string]interface{}{"Lang": "en", "LangCode": "en"}
applyOwnerLocale(data, &db.User{Language: "ja"})
assert.Equal(t, "ja", data["Lang"])
assert.Equal(t, "ja", data["LangCode"])
// 发布者没配语言:保留 Contexter 解析出的访客/站点默认值,不要清空
fallback := map[string]interface{}{"Lang": "en", "LangCode": "en"}
applyOwnerLocale(fallback, &db.User{Language: ""})
assert.Equal(t, "en", fallback["LangCode"])
}
+46 -32
View File
@@ -33,6 +33,7 @@ import (
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/theme"
)
// ctxLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
@@ -43,6 +44,19 @@ func ctxLang(ctx context.Context) string {
return i18n.Default
}
// applyOwnerLocale 让界面语言与主题跟随投票发布者,与提问箱页面同一套规则:
// 投票链接是发布者对外分享的内容,访客看到的应当是他的语言与配色。
// 发布者未配置语言时保留 Contexter 解析出的值(访客 cookie / 站点默认)。
func applyOwnerLocale(data map[string]interface{}, owner *db.User) {
data["Owner"] = owner
if owner.Language != "" {
lang := i18n.Resolve(owner.Language)
data["Lang"] = lang
data["LangCode"] = lang
}
data["ThemeSlug"] = theme.Resolve(owner.Theme)
}
// resultsCache 投票结果聚合统计的短 TTL 进程内缓存。
// 结果统计需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),缓存可显著降低数据库实时重算与 CPU 开销;
// 结果页非强一致,30s 陈旧可接受(新提交最多延迟 30s 反映)。多实例部署时各实例独立缓存,不影响正确性。
@@ -596,6 +610,14 @@ func Page(ctx context.Context) {
now := time.Now()
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
// 发布者的语言与主题:必须在任何 i18n 取词之前应用,否则「投票功能已关闭」
// 这类提示会先按访客语言生成。箱主已删除(GetByID 失败)则保持站点默认外观。
if owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID); err == nil {
applyOwnerLocale(ctx.Data, owner)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get vote owner")
}
// 箱主侧的投票功能开关(开关 / 定时开放 / 定量收集):关闭时访客不可参与,
// 页面本身仍可访问(结果展示策略不受影响)。
voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote)
@@ -609,12 +631,6 @@ func Page(ctx context.Context) {
ctx.Data["Ended"] = isVoteEnded(vote, now)
ctx.Data["VoteScopeRegisteredOnly"] = vote.VoterScope == db.VoteVoterScopeRegisteredOnly
// 箱主信息
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err == nil {
ctx.Data["Owner"] = owner
}
// 选项 / 表单字段
if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
@@ -680,14 +696,24 @@ type formFieldInput struct {
// Submit 提交投票。
func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
lang := ctxLang(ctx)
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.notFound"))
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
ctx.Redirect("/")
return
}
// 发布者的语言与主题:提交成功/失败都回跳投票页,提示文案必须与页面语言一致。
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote owner")
ctx.SetInternalErrorFlash()
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
applyOwnerLocale(ctx.Data, owner)
lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
@@ -766,17 +792,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
voterToken = &token
}
// 解析提交内容。
// 先取投票发起人的全局屏蔽词表(users.block_words):投票提交中的开放文本
// (自定义补充选项、表单“其他”文本、文本/长文本字段)需要与提问/私聊一样
// 命中即拒,因此把 owner 一并传入 parseSubmission 做本地词表检查。
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote owner")
ctx.SetInternalErrorFlash()
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
// 解析提交内容。投票发起人的全局屏蔽词表(users.block_words)用于校验开放文本:
// 自定义补充选项、表单“其他”文本、文本/长文本字段命中即拒(与提问/私聊一致)。
submission, err := parseSubmission(ctx, vote, owner)
if err != nil {
ctx.SetErrorFlash(err.Error())
@@ -822,19 +839,16 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
}
}
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)
if vote.Type == db.VoteTypeForm {
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err == nil && owner.Notify == db.NotifyTypeEmail {
email := owner.Email
title := vote.Title
voteID := vote.ID
shared.SafeGo(func() {
if err := mail.SendNewVoteSubmissionMail(email, title, voteID); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new vote submission mail")
}
})
}
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)。owner 在上方已取过,直接复用。
if vote.Type == db.VoteTypeForm && owner.Notify == db.NotifyTypeEmail {
email := owner.Email
title := vote.Title
voteID := vote.ID
shared.SafeGo(func() {
if err := mail.SendNewVoteSubmissionMail(email, title, voteID); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new vote submission mail")
}
})
}
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
+6
View File
@@ -410,6 +410,12 @@ a:hover, .uk-link:hover {
background-color: var(--tb-primary);
border-color: transparent;
}
/* 胶囊按钮/状态标签(提问管理里的「置顶」「未回答」,投票的「已参与」)也走主色。
danger / warning / success / secondary 是语义色,必须用 :not() 排除——
同特异性下 base.css 在 UIkit 之后加载,不排除会把它们一并刷成主色。 */
.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary) {
background-color: var(--tb-primary);
}
.uk-input:focus,
.uk-select:focus,
.uk-textarea:focus {
+1 -1
View File
@@ -7,7 +7,7 @@
{
"collectionId": "tamabox",
"collectionName": "tamabox-changelogs",
"content": "新增:箱主主题功能。设置页可为主题选择配色、图案与整页背景(不影响布局);提问箱页面(箱主页 / 单题页 / 密码页 / 分享卡)与箱主本人后台页面都会跟随所选主题,首页、探索等公开页保持站点默认外观;未选择时跟随站点默认主题。<br>\n新增:主题底稿 static/css/themes/_scaffold.css,复制改名为 &lt;slug&gt;.css 并在 [theme] available 登记即可新增一套主题,无需改模板;示例主题「樱花」。",
"content": "新增:箱主主题功能。设置页可为主题选择配色、图案与整页背景(不影响布局);提问箱页面(箱主页 / 单题页 / 密码页 / 分享卡)、你发布的投票页与箱主本人后台都会跟随所选主题,首页、探索等公开页保持站点默认外观;未选择时跟随站点默认主题。<br>\n新增:主题底稿 static/css/themes/_scaffold.css,复制改名为 &lt;slug&gt;.css 并在 [theme] available 登记即可新增一套主题,无需改模板;示例主题「樱花」。",
"created": "2026-10-19 15:10:00.000Z",
"id": "none",
"published_at": "2026-10-19 15:10:00.000Z",