fix(ui): 提交防重复覆盖验证码路径、waline 按页加载、懒加载、safe-area、硬编码文案接字典

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

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

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

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

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

实测:箱主语言设为 en 后箱主页显示 View answer ×2 / Load more / No more posts,中文零残留。
This commit is contained in:
tamakyi
2026-10-29 16:28:53 +08:00
parent f2e0c43083
commit 03cb5ddcf0
13 changed files with 98 additions and 41 deletions
+44 -24
View File
@@ -62,6 +62,49 @@
});
}
// tbBeginSubmit 把表单切到「提交中」状态:触发按钮换成 spinner + 本地化文案,
// 并禁用表单内所有按钮。
//
// 抽成具名函数(而不是内联在下面的事件监听里)是因为 **程序化 submit() 不触发
// submit 事件**:走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,
// 原本完全绕过下面那个监听器 —— 而默认部署 captcha_type = recaptcha,
// 也就是主提问流一直没有防重复点击保护。那些回调现在先调本函数再 submit()。
function tbBeginSubmit(form, trigger) {
if (!form) return;
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
if (form.getAttribute('target') === '_blank') return;
// 触发提交的按钮(programmatic submit 时调用方传 null,回退到首个 submit 按钮)
var btn = trigger || 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 (btn && btn.tagName === 'BUTTON') {
// 已经切过一次就不再覆盖 data-tb-restore,否则会把 spinner 的 HTML 当成原始内容,
// 导致 bfcache 复位后按钮永远停在转圈上。
if (!btn.hasAttribute('data-tb-restore')) {
btn.setAttribute('data-tb-restore', btn.innerHTML);
}
btn.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 上供 header 里的验证码回调调用(脚本顺序:header → footer,
// 但回调发生在用户点击之后,此时本函数已定义)
window.tbBeginSubmit = tbBeginSubmit;
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
@@ -73,30 +116,7 @@
return;
}
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
if (form.getAttribute('target') === '_blank') return;
// 触发提交的按钮(programmatic submit 时 e.submitter 为 null,回退到首个 submit 按钮)
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');
});
tbBeginSubmit(form, e.submitter);
});
// bfcache 还原时复位按钮
+16 -5
View File
@@ -24,11 +24,9 @@
<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) -->
<!-- dayjs/alpinejs/vconsole 已迁 cdnjs.snrat.com(sha256 与官方一致,国内实测快 2-7x) -->
<script defer src="https://cdnjs.snrat.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
{{ if .VConsole }}
@@ -36,7 +34,6 @@
<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>
@@ -105,7 +102,21 @@
}</script>
{{ else }}
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script>
<script>function onSubmit() {document.getElementById('form').submit();}</script>
<script>
// reCAPTCHA v3 回调:拿到 token 后手动提交表单。
//
// 程序化 submit() **不触发 submit 事件**,所以 footer 里那个全局监听器
// (禁用按钮 + 转圈)对它完全无效 —— 而默认配置 captcha_type = recaptcha 时,
// 这就是主提问流的提交路径,等于一直没有防重复点击。
// 这里先显式切到「提交中」再 submit。tbBeginSubmit 定义在 footer.html,
// 脚本顺序在本文件之后;回调发生在用户点击之后,那时它已就绪,仍做一次判空兜底。
function onSubmit() {
var f = document.getElementById('form');
if (!f) return;
if (window.tbBeginSubmit) { window.tbBeginSubmit(f, null); }
f.submit();
}
</script>
{{ end }}
{{ if ne SentryDSN "" }}
<script
+3
View File
@@ -169,6 +169,9 @@
</ul>
</details>
<!-- 评论区资源只在本页加载(此前挂在 header 里,等于每页都下载 ~200KB 的 waline.js) -->
<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/waline/2.15.8/waline.js"></script>
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
+5 -5
View File
@@ -4,7 +4,7 @@
<div data-qid="{{$elem.ID}}">
<hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right"
href="/_/{{$.PageUser.Domain}}/{{$elem.ID}}">查看回答</a>
href="/_/{{$.PageUser.Domain}}/{{$elem.ID}}">{{t $.LangCode "qlist.viewAnswer"}}</a>
<div class="uk-text-left uk-text-small uk-text-muted">
{{if $elem.IsPinned}}<span class="tb-pin-flag" title="{{t $.LangCode "ask.pinned"}}">⭐</span>{{end}}
{{Date $elem.CreatedAt "Y-m-d H:i:s"}}
@@ -58,7 +58,7 @@
<div>
<hr>
<a class='uk-button uk-button-default uk-button-small uk-float-right'
href='/_/{{.PageUser.Domain}}/${question.id}'>查看回答</a>
href='/_/{{.PageUser.Domain}}/${question.id}'>{{t .LangCode "qlist.viewAnswer"}}</a>
<div class='uk-text-left uk-text-small uk-text-muted'>
${question.is_pinned ? '<span class=\'tb-pin-flag\' title=\'{{$pinTitle}}\'>⭐</span>' : ''}
${dayjs(question.created_at).format('YYYY-MM-DD HH:mm:ss')}
@@ -72,12 +72,12 @@
// 网络失败/响应非法 JSON 时复位 loading,避免按钮永远停留在加载中状态
.catch(() => { loading = false })
}" class="uk-button uk-button-default uk-width-1-1 uk-margin-small-bottom">
<span x-show="!loading">加载更多</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> 加载中...</span>
<span x-show="!loading">{{t .LangCode "qlist.loadMore"}}</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> {{t .LangCode "qlist.loading"}}</span>
</button>
<div x-show="!more" class="uk-text-meta uk-text-center">
<hr>
无更多留言
{{t .LangCode "qlist.noMore"}}
<br><br>
</div>
</div>
+5 -2
View File
@@ -78,7 +78,7 @@
{{ range $index, $elem := .AskUploadImages }}
<div uk-lightbox>
<a href="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="" loading="lazy">
</a>
</div>
{{ end }}
@@ -96,7 +96,7 @@
{{ range $index, $elem := .AnswerUploadImages }}
<div uk-lightbox>
<a href="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="" loading="lazy">
</a>
</div>
{{ end }}
@@ -147,6 +147,9 @@
{{ end }}
</div>
{{ if not .CanDelete }}
<!-- 评论区资源只在本页加载(此前挂在 header 里,等于每页都下载) -->
<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/waline/2.15.8/waline.js"></script>
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
+1 -1
View File
@@ -98,7 +98,7 @@ document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
<hr class="uk-divider-icon">
{{ if ne (len .PageQuestions) 0 }}
<p class="uk-text-left uk-text-muted uk-text-small">@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})</p>
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
{{ template "question/history-template" . }}
{{ end }}
</div>
+2 -2
View File
@@ -374,7 +374,7 @@
<div class="uk-card uk-card-default uk-card-hover uk-card-body">
<div class="uk-flex uk-flex-top">
<div>
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar">
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar" loading="lazy">
</div>
<div class="explore-info">
<div class="explore-name truncate">
@@ -587,7 +587,7 @@ document.addEventListener('DOMContentLoaded', function() {
html += `
<div>
<a href="/_/${escapeHTML(user.domain)}" class="recommend-card">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar" loading="lazy">
<div class="recommend-info">
<div class="recommend-name truncate">${escapeHTML(user.name)}</div>
<div class="recommend-tags">${tagsHtml}</div>
+1 -1
View File
@@ -137,7 +137,7 @@
{{ end }}
{{ else if eq .Vote.Type "multiple" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.multipleHint"}}(最多 {{ .Vote.MaxChoices }} 项)</div>
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.multipleHintWithMax" .Vote.MaxChoices}}</div>
<div id="multiple-options">
{{ range .Options }}
<label class="tb-choice">