chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body">
<h2>关于我</h2>
<div>
<p>建立本站初衷是为了找一个自己出道时用的提问箱,当时正赶上很多提问箱崩掉,剩下的一些都是要么需要下载APP,需要登陆注册的那种很臃肿的提问箱。</p>
<p>那如果提问需要后台实名,和在直播间直接提问有什么区别?就只是让主播看不到你的ID?那还不如开小号方便。</p>
<p>另一方面,提问和回答只是个简简单单的流程,需要再单独下载个APP吗?只需要一个浏览器,谁都能直接用,这是最好的选择。</p>
<p>本站原先使用的是<a href="https://github.com/Xinrea/JoiAsk">JoiAsk</a>的提问箱,也是一款开源的,无需注册和APP的提问箱,但无问题回复功能,估计是给答主直播回答用的,同时因为当时并未能找到一个合适的存储桶,一些静态资源加载缓慢,后面就弃用了。一段时间之后终于找到了Nekobox这个网站,内容很简单但是很完善,可惜2023年作者因为自身原因关站一段时间。好在它是开源的,一番捣鼓之后也正常运行了,在加上图片功能之后已经接近我心中理想的提问箱了。感恩作者。</p>
</div>
</div>
{{template "base/footer" .}}
+6
View File
@@ -0,0 +1,6 @@
{{template "base/header" .}}
<legend class="uk-legend">{{t .LangCode "auth.forgot.title"}}</legend>
<h5>
{{t .LangCode "auth.forgot.sent" .email}}
</h5>
{{template "base/footer" .}}
+23
View File
@@ -0,0 +1,23 @@
{{template "base/header" .}}
<form method="post" id="form">
<fieldset class="uk-fieldset">
{{ .CSRFTokenHTML }}
<legend class="uk-legend">{{t .LangCode "auth.forgot.title"}}</legend>
{{template "base/alert" .}}
<div class="uk-margin">
<label class="uk-form-label" for="email">{{t .LangCode "auth.forgot.email"}}</label>
<input name="email" id="email" class="uk-input" type="text">
</div>
<div class="uk-margin">
{{ if eq .CaptchaType "math" }}
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<button type="button" class="uk-button uk-button-primary math-captcha-trigger">{{t .LangCode "auth.forgot.submitBtn"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-primary g-recaptcha" data-sitekey="{{.RecaptchaSiteKey}}"
data-callback="onSubmit">{{t .LangCode "auth.forgot.submitBtn"}}
</button>
{{ end }}
</div>
</fieldset>
</form>
{{template "base/footer" .}}
+29
View File
@@ -0,0 +1,29 @@
{{template "base/header" .}}
<form method="post" id="form">
<fieldset class="uk-fieldset">
{{ .CSRFTokenHTML }}
<legend class="uk-legend">{{t .LangCode "auth.login.title"}}</legend>
{{template "base/alert" .}}
<div class="uk-margin">
<label class="uk-form-label" for="email">{{t .LangCode "auth.login.email"}}</label>
<input name="email" id="email" class="uk-input" type="text" value="{{.email}}">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="password">{{t .LangCode "auth.login.password"}}</label>
<input type="password" name="password" id="password" class="uk-input">
</div>
<div class="uk-margin">
{{ if eq .CaptchaType "math" }}
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<button type="button" class="uk-button uk-button-primary math-captcha-trigger">{{t .LangCode "auth.login.submit"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-primary g-recaptcha" data-sitekey="{{.RecaptchaSiteKey}}"
data-callback="onSubmit">{{t .LangCode "auth.login.submit"}}
</button>
{{ end }}
<a href="/forgot-password" class="uk-button uk-button-default">{{t .LangCode "auth.login.forgot"}}
</a>
</div>
</fieldset>
</form>
{{template "base/footer" .}}
+23
View File
@@ -0,0 +1,23 @@
{{template "base/header" .}}
<form method="post" id="form">
<fieldset class="uk-fieldset">
{{ .CSRFTokenHTML }}
<legend class="uk-legend">{{t .LangCode "auth.recover.title"}}</legend>
{{template "base/alert" .}}
<div class="uk-margin">
{{t .LangCode "auth.recover.greeting" .User.Name}}
</div>
<div class="uk-margin">
<label class="uk-form-label" for="new_password">{{t .LangCode "auth.recover.password"}}</label>
<input type="password" name="new_password" id="new_password" class="uk-input">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="repeat_password">{{t .LangCode "auth.recover.confirm"}}</label>
<input type="password" name="repeat_password" id="repeat_password" class="uk-input">
</div>
<div class="uk-margin">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "auth.recover.submit"}}</button>
</div>
</fieldset>
</form>
{{template "base/footer" .}}
+61
View File
@@ -0,0 +1,61 @@
{{template "base/header" .}}
<style>
.tb-domain-sep {
background-color: #ffe0e0;
color: #c0392b;
padding: 0 4px;
border-radius: 4px;
font-weight: 600;
}
html[data-theme="dark"] .tb-domain-sep {
background-color: rgba(231, 76, 60, 0.25);
color: #ff8a80;
}
</style>
<form method="post" id="form">
{{ .CSRFTokenHTML }}
<fieldset class="uk-fieldset">
<legend class="uk-legend">{{t .LangCode "auth.register.title"}}</legend>
{{template "base/alert" .}}
<div class="uk-margin">
<label class="uk-form-label" for="email">{{t .LangCode "auth.register.email"}}</label>
<input name="email" id="email" class="uk-input" type="text" value="{{.email}}">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="domain">{{t .LangCode "auth.register.domain.prefix" .ExternalURL}}<span class="tb-domain-sep">/_/</span>{{t .LangCode "auth.register.domain.suffix"}}</label>
<input name="domain" id="domain" class="uk-input" type="text" value="{{.domain}}">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="name">{{t .LangCode "auth.register.name"}}</label>
<input name="name" id="name" class="uk-input" type="text" value="{{.name}}">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="password">{{t .LangCode "auth.register.password"}}</label>
<input type="password" name="password" id="password" class="uk-input">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="repeat_password">{{t .LangCode "auth.register.confirm"}}</label>
<input type="password" name="repeat_password" id="repeat_password" class="uk-input">
</div>
<div class="uk-margin">
<label class="uk-form-label" for="language">{{t .LangCode "auth.register.language"}}</label>
<select name="language" id="language" class="uk-select">
<option value="">{{t .LangCode "auth.register.languageDefault"}}</option>
{{ range .SupportedLangs }}
<option value="{{ .Code }}" {{ if eq .Code $.LangCode }}selected{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
</div>
<div class="uk-margin">
{{ if eq .CaptchaType "math" }}
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<button type="button" class="uk-button uk-button-primary math-captcha-trigger">{{t .LangCode "auth.register.submit"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-primary g-recaptcha" data-sitekey="{{.RecaptchaSiteKey}}"
data-callback="onSubmit">{{t .LangCode "auth.register.submit"}}
</button>
{{ end }}
</div>
</fieldset>
</form>
{{template "base/footer" .}}
+32
View File
@@ -0,0 +1,32 @@
{{if .Error}}
<div class="uk-alert-danger" uk-alert>
<a class="uk-alert-close" uk-close></a>
<div>{{.Error}}</div>
{{ if .FlashTip }}<span class="uk-text-left uk-text-small">{{.FlashTip}}</span> {{ end }}
</div>
{{end}}
{{if .Warning}}
<div class="uk-alert-warning" uk-alert>
<a class="uk-alert-close" uk-close></a>
<div>{{.Warning}}</div>
{{ if .FlashTip }}<span class="uk-text-left uk-text-small">{{.FlashTip}}</span> {{ end }}
</div>
{{end}}
{{if .Success}}
<div class="uk-alert-success" uk-alert>
<a class="uk-alert-close" uk-close></a>
<div>{{.Success}}</div>
{{ if .FlashTip }}<span class="uk-text-left uk-text-small">{{.FlashTip}}</span> {{ end }}
</div>
{{end}}
{{if .Info}}
<div class="uk-alert-primary" uk-alert>
<a class="uk-alert-close" uk-close></a>
<div>{{.Info}}</div>
{{ if .FlashTip }}<span class="uk-text-left uk-text-small">{{.FlashTip}}</span> {{ end }}
</div>
{{end}}
+102
View File
@@ -0,0 +1,102 @@
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
<br>Powered by NekoBox | {{ ICP }}<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
<br>
<!--<a href="/pixel">画板</a> | -->
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
| <a href="https://blog.shiroko.one" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
| <a href="https://chat.shiroko.one" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
| <a href="https://share.shiroko.one" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
| <a href="https://status.shiroko.one" target="_blank">{{t .LangCode "site.footer.status"}}</a>
| <a href="https://shiroko.one" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
</p>
<script>
// 页脚「提问箱已成功存活」倒计时:原 R2 cdn/time.box.js(1.6KB)内联于此,
// 消除全站每页一个 R2 慢 defer(实测 0.7-1.7s 波动)。相对原脚本两处修复:
// 1) 先判空再写入(原版先赋值后判空,box_runtime 缺失时会抛 ReferenceError 中断脚本);
// 2) 首帧立即填充(原版 DOMContentLoaded + 首个 1s 间隔后才显示,首秒为空)。
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000); // 提问箱创建时刻(UTC)
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create; // UTC+8 秒级存活
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>
// 提交反馈(P0):点击任意表单的提交按钮后,立即把触发按钮变成「转圈 + 本地化文字」,
// 并禁用表单内全部按钮,物理阻止在上传/网络慢时的重复点击与重复提交。
// 浏览器前进/后退(bfcache)回到本页时自动复位,避免停留在禁用或转圈状态。
// 纯内联反馈,不使用全屏遮罩。
var SUBMIT_I18N = {
submitting: '{{t .LangCode "common.submitting"}}',
uploading: '{{t .LangCode "common.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;
// 页面级提交反馈必须尊重前置校验:投票编辑页等 onsubmit 返回 false 时,
// submit 事件仍可能继续到达此监听器;此时绝不能切换到“提交中”状态。
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
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');
});
});
// bfcache 还原时复位按钮
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body">
<h2>修改记录</h2>
<div>
<p>为使首页更为简洁,今后修改的相关记录存档在此处(主要信息仍然会在首页提示)。</p>
</div>
<hr>
<br>
<div x-data="{ c: '' }" x-init="c = await (async () => {
let log = (await(await fetch('/static/tamabox.changelogs.json')).json()).items[0];
return `<p class=uk-article-meta>修改记录 - ${new Date(log.published_at).toLocaleDateString()}</p><span class=uk-text-small>${log.content}</span>`;
})()" x-html="c">
</div>
</div>
{{template "base/footer" .}}
+195
View File
@@ -0,0 +1,195 @@
{{template "base/header" .}}
<style>
/* 自定义灯箱样式 */
.custom-lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
flex-direction: column;
}
.custom-lightbox.active {
display: flex;
}
.custom-lightbox img {
max-width: 90%;
max-height: 80%;
border-radius: 8px;
box-shadow: 0 0 30px rgba(0,0,0,0.5);
}
.custom-lightbox .close-btn {
position: absolute;
top: 20px;
right: 30px;
color: #fff;
font-size: 40px;
cursor: pointer;
font-weight: bold;
}
.custom-lightbox .caption {
color: #ccc;
margin-top: 10px;
font-size: 14px;
}
</style>
<div class="uk-container uk-container-small uk-margin-top">
<h2 class="uk-text-center">
{{ if .IsOwner }}{{t .LangCode "chat.withAsker"}}{{ else }}{{t .LangCode "chat.withUser" .PageUser.Name}}{{ end }}
</h2>
<p class="uk-text-center uk-text-meta">
{{ if .Conversation.IsClosed }}
<span class="uk-label uk-label-danger">{{t .LangCode "chat.closed"}}</span>
{{ else }}
{{t .LangCode "chat.remaining" (sub .Conversation.MaxMessages .Conversation.MessageCount) .Conversation.MaxMessages}}
{{ end }}
</p>
<div class="uk-card uk-card-default uk-card-body uk-margin uk-padding-small">
<ul class="uk-list uk-list-divider">
{{ range .Messages }}
{{ $isMyMessage := eq .SenderID $.LoggedUser.ID }}
<li class="uk-margin-small-bottom">
<div class="uk-flex {{ if $isMyMessage }}uk-flex-right{{ else }}uk-flex-left{{ end }}">
<div class="uk-card uk-card-hover uk-card-body uk-padding-small uk-margin-small-left uk-margin-small-right"
style="max-width: 75%; border-radius: 12px; background-color: {{ if $isMyMessage }}#e6ffe6{{ else }}#f0f8ff{{ end }};">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom">
<span class="uk-icon" uk-icon="user"></span>
<strong class="uk-margin-small-left">
{{ if $isMyMessage }}{{t $.LangCode "chat.me"}}{{ else }}{{t $.LangCode "chat.other"}}{{ end }}
</strong>
<span class="uk-text-meta uk-margin-small-left" style="font-size: 0.75rem;">
{{ .CreatedAt.Format "01-02 15:04" }}
</span>
</div>
{{ if and $.IsOwner $.IsBlocked (eq .SenderID $.Conversation.AskerID) }}
<p class="uk-margin-remove"><span class="uk-text-muted uk-text-italic">{{t $.LangCode "chat.userBlocked"}}</span></p>
{{ else }}
{{ if .Content }}
<p class="uk-margin-remove">{{ .Content }}</p>
{{ end }}
{{ range (index $.MessageImages .ID) }}
<div class="uk-margin-small-top">
<img src="{{ .URL }}" alt="{{ .FileName }}"
style="max-width: 200px; max-height: 200px; border-radius: 8px; cursor: pointer;"
onclick="openLightbox('{{ .URL }}', '{{ .FileName }}')" />
</div>
{{ end }}
{{ end }}
</div>
</div>
</li>
{{ end }}
</ul>
</div>
{{ if .CanSend }}
<div class="uk-card uk-card-default uk-card-body uk-margin-top">
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/send" enctype="multipart/form-data">
{{ .CSRFTokenHTML }}
<textarea name="content" class="uk-textarea" rows="3" placeholder="{{t .LangCode "chat.input"}}" maxlength="{{ .ChatMessageLimit }}"></textarea>
<div class="uk-margin-small-top" style="display: flex; align-items: center; gap: 10px;">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image"></a>
<span style="font-size: 13px; color: #666;">{{t .LangCode "chat.addImage"}}</span>
<input type="file" name="image" accept="image/*">
</div>
<span class="uk-text-meta" id="file-name"></span>
</div>
<div class="uk-margin-small-top uk-text-right">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "chat.send"}}</button>
</div>
</form>
</div>
{{ else }}
<div class="uk-text-center uk-margin-top">
<p class="uk-text-warning">{{t .LangCode "chat.closedNoSend"}}</p>
<a href="/_/{{.PageUser.Domain}}" class="uk-button uk-button-default">{{t .LangCode "common.backHome"}}</a>
</div>
{{ end }}
{{ if .IsOwner }}
<div class="uk-margin-top uk-text-center">
<a class="uk-button uk-button-{{ if .IsBlocked }}default{{ else }}warning{{ end }} uk-button-small" href="#">
{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}
</a>
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click">
<div class="uk-card uk-card-default uk-card-body">
<h3 class="uk-card-title">{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}?</h3>
<p>{{ if .IsBlocked }}{{t .LangCode "chat.confirmUnblock"}}{{ else }}{{t .LangCode "chat.confirmBlock"}}{{ end }}</p>
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/block">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-{{ if .IsBlocked }}primary{{ else }}danger{{ end }}">
{{t .LangCode "chat.confirm"}}{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}
</button>
</form>
</div>
</div>
<a class="uk-button uk-button-danger uk-button-small" href="#">{{t .LangCode "chat.delete"}}</a>
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click">
<div class="uk-card uk-card-default uk-card-body">
<h3 class="uk-card-title">{{t .LangCode "qitem.danger"}}</h3>
<p>{{t .LangCode "chat.deleteConfirm"}}</p>
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/delete">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-danger">{{t .LangCode "qitem.deleteConfirmBtn"}}</button>
</form>
</div>
</div>
</div>
{{ end }}
</div>
<!-- 自定义灯箱结构 -->
<div id="customLightbox" class="custom-lightbox" onclick="closeLightbox()">
<span class="close-btn" onclick="closeLightbox()">&times;</span>
<img id="lightboxImg" src="" alt="" />
<div id="lightboxCaption" class="caption"></div>
</div>
<script>
// 显示已选文件名
var fileInput = document.querySelector('input[name="image"]');
var fileNameSpan = document.getElementById('file-name');
if (fileInput && fileNameSpan) {
fileInput.addEventListener('change', function() {
if (fileInput.files.length > 0) {
fileNameSpan.textContent = fileInput.files[0].name;
} else {
fileNameSpan.textContent = '';
}
});
}
// 自定义灯箱函数
function openLightbox(src, caption) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightboxCaption').textContent = caption || '';
document.getElementById('customLightbox').classList.add('active');
}
function closeLightbox() {
document.getElementById('customLightbox').classList.remove('active');
// 清空图片避免后台加载消耗
document.getElementById('lightboxImg').src = '';
}
// ESC 关闭
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeLightbox();
}
});
</script>
{{template "base/footer" .}}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package templates
import (
"embed"
)
//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html change-logs.html pixel.html maintenance-mode.html
var FS embed.FS
+123
View File
@@ -0,0 +1,123 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body">
<h1 class="uk-heading-small">{{t .LangCode "site.title"}}</h1>
<p style="color: red;">重大消息!本站已切换到<a href="https://box.shiroko.one/">box.shiroko.one</a>,原域名将在2026年12月24日到期,期间将通过301转发指向新域名,到期后旧域名将彻底不在使用。<br>问:为什么?答:.guru域名连续涨价,现在一年三百出头了遭不住。<br>问:我该做什么?答:记住新域名,如果之前有通过二维码分享出去的,记得替换成新的二维码。</p><hr>
<p><br>⭐一个无需注册即可提问的匿名提问箱。你不需要下载App,不需要绑定社交账号,甚至连邮箱都不需要是真的。<br>⭐本站在原版的基础上新增了问题页面的二维码、私聊、投票、追加提问等功能以及更多自定义内容。各位用户可自行修改二维码背景图、颜色等个性化配置。<br>⭐可以到站长唯一提问提问箱链接<a href="//box.shiroko.one/_/tamakyi">小狼的提问箱</a>尝试相关功能或给我提意见。<br>⭐可以加群:<a href="http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=rRy9g5XY34LIzOITGGmQHjX6BIICFpam&authKey=EjyNjp60KyG8BtKZGN8BXJaf1jl8okkCcUhVF2zAnGPRjhvuSdopnVqOSfSymnGg&noverify=0&group_code=777163254">小狼的群</a>一起水群。<br></p>
<hr><p style="color: red;">近期将国内段访问交由EdgeOne接管,境外访问照旧由CloudFlare提供,经测试国内访问速度有所提升。但因EdgeOne Free套餐不提供CIDR列表,IP放行采取自动抓取手动维护模式,如果出现521错误请刷新一下页面,过几天应该就好了。</p>
<hr><p style="color: red; font-weight: bold;">近期更新:<br>⭐2026.9 增加了提问、投票、私聊的总开关,可以设置定时收集、定量收集或关闭功能,顺便调整了前后端显示,看起来更易用了一些。<br>⭐2026.8 新增了<a href="https://box.shiroko.one/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.shiroko.one/_/tamakyi/2894">Markdown显示</a>、置顶显示等多个功能,详情见<a href="https://blog.shiroko.one/record/tamabox_changelog_20260813">这里</a>。<br>⭐2026.8 放弃使用recaptcha等对国内用户非常不友好的验证方式,更新了密码加密方式,修复了图片上传等一系列问题。<br>⭐2026.5 增加了私聊功能,可进行连续性对话,详见<a href="https://blog.shiroko.one/record/tamabox-update-20260513">介绍</a>;另有增加提问箱的卡片分享,更直观和美观。<br>⭐2026.2 新增了用户展示和标签功能,如果你希望陌生人给你提问,可以在设置页面给自己贴上5个标签并选择是否展示在探索页面:<a href="https://box.shiroko.one/explore">点我访问</a>,详情见<a href="https://blog.shiroko.one/record/tamabox-tags">狼的公告板</a><br></p>
<hr><p>一点提醒:<br>⭐本站唯一指定认证站长:<a href="//box.shiroko.one/_/tamakyi">小狼</a>,注意不要被骗。
<br>⭐本站不允许垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义、灌水提问的出现,如有涉证或不良言论将直接删除。</p>
</div>
<p>{{t .LangCode "site.stats"}}:<pre style="overflow: hidden;" id="site-stats-text">{{if .SiteStats}}{{.SiteStats.Text}}{{end}}</pre></p>
<script type="text/javascript">
// 首页统计已改为程序内直接计算(服务端 30 分钟缓存),这里每 30 分钟定时拉取最新值刷新,
// 与原来 crontab 生成 question.count.txt 的节奏一致,且不再依赖外部静态文件。
document.addEventListener('DOMContentLoaded', function () {
setInterval(function () {
fetch("/api/v1/site-stats")
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data;
if (!d || !d.text) return;
var el = document.getElementById("site-stats-text");
if (el) el.textContent = d.text;
})
.catch(function () {});
}, 30 * 60 * 1000);
});
</script>
<!--<script src="https://r2.337845818.best/oss-to-r2/nekobox/cdn/waline/client/v2/waline.js"></script>
<link href='https://r2.337845818.best/oss-to-r2/nekobox/cdn/waline/client/v2/waline-no-commits.css' rel='stylesheet' />-->
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const waline = Waline.init({
el: '#waline',
//后端服务器地址
serverURL: 'https://waline.337845818.best/',
path: window.location.href,
// 中文默认
locale: {
nick: '昵称',
nickError: '昵称不能少于3个字符',
mail: '邮箱',
mailError: '请填写正确的邮件地址',
link: '网址',
optional: '可选',
placeholder: '欢迎评论(可以无需登录,但需要手工审核,不会立即展现)',
sofa: '来发评论吧~',
submit: '提交',
like: '喜欢',
cancelLike: '取消喜欢',
reply: '回复',
cancelReply: '取消回复',
comment: '评论',
refresh: '刷新',
more: '加载更多...',
preview: '预览',
emoji: '表情',
uploadImage: '上传图片',
seconds: '秒前',
minutes: '分钟前',
hours: '小时前',
days: '天前',
now: '刚刚',
uploading: '正在上传',
login: '登录',
logout: '退出',
admin: '博主',
sticky: '置顶',
word: '字',
wordHint: '评论字数应在 $0 到 $1 字之间!\n当前字数:$2',
anonymous: '无名小狼',
level0: '潜水',
level1: '冒泡',
level2: '吐槽',
level3: '活跃',
level4: '话痨',
level5: '传说',
gif: '表情包',
gifSearchPlaceholder: '搜索表情包',
profile: '个人资料',
approved: '通过',
waiting: '待审核',
spam: '垃圾',
unsticky: '取消置顶',
oldest: '按倒序',
latest: '按正序',
hottest: '按热度',
reactionTitle: '',
reaction0: '哇哦',
reaction1: 'prpr',
reaction2: '流汗',
reaction3: '生气',
reaction4: '流鼻血',
reaction5: 'あべ',
},
reaction: [
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGQ3OTRhYzRjMTBkZmZhZDVmZTA1EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwOGEyOTlhYzRjMTBmOWFkZjAwYRCIzNcv.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGRhOTVhYzRjMTBkNmViYTVlNTA2EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGU1OTJhYzRjMTBmMzk3ZmVmNTAyEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGNkODdhYzRjMTBjYmEwOTFjNjAxEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwODliOGVhYzRjMTBmOGM0ZWY1ZRCIzNcv.gif',
],
meta: ['nick','mail'],
requiredMeta: ['mail'],
pageview: true,
search: false,
login: 'enable',
wordLimit: 50,
pageSize: 5,
avatar: 'identicon',
emoji: [
'https://gcore.jsdelivr.net/gh/tamakyi/emoji-collection@main/acfun-color/60x60',
],
//禁止图片上传按钮
imageUploader: false,
//不显示版权标志
copyright: false,
});
});
</script>
{{template "base/footer" .}}
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
<style>.awl a {
color: #FFFFFF;
text-decoration: none;
}
.abml a {
color: #000000;
font-family: Roboto-Medium, Helvetica, Arial, sans-serif;
font-weight: bold;
text-decoration: none;
}
.adgl a {
color: rgba(0, 0, 0, 0.87);
text-decoration: none;
}
.afal a {
color: #b0b0b0;
text-decoration: none;
}
@media screen and (min-width: 600px) {
.v2sp {
padding: 6px 30px 0px;
}
.v2rsp {
padding: 0px 10px;
}
}
@media screen and (min-width: 600px) {
.mdv2rw {
padding: 40px 40px;
}
} </style>
<link href="//fonts.loli.net/css?family=Google+Sans" rel="stylesheet" type="text/css"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center" class="mdv2rw">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
您在 狼的提问箱 的提问有了回复
</div>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px; font-weight: bolder">
{{.question}}</p>
</td>
</tr>
</tbody>
</table>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px;">
{{.answer}}</p>
</td>
</tr>
</tbody>
</table>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 20px;padding-top: 20px; text-align: center;">
<div style="text-align: center;">
<a href="{{.link}}" target="_blank"
link-id="main-button-link"
style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif; line-height: 16px; color: #ffffff; font-weight: 400; text-decoration: none;font-size: 14px;display:inline-block;padding: 10px 24px;background-color: #4184F3; border-radius: 5px; min-width: 90px;">
查看提问
</a>
</div>
<br/>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
我们向您发送这封邮件来告诉您在 狼的提问箱 的活动状态,若您未曾使用过 狼的提问箱,请忽略本邮件。
</div>
<div style="direction: ltr;">
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body>
<p>您好!</p>
<p>您收到一个新的私聊对话,内容如下:</p>
<div style="border-left: 4px solid #e0e0e0; padding: 12px; margin: 12px 0; background-color: #f9f9f9; border-radius: 4px; color: #333333;">
{{.content}}
</div>
<p>点击下方链接查看并回复:</p>
<p><a href="{{.link}}">{{.link}}</a></p>
<br>
<p style="color: #999999; font-size: 12px;">此邮件由系统自动发送,请勿回复。<br>© {{.year}} 狼的提问箱</p>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
<style>.awl a {
color: #FFFFFF;
text-decoration: none;
}
.abml a {
color: #000000;
font-family: Roboto-Medium, Helvetica, Arial, sans-serif;
font-weight: bold;
text-decoration: none;
}
.adgl a {
color: rgba(0, 0, 0, 0.87);
text-decoration: none;
}
.afal a {
color: #b0b0b0;
text-decoration: none;
}
@media screen and (min-width: 600px) {
.v2sp {
padding: 6px 30px 0px;
}
.v2rsp {
padding: 0px 10px;
}
}
@media screen and (min-width: 600px) {
.mdv2rw {
padding: 40px 40px;
}
} </style>
<link href="//fonts.loli.net/css?family=Google+Sans" rel="stylesheet" type="text/css"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center" class="mdv2rw">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
您的提问有了一条追问
</div>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px;">
原提问:{{.question}}</p>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54); font-size: 14px; line-height: 20px;">
追问:{{.followup}}</p>
</td>
</tr>
</tbody>
</table>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 20px;padding-top: 20px; text-align: center;">
<div style="text-align: center;">
<a href="{{.link}}" target="_blank"
link-id="main-button-link"
style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif; line-height: 16px; color: #ffffff; font-weight: 400; text-decoration: none;font-size: 14px;display:inline-block;padding: 10px 24px;background-color: #4184F3; border-radius: 5px; min-width: 90px;">
查看追问
</a>
</div>
<br/>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
我们向您发送这封邮件来告诉您账号的状态,若您未曾在 狼的提问箱 注册过账号,请忽略本邮件。
</div>
<div style="direction: ltr;">
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body>
<p>您好!</p>
<p>您收到一条私聊新消息:</p>
<div style="border-left: 4px solid #e0e0e0; padding: 12px; margin: 12px 0; background-color: #f9f9f9; border-radius: 4px; color: #333333;">
{{.content}}
</div>
<p>点击下方链接查看对话:</p>
<p><a href="{{.link}}">{{.link}}</a></p>
<br>
<p style="color: #999999; font-size: 12px;">此邮件由系统自动发送,请勿回复。<br>© {{.year}} 狼的提问箱</p>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
<style>.awl a {
color: #FFFFFF;
text-decoration: none;
}
.abml a {
color: #000000;
font-family: Roboto-Medium, Helvetica, Arial, sans-serif;
font-weight: bold;
text-decoration: none;
}
.adgl a {
color: rgba(0, 0, 0, 0.87);
text-decoration: none;
}
.afal a {
color: #b0b0b0;
text-decoration: none;
}
@media screen and (min-width: 600px) {
.v2sp {
padding: 6px 30px 0px;
}
.v2rsp {
padding: 0px 10px;
}
}
@media screen and (min-width: 600px) {
.mdv2rw {
padding: 40px 40px;
}
} </style>
<link href="//fonts.loli.net/css?family=Google+Sans" rel="stylesheet" type="text/css"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center" class="mdv2rw">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
您在 狼的提问箱 发送了一个提问
</div>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px;">{{.question}}</p>
</td>
</tr>
</tbody>
</table>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 20px;padding-top: 20px; text-align: center;">
<div style="text-align: center;">
<a href="{{.link}}" target="_blank"
link-id="main-button-link"
style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif; line-height: 16px; color: #ffffff; font-weight: 400; text-decoration: none;font-size: 14px;display:inline-block;padding: 10px 24px;background-color: #4184F3; border-radius: 5px; min-width: 90px;">
查看提问
</a>
</div>
<br/>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
我们向您发送这封邮件来告诉您账号的状态,若您未曾在 狼的提问箱 进行过提问,请忽略本邮件。
</div>
<div style="direction: ltr;">
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
<style>.awl a {
color: #FFFFFF;
text-decoration: none;
}
.abml a {
color: #000000;
font-family: Roboto-Medium, Helvetica, Arial, sans-serif;
font-weight: bold;
text-decoration: none;
}
.adgl a {
color: rgba(0, 0, 0, 0.87);
text-decoration: none;
}
.afal a {
color: #b0b0b0;
text-decoration: none;
}
@media screen and (min-width: 600px) {
.v2sp {
padding: 6px 30px 0px;
}
.v2rsp {
padding: 0px 10px;
}
}
@media screen and (min-width: 600px) {
.mdv2rw {
padding: 40px 40px;
}
} </style>
<link href="//fonts.loli.net/css?family=Google+Sans" rel="stylesheet" type="text/css"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center" class="mdv2rw">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
您的 狼的提问箱 收到一个新的提问
</div>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px;">{{.question}}</p>
</td>
</tr>
</tbody>
</table>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 20px;padding-top: 20px; text-align: center;">
<div style="text-align: center;">
<a href="{{.link}}" target="_blank"
link-id="main-button-link"
style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif; line-height: 16px; color: #ffffff; font-weight: 400; text-decoration: none;font-size: 14px;display:inline-block;padding: 10px 24px;background-color: #4184F3; border-radius: 5px; min-width: 90px;">
查看提问
</a>
</div>
<br/>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
我们向您发送这封邮件来告诉您账号的状态,若您未曾在 狼的提问箱 注册过账号,请忽略本邮件。
</div>
<div style="direction: ltr;">
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
<link href="//fonts.loli.net/css?family=Google+Sans" rel="stylesheet" type="text/css"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center" class="mdv2rw">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
您的投票收到了新回复
</div>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<p style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px;">投票《{{.title}}》收到了一条新的表单回复,点击下方按钮查看。</p>
</td>
</tr>
</tbody>
</table>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 20px;padding-top: 20px; text-align: center;">
<div style="text-align: center;">
<a href="{{.link}}" target="_blank"
style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif; line-height: 16px; color: #ffffff; font-weight: 400; text-decoration: none;font-size: 14px;display:inline-block;padding: 10px 24px;background-color: #4184F3; border-radius: 5px; min-width: 90px;">
查看回复
</a>
</div>
<div style="margin-top: 16px; padding: 10px 12px; border-radius: 6px; background-color: #fff8e1; color: #7a5b00; font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 12px; line-height: 18px; text-align: left;">
提示:直接回复此邮件不能提交回答,请点击上方“查看回复”按钮完成操作。
</div>
<br/>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
此邮件由系统自动发送,请勿直接回复。直接回复邮件不能实现回答,请通过邮件中的按钮访问页面。<br/>
若您未曾在 狼的提问箱 创建过投票,请忽略本邮件。
</div>
<div style="direction: ltr;">
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
<style>.awl a {
color: #FFFFFF;
text-decoration: none;
}
.abml a {
color: #000000;
font-family: Roboto-Medium, Helvetica, Arial, sans-serif;
font-weight: bold;
text-decoration: none;
}
.adgl a {
color: rgba(0, 0, 0, 0.87);
text-decoration: none;
}
.afal a {
color: #b0b0b0;
text-decoration: none;
}
@media screen and (min-width: 600px) {
.v2sp {
padding: 6px 30px 0px;
}
.v2rsp {
padding: 0px 10px;
}
}
@media screen and (min-width: 600px) {
.mdv2rw {
padding: 40px 40px;
}
} </style>
<link href="//fonts.loli.net/css?family=Google+Sans" rel="stylesheet" type="text/css"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center" class="mdv2rw">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
您正在找回 狼的提问箱 注册账号的密码
</div>
<table align="center" style="margin-top:8px;">
<tbody>
<tr style="line-height: normal;">
<td>
<a style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.87); font-size: 14px; line-height: 20px;">{{.email}}</a>
</td>
</tr>
</tbody>
</table>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 20px;padding-top: 20px; text-align: center;">
<div style="text-align: center;">
<a href="{{.link}}" target="_blank"
link-id="main-button-link"
style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif; line-height: 16px; color: #ffffff; font-weight: 400; text-decoration: none;font-size: 14px;display:inline-block;padding: 10px 24px;background-color: #4184F3; border-radius: 5px; min-width: 90px;">
重新设置密码
</a>
</div>
<br/>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
我们向您发送这封邮件来告诉您账号的状态,若您未曾在 狼的提问箱 注册过账号,请忽略本邮件。
</div>
<div style="direction: ltr;">
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="format-detection" content="email=no"/>
<meta name="format-detection" content="date=no"/>
</head>
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
<tbody>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
<tr align="center">
<td>
<table border="0" cellspacing="0" cellpadding="0"
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
<tbody>
<tr>
<td width="8" style="width: 8px;"></td>
<td>
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
align="center">
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
<div style="font-size: 24px;">
检测到疑似批量扫描
</div>
<div style="font-size: 13px; color: rgba(0,0,0,0.54); margin-top: 6px;">
{{.time}}
</div>
</div>
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 22px; padding-top: 20px; text-align: left;">
<div style="padding: 12px 14px; border-radius: 6px; background-color: #fdecea; border: 1px solid #f5c6cb; margin-bottom: 14px;">
<div><b>来源 IP:</b>{{.ip}}</div>
<div style="margin-top: 4px;"><b>网络归属:</b>{{.provider}}</div>
<div style="margin-top: 4px;"><b>处置:</b>已自动临时封禁 <b>{{.duration}}</b> 分钟,到期自动解除</div>
{{if .proxy_hint}}
<div style="margin-top: 8px; font-size: 13px; color: #b71c1c;">⚠️ 该 IP 属于 CDN / 代理网络,攻击者可随时更换出口 IP,本次封禁可能无法拦截真实来源。</div>
{{end}}
</div>
<div style="font-size: 13px; color: rgba(0,0,0,0.54);">命中路径样本:</div>
<div style="font-family: monospace; font-size: 12px; color: #333; background-color: #f7f7f7; padding: 10px 12px; border-radius: 6px; margin-top: 6px; word-break: break-all;">
{{.paths}}
</div>
<div style="font-size: 12px; color: rgba(0,0,0,0.54); margin-top: 12px;">
该 IP 在统计窗口内频繁访问后台/敏感路径或产生大量 404,已自动加入临时封禁名单。如属误报,请检查服务器访问日志确认。
</div>
</div>
</div>
<div style="text-align: left;">
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
<div>
此邮件由系统自动发送,请勿直接回复。<br/>
{{ .year }} 狼的提问箱
</div>
</div>
</div>
</td>
<td width="8" style="width: 8px;"></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr height="32" style="height: 32px;">
<td></td>
</tr>
</tbody>
</table>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body">
<h2>{{t .LangCode "maintenance.title"}}</h2>
<div>
<p>{{t .LangCode "maintenance.desc"}}</p>
</div>
</div>
{{template "base/footer" .}}
+148
View File
@@ -0,0 +1,148 @@
package templates
import (
"bytes"
gotemplate "html/template"
"testing"
"time"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// TestPercentRender 渲染投票结果页并检查百分比与进度条是否真实输出。
func TestPercentRender(t *testing.T) {
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
tpl := gotemplate.New("Flamego.Template")
for _, f := range fs.Files() {
tt := tpl.New(f.Name())
for _, funcMap := range templatepkg.FuncMap() {
tt.Funcs(funcMap)
}
dataBytes, err := f.Data()
if err != nil {
t.Fatalf("get data of %q: %v", f.Name(), err)
}
if _, err := tt.Parse(string(dataBytes)); err != nil {
t.Fatalf("parse %q: %v", f.Name(), err)
}
}
data := map[string]interface{}{
"LangCode": "zh-CN",
"IsUserPage": true,
"Vote": &db.Vote{Model: dbutil.Model{ID: 1}, Title: "表单结果", Type: db.VoteTypeForm},
"Total": int64(1),
"HasTextFields": true,
"FormFields": []*db.VoteFormField{
{Label: "满意度", FieldType: db.VoteFormFieldTypeRadio, Options: `["满意","一般"]`},
},
"Results": map[string]interface{}{
"Type": "form", "FormCount": int64(1), "FormFieldStats": []interface{}{
map[string]interface{}{
"Field": map[string]interface{}{"Label": "满意度", "FieldType": "radio"},
"Total": int64(3),
"OptionStats": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "满意"}, "Count": int64(2)},
map[string]interface{}{"Option": map[string]interface{}{"Content": "一般"}, "Count": int64(1)},
},
},
},
},
"Submissions": []interface{}{
map[string]interface{}{"CreatedAt": time.Now(), "Fields": map[string]interface{}{"满意度": "满意"}},
},
"CSRFTokenHTML": gotemplate.HTML(""),
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "user/vote-results", data); err != nil {
t.Fatalf("execute: %v", err)
}
html := buf.String()
// 2/3 取整 = 66%,1/3 取整 = 33%;进度条宽度同步渲染
for _, want := range []string{"(66%)", "(33%)", "width: 66%", "width: 33%", "uk-progressbar"} {
if !bytes.Contains([]byte(html), []byte(want)) {
t.Errorf("HTML missing %q", want)
}
}
}
// TestInlineTextRepliesRender 验证表单结果页:文本类字段的逐条提交内容内联在题目
// 下方、默认收起(含 hidden 属性),点击"查看文本提交(N)"展开。
func TestInlineTextRepliesRender(t *testing.T) {
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
tpl := gotemplate.New("Flamego.Template")
for _, f := range fs.Files() {
tt := tpl.New(f.Name())
for _, funcMap := range templatepkg.FuncMap() {
tt.Funcs(funcMap)
}
dataBytes, err := f.Data()
if err != nil {
t.Fatalf("get data of %q: %v", f.Name(), err)
}
if _, err := tt.Parse(string(dataBytes)); err != nil {
t.Fatalf("parse %q: %v", f.Name(), err)
}
}
data := map[string]interface{}{
"LangCode": "zh-CN",
"IsUserPage": true,
"Vote": &db.Vote{Model: dbutil.Model{ID: 1}, Title: "表单结果", Type: db.VoteTypeForm},
"Total": int64(1),
"FormFields": []*db.VoteFormField{
{Model: dbutil.Model{ID: 10}, Label: "建议", FieldType: db.VoteFormFieldTypeTextarea},
},
"Results": map[string]interface{}{
"Type": "form", "FormCount": int64(1), "FormFieldStats": []interface{}{
map[string]interface{}{
"Field": map[string]interface{}{"ID": int64(10), "Label": "建议", "FieldType": "textarea"},
"Total": int64(2),
"TextCount": int64(2),
},
},
},
"TextReplies": map[string]interface{}{
"建议": []interface{}{
map[string]interface{}{"CreatedAt": time.Now(), "Text": "希望增加夜间模式"},
map[string]interface{}{"CreatedAt": time.Now(), "Text": "页面很清爽"},
},
},
"CSRFTokenHTML": gotemplate.HTML(""),
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "user/vote-results", data); err != nil {
t.Fatalf("execute: %v", err)
}
html := buf.String()
// 内联折叠入口 + 两则文本均存在 + 默认收起(hidden)
for _, want := range []string{
"查看文本提交(2)",
"希望增加夜间模式",
"页面很清爽",
`id="replies-10"`,
`id="caret-10"`,
} {
if !bytes.Contains([]byte(html), []byte(want)) {
t.Errorf("HTML missing %q", want)
}
}
// 折叠内容默认 hidden,不随页面始终铺在底部
if !bytes.Contains([]byte(html), []byte(`id="replies-10" hidden`)) {
t.Errorf("inline text replies should be hidden by default")
}
}
+19
View File
@@ -0,0 +1,19 @@
{{template "base/header" .}}
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/lib/neko-pixel/0.0.21/style.css"/>
<div class="uk-alert-warning uk-container uk-text-small" uk-alert>
<p>
请各位用户知悉并确认在使用画板功能时,不会发起及/或参与以下内容:
1. 涉及地域、种族、性别、宗教、政治等方面的歧视性内容;
2. 侮辱、诽谤他人,侵害他人合法权益的内容;
3. 含有淫秽色情、血腥暴力、赌博的内容;
4. 其他在法律法规中禁止的或不适宜的内容。
如果您实施了违反上述规范的行为,我们将视情况采取相关措施,并保留移交司法机关或者行政机关的权利。
</p>
</div>
<div id="app"></div>
<script>
var NEKO_CONFIG = {pixelBaseURL: '/api/v1/pixel'}
</script>
<script src="https://r2.337845818.best/tamabox/cdn/lib/neko-pixel/0.0.21/neko-pixel-app.umd.js"></script>
{{template "base/footer" .}}
+83
View File
@@ -0,0 +1,83 @@
{{$pinTitle := t .LangCode "ask.pinned"}}
<div id="question-list">
{{range $index, $elem := .PageQuestions}}
<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>
<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"}}
</div>
<p class="uk-text-small uk-text-break tb-preline tb-clamp-5">{{MarkdownPlainText $elem.Content}}</p>
</div>
{{end}}
</div>
<!-- 翻页游标与服务端 EncodePinCursor 保持一致:
isPinned(0/1) + createdAt 的 UnixNano + id 组成的复合键集游标。 -->
<!-- 初始 more 由服务端给出:首屏已展示全部提问时按钮直接不渲染,
之后每次加载以接口返回的 has_more 为准,最后一页加载完立即隐藏按钮。 -->
<div x-data="{ more: {{ if .PageQuestionHasMore }}true{{ else }}false{{ end }}, loading: false, cursor: '{{.PageQuestionCursor}}' }">
<button x-show="more" x-bind:disabled="loading" x-on:click.debounce="() => {
if (loading) return
loading = true
fetch(`/api/v1/user/{{.PageUser.Domain}}/questions?cursor=${cursor}`)
.then(response => response.json())
.then(resp => {
loading = false
// 外层为 ctx.JSON 的统一包装 {code, data, message},内层才是分页负载;
// code !== 0(接口报错)时保持 more 不变,仅复位 loading,下次可重试。
if (!resp || resp.code !== 0) return
const payload = resp.data || {}
let items = Array.isArray(payload.data) ? payload.data : []
if (items.length === 0) {
more = false
return
}
// 防重兜底:先扫描 #question-list 下全部已渲染条目(含服务端首屏与既往
// 追加)的 data-qid 构造“已见”集合,仅追加未出现过的提问。若服务端因游标
// 未前进等原因返回了与已见内容相同的批次(重复加载历史 bug 的表现形态),
// 直接按“到底”处理并隐藏按钮,杜绝无限重复追加。
const seenQIDs = new Set()
document.querySelectorAll('#question-list [data-qid]').forEach(el => seenQIDs.add(Number(el.dataset.qid)))
items = items.filter(q => !seenQIDs.has(q.id))
if (items.length === 0) {
more = false
return
}
// 服务端下发复合键集游标(见上方注释),前端不自行拼接;
// has_more 由服务端多取一条探测得出,最后一页加载完立即隐藏按钮。
more = payload.has_more !== false
cursor = payload.next_cursor || cursor
items.forEach(question => {
const div = document.createElement('div')
div.dataset.qid = question.id
div.innerHTML = `
<div>
<hr>
<a class='uk-button uk-button-default uk-button-small uk-float-right'
href='/_/{{.PageUser.Domain}}/${question.id}'>查看回答</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')}
</div>
<p class='uk-text-small uk-text-break tb-preline tb-clamp-5'></p>
</div>`
div.getElementsByTagName('p')[0].innerText = question.content
document.getElementById('question-list').appendChild(div)
})
})
// 网络失败/响应非法 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>
</button>
<div x-show="!more" class="uk-text-meta uk-text-center">
<hr>
无更多留言
<br><br>
</div>
</div>
+425
View File
@@ -0,0 +1,425 @@
{{template "base/header" .}}
<link rel="shortcut icon"
href="{{.PageUser.Avatar}}?x-oss-process=image/auto-orient,1/quality,q_70/sharpen,200/resize,limit_0,m_fill,w_200,h_200"/>
<link rel="apple-touch-icon"
href="{{.PageUser.Avatar}}?x-oss-process=image/auto-orient,1/quality,q_70/sharpen,200/resize,limit_0,m_fill,w_200,h_200"/>
<link rel="icon" sizes="192x192"
href="{{.PageUser.Avatar}}?x-oss-process=image/auto-orient,1/quality,q_70/sharpen,200/resize,limit_0,m_fill,w_200,h_200">
<style>
a.uk-button.uk-button-link {
text-transform: none;
}
/* 回答行:左侧「回答」与右侧「标记为已读/标记为未读」按钮等高 */
.tb-ans-row .uk-button {
height: 40px;
line-height: 38px;
}
.tb-followup-card {
border: 1px solid #e7e9ec;
border-left: 3px solid #b0b6bd;
border-radius: 8px;
padding: 12px 14px;
background: #fbfbfc;
}
html[data-theme="dark"] .tb-followup-card {
background: #1b1e23;
border-color: #2c2f36;
border-left-color: #3a4250;
}
html[data-theme="dark"] .tb-followup-card .uk-button-default {
background-color: #2c2f36;
color: #e5e9ec;
border-color: #3a4250;
}
html[data-theme="dark"] .tb-followup-card .uk-button-default:hover {
background-color: #353941;
color: #ffffff;
}
.tb-img-uploader {
width: 100%;
background-color: #f8f8f8;
display: flex;
padding-top: 5px;
padding-bottom: 5px;
align-items: center;
justify-content: flex-start;
gap: 5px;
}
html[data-theme="dark"] .tb-img-uploader {
background-color: #1b1e23;
}
</style>
<div>
<div class="uk-card uk-card-default">
<div class="uk-card-header">
<div class="uk-text-left uk-text-small uk-text-muted">{{Date .Question.CreatedAt "Y-m-d H:i:s"}}</div>
<div class="md-body uk-text-break uk-margin-top uk-margin-bottom">{{MarkdownFormat .Question.Content}}</div>
{{ if .AskUploadImages }}
<ul class="uk-thumbnav" uk-margin>
{{ 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="">
</a>
</div>
{{ end }}
</ul>
{{ end }}
</div>
{{if ne .Question.Answer ""}}
<div class="uk-card-body">
<!--<div class="uk-text-left uk-text-small uk-text-muted">{{Date .Question.UpdatedAt "Y-m-d H:i:s"}}</div>-->
<div id="answer-display">
<div class="md-body uk-text-break">{{MarkdownFormat .Question.Answer}}</div>
{{ if .AnswerUploadImages }}
<ul class="uk-thumbnav" uk-margin>
{{ 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="">
</a>
</div>
{{ end }}
</ul>
{{ end }}
</div>
<p class="uk-text-small uk-text-right uk-text-muted">{{t .LangCode "qitem.answeredBy" .PageUser.Name (Date .Question.UpdatedAt "Y-m-d H:i:s")}}</p>
<!-- 分享此提问(左)与 更新回答(右)一左一右分开;箱主未登录(无更新回答按钮)时分享按钮居中;编辑区在下方由 tbToggleAnswerEditor 控制 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap uk-margin-small-top {{ if not .CanDelete }}uk-flex-center{{ end }}" style="gap: 8px;">
{{ if and (not .Question.IsPrivate) (ne .Question.Answer "") }}
<a class="uk-button uk-button-default uk-button-small" href="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/share-card" target="_blank">📤 {{t .LangCode "qitem.shareCard"}}</a>
{{ end }}
{{ if .CanDelete }}
<div id="answer-edit-trigger" style="margin-left: auto;">
<button type="button" class="uk-button uk-button-default uk-button-small" onclick="tbToggleAnswerEditor()">{{t .LangCode "qitem.updateAnswer"}}</button>
</div>
{{ end }}
</div>
{{ if not .CanDelete }}
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const waline = Waline.init({
el: '#waline',
//后端服务器地址
serverURL: 'https://waline.337845818.best/',
path: window.location.href,
// 中文默认
locale: {
nick: '昵称',
nickError: '昵称不能少于3个字符',
mail: '邮箱',
mailError: '请填写正确的邮件地址',
link: '网址',
optional: '可选',
placeholder: '欢迎评论(需要手工审核,不会立即展现)',
sofa: '来发评论吧~',
submit: '提交',
like: '喜欢',
cancelLike: '取消喜欢',
reply: '回复',
cancelReply: '取消回复',
comment: '评论',
refresh: '刷新',
more: '加载更多...',
preview: '预览',
emoji: '表情',
uploadImage: '上传图片',
seconds: '秒前',
minutes: '分钟前',
hours: '小时前',
days: '天前',
now: '刚刚',
uploading: '正在上传',
login: '登录',
logout: '退出',
admin: '博主',
sticky: '置顶',
word: '字',
wordHint: '评论字数应在 $0 到 $1 字之间!\n当前字数:$2',
anonymous: '无名小狼',
level0: '潜水',
level1: '冒泡',
level2: '吐槽',
level3: '活跃',
level4: '话痨',
level5: '传说',
gif: '表情包',
gifSearchPlaceholder: '搜索表情包',
profile: '个人资料',
approved: '通过',
waiting: '待审核',
spam: '垃圾',
unsticky: '取消置顶',
oldest: '按倒序',
latest: '按正序',
hottest: '按热度',
reactionTitle: '',
reaction0: '哇哦',
reaction1: 'prpr',
reaction2: '流汗',
reaction3: '生气',
reaction4: '流鼻血',
reaction5: 'あべ',
},
reaction: [
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGQ3OTRhYzRjMTBkZmZhZDVmZTA1EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwOGEyOTlhYzRjMTBmOWFkZjAwYRCIzNcv.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGRhOTVhYzRjMTBkNmViYTVlNTA2EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGU1OTJhYzRjMTBmMzk3ZmVmNTAyEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGNkODdhYzRjMTBjYmEwOTFjNjAxEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwODliOGVhYzRjMTBmOGM0ZWY1ZRCIzNcv.gif',
],
meta: ['nick','mail'],
requiredMeta: ['mail'],
pageview: true,
search: false,
comment: false,
login: 'disable',
wordLimit: 50,
pageSize: 5,
avatar: 'monsterid',
emoji: [
'https://gcore.jsdelivr.net/gh/tamakyi/emoji-collection@main/acfun-color/60x60',
],
//禁止图片上传按钮
imageUploader: false,
//不显示版权标志
copyright: false,
});
});
</script>
{{ end }}
{{ if .CanDelete }}
<div id="answer-editor" style="display:none;">
<hr>
<form method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/answer" enctype="multipart/form-data">
{{ .CSRFTokenHTML }}
<div class="uk-margin uk-text-center">
<textarea name="answer" class="uk-textarea" rows="5" maxlength="{{ .Limits.AnswerContentLen }}"
placeholder="{{t .LangCode "qitem.answerPlaceholder"}}">{{ .Question.Answer }}</textarea>
<div x-data="{ files: null }" class="tb-img-uploader">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image" style="margin-left: 10px"></a>
<span style="font-size: 12px" x-text="files ? files[0].name : '{{t .LangCode "ask.addImage"}}'"></span>
<input id="uploader" name="images" type="file" accept="image/*" x-on:change="files = Object.values($event.target.files)">
</div>
<button x-show="files" type="button" class="uk-icon-link" uk-icon="close" style="margin-left: 10px;"
@click="files = null"></button>
</div>
</div>
{{ if ne .Question.ReceiveReplyEmail "" }}
<div class="uk-alert-warning uk-text-small" uk-alert>
<p>{{t .LangCode "qitem.emailNotifyHint"}}</p>
</div>
{{ end }}
<div class="uk-margin uk-text-center">
<button type="submit" class="uk-button uk-button-primary uk-button-small">{{t .LangCode "qitem.updateAnswer"}}</button>
<button type="button" class="uk-button uk-button-default uk-button-small uk-margin-small-left" onclick="tbToggleAnswerEditor()">{{t .LangCode "common.cancel"}}</button>
</div>
</form>
</div>
{{ end }}
</div>
{{ else if .CanDelete }}
<div class="uk-card-body uk-padding-remove-top">
<hr>
<h5 class="uk-text-center">{{t .LangCode "qitem.answerTitle"}}</h5>
<form id="tb-answer-form" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/answer" enctype="multipart/form-data">
{{ .CSRFTokenHTML }}
<div class="uk-margin uk-text-center">
<textarea name="answer" class="uk-textarea" rows="5" maxlength="{{ .Limits.AnswerContentLen }}"
placeholder="{{t .LangCode "qitem.answerPlaceholder"}}">{{ .answer }}</textarea>
<div x-data="{ files: null }" style="width: 100%; background-color: #f8f8f8; display: flex; padding-top: 5px; padding-bottom: 5px; align-items: center; justify-content: start;gap: 5px">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image" style="margin-left: 10px"></a>
<span style="font-size: 12px" x-text="files ? files[0].name : '{{t .LangCode "ask.addImage"}}'"></span>
<input id="uploader" name="images" type="file" accept="image/*" x-on:change="files = Object.values($event.target.files)">
</div>
<button x-show="files" type="button" class="uk-icon-link" uk-icon="close" style="margin-left: 10px;"
@click="files = null"></button>
</div>
</div>
{{ if ne .Question.ReceiveReplyEmail "" }}
<div class="uk-alert-warning uk-text-small" uk-alert>
<p>{{t .LangCode "qitem.emailNotifyHint"}}</p>
</div>
{{ end }}
</form>
<!-- 回答(左)+ 阅读状态(右)同一行,压缩空间;tb-ans-row 保证左右按钮等高 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap uk-margin-small-top tb-ans-row" style="gap: 12px; justify-content: space-between;">
<button type="submit" form="tb-answer-form" class="uk-button uk-button-primary">{{t .LangCode "qitem.submitAnswer"}}</button>
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 8px;">
{{ if .Question.IsRead }}
<span class="uk-text-small uk-text-muted">✓ {{t .LangCode "qlist.read"}}</span>
<form class="uk-display-inline" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/unread">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-default">{{t .LangCode "qitem.markUnread"}}</button>
</form>
{{ else }}
<form method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/read">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-default">{{t .LangCode "qitem.markRead"}}</button>
</form>
{{ end }}
</div>
</div>
</div>
{{ end }}
<!-- 已回答的阅读状态(箱主):该行仅此一个元素,居中显示 -->
{{ if and .CanDelete (ne .Question.Answer "") }}
<div class="uk-card-body uk-padding-remove-top">
<div class="uk-text-center">
<span class="uk-label uk-label-success">✓ {{t .LangCode "qlist.read"}}</span>
<span class="uk-text-small uk-text-muted">{{t .LangCode "qitem.readLocked"}}</span>
</div>
</div>
{{ end }}
{{ if .FollowUps }}
<div class="uk-card-body uk-padding-remove-top">
{{ range $index, $elem := .FollowUps }}
<div class="tb-followup-card uk-margin-small-bottom">
<p class="uk-text-small uk-text-break uk-margin-remove-bottom"><strong>{{t $.LangCode "followup.content"}}</strong>{{ $elem.Content }}</p>
{{ if ne $elem.Answer "" }}
{{ if $.CanDelete }}
<div id="fu-display-{{ $index }}" class="uk-text-small uk-text-muted uk-text-break uk-margin-small-top">@{{ $.PageUser.Name }}:{{ MarkdownFormat $elem.Answer }}</div>
<div class="uk-margin-small-top uk-text-right">
<button type="button" class="uk-button uk-button-default uk-button-small" onclick="tbToggleFollowUpEditor({{ $index }})">{{t $.LangCode "qitem.updateAnswer"}}</button>
</div>
<div id="fu-editor-{{ $index }}" style="display:none;">
<hr>
<form method="post" action="/_/{{ $.PageUser.Domain }}/{{ $.Question.ID }}/follow-up/{{ $elem.ID }}/answer" class="uk-margin-small-top">
{{ $.CSRFTokenHTML }}
<textarea name="answer" class="uk-textarea" rows="3" maxlength="{{ $.Limits.AnswerContentLen }}" placeholder="{{t $.LangCode "followup.answerPlaceholder"}}">{{ $elem.Answer }}</textarea>
<div class="uk-margin-small-top uk-text-right">
<button type="submit" class="uk-button uk-button-primary uk-button-small">{{t $.LangCode "qitem.updateAnswer"}}</button>
<button type="button" class="uk-button uk-button-default uk-button-small uk-margin-small-left" onclick="tbToggleFollowUpEditor({{ $index }})">{{t $.LangCode "common.cancel"}}</button>
</div>
</form>
</div>
{{ else }}
<div class="uk-text-small uk-text-muted uk-text-break uk-margin-small-top">@{{ $.PageUser.Name }}:{{ MarkdownFormat $elem.Answer }}</div>
{{ end }}
{{ else if $.CanDelete }}
<form method="post" action="/_/{{ $.PageUser.Domain }}/{{ $.Question.ID }}/follow-up/{{ $elem.ID }}/answer" class="uk-margin-small-top">
{{ $.CSRFTokenHTML }}
<textarea name="answer" class="uk-textarea" rows="3" maxlength="{{ $.Limits.AnswerContentLen }}" placeholder="{{t $.LangCode "followup.answerPlaceholder"}}"></textarea>
<div class="uk-margin-small-top uk-text-right">
<button type="submit" class="uk-button uk-button-primary uk-button-small">{{t $.LangCode "followup.answerBtn"}}</button>
</div>
</form>
{{ else }}
<p class="uk-text-small uk-text-muted uk-margin-small-top">{{t $.LangCode "followup.waiting"}}</p>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{ if and .CanFollowUp (not .CanDelete) }}
<div class="uk-card-body uk-padding-remove-top">
<div class="tb-followup-card">
<h5 class="uk-margin-remove-bottom">{{t .LangCode "followup.askMore"}}</h5>
<p class="uk-text-small uk-text-muted">{{t .LangCode "followup.remaining" .FollowUpRemaining .FollowUpLimit}}</p>
<form method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/follow-up">
{{ .CSRFTokenHTML }}
<input type="hidden" name="token" value="{{ .QuestionToken }}">
<textarea name="content" class="uk-textarea" rows="3" maxlength="{{ .Limits.FollowUpContentLen }}" placeholder="{{t .LangCode "followup.contentPlaceholder"}}"></textarea>
<div class="uk-margin uk-text-center">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "followup.submit"}}</button>
</div>
</form>
</div>
</div>
{{ end }}
{{ if and .WaitingFollowUpReply (not .CanDelete) }}
<div class="uk-card-body uk-padding-remove-top">
<div class="tb-followup-card uk-text-small uk-text-muted uk-text-center">{{t .LangCode "followup.waitingReply"}}</div>
</div>
{{ end }}
<div class="uk-card-footer">
{{template "base/alert" .}}
{{ if .CanDelete }}
{{ if .Question.IsPrivate }}
<form class="uk-float-right" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/set-public">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-default uk-button-small">{{t .LangCode "qitem.public"}}</button>
</form>
{{ else }}
<form class="uk-float-right" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/set-private">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-default uk-button-small">{{t .LangCode "qitem.private"}}</button>
</form>
{{ end }}
<a class="uk-button uk-button-danger uk-button-small" href="#">{{t .LangCode "qitem.delete"}}</a>
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click">
<div class="uk-card uk-card-default uk-card-body">
<h3 class="uk-card-title">{{t .LangCode "qitem.danger"}}</h3>
<p>{{t .LangCode "qitem.deleteConfirm"}}</p>
<form class="uk-float-right"
method="post"
action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/delete">
{{ .CSRFTokenHTML }}
<button class="uk-button uk-button-danger">{{t .LangCode "qitem.deleteConfirmBtn"}}</button>
</form>
</div>
</div>
{{ end }}
<div class="uk-margin uk-text-center">
{{ $backTo := printf "/_/%s" .PageUser.Domain }}
{{ if eq .BackFrom "admin" }}{{ $backTo = .BackTo }}{{ end }}
<a class="uk-button uk-button-link" href="{{ $backTo }}">{{t .LangCode "qitem.backToBox" .PageUser.Name}}</a>
</div>
<!-- Box owner can't create new question here. -->
{{if not .IsOwnPage }}
<h5 class="uk-text-center">{{t .LangCode "qitem.askMore"}}</h5>
{{template "question/new-question-template" .}}
{{ end }}
<hr class="uk-divider-icon">
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
{{ template "question/history-template" . }}
</div>
</div>
</div>
<script>
function tbToggleAnswerEditor() {
var disp = document.getElementById('answer-display');
var trig = document.getElementById('answer-edit-trigger');
var ed = document.getElementById('answer-editor');
if (!ed) return;
if (ed.style.display === 'none') {
ed.style.display = 'block';
if (disp) disp.style.display = 'none';
if (trig) trig.style.display = 'none';
} else {
ed.style.display = 'none';
if (disp) disp.style.display = 'block';
if (trig) trig.style.display = 'block';
}
}
function tbToggleFollowUpEditor(idx) {
var disp = document.getElementById('fu-display-' + idx);
var ed = document.getElementById('fu-editor-' + idx);
if (!ed) return;
if (ed.style.display === 'none') {
ed.style.display = 'block';
if (disp) disp.style.display = 'none';
} else {
ed.style.display = 'none';
if (disp) disp.style.display = 'block';
}
}
</script>
{{template "base/footer" .}}
+310
View File
@@ -0,0 +1,310 @@
{{template "base/header" .}}
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目(R2 对无 Origin 请求的响应无 Vary,可缓存 31 天) -->
<link rel="shortcut icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
<link rel="apple-touch-icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
<link rel="icon" sizes="192x192" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
<style>
/* 投票列表:与探索页"全部投票"一致的列表行样式 */
.profile-votes-list { display: grid; gap: 0; }
.profile-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--tb-border, #e9e9e9);
text-decoration: none;
}
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border, #e9e9e9); }
.profile-vote-item:hover { background: var(--tb-surface-hover, #f8fbff); text-decoration: none; }
.profile-vote-content { min-width: 0; }
.profile-vote-title {
display: block;
overflow: hidden;
color: var(--tb-text, #333);
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.profile-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
.profile-vote-action {
flex: 0 0 auto;
color: var(--tb-primary, #1e87f0);
font-size: 0.82rem;
white-space: nowrap;
}
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
/* 个人主页 Tab:与探索页一致的胶囊切换样式 */
/* 个人主页 Tab:全宽等分,置于主体顶部,作为一级导航(移动端整行可点) */
.profile-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-top: 20px;
margin-bottom: 18px;
border: 1px solid var(--tb-border, #e5e5e5);
border-radius: 9px;
background: var(--tb-surface-2, #fafafa);
}
.profile-tab {
flex: 1;
border: 0;
border-radius: 6px;
padding: 9px 15px;
background: transparent;
color: var(--tb-text-muted, #666);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.profile-tab:hover { color: var(--tb-primary, #1e87f0); }
.profile-tab.is-active {
background: var(--tb-surface, #fff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: var(--tb-primary, #1e87f0);
font-weight: 600;
}
/* 功能关闭时置灰禁用的 Tab(如投票功能关闭) */
.profile-tab.is-disabled,
.profile-tab:disabled {
cursor: not-allowed;
color: var(--tb-text-muted, #999);
opacity: 0.7;
}
.profile-tab.is-disabled:hover { color: var(--tb-text-muted, #999); }
.profile-panel[hidden] { display: none !important; }
html[data-theme="dark"] .profile-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
</style>
<div class="uk-card uk-card-default uk-text-center">
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
<div class="uk-card-body">
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
<p id="introcolor">{{ .PageUser.Intro }}</p>
<div class="icon-container">
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
<a href="https://{{.PageUser.Bililink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
</a>
{{ end }}
</div>
</div>
</div>
</div>
<script>
document.getElementById("usernamecolor").style.color = "{{.PageUser.Usernamecolor}}";
document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
</script>
<div>
<div class="uk-card uk-card-default uk-card-small uk-card-body">
{{ if .CanAsk }}
<!-- jQuery 已改为按需加载:defer 按文档顺序先于 easy.qrcode 执行,保证就绪 -->
<script defer src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
<center><div id="boxqrimg"></div></center>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
var options = {
text: window.location.href,
width: 200,
height: 200,
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
correctLevel: QRCode.CorrectLevel.H,
logo: (function () {
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
})(),
logoWidth: 60,
logoHeight: 60,
logoBackgroundTransparent: true,
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
crossOrigin: 'anonymous',
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
autoColor: false,
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
AO: '',
AI: '',
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
};
// 降级渲染:头像(logo)/ 背景图加载慢/失败时先出基础码,探针成功后再升级带 logo 版
window.tbRenderQR(document.getElementById("boxqrimg"), function () { return options; });
});
</script>
<p class="uk-text-center uk-text-small">{{t .LangCode "ask.scan" .PageUser.Name}}<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
<div class="uk-text-center uk-margin-small-top">
<a id="share-btn" href="/_/{{ .PageUser.Domain }}/share-card" download class="uk-button uk-button-default uk-button-small">
📤 {{t .LangCode "ask.share"}}
</a>
</div>
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
<p class="uk-text-large uk-text-muted">{{t .LangCode "ask.generating"}}</p>
</div>
</div>
<script>
(function() {
var btn = document.getElementById('share-btn');
var overlay = document.getElementById('share-loading-overlay');
if (!btn || !overlay) return;
btn.addEventListener('click', function(e) {
e.preventDefault();
overlay.style.display = 'block';
btn.style.pointerEvents = 'none';
fetch(btn.href)
.then(function(response) {
if (!response.ok) throw new Error('{{t .LangCode "ask.networkError"}}');
return response.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = '{{t .LangCode "ask.shareFilename"}}';
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
})
.catch(function(err) {
alert('{{t .LangCode "ask.genFailed"}}');
console.error(err);
})
.finally(function() {
overlay.style.display = 'none';
btn.style.pointerEvents = 'auto';
});
});
})();
</script>
{{ end }}
{{template "base/alert" .}}
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
<div class="profile-tabs" role="tablist">
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
{{ if .VoteEnabled }}
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "votes" }} is-active{{ end }}" data-profile-tab="votes" role="tab" aria-selected="{{ if eq .ActiveProfileTab "votes" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.votesTab"}}</button>
{{ else }}
<!-- 投票功能关闭:保留 Tab 展示但置灰禁用,hover 提示原因 -->
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="{{ .VoteClosedMessage }}">{{t .LangCode "explore.votesTab"}} <span uk-icon="icon: lock; ratio: 0.8"></span></button>
{{ end }}
</div>
<div class="profile-panel" data-profile-panel="questions"{{ if eq .ActiveProfileTab "votes" }} hidden{{ end }}>
{{template "question/new-question-template" .}}
{{ if ne (len .PageQuestions) 0 }}
<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>
<div class="profile-panel" data-profile-panel="votes"{{ if ne .ActiveProfileTab "votes" }} hidden{{ end }}>
{{ if .PageVotes }}
<!-- 匿名投票板块:箱主"进行中"的公开投票(列表行样式,与探索页"全部投票"一致) -->
<div class="profile-votes-list">
{{ range .PageVotes }}
<a href="/vote/{{ .LinkToken }}" class="profile-vote-item">
<div class="profile-vote-content">
<span class="profile-vote-title" title="{{ .Title }}">{{ .Title }}</span>
<span class="profile-vote-meta">
{{ if eq .Type "single" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
{{ if eq .Type "multiple" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
{{ if eq .Type "rating" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
{{ if eq .Type "rank" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
{{ if eq .Type "form" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
<span class="uk-text-meta">{{ .CreatedAt.Format "2006-01-02" }}</span>
</span>
</div>
<span class="profile-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{ end }}
</div>
{{ else }}
<div class="uk-alert uk-alert-primary uk-margin-remove-top">{{t .LangCode "explore.noVotes"}}</div>
{{ end }}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tabs = document.querySelectorAll('[data-profile-tab]');
var panels = document.querySelectorAll('[data-profile-panel]');
function showPanel(name) {
tabs.forEach(function(tab) {
var active = tab.dataset.profileTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.profilePanel !== name;
});
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
});
// 仅当投票 Tab 可点击(功能开启)时才响应 #votes hash,避免投票关闭时切到空面板
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
showPanel('votes');
}
});
</script>
{{template "base/footer" .}}
@@ -0,0 +1,232 @@
{{ if .AskClosedMessage }}
<div>
<div uk-alert class="uk-text-center">
<p>{{ .AskClosedMessage }}</p>
{{ if .AskNextOpenAtStr }}
<p class="uk-text-small uk-text-muted">{{t .LangCode "ask.nextOpenAt" .AskNextOpenAtStr}}</p>
{{ end }}
{{ if .AskLastCloseAtStr }}
<p class="uk-text-small uk-text-muted">{{t .LangCode "ask.lastCloseAt" .AskLastCloseAtStr}}</p>
{{ end }}
</div>
<!-- 提问功能关闭但私聊开启:保留独立私聊入口(提问与私聊是独立功能) -->
{{ if .AllowChat }}
<div class="uk-margin-top">
{{ if .LoggedUser }}
<form method="post" action="/_/{{.PageUser.Domain}}" id="chat-only-form" enctype="multipart/form-data">
{{ .CSRFTokenHTML }}
{{ if .PageUser.AskPassword }}
<input type="hidden" name="ask_password" id="ask_password_input" value="">
{{ end }}
<input type="hidden" name="start_chat_val" value="true">
<input type="hidden" name="g-recaptcha-response" id="chat-g-recaptcha-response" value="">
<div class="uk-margin uk-text-center">
<textarea name="content" class="uk-textarea" rows="4" placeholder="{{t .LangCode "ask.chatPlaceholder"}}" maxlength="{{ .Limits.ChatMessageLen }}"></textarea>
</div>
<div class="uk-margin uk-text-center">
{{ if eq .CaptchaType "math" }}
<button type="button" class="uk-button uk-button-secondary math-captcha-trigger" id="chat-only-submit-btn">{{t .LangCode "ask.chat"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-secondary g-recaptcha"
data-sitekey="{{.RecaptchaSiteKey}}" data-callback="onChatOnlySubmit"
id="chat-only-submit-btn">{{t .LangCode "ask.chat"}}</button>
{{ end }}
<div class="uk-text-meta">{{t .LangCode "ask.chatLimit" .PageUser.ChatMaxMessages}}</div>
</div>
</form>
{{ else }}
<!-- 未登录:私聊需登录 -->
<div class="uk-text-center uk-margin">
<p class="uk-text-small uk-text-muted">{{t .LangCode "ask.loginRequiredChat"}}</p>
<a class="uk-button uk-button-secondary uk-button-small" href="/login?to={{.CurrentURI}}">{{t .LangCode "ask.goLogin"}}</a>
</div>
{{ end }}
</div>
{{ end }}
{{ if and .AllowChat .LoggedUser }}
{{ if ne .CaptchaType "math" }}
<!-- 独立私聊表单的验证码回调(提问表单的 onSubmit 只提交 #form) -->
<script>
function onChatOnlySubmit(token) {
var form = document.getElementById('chat-only-form');
if (!form) return;
var btn = document.getElementById('chat-only-submit-btn');
if (btn) { btn.disabled = true; btn.innerText = '{{t .LangCode "common.verifying"}}'; }
var needPassword = {{ if .PageUser.AskPassword }}true{{ else }}false{{ end }};
if (needPassword) {
UIkit.modal.prompt('{{t .LangCode "ask.needPassword"}}', '', 'password')
.then(function(password) {
if (password) {
var pw = form.querySelector('#ask_password_input');
if (pw) pw.value = password;
form.submit();
} else {
if (btn) { btn.disabled = false; btn.innerText = '{{t .LangCode "ask.chat"}}'; }
}
});
} else {
form.submit();
}
}
</script>
{{ end }}
{{ end }}
</div>
{{ else if not .CanAsk }}
<div>
<div uk-alert class="uk-text-center">
<p>{{t .LangCode "ask.needLogin"}}</p>
</div>
<div class="uk-margin uk-text-center">
<a class="uk-button uk-button-primary" href="/login?to={{.CurrentURI}}">{{t .LangCode "ask.goLogin"}}</a>
</div>
</div>
{{ else }}
<form method="post" action="/_/{{.PageUser.Domain}}" enctype="multipart/form-data" id="form">
{{ .CSRFTokenHTML }}
{{if .PageUser.AskPassword}}
<input type="hidden" name="ask_password" id="ask_password_input" value="">
{{end}}
<input type="hidden" name="start_chat_val" id="start_chat_val" value="false">
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<div class="uk-margin uk-text-center">
<textarea name="content" class="uk-textarea" rows="5" placeholder="{{t .LangCode "ask.placeholder"}}"
maxlength="{{ .Limits.QuestionContentLen }}">{{.content}}</textarea>
<div x-data="{ files: null }" style="width: 100%; background-color: #f8f8f8; display: flex; padding-top: 5px; padding-bottom: 5px; align-items: center; justify-content: start;gap: 5px">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image" style="margin-left: 10px"></a>
<span style="font-size: 12px" x-text="files ? files[0].name : '{{t .LangCode "ask.addImage"}}'"></span>
<input id="uploader" name="images" type="file" accept="image/*" x-on:change="files = Object.values($event.target.files)">
</div>
<button x-show="files" type="button" class="uk-icon-link" uk-icon="close" style="margin-left: 10px;"
@click="files = null"></button>
</div>
</div>
<div class="uk-margin uk-grid-small">
<label class="uk-text-small">
<input name="private" class="uk-checkbox" type="checkbox"> {{t .LangCode "ask.private"}}
</label>
<label class="uk-text-small">
<input name="receive_reply_via_email" id="notify-checkbox" class="uk-checkbox" type="checkbox"> {{t .LangCode "ask.notify"}}
</label>
<div id="email-container" style="width: 100%; display: none;" class="uk-margin-small-top">
<input class="uk-input uk-form-small" type="email" name="receive_reply_email" id="email-input" placeholder="{{t .LangCode "ask.emailPlaceholder"}}">
</div>
</div>
<div class="uk-margin uk-text-center">
<div class="uk-grid-small uk-flex-center" uk-grid>
<div>
{{ if eq .CaptchaType "math" }}
<button type="button" class="uk-button uk-button-primary math-captcha-trigger" id="normal-submit-btn">{{t .LangCode "ask.submit"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-primary g-recaptcha"
data-sitekey="{{.RecaptchaSiteKey}}" data-callback="onSubmit"
id="normal-submit-btn">{{t .LangCode "ask.submit"}}</button>
{{ end }}
</div>
{{ if not .AllowChat }}
<div>
<button type="button" class="uk-button uk-button-secondary" disabled
uk-tooltip="{{t .LangCode "ask.chatDisabled"}}">{{t .LangCode "ask.chat"}}</button>
</div>
{{ else if .LoggedUser }}
<div>
<button type="button" class="uk-button uk-button-secondary" id="chat-submit-btn">{{t .LangCode "ask.chat"}}</button>
<div class="uk-text-meta">{{t .LangCode "ask.chatLimit" .PageUser.ChatMaxMessages}}</div>
</div>
{{ else }}
<div>
<button type="button" class="uk-button uk-button-secondary" id="chat-submit-btn">{{t .LangCode "ask.chat"}}</button>
</div>
{{ end }}
</div>
</div>
</form>
<script>
// 邮箱显隐与验证逻辑
var notifyCheckbox = document.getElementById('notify-checkbox');
var emailContainer = document.getElementById('email-container');
var emailInput = document.getElementById('email-input');
if (notifyCheckbox && emailContainer && emailInput) {
notifyCheckbox.addEventListener('change', function() {
if (this.checked) {
emailContainer.style.display = 'block';
emailInput.setAttribute('required', 'required');
} else {
emailContainer.style.display = 'none';
emailInput.removeAttribute('required');
emailInput.value = '';
}
});
}
// 私聊按钮:设置标识后触发验证弹窗
var chatBtn = document.getElementById('chat-submit-btn');
if (chatBtn) {
chatBtn.addEventListener('click', function() {
var isLogged = {{ if .LoggedUser }}true{{ else }}false{{ end }};
if (!isLogged) {
var currentURI = '{{.CurrentURI}}';
UIkit.modal.dialog(
'<div class="uk-text-center uk-padding" style="min-height: 150px; display: flex; flex-direction: column; justify-content: center;">' +
'<h3 class="uk-modal-title uk-margin-small-bottom">{{t .LangCode "ask.loginRequired"}}</h3>' +
'<p class="uk-text-muted uk-margin-small-bottom">{{t .LangCode "ask.loginRequiredChat"}}</p>' +
'<a href="/login?to=' + encodeURIComponent(currentURI) + '" class="uk-button uk-button-primary">{{t .LangCode "ask.goLogin"}}</a>' +
'</div>',
{width: '400px'}
);
return;
}
document.getElementById('start_chat_val').value = 'true';
{{ if eq .CaptchaType "math" }}
var submitBtn = document.getElementById('normal-submit-btn');
if (submitBtn) submitBtn.click();
{{ else }}
document.getElementById('normal-submit-btn').click();
{{ end }}
});
}
{{ if ne .CaptchaType "math" }}
function onSubmit(token) {
var btn = document.querySelector('.g-recaptcha');
var originalText = btn.innerText;
if (notifyCheckbox && notifyCheckbox.checked && emailInput && emailInput.value.trim() === '') {
alert(CAPTCHA_I18N.needEmail);
grecaptcha.reset();
return;
}
btn.disabled = true;
btn.innerText = '{{t .LangCode "common.verifying"}}';
var needPassword = {{if .PageUser.AskPassword}}true{{else}}false{{end}};
if (needPassword) {
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
.then(function(password) {
if (password) {
document.getElementById('ask_password_input').value = password;
document.getElementById('form').submit();
} else {
btn.disabled = false;
btn.innerText = originalText;
grecaptcha.reset();
}
});
} else {
document.getElementById('form').submit();
}
}
{{ end }}
</script>
{{ end }}
@@ -0,0 +1,145 @@
{{ if not .CanAsk }}
<div>
<div uk-alert class="uk-text-center">
<p>提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。</p>
</div>
<div class="uk-margin uk-text-center">
<a class="uk-button uk-button-primary" href="/login?to={{.CurrentURI}}">前往登录</a>
</div>
</div>
{{ else }}
<form method="post" action="/_/{{.PageUser.Domain}}" enctype="multipart/form-data" id="form">
{{ .CSRFTokenHTML }}
{{if .PageUser.AskPassword}}
<input type="hidden" name="ask_password" id="ask_password_input" value="">
{{end}}
<input type="hidden" name="start_chat_val" id="start_chat_val" value="false">
<div class="uk-margin uk-text-center">
<textarea name="content" class="uk-textarea" rows="5" placeholder="在此处撰写你的问题..."
maxlength="1000">{{.content}}</textarea>
<div x-data="{ files: null }" style="width: 100%; background-color: #f8f8f8; display: flex; padding-top: 5px; padding-bottom: 5px; align-items: center; justify-content: start;gap: 5px">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image" style="margin-left: 10px"></a>
<span style="font-size: 12px" x-text="files ? files[0].name : '添加图片'"></span>
<input id="uploader" name="images" type="file" accept="image/*" x-on:change="files = Object.values($event.target.files)">
</div>
<button x-show="files" type="button" class="uk-icon-link" uk-icon="close" style="margin-left: 10px;"
@click="files = null"></button>
</div>
</div>
<div class="uk-margin uk-grid-small">
<label class="uk-text-small">
<input name="private" class="uk-checkbox" type="checkbox"> 回复后不公开
</label>
<label class="uk-text-small">
<input name="receive_reply_via_email" id="notify-checkbox" class="uk-checkbox" type="checkbox"> 接收通知
</label>
<div id="email-container" style="width: 100%; display: none;" class="uk-margin-small-top">
<input class="uk-input uk-form-small" type="email" name="receive_reply_email" id="email-input" placeholder="请输入你的邮箱地址">
</div>
</div>
<div class="uk-margin uk-text-center">
<div class="uk-grid-small uk-flex-center" uk-grid>
<div>
<button type="submit" class="uk-button uk-button-primary g-recaptcha"
data-sitekey="{{.RecaptchaSiteKey}}" data-callback="onSubmit"
id="normal-submit-btn">发送提问</button>
</div>
{{ if and .LoggedUser .PageUser.AllowChat }}
<div>
<button type="button" class="uk-button uk-button-secondary" id="chat-submit-btn">发起私聊</button>
<div class="uk-text-meta">消息上限:{{.PageUser.ChatMaxMessages}}条</div>
</div>
{{ else if not .LoggedUser }}
<div>
<button type="button" class="uk-button uk-button-secondary" id="chat-submit-btn">发起私聊</button>
</div>
{{ else }}
<div>
<button type="button" class="uk-button uk-button-secondary" disabled uk-tooltip="该提问箱未开启私聊">发起私聊</button>
</div>
{{ end }}
</div>
</div>
</form>
<script>
// 邮箱显隐与验证逻辑
var notifyCheckbox = document.getElementById('notify-checkbox');
var emailContainer = document.getElementById('email-container');
var emailInput = document.getElementById('email-input');
if (notifyCheckbox && emailContainer && emailInput) {
notifyCheckbox.addEventListener('change', function() {
if (this.checked) {
emailContainer.style.display = 'block';
emailInput.setAttribute('required', 'required');
} else {
emailContainer.style.display = 'none';
emailInput.removeAttribute('required');
emailInput.value = ''; // 清空已填内容
}
});
}
// 私聊按钮点击处理
var chatBtn = document.getElementById('chat-submit-btn');
if (chatBtn) {
chatBtn.addEventListener('click', function() {
var isLogged = {{ if .LoggedUser }}true{{ else }}false{{ end }};
if (!isLogged) {
var currentURI = '{{.CurrentURI}}';
UIkit.modal.dialog(
'<div class="uk-text-center uk-padding" style="min-height: 150px; display: flex; flex-direction: column; justify-content: center;">' +
'<h3 class="uk-modal-title uk-margin-small-bottom">需要登录</h3>' +
'<p class="uk-text-muted uk-margin-small-bottom">发起私聊需要登录账号</p>' +
'<a href="/login?to=' + encodeURIComponent(currentURI) + '" class="uk-button uk-button-primary">去登录</a>' +
'</div>',
{width: '400px'}
);
return;
}
// 已登录,设置私聊标识并触发提交
document.getElementById('start_chat_val').value = 'true';
document.getElementById('normal-submit-btn').click();
});
}
function onSubmit(token) {
var btn = document.querySelector('.g-recaptcha');
var originalText = btn.innerText;
// 手动验证邮箱(字段名已修正为 receive_reply_email)
if (notifyCheckbox && notifyCheckbox.checked && emailInput && emailInput.value.trim() === '') {
alert('请填写接收通知的邮箱地址');
grecaptcha.reset(); // 重置验证码
return;
}
btn.disabled = true;
btn.innerText = '验证中...';
var needPassword = {{if .PageUser.AskPassword}}true{{else}}false{{end}};
if (needPassword) {
UIkit.modal.prompt('此提问箱需要密码', '', 'password')
.then(function(password) {
if (password) {
document.getElementById('ask_password_input').value = password;
document.getElementById('form').submit();
} else {
btn.disabled = false;
btn.innerText = originalText;
grecaptcha.reset();
}
});
} else {
document.getElementById('form').submit();
}
}
</script>
{{ end }}
+62
View File
@@ -0,0 +1,62 @@
{{template "base/header" .}}
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目 -->
<link rel="shortcut icon"
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
<link rel="apple-touch-icon"
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
<link rel="icon" sizes="192x192"
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
<style>
.password-card {
max-width: 450px;
margin: 0 auto;
margin-top: 50px;
}
.password-icon {
text-align: center;
font-size: 3em;
color: #faa05a;
margin-bottom: 15px;
}
.password-desc {
text-align: center;
color: #666;
margin-bottom: 25px;
}
</style>
<div class="uk-container uk-container-xsmall">
<div class="uk-card uk-card-default uk-card-body password-card uk-box-shadow-medium">
<div class="password-icon">
<span uk-icon="icon: lock; ratio: 3"></span>
</div>
<h3 class="uk-text-center uk-margin-small-top">{{ .PageUser.Name }}{{t .LangCode "site.logoSuffix"}}</h3>
<p class="password-desc">{{t .LangCode "password.desc"}}</p>
{{template "base/alert" .}}
<form method="post" action="/_/{{.PageUser.Domain}}/password" class="uk-form-stacked">
{{ .CSRFTokenHTML }}
<input type="hidden" name="next" value="{{.Next}}">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1">
<span class="uk-form-icon uk-form-icon" uk-icon="icon: lock"></span>
<input name="password" class="uk-input uk-form-large" type="password" placeholder="{{t .LangCode "password.input"}}" autofocus>
</div>
</div>
<div class="uk-margin">
<button type="submit" class="uk-button uk-button-primary uk-width-1-1 uk-button-large">
<span uk-icon="icon: sign-in"></span> {{t .LangCode "password.enter"}}
</button>
</div>
</form>
<div class="uk-text-center uk-margin-small-top">
<a href="/" class="uk-text-muted uk-text-small">← {{t .LangCode "common.backHome"}}</a>
</div>
</div>
</div>
{{template "base/footer" .}}
+106
View File
@@ -0,0 +1,106 @@
{{template "base/header" .}}
<!--<link rel="stylesheet" href="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-y/uikit/3.3.3/css/uikit.min.css">-->
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目 -->
<link rel="shortcut icon"
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
<link rel="apple-touch-icon"
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
<link rel="icon" sizes="192x192"
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
<div class="uk-card uk-card-default uk-text-center">
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
<div class="uk-card-body">
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
<p id="introcolor">{{ .PageUser.Intro }}</p>
<div class="icon-container">
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
<a href="https://{{.PageUser.Bililink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
</a>
{{ end }}
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
</a>
{{ end }}
</div>
</div>
</div>
</div>
<script>
document.getElementById("usernamecolor").style.color = {{.PageUser.Usernamecolor}};
document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
</script>
<div>
<div class="uk-card uk-card-default uk-card-small uk-card-body">
{{ if .CanAsk }}
<!-- jQuery 按需加载(同步,保证下方内联二维码初始化就绪) -->
<script src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
<center><div id="boxqrimg"></div></center>
<script type="text/javascript">
var options = {
text: window.location.href,
width: 200,
height: 200,
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
correctLevel: QRCode.CorrectLevel.H,
logo: (function () {
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
})(),
logoWidth: 60,
logoHeight: 60,
logoBackgroundTransparent: true,
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
crossOrigin: 'anonymous',
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
autoColor: false,
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
AO: '',
AI: '',
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
};
// 降级渲染:头像(logo)/ 背景图加载慢/失败时先出基础码,探针成功后再升级带 logo 版
window.tbRenderQR(document.getElementById("boxqrimg"), function () { return options; });
</script>
<p class="uk-text-center uk-text-small">扫码向@{{ .PageUser.Name }}提问<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
{{ end }}
{{template "base/alert" .}}
{{template "question/new-question-template" .}}
<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>
{{ template "question/history-template" . }}
{{ end }}
</div>
</div>
{{template "base/footer" .}}
+54
View File
@@ -0,0 +1,54 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body">
<div class="uk-text-center uk-margin-bottom">
<span uk-icon="icon: check; ratio: 2" class="uk-text-success"></span>
<h3 class="uk-margin-remove">{{t .LangCode "success.title"}}</h3>
</div>
<p class="uk-text-small uk-text-muted">{{t .LangCode "success.yourQuestion"}}</p>
<div class="md-body uk-text-break">{{MarkdownFormat .Question.Content}}</div>
{{ if .HasReceiveEmail }}
<div class="uk-alert-success" uk-alert>
<p>{{t .LangCode "success.emailSent"}}</p>
</div>
{{ else }}
<div class="uk-alert-danger" uk-alert>
<p>{{t .LangCode "success.saveLink" | safe}}</p>
</div>
{{ end }}
<div class="uk-margin">
<label class="uk-form-label uk-text-small">{{t .LangCode "success.privateLink"}}</label>
<div class="uk-flex uk-flex-middle">
<input id="private-url-input" class="uk-input uk-form-small uk-width-expand" type="text" value="{{.PrivateAbsURL}}" readonly>
<button id="copy-btn" class="uk-button uk-button-primary uk-button-small uk-margin-small-left uk-flex-none" type="button">{{t .LangCode "success.copy"}}</button>
</div>
<p class="uk-text-small uk-text-muted">{{t .LangCode "success.token" .Question.Token}}</p>
</div>
<div class="uk-text-center uk-margin-top">
<a class="uk-button uk-button-default uk-button-small" href="{{.PrivateURL}}">{{t .LangCode "success.viewQuestion"}}</a>
<a class="uk-button uk-button-default uk-button-small uk-margin-small-left" href="/_/{{.PageUser.Domain}}">{{t .LangCode "success.backToBox" .PageUser.Name}}</a>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
var input = document.getElementById('private-url-input');
var btn = document.getElementById('copy-btn');
if (!input || !btn) return;
btn.addEventListener('click', function () {
input.select();
input.setSelectionRange(0, 99999);
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) {}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(input.value).then(function () { ok = true; }, function () {});
}
btn.textContent = ok ? '{{t .LangCode "success.copied"}}' : '{{t .LangCode "success.copyFailed"}}';
setTimeout(function () { btn.textContent = '{{t .LangCode "success.copy"}}'; }, 2000);
});
});
</script>
{{template "base/footer" .}}
+271
View File
@@ -0,0 +1,271 @@
package templates
import (
gotemplate "html/template"
"strings"
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/conf"
)
type mockPageQuestion struct {
ID uint
Content string
CreatedAt time.Time
IsPinned bool
Answer string
}
func publicBaseData() map[string]interface{} {
pageUser := map[string]interface{}{
"Domain": "tester", "Name": "测试箱主", "Avatar": "", "Background": "",
"Intro": "欢迎提问", "Usernamecolor": "#000", "Introcolor": "#666",
"Acfunlink": "none", "Bililink": "none", "Weibolink": "none",
"Xiaohongshulink": "none", "Bloglink": "none",
"Qrcodebackcolor": "#fff", "Qrcodecolor": "#000", "Dotscale": "0.35",
"BackgroundImage": "", "Backgroundimagealpha": "0.8", "Qrcodepdpcolor": "#000",
"HarassmentSetting": "disabled", "AllowChat": true, "ChatMaxMessages": 10,
}
return map[string]interface{}{
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": false,
"IsUserPage": true,
"IsOwnPage": false,
"IsPixel": false,
"VConsole": false,
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"RecaptchaSiteKey": "",
"CaptchaType": "recaptcha",
"PageUser": pageUser,
"PageUserRaw": pageUser,
"PageQuestions": []mockPageQuestion{
{ID: 1, Content: "置顶的问题", CreatedAt: time.Now(), IsPinned: true, Answer: "答1"},
{ID: 2, Content: "普通问题", CreatedAt: time.Now().Add(-time.Hour), IsPinned: false, Answer: "答2"},
},
"PageQuestionCursor": "2",
"CanAsk": true,
"AnsweredCount": 2,
"ICP": "",
// [limits] 全站上限(生产由 Contexter 全局注入 conf.Limits),供提问/回答/追问
// 输入框 maxlength 与私聊发起框同源
"Limits": conf.Limits,
}
}
// TestRenderPublicQuestionList 渲染提问箱主页(含置顶条目),复现公开页 500。
func TestRenderPublicQuestionList(t *testing.T) {
if err := renderWithData(t, "question/list", publicBaseData()); err != nil {
t.Fatalf("render question/list failed: %v", err)
}
}
// TestRenderQuestionListClosed 提问功能关闭时:展示关闭原因 + 下次开放/上次关闭时间(问题 3 的模板路径)。
func TestRenderQuestionListClosed(t *testing.T) {
base := publicBaseData()
base["CanAsk"] = false
base["AskState"] = map[string]interface{}{"Open": false, "Reason": "not_started"}
base["AskClosedMessage"] = "提问功能尚未开放"
base["AskNextOpenAtStr"] = "2026-09-02 10:00"
base["AskLastCloseAtStr"] = "2026-09-01 18:00"
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (closed) failed: %v", err)
}
if !strings.Contains(out, "提问功能尚未开放") {
t.Fatal("closed reason not rendered")
}
if !strings.Contains(out, "下次开放时间:2026-09-02 10:00") {
t.Fatal("next open time not rendered")
}
if !strings.Contains(out, "上次关闭时间:2026-09-01 18:00") {
t.Fatal("last close time not rendered")
}
}
// TestRenderQuestionListClosedPermanent 一次性窗口结束后(无循环定时):只展示上次关闭时间,不展示下次开放。
func TestRenderQuestionListClosedPermanent(t *testing.T) {
base := publicBaseData()
base["CanAsk"] = false
base["AskState"] = map[string]interface{}{"Open": false, "Reason": "ended"}
base["AskClosedMessage"] = "提问功能已结束收集"
base["AskNextOpenAtStr"] = ""
base["AskLastCloseAtStr"] = "2026-08-31 18:00"
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (ended) failed: %v", err)
}
if strings.Contains(out, "下次开放时间") {
t.Fatal("next open time should NOT be rendered when permanently closed")
}
if !strings.Contains(out, "上次关闭时间:2026-08-31 18:00") {
t.Fatal("last close time not rendered")
}
}
// TestRenderQuestionListRegisterOnlyCaption 未开启「仅允许注册用户」时,二维码文案保持原样。
func TestRenderQuestionListRegisterOnlyCaption(t *testing.T) {
base := publicBaseData()
base["RegisterOnlyAsk"] = false
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list failed: %v", err)
}
if !strings.Contains(out, "谁都可以以匿名的形式提问") {
t.Fatal("anonymous caption should be rendered when not register-only")
}
if strings.Contains(out, "仅允许注册用户向TA提问") {
t.Fatal("register-only caption should NOT be rendered when not register-only")
}
}
// TestRenderQuestionListRegisterOnlyOn 开启「仅允许注册用户」(箱主登录态可提问)时,二维码文案联动(问题 2)。
func TestRenderQuestionListRegisterOnlyOn(t *testing.T) {
base := publicBaseData()
base["CanAsk"] = true
base["RegisterOnlyAsk"] = true
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list failed: %v", err)
}
if !strings.Contains(out, "仅允许注册用户向TA提问") {
t.Fatal("register-only caption should be rendered when register-only enabled")
}
if strings.Contains(out, "谁都可以以匿名的形式提问") {
t.Fatal("anonymous caption should NOT be rendered when register-only enabled")
}
}
// TestRenderQuestionListVoteDisabled 投票功能关闭时:投票 Tab 保留显示但置灰禁用,并带原因 tooltip。
func TestRenderQuestionListVoteDisabled(t *testing.T) {
base := publicBaseData()
base["VoteEnabled"] = false
base["VoteClosedMessage"] = "该提问箱已关闭投票功能"
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (vote disabled) failed: %v", err)
}
if !strings.Contains(out, "profile-tab is-disabled") {
t.Fatal("vote tab should stay visible but disabled")
}
if !strings.Contains(out, "disabled") {
t.Fatal("vote tab should carry disabled attribute")
}
if !strings.Contains(out, "该提问箱已关闭投票功能") {
t.Fatal("vote tab should carry the close reason tooltip")
}
// 注意:JS 守卫代码里也有 [data-profile-tab="votes"] 选择器,须按按钮元素精确断言
if strings.Contains(out, `data-profile-tab="votes" role="tab"`) {
t.Fatal("disabled vote tab must NOT be switchable")
}
}
// TestRenderQuestionListVoteEnabled 投票功能正常时:投票 Tab 可点击切换。
func TestRenderQuestionListVoteEnabled(t *testing.T) {
base := publicBaseData()
base["VoteEnabled"] = true
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (vote enabled) failed: %v", err)
}
if !strings.Contains(out, `data-profile-tab="votes"`) {
t.Fatal("vote tab should be switchable when enabled")
}
if strings.Contains(out, "profile-tab is-disabled") {
t.Fatal("vote tab must not be disabled when enabled")
}
}
// TestRenderQuestionListChatOnly 提问功能关闭但私聊开启时:保留独立私聊入口(含已登录/未登录两种形态)。
func TestRenderQuestionListChatOnly(t *testing.T) {
base := publicBaseData()
base["CanAsk"] = false
base["AskState"] = map[string]interface{}{"Open": false, "Reason": "disabled"}
base["AskClosedMessage"] = "提问箱主人已关闭提问功能"
base["AskNextOpenAtStr"] = ""
base["AskLastCloseAtStr"] = ""
base["AllowChat"] = true
base["ChatMaxMessages"] = 20
base["LoggedUser"] = map[string]interface{}{"Domain": "tester", "Name": "测试箱主"}
// 已登录:渲染独立私聊表单
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (chat only, logged in) failed: %v", err)
}
if !strings.Contains(out, "提问箱主人已关闭提问功能") {
t.Fatal("ask closed reason should still be shown")
}
if !strings.Contains(out, `id="chat-only-form"`) {
t.Fatal("chat-only form should be rendered when chat enabled and logged in")
}
if !strings.Contains(out, `id="chat-only-form" enctype="multipart/form-data"`) {
t.Fatal("chat-only form must be multipart, otherwise form.Bind panics on the Images field")
}
if !strings.Contains(out, `name="start_chat_val" value="true"`) {
t.Fatal("chat-only form must submit as a chat request")
}
if !strings.Contains(out, "在此处撰写你的私聊消息...") {
t.Fatal("chat placeholder should be rendered")
}
if !strings.Contains(out, "onChatOnlySubmit") {
t.Fatal("chat-only captcha callback should be defined for non-math captcha")
}
// 未登录:渲染登录引导而非表单
base["LoggedUser"] = nil
out, err = renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (chat only, anonymous) failed: %v", err)
}
if strings.Contains(out, `id="chat-only-form"`) {
t.Fatal("chat-only form must not be rendered for anonymous visitors")
}
if !strings.Contains(out, "/login?to=") {
t.Fatal("anonymous visitors should get a login link to start chats")
}
}
// TestRenderQuestionListChatOnlyNoChat 提问关闭且私聊也关闭:不渲染私聊入口。
func TestRenderQuestionListChatOnlyNoChat(t *testing.T) {
base := publicBaseData()
base["CanAsk"] = false
base["AskClosedMessage"] = "提问箱主人已关闭提问功能"
base["AllowChat"] = false
base["LoggedUser"] = map[string]interface{}{"Domain": "tester", "Name": "测试箱主"}
out, err := renderToString(t, "question/list", base)
if err != nil {
t.Fatalf("render question/list (all closed) failed: %v", err)
}
if strings.Contains(out, `id="chat-only-form"`) {
t.Fatal("chat-only form must not be rendered when chat is disabled")
}
}
// TestRenderPublicQuestionItem 渲染单题页(含历史列表),复现公开页 500。
func TestRenderPublicQuestionItem(t *testing.T) {
data := publicBaseData()
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "置顶的问题", "Answer": "答1", "CreatedAt": time.Now(),
"UpdatedAt": time.Now(), "IsPrivate": false, "IsPinned": true,
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["FollowUpRemaining"] = 0
data["FollowUpLimit"] = 3
data["IsOwnPage"] = true
data["IsQuestioner"] = false
data["IsBlocked"] = false
data["AllowChat"] = true
if err := renderWithData(t, "question/item", data); err != nil {
t.Fatalf("render question/item failed: %v", err)
}
}
+313
View File
@@ -0,0 +1,313 @@
package templates
import (
"bytes"
"fmt"
gotemplate "html/template"
"strings"
"testing"
"time"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// renderWithData 用给定数据渲染模板(复刻生产环境 Templater + FuncMap)。
func renderWithData(t *testing.T, name string, data map[string]interface{}) error {
t.Helper()
_, err := renderToString(t, name, data)
return err
}
// renderToString 渲染模板并返回输出,供需要断言渲染结果的用例使用。
func renderToString(t *testing.T, name string, data map[string]interface{}) (string, error) {
t.Helper()
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
tpl := gotemplate.New("Flamego.Template")
for _, f := range fs.Files() {
tt := tpl.New(f.Name())
for _, funcMap := range templatepkg.FuncMap() {
tt.Funcs(funcMap)
}
dataBytes, err := f.Data()
if err != nil {
t.Fatalf("get data of %q: %v", f.Name(), err)
}
if _, err := tt.Parse(string(dataBytes)); err != nil {
t.Fatalf("parse %q: %v", f.Name(), err)
}
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return buf.String(), nil
}
// mockLoggedUser 覆盖 templates/user/profile.html 引用到的 LoggedUser 字段。
type mockLoggedUser struct {
Domain string
Name string
Email string
Avatar string
Background string
BackgroundImage string
Backgroundimagealpha string
Intro string
Introcolor string
Usernamecolor string
Qrcodebackcolor string
Qrcodecolor string
Qrcodepdpcolor string
Dotscale string
UseCustomQRColor bool
Notify string
Language string
HarassmentSetting string
BlockWords string
Tags string
ShowInHome bool
Acfunlink string
Bililink string
Weibolink string
Xiaohongshulink string
Bloglink string
ChatMaxMessages int
DailyMessageLimit int
}
// profileBaseData 构造 /user/profile 渲染所需的基础数据。
func profileBaseData() map[string]interface{} {
return map[string]interface{}{
"Title": "设置",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": &mockLoggedUser{
Domain: "tester", Name: "测试箱主", Email: "[email protected]",
Usernamecolor: "#000", Introcolor: "#666", Qrcodecolor: "#000",
Qrcodepdpcolor: "#000", Qrcodebackcolor: "#fff", Dotscale: "0.35",
Backgroundimagealpha: "0.8", Notify: "email", Language: "zh-CN",
HarassmentSetting: "none", BlockWords: "广告,spam", Tags: "杂谈",
ChatMaxMessages: 20, DailyMessageLimit: 0,
},
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"HasPagePassword": false,
"HasAskPassword": true,
"ActiveProfileTab": "account",
"ICP": "",
// 标签 / 屏蔽词上限,供标签块输入组件做实时提示(生产由 route/user.Profile 注入,
// 值来自 conf.Limits)
"TagLimits": map[string]int{
"MaxTags": conf.Limits.MaxTags,
"MaxTagLen": conf.Limits.MaxTagLen,
"MaxBlockWords": conf.Limits.MaxBlockWords,
"MaxBlockWordLen": conf.Limits.MaxBlockWordLen,
},
// 私聊面板数值上下界(同样来自 conf.Limits)
"ChatLimits": map[string]int{
"MaxChatMessages": conf.Limits.ChatMaxMessages,
"DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax,
},
}
}
// TestRenderUserProfileTabs 渲染整合后的账号设置页:用户设置 + 提问/私聊/投票 三个功能面板,
// 覆盖"开启 / 手动关闭 / 已达定量上限"三种功能状态。
func TestRenderUserProfileTabs(t *testing.T) {
type mockControl struct {
Enabled bool
OpenAt *time.Time
CloseAt *time.Time
QuotaLimit int64
QuotaCount int64
QuotaResetAt *time.Time
}
type mockState struct {
Enabled bool
Open bool
Reason string
QuotaLimit int64
QuotaCount int64
}
type mockFeature struct {
Key string
LangCode string
NameText string
StatusText string
CSRFTokenHTML interface{}
Control *mockControl
State *mockState
OpenAtStr string
CloseAtStr string
QuotaResetAtStr string
ScheduleDays string
ScheduleStart string
ScheduleEnd string
Mode string
}
resetAt := time.Now().Add(-24 * time.Hour)
csrf := gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`)
base := profileBaseData()
base["QuotaLimitMax"] = db.QuotaLimitMax
base["CSRFTokenHTML"] = csrf
base["FeatureQuestion"] = &mockFeature{
Key: "question", LangCode: "zh-CN", NameText: "提问", StatusText: "进行中", CSRFTokenHTML: csrf,
Control: &mockControl{Enabled: true},
State: &mockState{Enabled: true, Open: true},
Mode: "always",
}
// 手动关闭
base["FeatureChat"] = &mockFeature{
Key: "chat", LangCode: "zh-CN", NameText: "私聊", StatusText: "已关闭", CSRFTokenHTML: csrf,
Control: &mockControl{Enabled: false},
State: &mockState{Enabled: false, Reason: "disabled"},
Mode: "always",
}
// 定时 + 定量:已达上限(历史数据存在循环定时,模式推断为 recurring)
base["FeatureVote"] = &mockFeature{
Key: "vote", LangCode: "zh-CN", NameText: "投票", StatusText: "已达上限", CSRFTokenHTML: csrf,
Control: &mockControl{Enabled: true, QuotaLimit: 100, QuotaCount: 100, QuotaResetAt: &resetAt},
State: &mockState{Enabled: true, Reason: "quota_reached", QuotaLimit: 100, QuotaCount: 100},
OpenAtStr: "2026-09-01T09:00", CloseAtStr: "2026-09-30T18:00",
QuotaResetAtStr: resetAt.Format("2006-01-02 15:04"),
ScheduleDays: "1,3,5", ScheduleStart: "09:00", ScheduleEnd: "18:00",
Mode: "recurring",
}
// 逐个面板渲染:模板能正常执行,且只有目标面板可见、其余面板带 hidden
all := []string{"account", "question", "chat", "vote"}
for _, tab := range all {
base["ActiveProfileTab"] = tab
out, err := renderToString(t, "user/profile", base)
if err != nil {
t.Fatalf("render user/profile (tab=%s) failed: %v", tab, err)
}
if tab == "account" {
// 标签块组件的上限常量已注入(缺注入时 with 会静默跳过,页面失去计数/锁定能力)
wantMax := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxTags, conf.Limits.MaxTagLen)
if !strings.Contains(out, wantMax) {
t.Fatalf("account: 标签输入缺 data-tb-max 注入,want %q", wantMax)
}
wantBlock := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxBlockWords, conf.Limits.MaxBlockWordLen)
if !strings.Contains(out, wantBlock) {
t.Fatalf("account: 屏蔽词输入缺 data-tb-max 注入,want %q", wantBlock)
}
}
if tab == "chat" {
// 私聊面板的 number 输入框 min/max 与 conf.Limits 同源
wantChat := fmt.Sprintf(`name="chat_max_messages" class="uk-input uk-width-small" type="number" min="1" max="%d"`, conf.Limits.ChatMaxMessages)
if !strings.Contains(out, wantChat) {
t.Fatalf("chat: 会话条数输入缺 conf 上限,want %q", wantChat)
}
wantDaily := fmt.Sprintf(`name="chat_daily_limit" class="uk-input uk-width-small" type="number" min="0" max="%d"`, conf.Limits.DailyMessageLimitMax)
if !strings.Contains(out, wantDaily) {
t.Fatalf("chat: 每日条数输入缺 conf 上限,want %q", wantDaily)
}
}
for _, other := range all {
want := fmt.Sprintf(`data-profile-panel="%s"`, other)
visible := strings.Contains(out, want+">")
hidden := strings.Contains(out, want+" hidden>")
if other == tab && !visible {
t.Fatalf("tab=%s: 面板 %s 未渲染为可见", tab, other)
}
if other != tab && !hidden {
t.Fatalf("tab=%s: 面板 %s 未隐藏", tab, other)
}
}
}
}
// TestRenderUserQuestionList 模拟 /user/questions 的数据渲染,复现 500 错误。
func TestRenderUserQuestionList(t *testing.T) {
type mockItem struct {
Type string
CreatedAt time.Time
Content string
Link string
IsAnswered bool
IsRead bool
IsPrivate bool
IsBlocked bool
QuestionID uint
IsPinned bool
PinLink string
}
type mockConv struct {
Token string
CreatedAt time.Time
}
type mockConvDetail struct {
Conversation *mockConv
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
base := map[string]interface{}{
"Title": "我的提问",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
"LoggedUserName": "tester",
"CurrentURI": "/user/questions",
"IsUserPage": true,
"IsPixel": false,
"VConsole": false,
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"RecaptchaSiteKey": "",
"CaptchaType": "recaptcha",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
"Stats": map[string]int64{
"total": 3, "answered": 2, "unanswered": 1, "read": 2, "unread": 1, "private": 1, "pinned": 1,
},
"ChatStats": map[string]int64{
"total": 2, "received": 1, "initiated": 1, "closed": 0,
},
"Keyword": "",
"SelectedSort": "latest",
"SelectedStatus": "all",
"SelectedRead": "all",
"SelectedType": "all",
"ReceivedItems": []interface{}{
mockItem{Type: "question_public", CreatedAt: time.Now(), Content: "你好吗?", Link: "/_/test/1",
IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"},
mockItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话", Link: "/_/test/2",
IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"},
},
"ReceivedChats": []interface{}{
mockItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc", IsBlocked: false},
},
"ConversationDetails": []interface{}{
mockConvDetail{Conversation: &mockConv{Token: "abc", CreatedAt: time.Now()},
OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi", IsBlocked: false},
},
}
if err := renderWithData(t, "user/question-list", base); err != nil {
t.Fatalf("render user/question-list failed: %v", err)
}
// 带搜索关键词的场景
base["Keyword"] = "hello"
if err := renderWithData(t, "user/question-list", base); err != nil {
t.Fatalf("render user/question-list (keyword) failed: %v", err)
}
}
+108
View File
@@ -0,0 +1,108 @@
{{template "base/header" .}}
<style>
.sponsor {
position: relative;
}
.sponsor > .popup {
width: 280px;
background: #eee;
color: #fff;
box-shadow: 0 0 5px 0 #ccc;
border-radius: 5px;
line-height: 0;
}
.sponsor > .alipay {
background: #00a2ea;
}
.sponsor > .popup > .head {
text-align: center;
padding-top: 30px;
}
.sponsor .qrcode {
text-align: center;
}
.sponsor .qrcode img {
margin: 30px auto;
width: 80%;
}
.sponsor ul.platform {
padding: 0 !important;
margin: 0 !important;
text-align: center;
background: #FFF;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.sponsor ul.platform li {
display: inline-block;
cursor: pointer;
margin: 5px;
filter: grayscale(100%);
}
.sponsor ul.platform li.active {
font-weight: 600;
filter: grayscale(0%);
}
.sponsor ul.platform li.icon-alipay {
background: url(https://cdn.github.red/wp-content/plugins/you-shang/image/alipay.svg) no-repeat center center;
width: 24px;
height: 24px;
background-size: cover;
}
</style>
<div class="uk-card uk-card-default uk-card-body">
<div class="uk-child-width-expand@s" uk-grid>
<div>
<h2>给 NekoBox 打钱</h2>
<p>NekoBox 创立于 2020 年疫情初期,一开始是我疫情在家花了三天时间随手写得一个匿名留言箱。</p>
<p>目前 NekoBox 的服务器、CDN 开销还是我一个人承担,如果你也想为我分担一些,欢迎给 NekoBox 打钱!👏🏻</p>
</div>
<div>
<div class="sponsor">
<div class="alipay popup" style="">
<div class="head">谢谢老板 Thanks♪(・ω・)ノ</div>
<div class="qrcode">
<div class="qrcode-li alipay"><img
src="https://cdn.github.red/wp-content/uploads/2021/02/alipay_qrcode.jpg">
<noscript><img src="https://cdn.github.red/wp-content/uploads/2021/02/alipay_qrcode.jpg"/></noscript>
</div>
</div>
<ul class="platform">
<li class="icon-alipay active" data-bg-color="#00a2ea" data-thanks="谢谢老板 Thanks♪(・ω・)ノ"></li>
</ul>
</div>
</div>
</div>
</div>
<div uk-alert class="uk-text-small">感谢以下小伙伴对 NekoBox 的支持!</div>
<table class="uk-table uk-table-small uk-table-divider">
<thead>
<tr>
<th>日期</th>
<th>附言</th>
<th>金额</th>
</tr>
</thead>
<tbody x-data="{ s: '' }" x-init="
s = await (async () => {
let html = '';
let items = (await(await fetch('https://pb.app.apicon.cn/api/collections/nekobox_sponsors/records?perPage=500&sort=-date')).json()).items;
items.forEach(item => {
html += `<tr class=uk-text-meta><td>${item.date.slice(0,10)}</td><td>${item.desc}</td><td>¥ ${item.price.toFixed(2)}</td></tr>`;
})
return html;
})()
" x-html="s">
</tbody>
</table>
</div>
{{template "base/footer" .}}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package templates
import (
gotemplate "html/template"
"regexp"
"testing"
"github.com/flamego/template"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// TestTemplatesParse 复刻生产环境(internal/route/route.go 中
// template.Templater + templatepkg.FuncMap())的模板加载逻辑:
// 逐个模板注册 FuncMap 并解析,任一文件解析失败即报错。
func TestTemplatesParse(t *testing.T) {
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
tpl := gotemplate.New("Flamego.Template")
for _, f := range fs.Files() {
tt := tpl.New(f.Name())
for _, funcMap := range templatepkg.FuncMap() {
tt.Funcs(funcMap)
}
data, err := f.Data()
if err != nil {
t.Fatalf("get data of %q: %v", f.Name(), err)
}
if _, err := tt.Parse(string(data)); err != nil {
t.Errorf("parse %q: %v", f.Name(), err)
}
}
}
var (
// definePattern 匹配 {{define "x"}} / {{block "x" ...}}:定义模板。
definePattern = regexp.MustCompile(`\{\{\s*(?:define|block)\s+"([^"]+)"`)
// refPattern 匹配 {{template "x" ...}}:引用模板。
refPattern = regexp.MustCompile(`\{\{\s*template\s+"([^"]+)"`)
)
// TestTemplateCrossReferences 检查所有 {{template "x"}} 引用的目标模板
// 是否真实存在(文件名或 define/block 定义),避免运行时 "no such template"。
func TestTemplateCrossReferences(t *testing.T) {
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
// 集合 = 所有模板文件名 + 文件内 define/block 定义的名称
names := make(map[string]bool)
fileData := make(map[string][]byte)
for _, f := range fs.Files() {
names[f.Name()] = true
data, err := f.Data()
if err != nil {
t.Fatalf("get data of %q: %v", f.Name(), err)
}
fileData[f.Name()] = data
}
for _, data := range fileData {
for _, m := range definePattern.FindAllStringSubmatch(string(data), -1) {
names[m[1]] = true
}
}
// 检查所有引用是否落在集合内
for name, data := range fileData {
for _, m := range refPattern.FindAllStringSubmatch(string(data), -1) {
if !names[m[1]] {
t.Errorf("%s: 引用了未定义的模板 %q", name, m[1])
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{{template "base/header" .}}
<form method="post" id="form">
<fieldset class="uk-fieldset">
{{ .CSRFTokenHTML }}
<legend class="uk-legend">{{t .LangCode "deactivate.title"}}</legend>
{{template "base/alert" .}}
<div class="uk-margin">
{{t .LangCode "deactivate.desc"}}
<br>
<br>
<button type="submit" class="uk-button uk-button-danger g-recaptcha" data-sitekey="{{.RecaptchaSiteKey}}"
data-callback="onSubmit">{{t .LangCode "deactivate.confirm"}}
</button>
</div>
</fieldset>
</form>
{{template "base/footer" .}}
+621
View File
@@ -0,0 +1,621 @@
{{template "base/header" .}}
<style>
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.explore-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
margin-right: 15px;
}
.explore-info {
flex: 1;
min-width: 0;
}
.explore-name {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 5px;
}
.explore-name a {
text-decoration: none;
color: #333;
}
.explore-name a:hover {
color: #1e87f0;
}
.explore-intro {
color: #666;
margin-bottom: 10px;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.6em;
}
.explore-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.explore-tags .uk-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-card {
display: flex;
align-items: center;
padding: 10px;
background: #f8f8f8;
border-radius: 8px;
transition: background 0.2s;
}
.recommend-card:hover {
background: #f0f0f0;
}
.recommend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.recommend-info {
flex: 1;
min-width: 0;
}
.recommend-name {
font-weight: bold;
font-size: 1rem;
}
.recommend-name a {
text-decoration: none;
color: #333;
}
.recommend-tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.recommend-tags .uk-label {
font-size: 0.7rem;
padding: 0 5px;
}
.hot-votes-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.hot-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 16px 18px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: #fafafa;
text-decoration: none;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.hot-vote-item:hover {
border-color: #1e87f0;
background: #f5faff;
text-decoration: none;
transform: translateY(-1px);
}
.hot-vote-content {
min-width: 0;
}
.hot-vote-title {
display: block;
overflow: hidden;
color: #333;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.hot-vote-meta .uk-label {
font-size: 0.72rem;
line-height: 1.6;
}
.hot-vote-action {
flex: 0 0 auto;
color: #1e87f0;
font-size: 0.82rem;
white-space: nowrap;
}
html[data-theme="dark"] .hot-vote-item {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .hot-vote-item:hover {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.10);
}
html[data-theme="dark"] .hot-vote-title {
color: var(--tb-text);
}
@media (max-width: 640px) {
.hot-votes-list {
grid-template-columns: 1fr;
}
.hot-vote-item {
padding: 14px;
}
}
.explore-tabs {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
margin: 2px auto 20px;
max-width: 320px;
border: 1px solid #e5e5e5;
border-radius: 9px;
background: #fafafa;
}
.explore-tab {
border: 0;
border-radius: 6px;
padding: 8px 15px;
background: transparent;
color: #666;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.explore-tab:hover { color: #1e87f0; }
.explore-tab.is-active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: #1e87f0;
font-weight: 600;
}
.explore-panel[hidden] { display: none !important; }
.explore-vote-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.explore-vote-toolbar p { margin: 4px 0 0; }
.vote-random-btn {
flex: 0 0 auto;
border: 1px solid #dce9ff;
border-radius: 7px;
padding: 7px 11px;
color: #1e87f0;
background: #f5faff;
cursor: pointer;
font-size: 0.84rem;
}
.vote-random-btn:hover { border-color: #1e87f0; background: #edf6ff; }
.all-votes-list { display: grid; gap: 10px; }
.all-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #e9e9e9;
text-decoration: none;
}
.all-vote-item:first-child { border-top: 1px solid #e9e9e9; }
.all-vote-item:hover { background: #f8fbff; text-decoration: none; }
.all-vote-item .hot-vote-title { max-width: 100%; }
html[data-theme="dark"] .explore-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .explore-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .explore-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .vote-random-btn { border-color: var(--tb-border); background: var(--tb-surface-2); color: var(--tb-primary); }
html[data-theme="dark"] .all-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .all-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
.explore-notice {
background: #f0f7ff;
border-left: 4px solid #1e87f0;
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 4px;
color: #333;
font-size: 0.9rem;
line-height: 1.6;
}
.explore-notice strong {
color: #1e87f0;
}
/* 极简「换一批」按钮:描边胶囊 + 悬停浅蓝底色 + 点击旋转反馈 */
.tb-shuffle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
font-size: 0.85rem;
line-height: 1.4;
color: #1e87f0;
background: transparent;
border: 1px solid #e3e3e3;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.tb-shuffle-btn:hover {
background: #f0f7ff;
border-color: #1e87f0;
}
.tb-shuffle-btn:active {
transform: scale(0.96);
}
.tb-shuffle-btn .uk-icon {
transition: transform 0.5s ease;
}
.tb-shuffle-btn.is-spinning .uk-icon {
transform: rotate(360deg);
}
html[data-theme="dark"] .tb-shuffle-btn {
color: #4ea8f5;
border-color: #3a3f45;
}
html[data-theme="dark"] .tb-shuffle-btn:hover {
background: rgba(78, 168, 245, 0.12);
border-color: #4ea8f5;
}
</style>
<div class="uk-container uk-container-small">
<h2>{{t .LangCode "explore.title"}}</h2>
<div class="explore-notice">
<p>{{t .LangCode "explore.notice" | safe}}</p>
</div>
<div class="explore-tabs" role="tablist" aria-label="{{t .LangCode "explore.title"}}">
<button type="button" class="explore-tab is-active" data-explore-tab="questions" role="tab" aria-selected="true">{{t .LangCode "explore.questionsTab"}}</button>
<button type="button" class="explore-tab" data-explore-tab="votes" role="tab" aria-selected="false">{{t .LangCode "explore.votesTab"}}</button>
</div>
<div class="explore-panel" data-explore-panel="questions">
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-flex uk-flex-between uk-flex-middle">
<h3 class="uk-card-title uk-margin-remove">{{t .LangCode "explore.recommend"}}</h3>
<button class="tb-shuffle-btn" id="refresh-recommend" type="button">
<span uk-icon="icon: refresh"></span> {{t .LangCode "explore.shuffle"}}
</button>
</div>
<div id="recommend-list" class="uk-grid-small uk-child-width-1-2@s uk-child-width-1-3@m uk-grid-match" uk-grid>
{{if .RecommendUsers}}
{{range $user := .RecommendUsers}}
<div>
<a href="/_/{{$user.Domain}}" class="recommend-card">
<img class="recommend-avatar" src="{{$user.Avatar}}" alt="avatar">
<div class="recommend-info">
<div class="recommend-name truncate">{{$user.Name}}</div>
<div class="recommend-tags">
{{if $user.TagList}}
{{range $index, $tag := $user.TagList}}
<span class="uk-label {{index $user.TagColors $index}}">{{$tag}}</span>
{{end}}
{{else}}
<span class="uk-text-muted">{{t $.LangCode "explore.noTags"}}</span>
{{end}}
</div>
</div>
</a>
</div>
{{end}}
{{else}}
<div class="uk-text-center uk-padding">{{t .LangCode "explore.noRecommend"}}</div>
{{end}}
</div>
</div>
<!-- 热门标签榜 -->
{{if .HotTags}}
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<h4 class="uk-card-title uk-margin-remove-bottom">{{t .LangCode "explore.hotTags"}}</h4>
<div class="uk-margin-small-top uk-flex uk-flex-wrap uk-flex-middle" style="gap: 6px;">
{{range $i, $tag := .HotTags}}
<a class="uk-label {{if eq (mod $i 5) 0}}uk-label-success{{else if eq (mod $i 5) 1}}uk-label-warning{{else if eq (mod $i 5) 2}}uk-label-danger{{else if eq (mod $i 5) 3}}uk-label-primary{{else}}uk-label-secondary{{end}}"
href="/explore?q={{urlquery $tag}}" style="text-decoration:none;">#{{$tag}}</a>
{{end}}
</div>
</div>
{{end}}
<form method="get" action="/explore" class="uk-margin uk-margin-bottom">
<div class="uk-grid-small" uk-grid>
<div class="uk-width-expand">
<div class="uk-inline uk-width-1-1">
<button class="uk-form-icon uk-form-icon-flip" uk-icon="icon: search" type="submit"></button>
<input class="uk-input" type="text" name="q" placeholder="{{t .LangCode "explore.search"}}" value="{{.Keyword}}">
</div>
</div>
<div class="uk-width-auto">
<select name="sort" class="uk-select" onchange="this.form.submit()">
<option value="latest" {{if eq .Sort "latest"}}selected{{end}}>{{t .LangCode "explore.sortLatest"}}</option>
<option value="oldest" {{if eq .Sort "oldest"}}selected{{end}}>{{t .LangCode "explore.sortOldest"}}</option>
</select>
</div>
</div>
</form>
{{if .Users}}
<div class="uk-child-width-1-1 uk-child-width-1-2@s uk-grid-match" uk-grid>
{{range .Users}}
{{$user := .}}
<div>
<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">
</div>
<div class="explore-info">
<div class="explore-name truncate">
<a href="/_/{{$user.Domain}}" title="{{$user.Name}}">{{$user.Name}}</a>
</div>
<div class="explore-intro" title="{{$user.Intro}}">
{{if $user.Intro}}{{$user.Intro}}{{else}}{{t $.LangCode "explore.lazy"}}{{end}}
</div>
<div class="explore-tags">
{{if $user.TagList}}
{{range $index, $tag := $user.TagList}}
<span class="uk-label {{index $user.TagColors $index}}" title="{{$tag}}">{{$tag}}</span>
{{end}}
{{else}}
<span class="uk-text-muted">{{t $.LangCode "explore.noTags"}}</span>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
</div>
<ul class="uk-pagination uk-flex-center uk-margin-top" uk-margin>
{{if gt .Page 1}}
<li><a href="?{{if .Keyword}}q={{.Keyword}}&{{end}}sort={{.Sort}}&page={{.PrevPage}}"><span uk-pagination-previous></span> {{t .LangCode "explore.prevPage"}}</a></li>
{{else}}
<li class="uk-disabled"><span><span uk-pagination-previous></span> {{t .LangCode "explore.prevPage"}}</span></li>
{{end}}
<li class="uk-active"><span>{{.Page}} / {{.TotalPages}}</span></li>
{{if lt .Page .TotalPages}}
<li><a href="?{{if .Keyword}}q={{.Keyword}}&{{end}}sort={{.Sort}}&page={{.NextPage}}">{{t .LangCode "explore.nextPage"}} <span uk-pagination-next></span></a></li>
{{else}}
<li class="uk-disabled"><span>{{t .LangCode "explore.nextPage"}} <span uk-pagination-next></span></span></li>
{{end}}
</ul>
{{else}}
<div class="uk-alert uk-alert-warning">{{t .LangCode "explore.noResult"}}</div>
{{end}}
</div>
<div class="explore-panel" data-explore-panel="votes" hidden>
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="explore-vote-toolbar">
<div>
<h3 class="uk-card-title uk-margin-remove">{{t .LangCode "explore.votesTitle"}}</h3>
<p class="uk-text-meta uk-text-small">{{t .LangCode "explore.votesDesc"}}</p>
</div>
<button type="button" class="vote-random-btn" id="random-vote-btn"><span uk-icon="icon: refresh; ratio: .8"></span> {{t .LangCode "explore.randomVote"}}</button>
</div>
{{if .HotVotes}}
<h4 class="uk-heading-bullet uk-margin-small-bottom">{{t .LangCode "explore.hotVotes"}}</h4>
<div class="hot-votes-list uk-margin-small-bottom">
{{range $vote := .HotVotes}}
<a href="/vote/{{$vote.LinkToken}}" class="hot-vote-item">
<div class="hot-vote-content">
<span class="hot-vote-title" title="{{$vote.Title}}">{{$vote.Title}}</span>
<span class="hot-vote-meta">
{{if eq $vote.Type "single"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{end}}
</span>
</div>
<span class="hot-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{end}}
</div>
{{end}}
<h4 class="uk-heading-bullet uk-margin-medium-top uk-margin-small-bottom">{{t .LangCode "explore.allVotes"}}</h4>
{{if .AllVotes}}
<div class="all-votes-list" id="all-votes-list">
{{range $vote := .AllVotes}}
<a href="/vote/{{$vote.LinkToken}}" class="all-vote-item">
<div class="hot-vote-content">
<span class="hot-vote-title" title="{{$vote.Title}}">{{$vote.Title}}</span>
<span class="hot-vote-meta">
{{if eq $vote.Type "single"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{end}}
<span class="uk-text-meta">{{$vote.CreatedAt.Format "2006-01-02"}}</span>
</span>
</div>
<span class="hot-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{end}}
</div>
{{else}}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "explore.noVotes"}}</div>
{{end}}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const recommendContainer = document.getElementById('recommend-list');
const refreshBtn = document.getElementById('refresh-recommend');
const tabs = document.querySelectorAll('[data-explore-tab]');
const panels = document.querySelectorAll('[data-explore-panel]');
function showExplorePanel(name) {
tabs.forEach(function(tab) {
const active = tab.dataset.exploreTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.explorePanel !== name;
});
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname + (name === 'votes' ? '#votes' : ''));
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showExplorePanel(tab.dataset.exploreTab); });
});
if (window.location.hash === '#votes') showExplorePanel('votes');
const randomVoteBtn = document.getElementById('random-vote-btn');
if (randomVoteBtn) {
randomVoteBtn.addEventListener('click', function() {
const votes = Array.from(document.querySelectorAll('#all-votes-list .all-vote-item'));
if (!votes.length) return;
const target = votes[Math.floor(Math.random() * votes.length)];
randomVoteBtn.classList.add('is-spinning');
target.style.outline = '2px solid #1e87f0';
target.style.outlineOffset = '2px';
window.setTimeout(function() {
window.location.href = target.href;
}, 350);
});
}
// 递归提取用户数组,无视包裹层数
function extractUsers(obj) {
if (Array.isArray(obj)) return obj;
if (!obj || typeof obj !== 'object') return [];
// 单个用户对象
if (obj.domain || obj.id) {
return [obj];
}
// 尝试从 data 中递归
if (obj.data) {
return extractUsers(obj.data);
}
// 遍历所有值
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const val = obj[key];
if (Array.isArray(val)) {
return val;
} else if (val && typeof val === 'object') {
const result = extractUsers(val);
if (result.length > 0) return result;
}
}
}
return [];
}
function escapeHTML(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 仅允许 http(s) 头像地址,阻断 javascript: 等伪协议。
function safeAvatarURL(url) {
if (typeof url !== 'string') return '';
if (/^https?:\/\//i.test(url)) return url;
return '';
}
function loadRandomRecommend() {
recommendContainer.innerHTML = '<div class="uk-text-center uk-padding"><div uk-spinner></div> {{t .LangCode "explore.loading"}}</div>';
fetch('/api/v1/explore/random')
.then(response => {
if (!response.ok) throw new Error(`HTTP {{t .LangCode "explore.loadFailed"}} ${response.status}`);
return response.json();
})
.then(resp => {
const users = extractUsers(resp);
if (users.length === 0) {
recommendContainer.innerHTML = '<div class="uk-text-center uk-padding">{{t .LangCode "explore.noRecommend"}}</div>';
return;
}
let html = '';
users.forEach(user => {
let tagsHtml = '';
if (user.tag_list && user.tag_list.length) {
user.tag_list.forEach((tag, idx) => {
const colorClass = (user.tag_color && user.tag_color[idx]) || 'uk-label-default';
tagsHtml += `<span class="uk-label ${escapeHTML(colorClass)}" title="${escapeHTML(tag)}">${escapeHTML(tag)}</span>`;
});
} else {
tagsHtml = '<span class="uk-text-muted">{{t .LangCode "explore.noTags"}}</span>';
}
html += `
<div>
<a href="/_/${escapeHTML(user.domain)}" class="recommend-card">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar">
<div class="recommend-info">
<div class="recommend-name truncate">${escapeHTML(user.name)}</div>
<div class="recommend-tags">${tagsHtml}</div>
</div>
</a>
</div>
`;
});
recommendContainer.innerHTML = html;
if (window.UIkit && UIkit.grid) {
UIkit.grid(recommendContainer);
}
})
.catch(error => {
console.error('Failed to load random recommends:', error);
recommendContainer.innerHTML = '<div class="uk-alert uk-alert-danger">加载失败:' + escapeHTML(error.message) + '</div>';
});
}
// 初始推荐已由服务端渲染,只有"换一批"按钮触发 AJAX
refreshBtn.addEventListener('click', function() {
// 重启旋转动画(先移再触发 reflow 再加,确保每次点击都播放)
refreshBtn.classList.remove('is-spinning');
void refreshBtn.offsetWidth;
refreshBtn.classList.add('is-spinning');
loadRandomRecommend();
});
});
</script>
{{template "base/footer" .}}
+135
View File
@@ -0,0 +1,135 @@
<!--
功能控制表单部分模板:提问 / 私聊 / 投票 三个功能面板共用。
渲染时「点」为单个功能视图(Key / Control / State / Mode / CSRFTokenHTML ...),
因此所有文案与令牌都可自取,无需访问根数据。
收集模式(mode)三选一,保存时互斥、后端同步清理未选模式的字段:
- always 长期开放:不限时间,保持开放直到手动关闭(仍受收集上限约束);
- window 定时收集:一次性开放 / 关闭时间窗口;
- recurring 循环定时:每周固定开放日 + 每日时间段循环。
私聊(chat)不提供收集上限:配额字段与重置计数按钮按 Key 条件隐藏,后端强制清零。
「保存」与「重置计数」同行:重置按钮通过 HTML5 form 属性指向独立的 reset 表单。
-->
<div class="feat-card">
<div class="feat-card-head">
<h3 class="uk-margin-remove">{{ .NameText }}</h3>
<span class="feat-status {{ if .State.Open }}is-open{{ else }}is-closed{{ end }}">{{ .StatusText }}</span>
</div>
<span class="feat-hint">{{t .LangCode (print "admin.featureHint." .Key)}}</span>
<form method="post" action="/user/profile/features/update">
{{ .CSRFTokenHTML }}
<input type="hidden" name="feature" value="{{ .Key }}">
<div class="uk-margin-small">
<label>
<input name="enabled" class="uk-checkbox" type="checkbox" {{ if .Control.Enabled }}checked{{ end }}>
<span class="uk-text-small">{{t .LangCode "admin.featureEnabled"}}</span>
</label>
</div>
<div class="uk-margin-small feat-field">
<span class="feat-field-label">{{t .LangCode "admin.featureMode"}}</span>
<div class="feat-modes">
<label class="feat-day{{ if eq .Mode "always" }} is-on{{ end }}">
<input name="mode" type="radio" value="always" {{ if eq .Mode "always" }}checked{{ end }}>
<span>{{t .LangCode "admin.featureModeAlways"}}</span>
</label>
<label class="feat-day{{ if eq .Mode "window" }} is-on{{ end }}">
<input name="mode" type="radio" value="window" {{ if eq .Mode "window" }}checked{{ end }}>
<span>{{t .LangCode "admin.featureModeWindow"}}</span>
</label>
<label class="feat-day{{ if eq .Mode "recurring" }} is-on{{ end }}">
<input name="mode" type="radio" value="recurring" {{ if eq .Mode "recurring" }}checked{{ end }}>
<span>{{t .LangCode "admin.featureModeRecurring"}}</span>
</label>
</div>
<span class="feat-hint">{{t .LangCode "admin.featureModeHint"}}</span>
</div>
<div class="uk-margin-small feat-field feat-window-fields">
<label>
<span>{{t .LangCode "admin.featureOpenAt"}}</span>
<input name="open_at" class="uk-input" type="datetime-local" value="{{ .OpenAtStr }}">
</label>
<label>
<span>{{t .LangCode "admin.featureCloseAt"}}</span>
<input name="close_at" class="uk-input" type="datetime-local" value="{{ .CloseAtStr }}">
</label>
<span class="feat-hint">{{t .LangCode "admin.featureTimeHint"}}</span>
</div>
<div class="uk-margin-small feat-field feat-recurring-fields">
<label>
<span>{{t .LangCode "admin.featureSchedule"}}</span>
</label>
<div class="feat-schedule">
<div class="feat-schedule-days">
{{ range $i := loop 7 }}
<label class="feat-day{{ if hasDay $.ScheduleDays (sub $i 1) }} is-on{{ end }}">
<input name="schedule_days" class="uk-checkbox" type="checkbox" value="{{ sub $i 1 }}" {{ if hasDay $.ScheduleDays (sub $i 1) }}checked{{ end }}>
<span>{{t $.LangCode (printf "weekday.%d" (sub $i 1))}}</span>
</label>
{{ end }}
</div>
<div class="feat-schedule-times">
<label>
<span>{{t .LangCode "admin.featureScheduleStart"}}</span>
<input name="schedule_start" class="uk-input" type="time" value="{{ .ScheduleStart }}">
</label>
<label>
<span>{{t .LangCode "admin.featureScheduleEnd"}}</span>
<input name="schedule_end" class="uk-input" type="time" value="{{ .ScheduleEnd }}">
</label>
</div>
<span class="feat-hint">{{t .LangCode "admin.featureScheduleHint"}}</span>
</div>
</div>
{{ if ne .Key "chat" }}
<div class="uk-margin-small feat-field">
<label>
<span>{{t .LangCode "admin.featureQuotaLimit"}}</span>
<input name="quota_limit" class="uk-input" type="number" min="0" max="1000000" value="{{ .Control.QuotaLimit }}">
</label>
<span class="feat-hint">{{t .LangCode "admin.featureQuotaHint"}}</span>
</div>
<div class="feat-quota-line">
{{t .LangCode "admin.featureQuotaCount"}}:{{ .State.QuotaCount }}
{{ if .Control.QuotaLimit }}/ {{ .Control.QuotaLimit }}{{ else }}<span class="uk-text-muted">({{t .LangCode "admin.featureQuotaUnlimited"}})</span>{{ end }}
{{ if .Control.QuotaResetAt }}<br>{{t .LangCode "admin.featureQuotaResetAt"}} {{ .QuotaResetAtStr }}{{ end }}
</div>
{{ end }}
<div class="feat-actions">
{{ if ne .Key "chat" }}
<button type="submit" class="uk-button uk-button-default" form="feat-reset-{{ .Key }}">{{t .LangCode "admin.featureResetQuota"}}</button>
{{ end }}
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "admin.featureSave"}}</button>
</div>
</form>
{{ if ne .Key "chat" }}
<form id="feat-reset-{{ .Key }}" method="post" action="/user/profile/features/reset-quota">
{{ .CSRFTokenHTML }}
<input type="hidden" name="feature" value="{{ .Key }}">
</form>
{{ end }}
<script>
(function () {
var card = document.currentScript && document.currentScript.closest('.feat-card');
if (!card) return;
var radios = card.querySelectorAll('input[name="mode"]');
var windowFields = card.querySelector('.feat-window-fields');
var recurringFields = card.querySelector('.feat-recurring-fields');
if (!radios.length) return;
function sync() {
var mode = '';
radios.forEach(function (r) {
var pill = r.closest('.feat-day');
if (pill) pill.classList.toggle('is-on', r.checked);
if (r.checked) mode = r.value;
});
if (windowFields) windowFields.classList.toggle('is-hidden', mode !== 'window');
if (recurringFields) recurringFields.classList.toggle('is-hidden', mode !== 'recurring');
}
radios.forEach(function (r) { r.addEventListener('change', sync); });
sync();
})();
</script>
</div>
+29
View File
@@ -0,0 +1,29 @@
<!--
全局屏蔽词表单部分模板:提问 / 私聊 / 投票 三个功能面板共用同一份词表设置。
渲染时「点」为 dict{ Root: 根数据, Action: 表单提交地址 }:
Root —— profile 页面根数据(LoggedUser / TagLimits / LangCode / CSRFTokenHTML …);
Action —— 表单 action,携带 ?tab= 来源面板参数,保存后回跳原面板。
三处展示同一份数据(users.block_words),任意一处修改保存后全部同步生效;
词表按 conf.Limits.MaxBlockWords / MaxBlockWordLen 上限校验(后端同一份配置)。
-->
{{ $root := .Root }}
<div class="uk-margin">
<form method="post" action="{{ .Action }}">
{{ $root.CSRFTokenHTML }}
<legend class="uk-legend">{{t $root.LangCode "admin.globalBlockWords"}}</legend>
<div class="uk-margin">
<p class="uk-text-meta uk-text-small">{{t $root.LangCode "admin.globalBlockWordsHint"}}</p>
<label class="uk-form-label">{{t $root.LangCode "admin.blockWords" | safe}}</label>
<input name="block_words" class="uk-input" type="text" value="{{$root.LoggedUser.BlockWords}}"
data-tb-tag-input
{{ with $root.TagLimits }}data-tb-max="{{ .MaxBlockWords }}" data-tb-max-len="{{ .MaxBlockWordLen }}"{{ end }}
data-tb-msg-too-many="{{t $root.LangCode "admin.flash.blockWordsTooMany"}}"
data-tb-msg-too-long="{{t $root.LangCode "admin.flash.blockWordTooLong"}}"
data-tb-msg-duplicate="{{t $root.LangCode "admin.tagInputDuplicate"}}"
data-tb-msg-hint="{{t $root.LangCode "admin.tagInputHint"}}">
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $root.LangCode "admin.updateBlockWords"}}</button>
</div>
</form>
</div>
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
{{template "base/header" .}}
<style>
.qlist-tabs {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
margin: 6px auto 20px;
max-width: 320px;
border: 1px solid #e5e5e5;
border-radius: 9px;
background: #fafafa;
}
.qlist-tab {
flex: 1 1 0;
border: 0;
border-radius: 6px;
padding: 8px 15px;
background: transparent;
color: #666;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.qlist-tab:hover { color: #1e87f0; }
.qlist-tab.is-active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: #1e87f0;
font-weight: 600;
}
.qlist-panel[hidden] { display: none !important; }
.all-votes-list { display: grid; gap: 10px; }
.all-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #e9e9e9;
text-decoration: none;
}
.all-vote-item:first-child { border-top: 1px solid #e9e9e9; }
.all-vote-item:hover { background: #f8fbff; text-decoration: none; }
.all-vote-item .hot-vote-title { max-width: 100%; }
.hot-vote-title {
display: block;
overflow: hidden;
color: #333;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-vote-title:hover { color: #1e87f0; }
.hot-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.hot-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
.hot-vote-action { flex: 0 0 auto; color: #1e87f0; font-size: 0.82rem; white-space: nowrap; }
/* 提问列表右上角:状态标签与操作按钮统一尺寸(等宽等高、垂直居中、完全对齐)。
每个元素显式带 .qlist-pill 类,选择器挂在 .qlist-pills 容器下提升优先级,
避免浏览器对 <button> 的默认样式或 UIkit .uk-label 造成差异。
移动端默认显示短文字标签(每语言短词,如 未读→新/New,宽度可控);
≥640px 显示完整文案(配合 title 悬浮提示,多语言安全)。 */
.qlist-pills .qlist-pill {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 40px;
padding: 0 8px;
box-sizing: border-box;
font-size: 0.72rem;
line-height: 1;
white-space: nowrap;
border-radius: 2px;
border: 0;
vertical-align: middle;
}
.qlist-pills .tb-short { display: inline; }
.qlist-pills .tb-text { display: none; }
/* 按钮额外重置浏览器默认样式,保证与 span 标签渲染完全一致 */
.qlist-pills .qlist-pill.tb-label-btn {
cursor: pointer;
font-family: inherit;
-webkit-appearance: none;
appearance: none;
background-image: none;
outline: none;
margin: 0;
}
.qlist-pills .qlist-pill.tb-label-btn:hover { opacity: 0.85; }
@media (min-width: 640px) {
/* 桌面端纯文字:隐藏移动端短词,只显示完整文案 */
.qlist-pills .qlist-pill { min-width: 62px; padding: 0 10px; }
.qlist-pills .tb-short { display: none; }
.qlist-pills .tb-text { display: inline; }
}
html[data-theme="dark"] .qlist-tabs { border-color: var(--tb-border); background: var(--tb-surface-2); }
html[data-theme="dark"] .qlist-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .qlist-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .all-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .all-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
html[data-theme="dark"] .hot-vote-title { color: var(--tb-text); }
/* 紧凑统计看板:替代原 UIkit 卡片网格。手机端 4 列(7 项=2 行)、桌面端 7 列 1 行,
轻量描边小格子、缩字号、去掉大卡片内边距,显著降低纵向高度,减少手机端滚动。
--4 修饰类用于项数较少的看板(如私聊 4 项),在所有断点固定 4 列,避免末尾留白过多。 */
.qlist-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
@media (min-width: 768px) {
.qlist-stats { grid-template-columns: repeat(7, 1fr); }
}
.qlist-stats--4 { grid-template-columns: repeat(4, 1fr); }
@media (min-width: 768px) {
.qlist-stats--4 { grid-template-columns: repeat(4, 1fr); }
}
/* --2 修饰类用于项数很少的看板(如投票 2 项),固定 2 列,避免末尾留白 */
.qlist-stats--2 { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 768px) {
.qlist-stats--2 { grid-template-columns: repeat(2, 1fr); }
}
.qlist-stat {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 9px;
padding: 8px 6px;
text-align: center;
}
.qlist-stat .qlist-stat-num {
display: block;
font-size: 1.15rem;
font-weight: 700;
line-height: 1.15;
color: #222;
}
.qlist-stat .qlist-stat-lbl {
display: block;
margin-top: 2px;
font-size: 0.7rem;
color: #888;
line-height: 1.15;
}
html[data-theme="dark"] .qlist-stat { background: var(--tb-surface); border-color: var(--tb-border); }
html[data-theme="dark"] .qlist-stat .qlist-stat-num { color: var(--tb-text); }
html[data-theme="dark"] .qlist-stat .qlist-stat-lbl { color: var(--tb-text-muted); }
</style>
<div class="uk-container">
<div class="uk-flex uk-flex-middle uk-flex-between uk-margin-small-bottom">
<h2 class="uk-margin-remove">{{t .LangCode "qlist.title"}}</h2>
<a class="uk-button uk-button-default uk-button-small" href="/user/profile">{{t .LangCode "admin.featuresEntry"}}</a>
</div>
<!-- 居中切换:提问 / 投票 / 私聊 -->
<div class="qlist-tabs" role="tablist" aria-label="{{t .LangCode "qlist.title"}}">
<button type="button" class="qlist-tab is-active" data-qlist-tab="questions" role="tab" aria-selected="true">{{t .LangCode "qlist.questionsTab"}}</button>
<button type="button" class="qlist-tab" data-qlist-tab="votes" role="tab" aria-selected="false">{{t .LangCode "qlist.votesTab"}}</button>
<button type="button" class="qlist-tab" data-qlist-tab="chats" role="tab" aria-selected="false">{{t .LangCode "qlist.chatsTab"}}</button>
</div>
<!-- 提问面板(默认展示) -->
<div class="qlist-panel" data-qlist-panel="questions">
<!-- 统一提示区:标记已读/未读、置顶等操作的成功/错误提示沿用 base/alert 样式 -->
{{template "base/alert" .}}
<!-- 提问箱统计看板(紧凑多列网格:手机4列/桌面7列,降低纵向高度) -->
<div class="qlist-stats">
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.total }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsTotal"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.answered }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsAnswered"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.unanswered }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsUnanswered"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.read }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsRead"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.unread }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsUnread"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.private }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsPrivate"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.pinned }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsPinned"}}</span></div>
</div>
<div class="uk-margin-medium-bottom uk-text-center">
<!-- 全文搜索自己提问箱 -->
<form method="get" action="" class="uk-form-stacked">
<div class="uk-flex uk-flex-center uk-flex-nowrap uk-overflow-auto">
<div class="uk-margin-small-right uk-width-expand" style="max-width: 420px;">
<input type="text" name="q" class="uk-input uk-form-small" value="{{ .Keyword }}" placeholder="{{t .LangCode "qlist.searchPlaceholder"}}" aria-label="{{t .LangCode "qlist.searchPlaceholder"}}">
</div>
<div class="uk-margin-small-right">
<button type="submit" class="uk-button uk-button-primary uk-button-small">{{t .LangCode "qlist.searchBtn"}}</button>
</div>
{{ if ne .Keyword "" }}
<div>
<a class="uk-button uk-button-default uk-button-small" href="/user/questions">{{t .LangCode "qlist.searchClear"}}</a>
</div>
{{ end }}
</div>
</form>
<form method="get" action="" class="uk-form-stacked uk-margin-small-top">
<div class="uk-flex uk-flex-center uk-flex-nowrap uk-overflow-auto">
<div class="uk-margin-small-right" style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.sort"}}</label>
<select name="sort" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="latest" {{if eq .SelectedSort "latest"}}selected{{end}}>{{t .LangCode "qlist.latest"}}</option>
<option value="oldest" {{if eq .SelectedSort "oldest"}}selected{{end}}>{{t .LangCode "qlist.oldest"}}</option>
</select>
</div>
<div class="uk-margin-small-right" style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.status"}}</label>
<select name="status" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="all" {{if eq .SelectedStatus "all"}}selected{{end}}>{{t .LangCode "qlist.all"}}</option>
<option value="answered" {{if eq .SelectedStatus "answered"}}selected{{end}}>{{t .LangCode "qlist.answered"}}</option>
<option value="unanswered" {{if eq .SelectedStatus "unanswered"}}selected{{end}}>{{t $.LangCode "qlist.unanswered"}}</option>
</select>
</div>
<div class="uk-margin-small-right" style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.type"}}</label>
<select name="type" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="all" {{if eq .SelectedType "all"}}selected{{end}}>{{t .LangCode "qlist.all"}}</option>
<option value="public" {{if eq .SelectedType "public"}}selected{{end}}>{{t .LangCode "qlist.public"}}</option>
<option value="private_question" {{if eq .SelectedType "private_question"}}selected{{end}}>{{t .LangCode "qlist.privateQuestion"}}</option>
</select>
</div>
<div style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.readStatus"}}</label>
<select name="read" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="all" {{if eq .SelectedRead "all"}}selected{{end}}>{{t .LangCode "qlist.all"}}</option>
<option value="read" {{if eq .SelectedRead "read"}}selected{{end}}>{{t .LangCode "qlist.read"}}</option>
<option value="unread" {{if eq .SelectedRead "unread"}}selected{{end}}>{{t .LangCode "qlist.unread"}}</option>
</select>
</div>
</div>
</form>
</div>
<!-- 我收到的提问(标签右浮同行时间,内容下一行) -->
<h4>{{t .LangCode "qlist.received"}}</h4>
{{ if .ReceivedItems }}
{{ range .ReceivedItems }}
<div class="uk-margin-medium-bottom" id="question-{{.QuestionID}}">
<hr>
<!-- 标签在左、按钮在右:两组分开布局;移动端状态/按钮只显示 emoji 图标,保持单行紧凑 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap qlist-pills" style="gap: 6px;">
<!-- 左:时间 + 状态标签 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 6px;">
<span class="uk-text-small uk-text-muted">{{Date .CreatedAt "Y-m-d H:i:s"}}</span>
{{ if .IsPinned }}
<span class="uk-label uk-label-warning qlist-pill" title="{{t $.LangCode "qlist.pinned"}}"><span class="tb-short">{{t $.LangCode "qlist.pinnedShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.pinned"}}</span></span>
{{ end }}
{{ if eq .Type "question_private" }}
<span class="uk-label uk-label-warning qlist-pill" title="{{t $.LangCode "qlist.private"}}"><span class="tb-short">{{t $.LangCode "qlist.privateShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.private"}}</span></span>
{{ end }}
{{ if not .IsAnswered }}
<span class="uk-label qlist-pill" title="{{t $.LangCode "qlist.unanswered"}}"><span class="tb-short">{{t $.LangCode "qlist.unansweredShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.unanswered"}}</span></span>
{{ end }}
{{ if .IsRead }}
<span class="uk-label uk-label-success qlist-pill" title="{{t $.LangCode "qlist.read"}}"><span class="tb-short">{{t $.LangCode "qlist.readShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.read"}}</span></span>
{{ else }}
<span class="uk-label qlist-pill" title="{{t $.LangCode "qlist.unread"}}"><span class="tb-short">{{t $.LangCode "qlist.unreadShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.unread"}}</span></span>
{{ end }}
</div>
<!-- 右:操作按钮(margin-left:auto 推到最右,换行后仍保持右对齐) -->
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 6px; margin-left: auto;">
{{ if not .IsRead }}
<!-- 快捷标记已读:沿用原 tb-label-btn 样式,display:contents 不破坏 flex 布局 -->
<form method="post" action="/_/{{$.LoggedUser.Domain}}/{{.QuestionID}}/read" style="margin: 0; display: contents;">
{{ $.CSRFTokenHTML }}
<button type="submit" class="uk-label tb-label-btn uk-label-success qlist-pill" title="{{t $.LangCode "qitem.markRead"}}"><span class="tb-short">{{t $.LangCode "qitem.markReadShort"}}</span><span class="tb-text">{{t $.LangCode "qitem.markRead"}}</span></button>
</form>
{{ end }}
<!-- form 用 display:contents 不产生盒子,置顶按钮直接参与 flex 对齐 -->
<form method="post" action="{{.PinLink}}" style="margin: 0; display: contents;">
{{ $.CSRFTokenHTML }}
<button type="submit" class="uk-label tb-label-btn {{ if .IsPinned }}uk-label-danger{{ else }}uk-label-primary{{ end }} qlist-pill" title="{{ if .IsPinned }}{{t $.LangCode "qlist.unpin"}}{{ else }}{{t $.LangCode "qlist.pin"}}{{ end }}"><span class="tb-short">{{ if .IsPinned }}{{t $.LangCode "qlist.unpinShort"}}{{ else }}{{t $.LangCode "qlist.pinShort"}}{{ end }}</span><span class="tb-text">{{ if .IsPinned }}{{t $.LangCode "qlist.unpin"}}{{ else }}{{t $.LangCode "qlist.pin"}}{{ end }}</span></button>
</form>
</div>
</div>
<a href="{{.Link}}">
<div class="uk-text-small uk-text-break tb-preline tb-clamp-5 uk-margin-small-top">{{MarkdownPlainText .Content}}</div>
</a>
</div>
{{ end }}
{{ if .HasMoreQuestions }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .QuestionLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
{{ else }}
<p class="uk-text-muted">{{t .LangCode "qlist.empty"}}</p>
{{ end }}
</div>
<!-- 投票面板 -->
<div class="qlist-panel" data-qlist-panel="votes" hidden>
<!-- 统一提示区:发布/结束/删除等操作的成功/错误提示沿用 base/alert 样式 -->
{{template "base/alert" .}}
<!-- 投票统计看板(与提问/私聊看板一致) -->
<div class="qlist-stats qlist-stats--2">
<div class="qlist-stat"><span class="qlist-stat-num">{{ .VoteStats.total }}</span><span class="qlist-stat-lbl">{{t .LangCode "vote.statsTotal"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .VoteStats.published }}</span><span class="qlist-stat-lbl">{{t .LangCode "vote.statsPublished"}}</span></div>
</div>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom uk-flex uk-flex-between uk-flex-middle">
<div>
<div class="uk-text-bold">{{t .LangCode "qlist.myVotes"}}</div>
<div class="uk-text-meta uk-text-small">{{t .LangCode "vote.manageDesc"}}</div>
</div>
<a class="uk-button uk-button-primary uk-button-small" href="/user/votes/new">{{t .LangCode "qlist.newVote"}}</a>
</div>
{{ if .MyVotes }}
{{ range .MyVotes }}
<div class="uk-margin-medium-bottom">
<hr>
<!-- 第一行:标题 + 类型 + 状态 + 元信息 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 8px;">
<span class="uk-text-bold">{{ if .Title }}{{ .Title }}{{ else }}{{t $.LangCode "qlist.untitledVote"}}{{ end }}</span>
{{ if eq .Type "single" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
{{ if eq .Type "multiple" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
{{ if eq .Type "rating" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
{{ if eq .Type "rank" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
{{ if eq .Type "form" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
{{ if eq .EffectiveStatus "draft" }}<span class="uk-label">{{t $.LangCode "vote.statusDraft"}}</span>{{ end }}
{{ if eq .EffectiveStatus "published" }}<span class="uk-label uk-label-success">{{t $.LangCode "vote.statusPublished"}}</span>{{ end }}
{{ if eq .EffectiveStatus "not_started" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.statusNotStarted"}}</span>{{ end }}
{{ if eq .EffectiveStatus "ended" }}<span class="uk-label uk-label-warning">{{t $.LangCode "vote.statusEnded"}}</span>{{ end }}
{{ if eq .EffectiveStatus "archived" }}<span class="uk-label uk-label-secondary">{{t $.LangCode "vote.statusArchived"}}</span>{{ end }}
<span class="uk-text-meta">{{t $.LangCode "vote.participants"}}: {{ .SubmissionCount }}</span>
{{ if .RemainingText }}<span class="uk-text-meta">{{ .RemainingText }}</span>{{ end }}
<span class="uk-text-meta">{{t $.LangCode "vote.createdAt"}}: {{ .CreatedAt.Format "2006-01-02 15:04" }}</span>
</div>
<!-- 第二行:操作按钮(统一右对齐,所有状态保持一致的两行布局) -->
<div class="uk-flex uk-flex-wrap uk-margin-small-top" style="gap: 6px; justify-content: flex-end;">
{{ if eq .EffectiveStatus "draft" }}
<form method="post" action="/user/votes/{{ .ID }}/publish" class="uk-display-inline">
{{ $.CSRFTokenHTML }}
<button class="uk-button uk-button-primary uk-button-small" onclick="return confirm('{{t $.LangCode "vote.confirmPublish"}}')">{{t $.LangCode "vote.publish"}}</button>
</form>
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/preview">{{t $.LangCode "vote.preview"}}</a>
{{ end }}
{{ if or (eq .EffectiveStatus "published") (eq .EffectiveStatus "not_started") }}
<form method="post" action="/user/votes/{{ .ID }}/close" class="uk-display-inline">
{{ $.CSRFTokenHTML }}
<button class="uk-button uk-button-warning uk-button-small" onclick="return confirm('{{t $.LangCode "vote.confirmClose"}}')">{{t $.LangCode "vote.close"}}</button>
</form>
{{ end }}
{{ if ne .EffectiveStatus "archived" }}
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/edit">{{t $.LangCode "vote.edit"}}</a>
{{ end }}
{{ if ne .EffectiveStatus "draft" }}
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/results">{{t $.LangCode "vote.results"}}</a>
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/export">{{t $.LangCode "vote.export"}}</a>
{{ end }}
{{ if or (eq .EffectiveStatus "published") (eq .EffectiveStatus "not_started") }}
<button class="uk-button uk-button-default uk-button-small" onclick="copyShareURL('{{ .LinkToken }}', this)">{{t $.LangCode "vote.share"}}</button>
{{ end }}
<form method="post" action="/user/votes/{{ .ID }}/delete" class="uk-display-inline">
{{ $.CSRFTokenHTML }}
<button class="uk-button uk-button-danger uk-button-small" onclick="return confirm('{{t $.LangCode "vote.confirmDelete"}}')">{{t $.LangCode "common.delete"}}</button>
</form>
</div>
</div>
{{ end }}
{{ if .HasMoreVotes }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .VoteLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
{{ else }}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "vote.empty"}}</div>
{{ end }}
</div>
<!-- 私聊面板 -->
<div class="qlist-panel" data-qlist-panel="chats" hidden>
<!-- 统一提示区:私聊相关操作的成功/错误提示沿用 base/alert 样式 -->
{{template "base/alert" .}}
<!-- 私聊统计看板(与提问/投票看板一致) -->
<div class="qlist-stats qlist-stats--4">
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.total }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsTotal"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.received }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsReceived"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.initiated }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsInitiated"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.closed }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsClosed"}}</span></div>
</div>
<!-- 我收到的私聊 -->
<h4>{{t .LangCode "qlist.receivedChats"}}({{ .ChatStats.received }})</h4>
{{ if .ReceivedChats }}
{{ range .ReceivedChats }}
<div class="uk-margin-medium-bottom">
<hr>
<div class="uk-float-right uk-flex uk-flex-middle uk-flex-wrap" style="gap: 6px;">
<span class="uk-label uk-label-success">{{t $.LangCode "chat.title"}}</span>
{{ if .IsBlocked }}
<span class="uk-label uk-label-danger">{{t $.LangCode "qlist.blocked"}}</span>
{{ end }}
</div>
<a href="{{.Link}}">
<div class="uk-text-left uk-text-small uk-text-muted">{{Date .CreatedAt "Y-m-d H:i:s"}}</div>
<div class="uk-text-small uk-text-break tb-preline tb-clamp-5 uk-margin-small-top">{{.Content}}</div>
</a>
</div>
{{ end }}
{{ else }}
<p class="uk-text-muted">{{t .LangCode "qlist.chatEmpty"}}</p>
{{ end }}
{{ if .HasMoreChats }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .ChatLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
<!-- 我发起的私聊 -->
{{ if .ConversationDetails }}
<h4 class="uk-margin-top">{{t .LangCode "qlist.myChats"}}({{ .ChatStats.initiated }})</h4>
{{ range .ConversationDetails }}
<a href="/_/{{.OwnerDomain}}/chat/{{.Conversation.Token}}" class="uk-display-block uk-overflow-hidden uk-margin-medium-bottom">
<div>
<hr>
<span class="uk-label uk-label-warning uk-float-right uk-margin-small-right">{{t $.LangCode "chat.title"}}</span>
{{ if .IsBlocked }}
<span class="uk-label uk-label-danger uk-float-right uk-margin-small-right">{{t $.LangCode "qlist.blocked"}}</span>
{{ end }}
<div class="uk-text-left uk-text-small uk-text-muted">{{Date .Conversation.CreatedAt "Y-m-d H:i:s"}}</div>
<div class="uk-text-small uk-text-break tb-preline tb-clamp-5 uk-margin-small-top">{{.FirstMessage}}</div>
</div>
</a>
{{ end }}
{{ end }}
{{ if .HasMoreAskedChats }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .AskedChatsLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
</div>
</div>
<script>
function copyShareURL(token, btn) {
var url = window.location.origin + "/vote/" + token;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function () {
var old = btn.textContent;
btn.textContent = '{{t .LangCode "vote.copied"}}';
setTimeout(function () { btn.textContent = old; }, 1500);
});
} else {
prompt('{{t .LangCode "vote.shareURL"}}', url);
}
}
document.addEventListener('DOMContentLoaded', function() {
const tabs = document.querySelectorAll('[data-qlist-tab]');
const panels = document.querySelectorAll('[data-qlist-panel]');
function showQlistPanel(name) {
tabs.forEach(function(tab) {
const active = tab.dataset.qlistTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.qlistPanel !== name;
});
if (window.history && window.history.replaceState) {
const hash = name === 'votes' ? '#votes' : (name === 'chats' ? '#chats' : '');
window.history.replaceState({}, document.title, window.location.pathname + hash);
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showQlistPanel(tab.dataset.qlistTab); });
});
if (window.location.hash === '#votes') showQlistPanel('votes');
if (window.location.hash === '#chats') showQlistPanel('chats');
});
</script>
{{template "base/footer" .}}
+392
View File
@@ -0,0 +1,392 @@
{{template "base/header" .}}
<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-bottom">
<h3 class="uk-margin-remove">
{{ if .IsEdit }}{{t .LangCode "vote.editTitle"}}{{ else }}{{t .LangCode "vote.newTitle"}}{{ end }}
</h3>
<a class="uk-button uk-button-default uk-button-small" href="/user/questions#votes">{{t .LangCode "common.back"}}</a>
</div>
<form method="post" action="{{ if .IsEdit }}/user/votes/{{ .Vote.ID }}{{ else }}/user/votes{{ end }}" onsubmit="return validateBuilderForm(event)">
{{ .CSRFTokenHTML }}
{{template "base/alert" .}}
<!-- 基本设置 -->
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.formBasic"}}</h4>
{{ if .LockedContent }}
<div class="uk-alert uk-alert-warning uk-margin-small-bottom">{{t .LangCode "vote.formLockedHint"}}</div>
{{ end }}
<div class="uk-margin-small">
<label class="uk-form-label">{{t .LangCode "vote.formTitle"}} *</label>
<input class="uk-input" type="text" name="title" maxlength="100" required value="{{ if .Vote }}{{ .Vote.Title }}{{ end }}" placeholder="{{t .LangCode "vote.formTitlePlaceholder"}}">
</div>
<div class="uk-margin-small">
<label class="uk-form-label">{{t .LangCode "vote.formDescription"}}</label>
<textarea class="uk-textarea" name="description" rows="3" maxlength="5000" placeholder="{{t .LangCode "vote.formDescriptionPlaceholder"}}">{{ if .Vote }}{{ .Vote.Description }}{{ end }}</textarea>
</div>
<div class="uk-grid-small uk-child-width-1-2@s uk-margin-small-top" uk-grid>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formType"}}</label>
{{ if .LockedContent }}
<input class="uk-input" type="text" disabled
value="{{ if eq .Vote.Type "single" }}{{t $.LangCode "vote.typeSingle"}}{{ else if eq .Vote.Type "multiple" }}{{t $.LangCode "vote.typeMultiple"}}{{ else if eq .Vote.Type "rating" }}{{t $.LangCode "vote.typeRating"}}{{ else if eq .Vote.Type "rank" }}{{t $.LangCode "vote.typeRank"}}{{ else }}{{t $.LangCode "vote.typeForm"}}{{ end }}">
{{ else }}
<select class="uk-select" name="type" id="vote-type" onchange="toggleEditor()">
<option value="single" {{ if and .Vote (eq .Vote.Type "single") }}selected{{ end }}>{{t .LangCode "vote.typeSingle"}}</option>
<option value="multiple" {{ if and .Vote (eq .Vote.Type "multiple") }}selected{{ end }}>{{t .LangCode "vote.typeMultiple"}}</option>
<option value="rating" {{ if and .Vote (eq .Vote.Type "rating") }}selected{{ end }}>{{t .LangCode "vote.typeRating"}}</option>
<option value="rank" {{ if and .Vote (eq .Vote.Type "rank") }}selected{{ end }}>{{t .LangCode "vote.typeRank"}}</option>
<option value="form" {{ if and .Vote (eq .Vote.Type "form") }}selected{{ end }}>{{t .LangCode "vote.typeForm"}}</option>
</select>
{{ end }}
</div>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formVisibility"}} *</label>
<select class="uk-select" name="visibility">
<option value="public" {{ if and .Vote (eq .Vote.Visibility "public") }}selected{{ end }}>{{t .LangCode "vote.formVisibilityPublic"}}</option>
<option value="link_only" {{ if and .Vote (eq .Vote.Visibility "link_only") }}selected{{ end }}>{{t .LangCode "vote.formVisibilityLinkOnly"}}</option>
</select>
</div>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formVoterScope"}} *</label>
<select class="uk-select" name="voter_scope">
<option value="anyone" {{ if and .Vote (eq .Vote.VoterScope "anyone") }}selected{{ end }}>{{t .LangCode "vote.formVoterScopeAnyone"}}</option>
<option value="registered_only" {{ if and .Vote (eq .Vote.VoterScope "registered_only") }}selected{{ end }}>{{t .LangCode "vote.formVoterScopeRegistered"}}</option>
</select>
</div>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formShowResult"}} *</label>
<select class="uk-select" name="show_result">
<option value="after_vote" {{ if and .Vote (eq .Vote.ShowResult "after_vote") }}selected{{ end }}>{{t .LangCode "vote.formShowResultAfterVote"}}</option>
<option value="after_end" {{ if and .Vote (eq .Vote.ShowResult "after_end") }}selected{{ end }}>{{t .LangCode "vote.formShowResultAfterEnd"}}</option>
<option value="owner_only" {{ if and .Vote (eq .Vote.ShowResult "owner_only") }}selected{{ end }}>{{t .LangCode "vote.formShowResultOwnerOnly"}}</option>
</select>
</div>
<div class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formStartAt"}}</label>
<input class="uk-input" type="datetime-local" name="start_at" value="{{ .StartAtStr }}">
</div>
<div class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formEndAt"}}</label>
<input class="uk-input" type="datetime-local" name="end_at" value="{{ .EndAtStr }}">
</div>
<div class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formQuotaLimit"}}</label>
<input class="uk-input" type="number" name="quota_limit" min="0" max="1000000" value="{{ if .Vote }}{{ .Vote.QuotaLimit }}{{ else }}0{{ end }}">
<span class="uk-text-meta uk-text-small">{{t .LangCode "vote.formQuotaHint"}}</span>
</div>
{{ if not .LockedContent }}
<div id="max-choices-field" class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formMaxChoices"}}</label>
<input class="uk-input" type="number" name="max_choices" min="1" max="20" value="{{ if .Vote }}{{ .Vote.MaxChoices }}{{ else }}3{{ end }}">
</div>
{{ end }}
</div>
<!-- 限投提醒:移出两列网格,桌面/移动均占满整行,避免与输入框并排显得局促 -->
<!-- registered_only 时限制维度变为"每账号",否则为"每设备",文案随之切换 -->
<div class="uk-margin-small-top">
<div id="scope-hint" class="uk-alert uk-alert-primary uk-margin-remove-bottom" role="status">
<span id="scope-hint-device"{{ if and .Vote (eq .Vote.VoterScope "registered_only") }} hidden{{ end }}>{{t .LangCode "vote.formMaxVotesPerFingerprint"}}</span><span id="scope-hint-account"{{ if and .Vote (eq .Vote.VoterScope "registered_only") }}{{ else }} hidden{{ end }}>{{t .LangCode "vote.formMaxVotesPerAccount"}}</span>
</div>
</div>
<div class="uk-margin-small-top uk-grid-small uk-child-width-1-2@s" uk-grid>
<!-- 全局"允许补充自定义选项"仅对单选/多选/排序生效;表单收集与评分类型由各自机制控制,此处隐藏避免误导 -->
<label id="global-custom-field" class="uk-form-label" {{ if and .Vote (or (eq .Vote.Type "form") (eq .Vote.Type "rating")) }}hidden{{ end }}><input class="uk-checkbox" type="checkbox" name="allow_custom" {{ if and .Vote .Vote.AllowCustom }}checked{{ end }}> {{t .LangCode "vote.formAllowCustom"}}</label>
<label class="uk-form-label"><input class="uk-checkbox" type="checkbox" name="require_captcha" checked disabled> {{t .LangCode "vote.formRequireCaptcha"}}</label>
</div>
</div>
{{ if not .LockedContent }}
<!-- 选项编辑器(单选/多选/排序;评分题无需选项) -->
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom" id="options-editor">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.formOptions"}}</h4>
<div id="rating-hint" class="uk-alert uk-alert-primary uk-margin-small-bottom" hidden>{{t .LangCode "vote.formRatingHint"}}</div>
<div id="options-list"></div>
<button type="button" class="uk-button uk-button-default uk-button-small" onclick="addOption()">{{t .LangCode "vote.addOption"}}</button>
<input type="hidden" name="options" id="options-payload">
</div>
<!-- 表单字段设计器(表单收集类型) -->
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom" id="fields-editor" hidden>
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.formFields"}}</h4>
<div id="fields-list"></div>
<button type="button" class="uk-button uk-button-default uk-button-small" onclick="addField()">{{t .LangCode "vote.addField"}}</button>
<input type="hidden" name="form_fields" id="fields-payload">
</div>
{{ end }}
<div class="uk-margin">
<input type="hidden" name="action" id="vote-action" value="save">
{{ if and .IsEdit (not .LockedContent) }}<button class="uk-button uk-button-default" type="submit" onclick="setVoteAction('preview')">{{t .LangCode "vote.preview"}}</button>{{ end }}
<button class="uk-button uk-button-primary" type="submit" onclick="setVoteAction('save')">{{ if .IsEdit }}{{t .LangCode "common.save"}}{{ else }}{{t .LangCode "vote.createDraft"}}{{ end }}</button>
{{ if not .LockedContent }}<button class="uk-button uk-button-secondary" type="submit" onclick="setVoteAction('publish')">{{t .LangCode "vote.publish"}}</button>{{ end }}
<a class="uk-button uk-button-default" href="/user/questions#votes">{{t .LangCode "common.cancel"}}</a>
</div>
<div id="builder-hint" class="uk-alert uk-alert-danger uk-margin-small-top" hidden></div>
</form>
<script>
var INITIAL_OPTIONS = {{ toJSON .InitialOptions }};
var INITIAL_FIELDS = {{ toJSON .InitialFields }};
// 站长侧单个选项文本上限,与后端 conf.Limits.VoteOptionLen([limits] 配置段)同源。
// 参与者的自定义补充文本上限是另一套 MAX_CUSTOM_LEN(conf.Limits.CustomAnswerLen),二者独立。
var MAX_OPTION_LEN = {{ .MaxVoteOptionLen }};
var optionsData = [];
var fieldsData = [];
function toggleEditor() {
var t = document.getElementById('vote-type').value;
var optEditor = document.getElementById('options-editor');
var fldEditor = document.getElementById('fields-editor');
var maxField = document.getElementById('max-choices-field');
var ratingHint = document.getElementById('rating-hint');
var globalCustom = document.getElementById('global-custom-field');
optEditor.hidden = (t === 'form');
fldEditor.hidden = (t !== 'form');
maxField.hidden = (t !== 'multiple');
ratingHint.hidden = (t !== 'rating');
if (globalCustom) { globalCustom.hidden = (t === 'form' || t === 'rating'); }
updateBuilderHint();
}
function addOption() { optionsData.push(''); renderOptions(); updateBuilderHint(); }
function removeOption(i) { optionsData.splice(i, 1); renderOptions(); updateBuilderHint(); }
function renderOptions() {
var list = document.getElementById('options-list');
list.innerHTML = '';
optionsData.forEach(function (val, i) {
var row = document.createElement('div');
row.className = 'uk-flex uk-flex-middle uk-margin-small-bottom';
row.style.gap = '6px';
row.innerHTML = '<span class="uk-text-meta">' + (i + 1) + '.</span>' +
'<input class="uk-input" style="flex:1" type="text" maxlength="' + MAX_OPTION_LEN + '" placeholder="{{t .LangCode "vote.optionPlaceholder"}}" value="' + escapeAttr(val) + '" oninput="optionsData[' + i + ']=this.value">' +
'<button type="button" class="uk-button uk-button-danger uk-button-small" onclick="removeOption(' + i + ')">{{t .LangCode "common.delete"}}</button>';
list.appendChild(row);
});
}
function addField() {
fieldsData.push({ field_type: 'radio', label: '', required: false, max_length: 0, optionsArr: [], allow_custom: false });
renderFields();
updateBuilderHint();
}
function removeField(i) { fieldsData.splice(i, 1); renderFields(); updateBuilderHint(); }
// 字段上下移:交换数组元素后重渲染;保存时 payload 顺序即新顺序(后端按数组下标写 SortOrder)
function moveField(i, dir) {
var j = i + dir;
if (j < 0 || j >= fieldsData.length) { return; }
var tmp = fieldsData[i]; fieldsData[i] = fieldsData[j]; fieldsData[j] = tmp;
renderFields();
updateBuilderHint();
}
function renderFields() {
var list = document.getElementById('fields-list');
list.innerHTML = '';
fieldsData.forEach(function (f, i) {
var box = document.createElement('div');
box.className = 'uk-card uk-card-default uk-card-small uk-card-body uk-margin-small-bottom';
box.innerHTML =
'<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-small-bottom">' +
'<span class="uk-text-bold">{{t .LangCode "vote.field"}} ' + (i + 1) + '</span>' +
'<div class="uk-flex uk-flex-middle" style="gap:6px">' +
'<button type="button" class="uk-button uk-button-default uk-button-small"' + (i === 0 ? ' disabled' : '') + ' onclick="moveField(' + i + ',-1)" title="{{t .LangCode "common.moveUp"}}">↑</button>' +
'<button type="button" class="uk-button uk-button-default uk-button-small"' + (i === fieldsData.length - 1 ? ' disabled' : '') + ' onclick="moveField(' + i + ',1)" title="{{t .LangCode "common.moveDown"}}">↓</button>' +
'<button type="button" class="uk-button uk-button-danger uk-button-small" onclick="removeField(' + i + ')">{{t .LangCode "common.delete"}}</button>' +
'</div>' +
'</div>' +
'<div class="uk-grid-small uk-child-width-1-2@s" uk-grid>' +
'<div><label class="uk-form-label">{{t .LangCode "vote.formFieldType"}}</label>' +
'<select class="uk-select" onchange="fieldsData[' + i + '].field_type=this.value;renderFields()">' +
'<option value="radio"' + (f.field_type === 'radio' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeRadio"}}</option>' +
'<option value="text"' + (f.field_type === 'text' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeText"}}</option>' +
'<option value="textarea"' + (f.field_type === 'textarea' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeTextarea"}}</option>' +
'<option value="select"' + (f.field_type === 'select' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeSelect"}}</option>' +
'<option value="checkbox"' + (f.field_type === 'checkbox' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeCheckbox"}}</option>' +
'<option value="number"' + (f.field_type === 'number' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeNumber"}}</option>' +
'<option value="rating"' + (f.field_type === 'rating' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeRating"}}</option>' +
'<option value="rank"' + (f.field_type === 'rank' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeRank"}}</option>' +
'</select></div>' +
'<div><label class="uk-form-label">{{t .LangCode "vote.formFieldLabel"}}</label>' +
'<input class="uk-input" type="text" maxlength="100" value="' + escapeAttr(f.label) + '" oninput="fieldsData[' + i + '].label=this.value"></div>' +
'</div>' +
'<div class="uk-grid-small uk-child-width-1-3@s uk-margin-small-top" uk-grid>' +
'<label class="uk-form-label"><input class="uk-checkbox" type="checkbox"' + (f.required ? ' checked' : '') + ' onchange="fieldsData[' + i + '].required=this.checked"> {{t .LangCode "vote.formFieldRequired"}}</label>' +
'<div id="maxlen-' + i + '"' + (isTextType(f.field_type) ? '' : ' style="display:none"') + '><label class="uk-form-label">{{t .LangCode "vote.formFieldMaxLength"}}</label>' +
'<input class="uk-input" type="number" min="0" max="2000" value="' + (f.max_length || 0) + '" oninput="fieldsData[' + i + '].max_length=parseInt(this.value||0)"></div>' +
'</div>' +
'<div class="uk-margin-small-top" id="opts-' + i + '"' + (isChoiceType(f.field_type) ? '' : ' hidden') + '>' +
'<label class="uk-form-label">{{t .LangCode "vote.formFieldOptions"}}</label>' +
'<div class="uk-text-meta uk-margin-small-top">{{t .LangCode "vote.formFieldOptionsHint"}}</div>' +
'<div id="fopts-' + i + '" class="uk-margin-small-top"></div>' +
'<button type="button" class="uk-button uk-button-default uk-button-small" onclick="addFieldOption(' + i + ')">{{t .LangCode "vote.addOption"}}</button>' +
'</div>' +
'<div class="uk-margin-small-top" id="custom-' + i + '"' + (isCustomType(f.field_type) ? '' : ' hidden') + '>' +
'<label class="uk-form-label"><input class="uk-checkbox" type="checkbox"' + (f.allow_custom ? ' checked' : '') + ' onchange="fieldsData[' + i + '].allow_custom=this.checked"> {{t .LangCode "vote.fieldAllowCustom"}}</label>' +
'</div>';
list.appendChild(box);
if (isChoiceType(f.field_type)) { renderFieldOptions(i); }
});
if (typeof UIkit !== 'undefined') { UIkit.update(list); }
}
function isChoiceType(t) { return t === 'select' || t === 'radio' || t === 'checkbox' || t === 'rank'; }
// 支持"补充自定义选项"的字段类型(rank 排序题不开放)
function isCustomType(t) { return t === 'select' || t === 'radio' || t === 'checkbox'; }
function isTextType(t) { return t === 'text' || t === 'textarea'; }
// 字段选项兼容处理:后端可能返回数组(新)或逗号字符串(旧数据/回填),统一为数组。
function normalizeFieldOptions(v) {
if (Array.isArray(v)) return v.slice();
if (typeof v === 'string' && v !== '') return v.split(',').map(function (x) { return x.trim(); }).filter(function (x) { return x !== ''; });
return [];
}
// 渲染单个字段的选项行(逐条 add/remove),避免整段文本框 + 逗号分隔。
function renderFieldOptions(i) {
var box = document.getElementById('fopts-' + i);
if (!box) return;
box.innerHTML = '';
var arr = fieldsData[i].optionsArr || [];
arr.forEach(function (val, j) {
var row = document.createElement('div');
row.className = 'uk-flex uk-flex-middle uk-margin-small-bottom';
row.style.gap = '6px';
row.innerHTML = '<span class="uk-text-meta">' + (j + 1) + '.</span>' +
'<input class="uk-input" style="flex:1" type="text" maxlength="' + MAX_OPTION_LEN + '" placeholder="{{t .LangCode "vote.optionPlaceholder"}}" value="' + escapeAttr(val) + '" oninput="fieldsData[' + i + '].optionsArr[' + j + ']=this.value">' +
'<button type="button" class="uk-button uk-button-danger uk-button-small" onclick="removeFieldOption(' + i + ',' + j + ')">{{t .LangCode "common.delete"}}</button>';
box.appendChild(row);
});
}
function addFieldOption(i) { (fieldsData[i].optionsArr = fieldsData[i].optionsArr || []).push(''); renderFieldOptions(i); }
function removeFieldOption(i, j) { fieldsData[i].optionsArr.splice(j, 1); renderFieldOptions(i); }
// 设置提交动作:save=保存草稿(宽松),publish=发布(一次性完整校验)。
function setVoteAction(a) { document.getElementById('vote-action').value = a; }
// 前端即时校验:根据题型/字段返回需提示给用户的信息;空字符串表示通过。
// 锁定模式(已发布投票)下无选项/字段编辑器,直接返回通过。
function computeBuilderHint() {
if (!document.getElementById('options-payload') && !document.getElementById('fields-payload')) {
return '';
}
var t = document.getElementById('vote-type').value;
if (t === 'single' || t === 'multiple' || t === 'rank') {
var cnt = 0;
for (var i = 0; i < optionsData.length; i++) {
if (optionsData[i].trim() !== '') cnt++;
}
if (cnt < 2) return '{{t .LangCode "vote.builderHintOptions"}}';
} else if (t === 'form') {
if (fieldsData.length < 1) return '{{t .LangCode "vote.builderHintFields"}}';
for (var i = 0; i < fieldsData.length; i++) {
var f = fieldsData[i];
if (isChoiceType(f.field_type)) {
var opts = (f.optionsArr || []).map(function (x) { return x.trim(); }).filter(function (x) { return x !== ''; });
if (opts.length < (f.field_type === 'rank' ? 2 : 1)) {
return '{{t .LangCode "vote.builderHintFieldOptions"}}';
}
}
}
}
return '';
}
// 实时更新提示条(切换题型/增删选项或字段时调用)
function updateBuilderHint() {
var m = computeBuilderHint();
var el = document.getElementById('builder-hint');
if (m) { el.textContent = m; el.hidden = false; } else { el.hidden = true; }
}
// 提交时执行:先收集 payload,再按当前动作做校验。
// 发布动作下校验不通过则阻止提交;草稿动作仅提示,不阻止(草稿本就允许暂存)。
function validateBuilderForm(event) {
buildPayload();
var m = computeBuilderHint();
var el = document.getElementById('builder-hint');
if (m) {
el.textContent = m; el.hidden = false;
if (document.getElementById('vote-action').value === 'publish') {
resetSubmitState();
var form = event && event.target ? event.target : document.querySelector('form');
if (form) form.setAttribute('data-tb-skip-submit-feedback', '1');
if (event) event.preventDefault();
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
} else {
el.hidden = true;
}
return true;
}
function buildPayload() {
// 发布后锁定模式:无选项/字段编辑器,跳过 JSON 收集
var optPayload = document.getElementById('options-payload');
if (optPayload) {
var options = optionsData.map(function (v) { return v.trim(); }).filter(function (v) { return v !== ''; });
optPayload.value = JSON.stringify(options);
}
var fldPayload = document.getElementById('fields-payload');
if (fldPayload) {
var fields = fieldsData.map(function (f) {
return { field_type: f.field_type, label: f.label, required: f.required, max_length: f.max_length || 0, options: (f.optionsArr || []), allow_custom: !!f.allow_custom };
});
fldPayload.value = JSON.stringify(fields);
}
return true;
}
function escapeAttr(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 限投维度提示:仅注册用户投票时切换为"每账号",否则"每设备"。
// 与提交逻辑一致——registered_only 下指纹维度不参与,约束实为每账号一票。
function updateScopeHint() {
var sel = document.querySelector('select[name="voter_scope"]');
var dev = document.getElementById('scope-hint-device');
var acc = document.getElementById('scope-hint-account');
if (!sel || !dev || !acc) return;
var isReg = sel.value === 'registered_only';
dev.hidden = isReg;
acc.hidden = !isReg;
}
(function init() {
// 发布后锁定模式:编辑器未渲染,跳过初始化
{{ if .LockedContent }}return;{{ end }}
optionsData = INITIAL_OPTIONS.slice();
fieldsData = INITIAL_FIELDS.map(function (f) { return { field_type: f.field_type || 'text', label: f.label || '', required: !!f.required, max_length: f.max_length || 0, optionsArr: normalizeFieldOptions(f.options), allow_custom: !!f.allow_custom }; });
renderOptions();
renderFields();
toggleEditor();
updateBuilderHint();
var scopeSel = document.querySelector('select[name="voter_scope"]');
if (scopeSel) { scopeSel.addEventListener('change', updateScopeHint); }
updateScopeHint();
})();
</script>
{{if .DraftSaved}}
<script>
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname);
}
</script>
{{end}}
{{template "base/footer" .}}
+182
View File
@@ -0,0 +1,182 @@
{{template "base/header" .}}
<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-bottom">
<h3 class="uk-margin-remove">{{t .LangCode "vote.resultsTitle"}}:{{ .Vote.Title }}</h3>
<div class="uk-flex uk-flex-middle" style="gap: 8px;">
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .Vote.ID }}/export">{{t .LangCode "vote.export"}}</a>
<a class="uk-button uk-button-default uk-button-small" href="/user/questions#votes">{{t .LangCode "common.back"}}</a>
</div>
</div>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom">
<div class="uk-text-large uk-text-bold">{{ .Total }}</div>
<div class="uk-text-meta">{{t .LangCode "vote.resultsTotal"}}</div>
</div>
{{ if eq .Vote.Type "form" }}
<!-- 表单收集(混合问卷)字段级聚合统计 -->
{{ if .Results.FormFieldStats }}
<h4>{{t .LangCode "vote.resultsOptions"}}</h4>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom">
{{ range $si, $stat := .Results.FormFieldStats }}
<div class="uk-margin">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 8px;">
<span class="uk-label">{{ add $si 1 }}</span>
<span class="uk-label {{ fieldTypeLabelClass $stat.Field.FieldType }}">{{ fieldTypeLabel $.LangCode $stat.Field.FieldType }}</span>
<span class="uk-text-bold">{{ $stat.Field.Label }}</span>
<span class="uk-text-meta uk-text-small">{{t $.LangCode "vote.resultsAnswered"}}: {{ $stat.Total }}</span>
</div>
{{ if or (eq $stat.Field.FieldType "radio") (eq $stat.Field.FieldType "select") (eq $stat.Field.FieldType "checkbox") }}
{{ $denom := $stat.Total }}
{{ if eq $stat.Field.FieldType "checkbox" }}{{ $denom = $stat.TotalSelections }}{{ end }}
{{ range $o := $stat.OptionStats }}
<div class="uk-margin-small-top">
<div class="uk-flex uk-flex-between">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{ $o.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ if $stat.CustomStats }}
<div class="uk-margin-small-top">
<div class="uk-text-meta">{{t $.LangCode "vote.page.customStats"}}:</div>
{{ range $c := $stat.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $c.Text }}</span>
<span class="uk-text-meta">{{ $c.Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rating" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRating"}}: {{ printf "%.1f" $stat.AvgRating }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $r := $stat.RatingCounts }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $r.Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ $r.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%;"></div>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rank" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRank"}}: {{ printf "%.2f" $stat.AvgRank }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $o := $stat.OptionStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{t $.LangCode "vote.page.avgRank"}}: {{ if gt $o.Count 0 }}{{ printf "%.2f" $o.AvgRank }}{{ else }}-{{ end }} · {{t $.LangCode "vote.page.rankedTimes"}}: {{ $o.Count }}</span>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "number" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgNumber"}}: {{ printf "%.1f" $stat.AvgNumber }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ else }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.fieldFilled"}}: {{ $stat.TextCount }}</div>
{{ $replies := index $.TextReplies $stat.Field.Label }}
{{ if $replies }}
<div class="uk-margin-small-top">
<div class="uk-text-small" style="cursor:pointer; user-select:none; color:#1e87f0;" onclick="toggleFieldReplies('{{ $stat.Field.ID }}')" title="{{t $.LangCode "vote.resultsToggleHint"}}">
<span id="caret-{{ $stat.Field.ID }}" style="display:inline-block; transition:transform .15s; transform:rotate(-90deg);">▾</span>
{{t $.LangCode "vote.resultsViewTexts"}}({{ len $replies }})
</div>
<div id="replies-{{ $stat.Field.ID }}" hidden>
<ul class="uk-list uk-list-divider uk-margin-small-top">
{{ range $r := $replies }}
<li>
<div class="uk-text-meta uk-text-small">{{ $r.CreatedAt.Format "2006-01-02 15:04" }}</div>
<div style="white-space:pre-wrap;">{{ $r.Text }}</div>
</li>
{{ end }}
</ul>
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
<!-- 文本类字段的逐条提交内容已内联在各题目下方(默认收起),此处无需额外区块 -->
{{ else if .Results }}
<!-- 选项 / 评分 / 排序统计 -->
<h4>{{t .LangCode "vote.resultsOptions"}}</h4>
<div class="uk-card uk-card-default uk-card-small uk-card-body">
{{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}
{{ range .Results.Options }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Option.Content }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}({{ .Percent }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ .Percent }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rating" }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.avgRating"}}: {{ printf "%.1f" .Results.AvgRating }}</div>
{{ range .Results.Ratings }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $.Total 0 }}0{{ else }}{{ percentOf .Count $.Total }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rank" }}
<table class="uk-table uk-table-small uk-table-divider">
<thead><tr><th>{{t .LangCode "vote.page.optionColumn"}}</th><th>{{t .LangCode "vote.page.avgRank"}}</th></tr></thead>
<tbody>
{{ range .Results.Rankings }}
<tr><td>{{ .Option.Content }}</td><td>{{ if gt .AvgRank 0.0 }}{{ printf "%.2f" .AvgRank }}{{ else }}-{{ end }}</td></tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ if .Results.CustomStats }}
<div class="uk-margin-top">
<div class="uk-text-bold">{{t .LangCode "vote.page.customStats"}}</div>
{{ range .Results.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ .Text }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="uk-alert uk-alert-primary">{{t .LangCode "vote.resultsEmpty"}}</div>
{{ end }}
<script>
// 文本类字段:点击"查看文本提交(N)"按该字段 ID 展开/收起逐条内容
function toggleFieldReplies(id) {
var box = document.getElementById('replies-' + id);
var caret = document.getElementById('caret-' + id);
if (!box) { return; }
if (box.hasAttribute('hidden')) {
box.removeAttribute('hidden');
if (caret) { caret.style.transform = 'rotate(0deg)'; }
} else {
box.setAttribute('hidden', '');
if (caret) { caret.style.transform = 'rotate(-90deg)'; }
}
}
</script>
{{template "base/footer" .}}
+526
View File
@@ -0,0 +1,526 @@
{{template "base/header" .}}
<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;
}
/* 卡片式选项:radio/checkbox 统一为可点选卡片,视觉更清晰、间距更舒适 */
.tb-choice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--tb-border, #e5e5e5);
border-radius: 8px;
background: var(--tb-surface, #fff);
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, #1e87f0);
background: var(--tb-surface-hover, #f5faff);
}
.tb-choice:has(input:checked) {
border-color: var(--tb-primary, #1e87f0);
background: var(--tb-surface-active, #edf6ff);
}
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;
}
/* 字数计数:紧跟在输入下方,超限由 JS 加 uk-text-danger 标红 */
.tb-text-counter {
margin-top: 2px;
text-align: right;
}
@media (max-width: 480px) {
.tb-choice { padding: 10px 12px; }
}
</style>
{{template "base/alert" .}}
<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;">
{{ if .Owner }}
<img class="uk-border-circle" src="{{ .Owner.Avatar }}" style="width: 40px; height: 40px;">
<div>
<a class="uk-text-bold" href="/_{{ .Owner.Domain }}">{{ .Owner.Name }}</a>
<div class="uk-text-meta uk-text-small">{{t .LangCode "vote.page.by"}}</div>
</div>
{{ end }}
<div class="uk-margin-auto-left">
{{ if .NotStarted }}<span class="uk-label">{{t .LangCode "vote.page.notStarted"}}</span>{{ end }}
{{ if .Ended }}<span class="uk-label uk-label-warning">{{t .LangCode "vote.page.ended"}}</span>{{ end }}
{{ if and .CanVote (not .Ended) }}<span class="uk-label uk-label-success">{{t .LangCode "vote.page.active"}}</span>{{ end }}
{{ if and .HasVoted (not .Ended) }}<span class="uk-label uk-label-primary">{{t .LangCode "vote.page.participated"}}</span>{{ end }}
</div>
</div>
<h3 class="uk-margin-remove-top uk-margin-small-bottom">{{ .Vote.Title }}</h3>
{{ if .Vote.Description }}
<p class="uk-margin-remove-top">{{ .Vote.Description }}</p>
{{ end }}
</div>
{{ if .PreviewMode }}
<div class="uk-alert uk-alert-warning">{{t .LangCode "vote.page.previewHint"}}</div>
{{ end }}
{{ if .FeatureClosedMessage }}
<div class="uk-alert uk-alert-warning">{{ .FeatureClosedMessage }}</div>
{{ end }}
{{ if and .NotStarted (not .Error) }}
<div class="uk-alert uk-alert-primary">{{t .LangCode "vote.page.notStartedHint"}}</div>
{{ else if and .Ended (not .ShowResults) (not .Error) }}
<div class="uk-alert uk-alert-warning">{{t .LangCode "vote.page.endedHint"}}</div>
{{ end }}
{{ if .CanVote }}
{{ if .VoteScopeRegisteredOnly }}
{{ if not .IsLogged }}
<div class="uk-alert uk-alert-warning">
{{t .LangCode "vote.page.loginRequired"}} <a href="/login?to={{ .CurrentURI }}">{{t .LangCode "common.login"}}</a>
</div>
{{ end }}
{{ else }}
<div class="uk-alert uk-alert-primary uk-text-small">{{t .LangCode "vote.page.anonymousHint"}}</div>
{{ end }}
<form method="post" action="/vote/{{ .Vote.LinkToken }}/submit" id="vote-form"{{ if not .PreviewMode }} onsubmit="return validateVoteForm()"{{ end }}>
{{ .CSRFTokenHTML }}
<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">
{{ if eq .Vote.Type "single" }}
{{ range .Options }}
<label class="tb-choice">
<input class="uk-radio" type="radio" name="option_id" value="{{ .ID }}" data-vote-required data-vote-label="{{ $.Vote.Title }}"><span>{{ .Content }}</span>
</label>
{{ 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 id="multiple-options">
{{ range .Options }}
<label class="tb-choice">
<input class="uk-checkbox" type="checkbox" name="option_ids" value="{{ .ID }}" data-vote-required data-vote-label="{{ $.Vote.Title }}"><span>{{ .Content }}</span>
</label>
{{ end }}
</div>
{{ else if eq .Vote.Type "rating" }}
<label class="uk-form-label">{{t .LangCode "vote.page.ratingLabel"}}</label>
<select class="uk-select" name="rating_value" style="max-width: 200px;" data-vote-required data-vote-label="{{ .Vote.Title }}">
<option value=""></option>
{{ range $i := loop 10 }}
<option value="{{ $i }}">{{ $i }} {{t $.LangCode "vote.page.ratingUnit"}}</option>
{{ end }}
</select>
{{ else if eq .Vote.Type "rank" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.rankHint"}}</div>
{{ range .Options }}
<div class="uk-margin-small-bottom uk-flex uk-flex-middle" style="gap: 10px;">
<span style="flex: 1;">{{ .Content }}</span>
<input class="uk-input" type="number" name="rank_{{ .ID }}" min="1" max="{{ len $.Options }}" style="width: 90px;" placeholder="{{t $.LangCode "vote.page.rankPlaceholder"}}" data-vote-required data-vote-label="{{ $.Vote.Title }}">
</div>
{{ end }}
{{ else if eq .Vote.Type "form" }}
{{ range $fi, $f := .FormFields }}
<div class="uk-margin">
<div class="tb-field-head">
<span class="uk-label">{{ add $fi 1 }}</span>
<span class="uk-label {{ fieldTypeLabelClass $f.FieldType }}">{{ fieldTypeLabel $.LangCode $f.FieldType }}</span>
<div class="tb-field-label">{{ $f.Label }}{{ if $f.Required }} <span class="uk-text-danger">*</span>{{ end }}</div>
</div>
{{ if eq $f.FieldType "text" }}
<input class="uk-input" type="text" name="field_{{ $f.ID }}" {{ if $f.MaxLength }}maxlength="{{ $f.MaxLength }}"{{ end }} {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ else if eq $f.FieldType "textarea" }}
<textarea class="uk-textarea" rows="3" name="field_{{ $f.ID }}" {{ if $f.MaxLength }}maxlength="{{ $f.MaxLength }}"{{ end }} {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}></textarea>
{{ else if eq $f.FieldType "select" }}
<select class="uk-select" name="field_{{ $f.ID }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
<option value="">{{t $.LangCode "vote.page.selectPlaceholder"}}</option>
{{ range parseOptions $f.Options }}
<option value="{{ . }}">{{ . }}</option>
{{ end }}
{{ if $f.AllowCustom }}
<option value="{{ $.CustomFieldMarker }}">{{t $.LangCode "vote.page.customOther"}}</option>
{{ end }}
</select>
{{ if $f.AllowCustom }}
<input class="uk-input tb-custom-input" type="text" name="field_{{ $f.ID }}_custom" placeholder="{{t $.LangCode "vote.page.customFieldPlaceholder"}}" data-vote-custom-input="{{ $f.ID }}" data-tb-text-counter disabled {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ end }}
{{ else if eq $f.FieldType "radio" }}
{{ range parseOptions $f.Options }}
<label class="tb-choice">
<input class="uk-radio" type="radio" name="field_{{ $f.ID }}" value="{{ . }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{ . }}</span>
</label>
{{ end }}
{{ if $f.AllowCustom }}
<label class="tb-choice">
<input class="uk-radio" type="radio" name="field_{{ $f.ID }}" value="{{ $.CustomFieldMarker }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{t $.LangCode "vote.page.customOther"}}</span>
</label>
<input class="uk-input tb-custom-input" type="text" name="field_{{ $f.ID }}_custom" placeholder="{{t $.LangCode "vote.page.customFieldPlaceholder"}}" data-vote-custom-input="{{ $f.ID }}" data-tb-text-counter disabled {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ end }}
{{ else if eq $f.FieldType "checkbox" }}
{{ range parseOptions $f.Options }}
<label class="tb-choice">
<input class="uk-checkbox" type="checkbox" name="field_{{ $f.ID }}" value="{{ . }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{ . }}</span>
</label>
{{ end }}
{{ if $f.AllowCustom }}
<label class="tb-choice">
<input class="uk-checkbox" type="checkbox" name="field_{{ $f.ID }}" value="{{ $.CustomFieldMarker }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{t $.LangCode "vote.page.customOther"}}</span>
</label>
<input class="uk-input tb-custom-input" type="text" name="field_{{ $f.ID }}_custom" placeholder="{{t $.LangCode "vote.page.customFieldPlaceholder"}}" data-vote-custom-input="{{ $f.ID }}" data-tb-text-counter disabled {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ end }}
{{ else if eq $f.FieldType "number" }}
<input class="uk-input" type="number" name="field_{{ $f.ID }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ else if eq $f.FieldType "rating" }}
<select class="uk-select" name="field_{{ $f.ID }}" style="max-width: 200px;" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
<option value=""></option>
{{ range $i := loop 10 }}
<option value="{{ $i }}">{{ $i }} {{t $.LangCode "vote.page.ratingUnit"}}</option>
{{ end }}
</select>
{{ else if eq $f.FieldType "rank" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">
{{ if $f.Required }}{{t $.LangCode "vote.page.rankHintRequired"}}{{ else }}{{t $.LangCode "vote.page.rankHintOptional"}}{{ end }}
</div>
{{ $opts := parseOptions $f.Options }}
{{ range $oi, $opt := $opts }}
<div class="uk-flex uk-flex-middle" style="gap: 8px;">
<span class="uk-flex-1">{{ $opt }}</span>
<input class="uk-input" type="number" name="field_{{ $f.ID }}_{{ $oi }}" min="1" max="{{ len $opts }}" style="width: 90px;" placeholder="{{t $.LangCode "vote.page.rankPlaceholder"}}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
{{ end }}
{{ if and .Vote.AllowCustom (or (eq .Vote.Type "single") (eq .Vote.Type "multiple") (eq .Vote.Type "rank")) }}
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "vote.page.customOption"}}</label>
<input class="uk-input" type="text" name="custom" placeholder="{{t .LangCode "vote.page.customPlaceholder"}}" data-tb-text-counter data-tb-text-max="{{ $.MaxCustomTextLen }}">
</div>
{{ end }}
</div>
<div class="uk-margin">
{{ if .PreviewMode }}
<button type="button" class="uk-button uk-button-primary" disabled>{{t .LangCode "vote.page.submit"}}</button>
<span class="uk-text-meta uk-text-small uk-margin-small-left">{{t .LangCode "vote.page.previewSubmitHint"}}</span>
{{ else if eq .CaptchaType "math" }}
<button type="button" class="uk-button uk-button-primary math-captcha-trigger" onclick="return voteValidate(event)">{{t .LangCode "vote.page.submit"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-primary g-recaptcha"
data-sitekey="{{.RecaptchaSiteKey}}" data-callback="onSubmit">{{t .LangCode "vote.page.submit"}}</button>
{{ end }}
</div>
</form>
{{ else if .HasVoted }}
{{ if and (not .Success) (not .Error) }}
<div class="uk-alert uk-alert-success">{{t .LangCode "vote.page.alreadyVoted"}}</div>
{{ end }}
{{ end }}
{{ if .ShowResults }}
<div class="uk-card uk-card-default uk-card-body">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.page.resultsTitle"}}</h4>
{{ if .Results }}
{{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.totalVotes"}}: {{ .Results.Total }}</div>
{{ range .Results.Options }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Option.Content }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}({{ .Percent }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ .Percent }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rating" }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.totalVotes"}}: {{ .Results.Total }} · {{t .LangCode "vote.page.avgRating"}}: {{ printf "%.1f" .Results.AvgRating }}</div>
{{ range .Results.Ratings }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $.Results.Total 0 }}0{{ else }}{{ percentOf .Count $.Results.Total }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rank" }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.totalVotes"}}: {{ .Results.Total }}</div>
<table class="uk-table uk-table-small uk-table-divider">
<thead><tr><th>{{t .LangCode "vote.page.optionColumn"}}</th><th>{{t .LangCode "vote.page.avgRank"}}</th></tr></thead>
<tbody>
{{ range .Results.Rankings }}
<tr><td>{{ .Option.Content }}</td><td>{{ if gt .AvgRank 0.0 }}{{ printf "%.2f" .AvgRank }}{{ else }}-{{ end }}</td></tr>
{{ end }}
</tbody>
</table>
{{ else if eq .Results.Type "form" }}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "vote.page.formCount"}}: {{ .Results.FormCount }}</div>
{{ range $si, $stat := .Results.FormFieldStats }}
<div class="uk-margin-top">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 8px;">
<span class="uk-label">{{ add $si 1 }}</span>
<span class="uk-label {{ fieldTypeLabelClass $stat.Field.FieldType }}">{{ fieldTypeLabel $.LangCode $stat.Field.FieldType }}</span>
<span class="uk-text-bold">{{ $stat.Field.Label }}</span>
<span class="uk-text-meta uk-text-small">{{t $.LangCode "vote.resultsAnswered"}}: {{ $stat.Total }}</span>
</div>
{{ if or (eq $stat.Field.FieldType "radio") (eq $stat.Field.FieldType "select") (eq $stat.Field.FieldType "checkbox") }}
{{ $denom := $stat.Total }}
{{ if eq $stat.Field.FieldType "checkbox" }}{{ $denom = $stat.TotalSelections }}{{ end }}
{{ range $o := $stat.OptionStats }}
<div class="uk-margin-small-top">
<div class="uk-flex uk-flex-between">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{ $o.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ if $stat.CustomStats }}
<div class="uk-margin-small-top">
<div class="uk-text-meta">{{t $.LangCode "vote.page.customStats"}}:</div>
{{ range $c := $stat.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $c.Text }}</span>
<span class="uk-text-meta">{{ $c.Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rating" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRating"}}: {{ printf "%.1f" $stat.AvgRating }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $r := $stat.RatingCounts }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $r.Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ $r.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%;"></div>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rank" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRank"}}: {{ printf "%.2f" $stat.AvgRank }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $o := $stat.OptionStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{t $.LangCode "vote.page.avgRank"}}: {{ if gt $o.Count 0 }}{{ printf "%.2f" $o.AvgRank }}{{ else }}-{{ end }} · {{t $.LangCode "vote.page.rankedTimes"}}: {{ $o.Count }}</span>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "number" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgNumber"}}: {{ printf "%.1f" $stat.AvgNumber }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ else }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.fieldFilled"}}: {{ $stat.TextCount }}</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ if .Results.CustomStats }}
<div class="uk-margin-top">
<div class="uk-text-bold">{{t .LangCode "vote.page.customStats"}}</div>
{{ range .Results.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ .Text }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
{{ else }}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "vote.resultsEmpty"}}</div>
{{ end }}
</div>
{{ end }}
{{ if and .IsOwner (not .ShowResults) }}
<div class="uk-margin-top uk-text-small">
<a href="/user/votes/{{ .Vote.ID }}/results">{{t .LangCode "vote.page.ownerManage"}}</a>
</div>
{{ end }}
<script>
// 自定义补充选项的字数上限,与后端校验同源(conf.Limits.CustomAnswerLen,[limits] 配置段)。
// 去掉输入框的 maxlength 后由这里做提示:超限不静默截断,而是标红并阻止提交。
var MAX_CUSTOM_LEN = {{ .MaxCustomTextLen }};
// 按 Unicode 码点计数,与后端 len([]rune(s)) 口径一致:
// emoji、部分生僻字在 UTF-16 中占两个 code unit,用 .length 会多算一倍。
function countChars(s) { return Array.from(String(s || '')).length; }
// 给带 data-tb-text-counter 的输入框挂实时字数计数;上限取 data-tb-text-max,缺省 MAX_CUSTOM_LEN。
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();
});
}
// 前端必填校验:text/textarea/number/select/rating 值非空;radio/checkbox 按 name 至少选一个;
// 排序题(vote 题型与表单 rank 字段)每个排名输入必填。
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('{{t .LangCode "vote.err.customTooLong" $.MaxCustomTextLen}}');
tooLong.focus();
return false;
}
// 单选/多选组:按 name 分组检查
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('{{t .LangCode "vote.page.mustComplete"}}「' + groups[name][0].getAttribute('data-vote-label') + '」');
return false;
}
}
// 其余必填元素:值非空(disabled 输入跳过——"其他"文本输入在未选中时处于禁用态)
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('{{t .LangCode "vote.page.mustComplete"}}「' + el.getAttribute('data-vote-label') + '」');
return false;
}
}
return true;
}
// math 验证码按钮:先校验,失败时阻止冒泡(不打开验证码弹窗)
function voteValidate(e) {
if (!validateVoteForm()) {
if (e) { e.stopPropagation(); e.preventDefault(); }
return false;
}
return true;
}
// recaptcha/turnstile 成功回调(覆盖 header 的通用 onSubmit):校验通过才提交
function onSubmit() {
if (validateVoteForm()) {
document.getElementById('vote-form').submit();
}
}
(function () {
var form = document.getElementById('vote-form');
if (!form) { return; }
setupTextCounters();
var maxChoices = {{ if eq .Vote.Type "multiple" }}{{ .Vote.MaxChoices }}{{ else }}0{{ end }};
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 = "{{ .CustomFieldMarker }}";
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>
{{template "base/footer" .}}
+465
View File
@@ -0,0 +1,465 @@
package templates
import (
gotemplate "html/template"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// voteBaseData 构造投票模板渲染的基础数据(仿 render_test.go 的 base)。
func voteBaseData() map[string]interface{} {
return map[string]interface{}{
"Title": "投票",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
"LoggedUserName": "tester",
"CurrentURI": "/vote/abc123",
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"RecaptchaSiteKey": "",
"CaptchaType": "recaptcha",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
// 参与端自定义补充选项的字数上限(生产由 route/vote 注入 conf.Limits.CustomAnswerLen)
"MaxCustomTextLen": conf.Limits.CustomAnswerLen,
"CustomFieldMarker": db.CustomFieldMarker,
// 站长侧单个选项文本上限(生产由 route/user 注入 conf.Limits.VoteOptionLen)
"MaxVoteOptionLen": conf.Limits.VoteOptionLen,
// [limits] 全站上限(生产由 Contexter 全局注入 conf.Limits),
// 供 question/list 嵌套的提问框 maxlength 同源
"Limits": conf.Limits,
}
}
func TestVoteFormUsesFixedDeviceLimitNotice(t *testing.T) {
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
for _, f := range fs.Files() {
if f.Name() != "user/vote-form" && f.Name() != "user/vote-form.html" {
continue
}
data, err := f.Data()
if err != nil {
t.Fatalf("get template data: %v", err)
}
source := string(data)
if strings.Contains(source, `name="max_votes_per_fingerprint"`) {
t.Fatal("fixed fingerprint limit must not be rendered as a form input")
}
if !strings.Contains(source, `vote.formMaxVotesPerFingerprint`) {
t.Fatal("fixed fingerprint limit notice is missing")
}
return
}
t.Fatal("user/vote-form.html not found")
}
func TestRenderVotePage(t *testing.T) {
opt1 := &db.VoteOption{Content: "选项 A"}
opt2 := &db.VoteOption{Content: "选项 B"}
t.Run("single can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "测试单选", Description: "描述", Type: db.VoteTypeSingle, LinkToken: "abc123", MaxChoices: 1}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (single) failed: %v", err)
}
})
t.Run("multiple can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "多选", Type: db.VoteTypeMultiple, LinkToken: "abc123", MaxChoices: 2}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2, {Content: "选项 C"}}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (multiple) failed: %v", err)
}
})
t.Run("rating can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "评分", Type: db.VoteTypeRating, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (rating) failed: %v", err)
}
})
t.Run("rank can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "排序", Type: db.VoteTypeRank, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (rank) failed: %v", err)
}
})
t.Run("preview draft form", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "草稿表单", Type: db.VoteTypeForm, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["FormFields"] = []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, FieldType: db.VoteFormFieldTypeText, Label: "昵称", Required: true, MaxLength: 20},
{Model: dbutil.Model{ID: 2}, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = true
base["IsLogged"] = true
base["PreviewMode"] = true
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (preview) failed: %v", err)
}
})
t.Run("custom text limit injected", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "自定义选项", Type: db.VoteTypeSingle, LinkToken: "abc123", AllowCustom: true}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["FormFields"] = []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`, AllowCustom: true},
}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
out, err := renderToString(t, "vote/page", base)
if err != nil {
t.Fatalf("render vote/page (custom) failed: %v", err)
}
// 缺注入时模板不报错,只会渲染出 <no value> 让整段脚本失效,必须显式断言
if strings.Contains(out, "<no value>") {
t.Fatal("rendered page contains <no value>: MaxCustomTextLen was not injected")
}
// 注意:html/template 在 <script> 上下文会主动插入安全空格,
// 实际输出形如 `var MAX_CUSTOM_LEN = 200 ;`,因此用正则而非字面量匹配。
want := regexp.MustCompile(`var MAX_CUSTOM_LEN\s*=\s*` + strconv.Itoa(conf.Limits.CustomAnswerLen) + `\s*;`)
if !want.MatchString(out) {
t.Fatalf("missing custom text limit constant, want /%s/", want)
}
if !strings.Contains(out, "data-tb-text-counter") {
t.Fatal("custom inputs are missing the data-tb-text-counter hook")
}
})
t.Run("form can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "表单", Type: db.VoteTypeForm, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["FormFields"] = []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, FieldType: db.VoteFormFieldTypeText, Label: "昵称", Required: true, MaxLength: 20},
{Model: dbutil.Model{ID: 2}, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
{Model: dbutil.Model{ID: 3}, FieldType: db.VoteFormFieldTypeCheckbox, Label: "喜欢的", Options: `["A","B","C"]`},
{Model: dbutil.Model{ID: 4}, FieldType: db.VoteFormFieldTypeRating, Label: "评分"},
{Model: dbutil.Model{ID: 5}, FieldType: db.VoteFormFieldTypeRank, Label: "排序", Options: `["首选","次选","末选"]`},
}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (form) failed: %v", err)
}
})
t.Run("already voted with results", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "已投", Type: db.VoteTypeSingle, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2}
base["HasVoted"] = true
base["CanVote"] = false
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = true
base["IsOwner"] = false
base["Results"] = map[string]interface{}{
"Type": "single",
"Total": int64(2),
"Options": []interface{}{},
}
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (voted) failed: %v", err)
}
})
t.Run("not started", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "未开始", Type: db.VoteTypeSingle, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["HasVoted"] = false
base["CanVote"] = false
base["NotStarted"] = true
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (not started) failed: %v", err)
}
})
}
func TestRenderVoteManagementPages(t *testing.T) {
t.Run("question list votes panel", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
// 提问箱页面渲染所需的字段(与 QuestionList 提供的一致)
base["Stats"] = map[string]int64{"total": 0, "answered": 0, "unanswered": 0, "private": 0, "pinned": 0, "chats": 0}
base["Keyword"] = ""
base["SelectedSort"] = "latest"
base["SelectedStatus"] = "all"
base["SelectedType"] = "all"
base["ReceivedItems"] = []interface{}{}
base["ConversationDetails"] = []interface{}{}
// 投票面板数据:投票管理已合并到「我的提问箱 · 投票」面板
base["VoteStats"] = map[string]int64{"total": 4, "published": 2}
base["MyVotes"] = []interface{}{
map[string]interface{}{
"ID": 1, "Title": "投票 1", "Type": "single", "Status": "draft", "EffectiveStatus": "draft",
"SubmissionCount": int64(0), "OptionCount": int64(2),
"CreatedAt": time.Now(), "LinkToken": "abc",
},
map[string]interface{}{
"ID": 2, "Title": "投票 2", "Type": "multiple", "Status": "published", "EffectiveStatus": "ended",
"SubmissionCount": int64(5), "OptionCount": int64(3),
"CreatedAt": time.Now(), "LinkToken": "def",
},
map[string]interface{}{
"ID": 3, "Title": "投票 3", "Type": "single", "Status": "published", "EffectiveStatus": "published",
"SubmissionCount": int64(2), "OptionCount": int64(2),
"CreatedAt": time.Now(), "LinkToken": "ghi", "RemainingText": "剩余 2 天 3 小时",
},
map[string]interface{}{
"ID": 4, "Title": "投票 4", "Type": "form", "Status": "published", "EffectiveStatus": "published",
"SubmissionCount": int64(1), "OptionCount": int64(0),
"CreatedAt": time.Now(), "LinkToken": "jkl", "RemainingText": "无限期",
},
}
if err := renderWithData(t, "user/question-list", base); err != nil {
t.Fatalf("render user/question-list (votes panel) failed: %v", err)
}
})
t.Run("vote form new", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["IsEdit"] = false
out, err := renderToString(t, "user/vote-form", base)
if err != nil {
t.Fatalf("render user/vote-form (new) failed: %v", err)
}
// 站长侧选项上限常量已注入并应用到两类选项输入(单选/多选选项 + 字段选项)。
// 缺注入时渲染出的会是 var MAX_OPTION_LEN = <no value>;,两个 maxlength 也失去同源上限。
wantVar := regexp.MustCompile(`var MAX_OPTION_LEN\s*=\s*` + strconv.Itoa(conf.Limits.VoteOptionLen) + `\s*;`)
if !wantVar.MatchString(out) {
t.Fatalf("vote-form: 缺 MAX_OPTION_LEN 注入,want %d", conf.Limits.VoteOptionLen)
}
wantMax := `maxlength="' + MAX_OPTION_LEN + '"`
if n := strings.Count(out, wantMax); n != 2 {
t.Fatalf("vote-form: 选项输入框应有两处引用 MAX_OPTION_LEN,实际 %d 处", n)
}
})
t.Run("vote form edit", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["IsEdit"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "编辑中", Type: db.VoteTypeMultiple, Visibility: "public",
VoterScope: "anyone", ShowResult: "after_vote", MaxChoices: 3, MaxVotesPerFingerprint: 1, RequireCaptcha: true}
base["InitialOptions"] = []string{"A", "B"}
base["InitialFields"] = []interface{}{}
if err := renderWithData(t, "user/vote-form", base); err != nil {
t.Fatalf("render user/vote-form (edit) failed: %v", err)
}
})
t.Run("vote form edit locked", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["IsEdit"] = true
base["LockedContent"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "进行中", Type: db.VoteTypeSingle, Visibility: "public",
VoterScope: "anyone", ShowResult: "after_vote", MaxChoices: 1, MaxVotesPerFingerprint: 1, RequireCaptcha: true}
base["InitialOptions"] = []string{"A", "B"}
base["InitialFields"] = []interface{}{}
if err := renderWithData(t, "user/vote-form", base); err != nil {
t.Fatalf("render user/vote-form (locked) failed: %v", err)
}
})
t.Run("vote results single", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "结果", Type: db.VoteTypeSingle}
base["Total"] = int64(2)
base["Results"] = map[string]interface{}{
"Type": "single",
"Options": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "A"}, "Count": int64(2), "Percent": 100},
},
"CustomStats": []interface{}{
map[string]interface{}{"Text": "自定义项", "Count": int64(1)},
},
}
if err := renderWithData(t, "user/vote-results", base); err != nil {
t.Fatalf("render user/vote-results (single) failed: %v", err)
}
})
t.Run("vote results form", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "表单结果", Type: db.VoteTypeForm}
base["Total"] = int64(1)
base["FormFields"] = []*db.VoteFormField{
{Label: "昵称", FieldType: db.VoteFormFieldTypeText},
{Label: "满意度", FieldType: db.VoteFormFieldTypeRadio, Options: `["满意","一般"]`},
}
base["Results"] = map[string]interface{}{
"Type": "form", "FormCount": int64(1), "FormFieldStats": []interface{}{
map[string]interface{}{
"Field": map[string]interface{}{"Label": "最喜欢的水果", "FieldType": "radio", "Options": `["苹果","香蕉"]`},
"Total": int64(1),
"OptionStats": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "苹果"}, "Count": int64(1)},
map[string]interface{}{"Option": map[string]interface{}{"Content": "香蕉"}, "Count": int64(0)},
},
},
map[string]interface{}{
"Field": map[string]interface{}{"Label": "整体评价", "FieldType": "rating"},
"Total": int64(1), "AvgRating": 9.0,
"RatingCounts": []interface{}{},
},
map[string]interface{}{
"Field": map[string]interface{}{"Label": "按喜好排序", "FieldType": "rank"},
"Total": int64(1), "AvgRank": 1.5,
"OptionStats": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "A"}, "Count": int64(1), "AvgRank": 1.0},
map[string]interface{}{"Option": map[string]interface{}{"Content": "B"}, "Count": int64(1), "AvgRank": 2.0},
},
},
map[string]interface{}{
"Field": map[string]interface{}{"Label": "建议", "FieldType": "textarea"},
"Total": int64(1), "TextCount": int64(1),
},
},
}
// 文本类字段逐条提交内容按字段标签归类(内联在题目下方折叠展示)
base["TextReplies"] = map[string]interface{}{
"建议": []interface{}{
map[string]interface{}{"CreatedAt": time.Now(), "Text": "希望支持深色模式"},
},
}
if err := renderWithData(t, "user/vote-results", base); err != nil {
t.Fatalf("render user/vote-results (form) failed: %v", err)
}
})
}
func TestRenderQuestionListWithVotes(t *testing.T) {
base := voteBaseData()
base["PageUser"] = map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": "",
"Background": "", "Intro": "", "UseCustomQRColor": false, "Qrcodebackcolor": "", "Qrcodecolor": "",
"Dotscale": "", "BackgroundImage": "", "Backgroundimagealpha": "", "Qrcodepdpcolor": "",
"Acfunlink": "", "Bililink": "", "Weibolink": "", "Xiaohongshulink": "", "Bloglink": "",
"Usernamecolor": "#000000", "Introcolor": "#000000"}
base["CanAsk"] = true
base["VoteEnabled"] = true
base["PageVotes"] = []*db.Vote{{Title: "板块投票", LinkToken: "abc123"}}
base["PageQuestions"] = []interface{}{}
base["AnsweredCount"] = int64(0)
base["PageQuestionCursor"] = ""
base["AllowChat"] = false
base["ChatMaxMessages"] = 20
if err := renderWithData(t, "question/list", base); err != nil {
t.Fatalf("render question/list with votes failed: %v", err)
}
// 箱主本人访问自己的提问箱:无进行中投票时也应显示管理入口(空态)
base["IsOwnPage"] = true
base["PageVotes"] = []interface{}{}
if err := renderWithData(t, "question/list", base); err != nil {
t.Fatalf("render question/list (own page, empty) failed: %v", err)
}
}
func TestRenderExploreWithHotVotes(t *testing.T) {
base := voteBaseData()
base["Users"] = []interface{}{}
base["RecommendUsers"] = []interface{}{}
base["HotTags"] = []string{}
base["Keyword"] = ""
base["Sort"] = "latest"
base["Page"] = 1
base["TotalPages"] = 1
base["PrevPage"] = 0
base["NextPage"] = 2
base["HotVotes"] = []*db.Vote{
{Title: "热门投票 1", LinkToken: "abc1", Type: db.VoteTypeSingle},
{Title: "热门投票 2", LinkToken: "abc2", Type: db.VoteTypeForm},
}
base["AllVotes"] = []*db.Vote{
{Title: "全部投票 1", LinkToken: "abc3", Type: db.VoteTypeMultiple, Model: dbutil.Model{CreatedAt: time.Now()}},
}
if err := renderWithData(t, "user/explore", base); err != nil {
t.Fatalf("render user/explore with hot votes failed: %v", err)
}
}