0904
This commit is contained in:
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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" .}}
|
||||
@@ -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()">×</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" .}}
|
||||
@@ -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
|
||||
@@ -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" .}}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!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>已自动临时封禁 <b>{{.duration}}</b> 分钟,到期自动解除</div>
|
||||
</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>
|
||||
@@ -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" .}}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" .}}
|
||||
@@ -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>
|
||||
@@ -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" .}}
|
||||
@@ -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 }}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" .}}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" .}}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 仅允许 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" .}}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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" .}}
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 限投维度提示:仅注册用户投票时切换为"每账号",否则"每设备"。
|
||||
// 与提交逻辑一致——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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user