theme v3
@@ -406,3 +406,31 @@ P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明
|
|||||||
|
|
||||||
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
|
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
|
||||||
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
|
渲染法:抓线上 `/_/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"` + 中文内容的不一致待部署后复测。
|
||||||
|
|||||||
@@ -156,14 +156,21 @@
|
|||||||
|
|
||||||
## 主题(箱主个性化)
|
## 主题(箱主个性化)
|
||||||
|
|
||||||
- 两个注入点,别再漏:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;
|
- 三个注入点,别再漏:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;
|
||||||
`/user/*` → `internal/context` 的 Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`)。
|
`/user/*` → `internal/context` 的 Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);
|
||||||
公开页(`/`、`/explore`、`/vote/{token}`)不跟随登录用户主题。
|
`/vote/{token}` → `route/vote.Page` 按发布者 `owner.Theme`。都看「箱主/发布者」的配置,不看访客。
|
||||||
|
全站页(`/`、`/explore`、`/login`)不跟随任何人的主题。
|
||||||
|
- 投票页**语言**也跟发布者(`applyOwnerLocale(ctx.Data, owner)`,与主题同一个函数):
|
||||||
|
必须在任何 i18n 取词之前调用,否则先渲染出的提示会按访客语言。箱主未配语言时才回退访客/站点默认。
|
||||||
- 主题 = 一个 css 文件只覆盖令牌;新增主题不用改 Go/模板,放 `static/css/themes/<slug>.css` +
|
- 主题 = 一个 css 文件只覆盖令牌;新增主题不用改 Go/模板,放 `static/css/themes/<slug>.css` +
|
||||||
`[theme] available` 登记即可(`static/fs.go` 已嵌整个目录)。底稿 `_scaffold.css`。
|
`[theme] available` 登记即可(`static/fs.go` 已嵌整个目录)。底稿 `_scaffold.css`。
|
||||||
- **必须同时写浅色块与深色块**(`html[data-theme-style=x]` / `html[data-theme="dark"][data-theme-style=x]`),
|
- **必须同时写浅色块与深色块**(`html[data-theme-style=x]` / `html[data-theme="dark"][data-theme-style=x]`),
|
||||||
`TestThemeCSSComplete` 会查;色块预览由浅色块的 `--tb-bg` + `--tb-primary` 自动生成(色值过正则防注入)。
|
`TestThemeCSSComplete` 会查;色块预览由浅色块的 `--tb-bg` + `--tb-primary` 自动生成(色值过正则防注入)。
|
||||||
- 令牌 `--tb-navbar`:顶栏底色,默认 `var(--tb-surface)`。页面底色偏彩时主题应覆盖它,
|
- 令牌 `--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。
|
- 主题底色用**纯色**,别用整页渐变——会被读成「底色分布不均匀」;图案建议 ≥24px、透明度 ≤0.06。
|
||||||
- 改了 base.css / 主题 css 必须 +1 `conf.StaticVersion`(两者共用同一个 v)。
|
- 改了 base.css / 主题 css 必须 +1 `conf.StaticVersion`(两者共用同一个 v)。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ var (
|
|||||||
BuildCommit = "dev"
|
BuildCommit = "dev"
|
||||||
|
|
||||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||||
StaticVersion = "20261019f"
|
StaticVersion = "20261019g"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ var enDict = map[string]string{
|
|||||||
|
|
||||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||||
"profile.theme.title": "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.followDefault": "Follow site default",
|
||||||
"profile.theme.save": "Save theme",
|
"profile.theme.save": "Save theme",
|
||||||
"profile.theme.saved": "Theme saved",
|
"profile.theme.saved": "Theme saved",
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ var jaDict = map[string]string{
|
|||||||
|
|
||||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||||
"profile.theme.title": "テーマ",
|
"profile.theme.title": "テーマ",
|
||||||
"profile.theme.hint": "質問箱のページと管理画面に適用されます。ホーム・探索などの公開ページは既定の外観のままです。",
|
"profile.theme.hint": "質問箱のページ、あなたが作成した投票、管理画面に適用されます。ホーム・探索などのサイト共通ページは既定の外観のままです。",
|
||||||
"profile.theme.followDefault": "サイト既定に従う",
|
"profile.theme.followDefault": "サイト既定に従う",
|
||||||
"profile.theme.save": "テーマを保存",
|
"profile.theme.save": "テーマを保存",
|
||||||
"profile.theme.saved": "テーマを保存しました",
|
"profile.theme.saved": "テーマを保存しました",
|
||||||
|
|||||||
@@ -874,7 +874,7 @@ var zhCNDict = map[string]string{
|
|||||||
|
|
||||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||||
"profile.theme.title": "主题外观",
|
"profile.theme.title": "主题外观",
|
||||||
"profile.theme.hint": "对提问箱页面和你的后台页面生效,首页/探索等公开页保持默认外观。",
|
"profile.theme.hint": "对提问箱页面、你发布的投票和你自己的后台生效;首页/探索等站方页面保持默认外观。",
|
||||||
"profile.theme.followDefault": "跟随站点默认",
|
"profile.theme.followDefault": "跟随站点默认",
|
||||||
"profile.theme.save": "保存主题",
|
"profile.theme.save": "保存主题",
|
||||||
"profile.theme.saved": "主题已保存",
|
"profile.theme.saved": "主题已保存",
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ var zhTWDict = map[string]string{
|
|||||||
|
|
||||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||||
"profile.theme.title": "主題外觀",
|
"profile.theme.title": "主題外觀",
|
||||||
"profile.theme.hint": "對提問箱頁面與你的後台頁面生效,首頁/探索等公開頁維持預設外觀。",
|
"profile.theme.hint": "對提問箱頁面、你發起的投票與你自己的後台生效;首頁/探索等站方頁面維持預設外觀。",
|
||||||
"profile.theme.followDefault": "跟隨站點預設",
|
"profile.theme.followDefault": "跟隨站點預設",
|
||||||
"profile.theme.save": "儲存主題",
|
"profile.theme.save": "儲存主題",
|
||||||
"profile.theme.saved": "主題已儲存",
|
"profile.theme.saved": "主題已儲存",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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&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>
|
||||||
|
|
||||||
@@ -410,6 +410,12 @@ a:hover, .uk-link:hover {
|
|||||||
background-color: var(--tb-primary);
|
background-color: var(--tb-primary);
|
||||||
border-color: transparent;
|
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-input:focus,
|
||||||
.uk-select:focus,
|
.uk-select:focus,
|
||||||
.uk-textarea:focus {
|
.uk-textarea:focus {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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"])
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ctxLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
|
// ctxLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
|
||||||
@@ -43,6 +44,19 @@ func ctxLang(ctx context.Context) string {
|
|||||||
return i18n.Default
|
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 进程内缓存。
|
// resultsCache 投票结果聚合统计的短 TTL 进程内缓存。
|
||||||
// 结果统计需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),缓存可显著降低数据库实时重算与 CPU 开销;
|
// 结果统计需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),缓存可显著降低数据库实时重算与 CPU 开销;
|
||||||
// 结果页非强一致,30s 陈旧可接受(新提交最多延迟 30s 反映)。多实例部署时各实例独立缓存,不影响正确性。
|
// 结果页非强一致,30s 陈旧可接受(新提交最多延迟 30s 反映)。多实例部署时各实例独立缓存,不影响正确性。
|
||||||
@@ -596,6 +610,14 @@ func Page(ctx context.Context) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
|
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)
|
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["Ended"] = isVoteEnded(vote, now)
|
||||||
ctx.Data["VoteScopeRegisteredOnly"] = vote.VoterScope == db.VoteVoterScopeRegisteredOnly
|
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 {
|
if vote.Type == db.VoteTypeForm {
|
||||||
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||||
@@ -680,14 +696,24 @@ type formFieldInput struct {
|
|||||||
|
|
||||||
// Submit 提交投票。
|
// Submit 提交投票。
|
||||||
func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||||
lang := ctxLang(ctx)
|
|
||||||
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
|
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.notFound"))
|
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
|
||||||
ctx.Redirect("/")
|
ctx.Redirect("/")
|
||||||
return
|
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。
|
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
|
||||||
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
|
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
|
||||||
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
|
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
|
||||||
@@ -766,17 +792,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
|||||||
voterToken = &token
|
voterToken = &token
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析提交内容。
|
// 解析提交内容。投票发起人的全局屏蔽词表(users.block_words)用于校验开放文本:
|
||||||
// 先取投票发起人的全局屏蔽词表(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
|
|
||||||
}
|
|
||||||
submission, err := parseSubmission(ctx, vote, owner)
|
submission, err := parseSubmission(ctx, vote, owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.SetErrorFlash(err.Error())
|
ctx.SetErrorFlash(err.Error())
|
||||||
@@ -822,10 +839,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)
|
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)。owner 在上方已取过,直接复用。
|
||||||
if vote.Type == db.VoteTypeForm {
|
if vote.Type == db.VoteTypeForm && owner.Notify == db.NotifyTypeEmail {
|
||||||
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
|
|
||||||
if err == nil && owner.Notify == db.NotifyTypeEmail {
|
|
||||||
email := owner.Email
|
email := owner.Email
|
||||||
title := vote.Title
|
title := vote.Title
|
||||||
voteID := vote.ID
|
voteID := vote.ID
|
||||||
@@ -835,7 +850,6 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
|
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
|
||||||
ctx.Redirect("/vote/" + vote.LinkToken)
|
ctx.Redirect("/vote/" + vote.LinkToken)
|
||||||
|
|||||||
@@ -410,6 +410,12 @@ a:hover, .uk-link:hover {
|
|||||||
background-color: var(--tb-primary);
|
background-color: var(--tb-primary);
|
||||||
border-color: transparent;
|
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-input:focus,
|
||||||
.uk-select:focus,
|
.uk-select:focus,
|
||||||
.uk-textarea:focus {
|
.uk-textarea:focus {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{
|
{
|
||||||
"collectionId": "tamabox",
|
"collectionId": "tamabox",
|
||||||
"collectionName": "tamabox-changelogs",
|
"collectionName": "tamabox-changelogs",
|
||||||
"content": "新增:箱主主题功能。设置页可为主题选择配色、图案与整页背景(不影响布局);提问箱页面(箱主页 / 单题页 / 密码页 / 分享卡)与箱主本人后台页面都会跟随所选主题,首页、探索等公开页保持站点默认外观;未选择时跟随站点默认主题。<br>\n新增:主题底稿 static/css/themes/_scaffold.css,复制改名为 <slug>.css 并在 [theme] available 登记即可新增一套主题,无需改模板;示例主题「樱花」。",
|
"content": "新增:箱主主题功能。设置页可为主题选择配色、图案与整页背景(不影响布局);提问箱页面(箱主页 / 单题页 / 密码页 / 分享卡)、你发布的投票页与箱主本人后台都会跟随所选主题,首页、探索等公开页保持站点默认外观;未选择时跟随站点默认主题。<br>\n新增:主题底稿 static/css/themes/_scaffold.css,复制改名为 <slug>.css 并在 [theme] available 登记即可新增一套主题,无需改模板;示例主题「樱花」。",
|
||||||
"created": "2026-10-19 15:10:00.000Z",
|
"created": "2026-10-19 15:10:00.000Z",
|
||||||
"id": "none",
|
"id": "none",
|
||||||
"published_at": "2026-10-19 15:10:00.000Z",
|
"published_at": "2026-10-19 15:10:00.000Z",
|
||||||
|
|||||||