chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+526
View File
@@ -0,0 +1,526 @@
{{template "base/header" .}}
<style>
/* 表单字段头:序号 + 类型标签一行,题目文字独占一行(长标题自动换行,不再挤压) */
.tb-field-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-bottom: 10px;
}
.tb-field-head .tb-field-label {
flex-basis: 100%;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
/* 卡片式选项:radio/checkbox 统一为可点选卡片,视觉更清晰、间距更舒适 */
.tb-choice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--tb-border, #e5e5e5);
border-radius: 8px;
background: var(--tb-surface, #fff);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.tb-choice:last-child { margin-bottom: 0; }
.tb-choice input[type="radio"], .tb-choice input[type="checkbox"] {
margin-top: 3px;
}
.tb-choice span {
flex: 1;
min-width: 0;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
.tb-choice:hover {
border-color: var(--tb-primary, #1e87f0);
background: var(--tb-surface-hover, #f5faff);
}
.tb-choice:has(input:checked) {
border-color: var(--tb-primary, #1e87f0);
background: var(--tb-surface-active, #edf6ff);
}
html[data-theme="dark"] .tb-choice {
border-color: var(--tb-border);
background: var(--tb-surface);
}
html[data-theme="dark"] .tb-choice:hover {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.08);
}
html[data-theme="dark"] .tb-choice:has(input:checked) {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.14);
}
/* 自定义补充选项的文本输入:与卡片间距对齐 */
.tb-custom-input {
margin-top: 2px;
}
/* 字数计数:紧跟在输入下方,超限由 JS 加 uk-text-danger 标红 */
.tb-text-counter {
margin-top: 2px;
text-align: right;
}
@media (max-width: 480px) {
.tb-choice { padding: 10px 12px; }
}
</style>
{{template "base/alert" .}}
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 10px;">
{{ if .Owner }}
<img class="uk-border-circle" src="{{ .Owner.Avatar }}" style="width: 40px; height: 40px;">
<div>
<a class="uk-text-bold" href="/_{{ .Owner.Domain }}">{{ .Owner.Name }}</a>
<div class="uk-text-meta uk-text-small">{{t .LangCode "vote.page.by"}}</div>
</div>
{{ end }}
<div class="uk-margin-auto-left">
{{ if .NotStarted }}<span class="uk-label">{{t .LangCode "vote.page.notStarted"}}</span>{{ end }}
{{ if .Ended }}<span class="uk-label uk-label-warning">{{t .LangCode "vote.page.ended"}}</span>{{ end }}
{{ if and .CanVote (not .Ended) }}<span class="uk-label uk-label-success">{{t .LangCode "vote.page.active"}}</span>{{ end }}
{{ if and .HasVoted (not .Ended) }}<span class="uk-label uk-label-primary">{{t .LangCode "vote.page.participated"}}</span>{{ end }}
</div>
</div>
<h3 class="uk-margin-remove-top uk-margin-small-bottom">{{ .Vote.Title }}</h3>
{{ if .Vote.Description }}
<p class="uk-margin-remove-top">{{ .Vote.Description }}</p>
{{ end }}
</div>
{{ if .PreviewMode }}
<div class="uk-alert uk-alert-warning">{{t .LangCode "vote.page.previewHint"}}</div>
{{ end }}
{{ if .FeatureClosedMessage }}
<div class="uk-alert uk-alert-warning">{{ .FeatureClosedMessage }}</div>
{{ end }}
{{ if and .NotStarted (not .Error) }}
<div class="uk-alert uk-alert-primary">{{t .LangCode "vote.page.notStartedHint"}}</div>
{{ else if and .Ended (not .ShowResults) (not .Error) }}
<div class="uk-alert uk-alert-warning">{{t .LangCode "vote.page.endedHint"}}</div>
{{ end }}
{{ if .CanVote }}
{{ if .VoteScopeRegisteredOnly }}
{{ if not .IsLogged }}
<div class="uk-alert uk-alert-warning">
{{t .LangCode "vote.page.loginRequired"}} <a href="/login?to={{ .CurrentURI }}">{{t .LangCode "common.login"}}</a>
</div>
{{ end }}
{{ else }}
<div class="uk-alert uk-alert-primary uk-text-small">{{t .LangCode "vote.page.anonymousHint"}}</div>
{{ end }}
<form method="post" action="/vote/{{ .Vote.LinkToken }}/submit" id="vote-form"{{ if not .PreviewMode }} onsubmit="return validateVoteForm()"{{ end }}>
{{ .CSRFTokenHTML }}
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
{{ if eq .Vote.Type "single" }}
{{ range .Options }}
<label class="tb-choice">
<input class="uk-radio" type="radio" name="option_id" value="{{ .ID }}" data-vote-required data-vote-label="{{ $.Vote.Title }}"><span>{{ .Content }}</span>
</label>
{{ end }}
{{ else if eq .Vote.Type "multiple" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.multipleHint"}}(最多 {{ .Vote.MaxChoices }} 项)</div>
<div id="multiple-options">
{{ range .Options }}
<label class="tb-choice">
<input class="uk-checkbox" type="checkbox" name="option_ids" value="{{ .ID }}" data-vote-required data-vote-label="{{ $.Vote.Title }}"><span>{{ .Content }}</span>
</label>
{{ end }}
</div>
{{ else if eq .Vote.Type "rating" }}
<label class="uk-form-label">{{t .LangCode "vote.page.ratingLabel"}}</label>
<select class="uk-select" name="rating_value" style="max-width: 200px;" data-vote-required data-vote-label="{{ .Vote.Title }}">
<option value=""></option>
{{ range $i := loop 10 }}
<option value="{{ $i }}">{{ $i }} {{t $.LangCode "vote.page.ratingUnit"}}</option>
{{ end }}
</select>
{{ else if eq .Vote.Type "rank" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.rankHint"}}</div>
{{ range .Options }}
<div class="uk-margin-small-bottom uk-flex uk-flex-middle" style="gap: 10px;">
<span style="flex: 1;">{{ .Content }}</span>
<input class="uk-input" type="number" name="rank_{{ .ID }}" min="1" max="{{ len $.Options }}" style="width: 90px;" placeholder="{{t $.LangCode "vote.page.rankPlaceholder"}}" data-vote-required data-vote-label="{{ $.Vote.Title }}">
</div>
{{ end }}
{{ else if eq .Vote.Type "form" }}
{{ range $fi, $f := .FormFields }}
<div class="uk-margin">
<div class="tb-field-head">
<span class="uk-label">{{ add $fi 1 }}</span>
<span class="uk-label {{ fieldTypeLabelClass $f.FieldType }}">{{ fieldTypeLabel $.LangCode $f.FieldType }}</span>
<div class="tb-field-label">{{ $f.Label }}{{ if $f.Required }} <span class="uk-text-danger">*</span>{{ end }}</div>
</div>
{{ if eq $f.FieldType "text" }}
<input class="uk-input" type="text" name="field_{{ $f.ID }}" {{ if $f.MaxLength }}maxlength="{{ $f.MaxLength }}"{{ end }} {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ else if eq $f.FieldType "textarea" }}
<textarea class="uk-textarea" rows="3" name="field_{{ $f.ID }}" {{ if $f.MaxLength }}maxlength="{{ $f.MaxLength }}"{{ end }} {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}></textarea>
{{ else if eq $f.FieldType "select" }}
<select class="uk-select" name="field_{{ $f.ID }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
<option value="">{{t $.LangCode "vote.page.selectPlaceholder"}}</option>
{{ range parseOptions $f.Options }}
<option value="{{ . }}">{{ . }}</option>
{{ end }}
{{ if $f.AllowCustom }}
<option value="{{ $.CustomFieldMarker }}">{{t $.LangCode "vote.page.customOther"}}</option>
{{ end }}
</select>
{{ if $f.AllowCustom }}
<input class="uk-input tb-custom-input" type="text" name="field_{{ $f.ID }}_custom" placeholder="{{t $.LangCode "vote.page.customFieldPlaceholder"}}" data-vote-custom-input="{{ $f.ID }}" data-tb-text-counter disabled {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ end }}
{{ else if eq $f.FieldType "radio" }}
{{ range parseOptions $f.Options }}
<label class="tb-choice">
<input class="uk-radio" type="radio" name="field_{{ $f.ID }}" value="{{ . }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{ . }}</span>
</label>
{{ end }}
{{ if $f.AllowCustom }}
<label class="tb-choice">
<input class="uk-radio" type="radio" name="field_{{ $f.ID }}" value="{{ $.CustomFieldMarker }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{t $.LangCode "vote.page.customOther"}}</span>
</label>
<input class="uk-input tb-custom-input" type="text" name="field_{{ $f.ID }}_custom" placeholder="{{t $.LangCode "vote.page.customFieldPlaceholder"}}" data-vote-custom-input="{{ $f.ID }}" data-tb-text-counter disabled {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ end }}
{{ else if eq $f.FieldType "checkbox" }}
{{ range parseOptions $f.Options }}
<label class="tb-choice">
<input class="uk-checkbox" type="checkbox" name="field_{{ $f.ID }}" value="{{ . }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{ . }}</span>
</label>
{{ end }}
{{ if $f.AllowCustom }}
<label class="tb-choice">
<input class="uk-checkbox" type="checkbox" name="field_{{ $f.ID }}" value="{{ $.CustomFieldMarker }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}><span>{{t $.LangCode "vote.page.customOther"}}</span>
</label>
<input class="uk-input tb-custom-input" type="text" name="field_{{ $f.ID }}_custom" placeholder="{{t $.LangCode "vote.page.customFieldPlaceholder"}}" data-vote-custom-input="{{ $f.ID }}" data-tb-text-counter disabled {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ end }}
{{ else if eq $f.FieldType "number" }}
<input class="uk-input" type="number" name="field_{{ $f.ID }}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
{{ else if eq $f.FieldType "rating" }}
<select class="uk-select" name="field_{{ $f.ID }}" style="max-width: 200px;" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
<option value=""></option>
{{ range $i := loop 10 }}
<option value="{{ $i }}">{{ $i }} {{t $.LangCode "vote.page.ratingUnit"}}</option>
{{ end }}
</select>
{{ else if eq $f.FieldType "rank" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">
{{ if $f.Required }}{{t $.LangCode "vote.page.rankHintRequired"}}{{ else }}{{t $.LangCode "vote.page.rankHintOptional"}}{{ end }}
</div>
{{ $opts := parseOptions $f.Options }}
{{ range $oi, $opt := $opts }}
<div class="uk-flex uk-flex-middle" style="gap: 8px;">
<span class="uk-flex-1">{{ $opt }}</span>
<input class="uk-input" type="number" name="field_{{ $f.ID }}_{{ $oi }}" min="1" max="{{ len $opts }}" style="width: 90px;" placeholder="{{t $.LangCode "vote.page.rankPlaceholder"}}" {{ if $f.Required }}data-vote-required data-vote-label="{{ $f.Label }}"{{ end }}>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
{{ end }}
{{ if and .Vote.AllowCustom (or (eq .Vote.Type "single") (eq .Vote.Type "multiple") (eq .Vote.Type "rank")) }}
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "vote.page.customOption"}}</label>
<input class="uk-input" type="text" name="custom" placeholder="{{t .LangCode "vote.page.customPlaceholder"}}" data-tb-text-counter data-tb-text-max="{{ $.MaxCustomTextLen }}">
</div>
{{ end }}
</div>
<div class="uk-margin">
{{ if .PreviewMode }}
<button type="button" class="uk-button uk-button-primary" disabled>{{t .LangCode "vote.page.submit"}}</button>
<span class="uk-text-meta uk-text-small uk-margin-small-left">{{t .LangCode "vote.page.previewSubmitHint"}}</span>
{{ else if eq .CaptchaType "math" }}
<button type="button" class="uk-button uk-button-primary math-captcha-trigger" onclick="return voteValidate(event)">{{t .LangCode "vote.page.submit"}}</button>
{{ else }}
<button type="submit" class="uk-button uk-button-primary g-recaptcha"
data-sitekey="{{.RecaptchaSiteKey}}" data-callback="onSubmit">{{t .LangCode "vote.page.submit"}}</button>
{{ end }}
</div>
</form>
{{ else if .HasVoted }}
{{ if and (not .Success) (not .Error) }}
<div class="uk-alert uk-alert-success">{{t .LangCode "vote.page.alreadyVoted"}}</div>
{{ end }}
{{ end }}
{{ if .ShowResults }}
<div class="uk-card uk-card-default uk-card-body">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.page.resultsTitle"}}</h4>
{{ if .Results }}
{{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.totalVotes"}}: {{ .Results.Total }}</div>
{{ range .Results.Options }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Option.Content }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}({{ .Percent }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ .Percent }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rating" }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.totalVotes"}}: {{ .Results.Total }} · {{t .LangCode "vote.page.avgRating"}}: {{ printf "%.1f" .Results.AvgRating }}</div>
{{ range .Results.Ratings }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $.Results.Total 0 }}0{{ else }}{{ percentOf .Count $.Results.Total }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rank" }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.totalVotes"}}: {{ .Results.Total }}</div>
<table class="uk-table uk-table-small uk-table-divider">
<thead><tr><th>{{t .LangCode "vote.page.optionColumn"}}</th><th>{{t .LangCode "vote.page.avgRank"}}</th></tr></thead>
<tbody>
{{ range .Results.Rankings }}
<tr><td>{{ .Option.Content }}</td><td>{{ if gt .AvgRank 0.0 }}{{ printf "%.2f" .AvgRank }}{{ else }}-{{ end }}</td></tr>
{{ end }}
</tbody>
</table>
{{ else if eq .Results.Type "form" }}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "vote.page.formCount"}}: {{ .Results.FormCount }}</div>
{{ range $si, $stat := .Results.FormFieldStats }}
<div class="uk-margin-top">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 8px;">
<span class="uk-label">{{ add $si 1 }}</span>
<span class="uk-label {{ fieldTypeLabelClass $stat.Field.FieldType }}">{{ fieldTypeLabel $.LangCode $stat.Field.FieldType }}</span>
<span class="uk-text-bold">{{ $stat.Field.Label }}</span>
<span class="uk-text-meta uk-text-small">{{t $.LangCode "vote.resultsAnswered"}}: {{ $stat.Total }}</span>
</div>
{{ if or (eq $stat.Field.FieldType "radio") (eq $stat.Field.FieldType "select") (eq $stat.Field.FieldType "checkbox") }}
{{ $denom := $stat.Total }}
{{ if eq $stat.Field.FieldType "checkbox" }}{{ $denom = $stat.TotalSelections }}{{ end }}
{{ range $o := $stat.OptionStats }}
<div class="uk-margin-small-top">
<div class="uk-flex uk-flex-between">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{ $o.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ if $stat.CustomStats }}
<div class="uk-margin-small-top">
<div class="uk-text-meta">{{t $.LangCode "vote.page.customStats"}}:</div>
{{ range $c := $stat.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $c.Text }}</span>
<span class="uk-text-meta">{{ $c.Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rating" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRating"}}: {{ printf "%.1f" $stat.AvgRating }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $r := $stat.RatingCounts }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $r.Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ $r.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%;"></div>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rank" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRank"}}: {{ printf "%.2f" $stat.AvgRank }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $o := $stat.OptionStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{t $.LangCode "vote.page.avgRank"}}: {{ if gt $o.Count 0 }}{{ printf "%.2f" $o.AvgRank }}{{ else }}-{{ end }} · {{t $.LangCode "vote.page.rankedTimes"}}: {{ $o.Count }}</span>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "number" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgNumber"}}: {{ printf "%.1f" $stat.AvgNumber }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ else }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.fieldFilled"}}: {{ $stat.TextCount }}</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ if .Results.CustomStats }}
<div class="uk-margin-top">
<div class="uk-text-bold">{{t .LangCode "vote.page.customStats"}}</div>
{{ range .Results.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ .Text }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
{{ else }}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "vote.resultsEmpty"}}</div>
{{ end }}
</div>
{{ end }}
{{ if and .IsOwner (not .ShowResults) }}
<div class="uk-margin-top uk-text-small">
<a href="/user/votes/{{ .Vote.ID }}/results">{{t .LangCode "vote.page.ownerManage"}}</a>
</div>
{{ end }}
<script>
// 自定义补充选项的字数上限,与后端校验同源(conf.Limits.CustomAnswerLen,[limits] 配置段)。
// 去掉输入框的 maxlength 后由这里做提示:超限不静默截断,而是标红并阻止提交。
var MAX_CUSTOM_LEN = {{ .MaxCustomTextLen }};
// 按 Unicode 码点计数,与后端 len([]rune(s)) 口径一致:
// emoji、部分生僻字在 UTF-16 中占两个 code unit,用 .length 会多算一倍。
function countChars(s) { return Array.from(String(s || '')).length; }
// 给带 data-tb-text-counter 的输入框挂实时字数计数;上限取 data-tb-text-max,缺省 MAX_CUSTOM_LEN。
function setupTextCounters() {
var form = document.getElementById('vote-form');
if (!form) { return; }
form.querySelectorAll('[data-tb-text-counter]').forEach(function (input) {
var max = parseInt(input.getAttribute('data-tb-text-max'), 10);
if (!max || max < 1) { max = MAX_CUSTOM_LEN; }
var counter = document.createElement('div');
counter.className = 'uk-text-meta uk-text-small tb-text-counter';
if (input.parentNode) { input.parentNode.insertBefore(counter, input.nextSibling); }
var update = function () {
var n = countChars(input.value);
counter.textContent = n + ' / ' + max;
if (n > max) {
counter.classList.add('uk-text-danger');
input.setAttribute('data-tb-too-long', '1');
} else {
counter.classList.remove('uk-text-danger');
input.removeAttribute('data-tb-too-long');
}
};
input.addEventListener('input', update);
update();
});
}
// 前端必填校验:text/textarea/number/select/rating 值非空;radio/checkbox 按 name 至少选一个;
// 排序题(vote 题型与表单 rank 字段)每个排名输入必填。
function validateVoteForm() {
var form = document.getElementById('vote-form');
if (!form) { return true; }
// 自定义补充选项超长:计数口径与后端一致,超限直接拦下并聚焦到该输入
var tooLong = form.querySelector('[data-tb-text-counter][data-tb-too-long]');
if (tooLong) {
alert('{{t .LangCode "vote.err.customTooLong" $.MaxCustomTextLen}}');
tooLong.focus();
return false;
}
// 单选/多选组:按 name 分组检查
var groups = {};
form.querySelectorAll('input[data-vote-required][type="radio"], input[data-vote-required][type="checkbox"]').forEach(function (el) {
(groups[el.name] = groups[el.name] || []).push(el);
});
for (var name in groups) {
var any = groups[name].some(function (el) { return el.checked; });
if (!any) {
alert('{{t .LangCode "vote.page.mustComplete"}}「' + groups[name][0].getAttribute('data-vote-label') + '」');
return false;
}
}
// 其余必填元素:值非空(disabled 输入跳过——"其他"文本输入在未选中时处于禁用态)
var els = form.querySelectorAll('[data-vote-required]:not(input[type="radio"]):not(input[type="checkbox"])');
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.disabled) { continue; }
if ((el.value || '').trim() === '') {
alert('{{t .LangCode "vote.page.mustComplete"}}「' + el.getAttribute('data-vote-label') + '」');
return false;
}
}
return true;
}
// math 验证码按钮:先校验,失败时阻止冒泡(不打开验证码弹窗)
function voteValidate(e) {
if (!validateVoteForm()) {
if (e) { e.stopPropagation(); e.preventDefault(); }
return false;
}
return true;
}
// recaptcha/turnstile 成功回调(覆盖 header 的通用 onSubmit):校验通过才提交
function onSubmit() {
if (validateVoteForm()) {
document.getElementById('vote-form').submit();
}
}
(function () {
var form = document.getElementById('vote-form');
if (!form) { return; }
setupTextCounters();
var maxChoices = {{ if eq .Vote.Type "multiple" }}{{ .Vote.MaxChoices }}{{ else }}0{{ end }};
if (maxChoices > 0) {
var boxes = form.querySelectorAll('input[type="checkbox"][name="option_ids"]');
boxes.forEach(function (box) {
box.addEventListener('change', function () {
var checked = form.querySelectorAll('input[type="checkbox"][name="option_ids"]:checked').length;
if (checked > maxChoices) { this.checked = false; }
});
});
}
// 表单字段"其他/自定义选项"输入框联动:选中保留标记时启用文本输入,否则禁用并清空
var CUSTOM_MARKER = "{{ .CustomFieldMarker }}";
form.querySelectorAll('[data-vote-custom-input]').forEach(function (input) {
var fid = input.getAttribute('data-vote-custom-input');
var group = form.querySelectorAll('input[name="field_' + fid + '"]');
var select = form.querySelector('select[name="field_' + fid + '"]');
var sync = function () {
var on = false;
if (select) {
on = (select.value === CUSTOM_MARKER);
} else {
group.forEach(function (r) { if (r.value === CUSTOM_MARKER && r.checked) { on = true; } });
}
input.disabled = !on;
if (!on) { input.value = ''; }
};
if (select) { select.addEventListener('change', sync); } else { group.forEach(function (r) { r.addEventListener('change', sync); }); }
sync();
});
})();
</script>
{{template "base/footer" .}}