This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{{template "base/header" .}}
<form method="post" id="form">
<fieldset class="uk-fieldset">
{{ .CSRFTokenHTML }}
<legend class="uk-legend">{{t .LangCode "deactivate.title"}}</legend>
{{template "base/alert" .}}
<div class="uk-margin">
{{t .LangCode "deactivate.desc"}}
<br>
<br>
<button type="submit" class="uk-button uk-button-danger g-recaptcha" data-sitekey="{{.RecaptchaSiteKey}}"
data-callback="onSubmit">{{t .LangCode "deactivate.confirm"}}
</button>
</div>
</fieldset>
</form>
{{template "base/footer" .}}
+621
View File
@@ -0,0 +1,621 @@
{{template "base/header" .}}
<style>
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.explore-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
margin-right: 15px;
}
.explore-info {
flex: 1;
min-width: 0;
}
.explore-name {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 5px;
}
.explore-name a {
text-decoration: none;
color: #333;
}
.explore-name a:hover {
color: #1e87f0;
}
.explore-intro {
color: #666;
margin-bottom: 10px;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.6em;
}
.explore-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.explore-tags .uk-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-card {
display: flex;
align-items: center;
padding: 10px;
background: #f8f8f8;
border-radius: 8px;
transition: background 0.2s;
}
.recommend-card:hover {
background: #f0f0f0;
}
.recommend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.recommend-info {
flex: 1;
min-width: 0;
}
.recommend-name {
font-weight: bold;
font-size: 1rem;
}
.recommend-name a {
text-decoration: none;
color: #333;
}
.recommend-tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.recommend-tags .uk-label {
font-size: 0.7rem;
padding: 0 5px;
}
.hot-votes-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.hot-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 16px 18px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: #fafafa;
text-decoration: none;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.hot-vote-item:hover {
border-color: #1e87f0;
background: #f5faff;
text-decoration: none;
transform: translateY(-1px);
}
.hot-vote-content {
min-width: 0;
}
.hot-vote-title {
display: block;
overflow: hidden;
color: #333;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.hot-vote-meta .uk-label {
font-size: 0.72rem;
line-height: 1.6;
}
.hot-vote-action {
flex: 0 0 auto;
color: #1e87f0;
font-size: 0.82rem;
white-space: nowrap;
}
html[data-theme="dark"] .hot-vote-item {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .hot-vote-item:hover {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.10);
}
html[data-theme="dark"] .hot-vote-title {
color: var(--tb-text);
}
@media (max-width: 640px) {
.hot-votes-list {
grid-template-columns: 1fr;
}
.hot-vote-item {
padding: 14px;
}
}
.explore-tabs {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
margin: 2px auto 20px;
max-width: 320px;
border: 1px solid #e5e5e5;
border-radius: 9px;
background: #fafafa;
}
.explore-tab {
border: 0;
border-radius: 6px;
padding: 8px 15px;
background: transparent;
color: #666;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.explore-tab:hover { color: #1e87f0; }
.explore-tab.is-active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: #1e87f0;
font-weight: 600;
}
.explore-panel[hidden] { display: none !important; }
.explore-vote-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.explore-vote-toolbar p { margin: 4px 0 0; }
.vote-random-btn {
flex: 0 0 auto;
border: 1px solid #dce9ff;
border-radius: 7px;
padding: 7px 11px;
color: #1e87f0;
background: #f5faff;
cursor: pointer;
font-size: 0.84rem;
}
.vote-random-btn:hover { border-color: #1e87f0; background: #edf6ff; }
.all-votes-list { display: grid; gap: 10px; }
.all-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #e9e9e9;
text-decoration: none;
}
.all-vote-item:first-child { border-top: 1px solid #e9e9e9; }
.all-vote-item:hover { background: #f8fbff; text-decoration: none; }
.all-vote-item .hot-vote-title { max-width: 100%; }
html[data-theme="dark"] .explore-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .explore-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .explore-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .vote-random-btn { border-color: var(--tb-border); background: var(--tb-surface-2); color: var(--tb-primary); }
html[data-theme="dark"] .all-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .all-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
.explore-notice {
background: #f0f7ff;
border-left: 4px solid #1e87f0;
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 4px;
color: #333;
font-size: 0.9rem;
line-height: 1.6;
}
.explore-notice strong {
color: #1e87f0;
}
/* 极简「换一批」按钮:描边胶囊 + 悬停浅蓝底色 + 点击旋转反馈 */
.tb-shuffle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
font-size: 0.85rem;
line-height: 1.4;
color: #1e87f0;
background: transparent;
border: 1px solid #e3e3e3;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.tb-shuffle-btn:hover {
background: #f0f7ff;
border-color: #1e87f0;
}
.tb-shuffle-btn:active {
transform: scale(0.96);
}
.tb-shuffle-btn .uk-icon {
transition: transform 0.5s ease;
}
.tb-shuffle-btn.is-spinning .uk-icon {
transform: rotate(360deg);
}
html[data-theme="dark"] .tb-shuffle-btn {
color: #4ea8f5;
border-color: #3a3f45;
}
html[data-theme="dark"] .tb-shuffle-btn:hover {
background: rgba(78, 168, 245, 0.12);
border-color: #4ea8f5;
}
</style>
<div class="uk-container uk-container-small">
<h2>{{t .LangCode "explore.title"}}</h2>
<div class="explore-notice">
<p>{{t .LangCode "explore.notice" | safe}}</p>
</div>
<div class="explore-tabs" role="tablist" aria-label="{{t .LangCode "explore.title"}}">
<button type="button" class="explore-tab is-active" data-explore-tab="questions" role="tab" aria-selected="true">{{t .LangCode "explore.questionsTab"}}</button>
<button type="button" class="explore-tab" data-explore-tab="votes" role="tab" aria-selected="false">{{t .LangCode "explore.votesTab"}}</button>
</div>
<div class="explore-panel" data-explore-panel="questions">
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-flex uk-flex-between uk-flex-middle">
<h3 class="uk-card-title uk-margin-remove">{{t .LangCode "explore.recommend"}}</h3>
<button class="tb-shuffle-btn" id="refresh-recommend" type="button">
<span uk-icon="icon: refresh"></span> {{t .LangCode "explore.shuffle"}}
</button>
</div>
<div id="recommend-list" class="uk-grid-small uk-child-width-1-2@s uk-child-width-1-3@m uk-grid-match" uk-grid>
{{if .RecommendUsers}}
{{range $user := .RecommendUsers}}
<div>
<a href="/_/{{$user.Domain}}" class="recommend-card">
<img class="recommend-avatar" src="{{$user.Avatar}}" alt="avatar">
<div class="recommend-info">
<div class="recommend-name truncate">{{$user.Name}}</div>
<div class="recommend-tags">
{{if $user.TagList}}
{{range $index, $tag := $user.TagList}}
<span class="uk-label {{index $user.TagColors $index}}">{{$tag}}</span>
{{end}}
{{else}}
<span class="uk-text-muted">{{t $.LangCode "explore.noTags"}}</span>
{{end}}
</div>
</div>
</a>
</div>
{{end}}
{{else}}
<div class="uk-text-center uk-padding">{{t .LangCode "explore.noRecommend"}}</div>
{{end}}
</div>
</div>
<!-- 热门标签榜 -->
{{if .HotTags}}
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<h4 class="uk-card-title uk-margin-remove-bottom">{{t .LangCode "explore.hotTags"}}</h4>
<div class="uk-margin-small-top uk-flex uk-flex-wrap uk-flex-middle" style="gap: 6px;">
{{range $i, $tag := .HotTags}}
<a class="uk-label {{if eq (mod $i 5) 0}}uk-label-success{{else if eq (mod $i 5) 1}}uk-label-warning{{else if eq (mod $i 5) 2}}uk-label-danger{{else if eq (mod $i 5) 3}}uk-label-primary{{else}}uk-label-secondary{{end}}"
href="/explore?q={{urlquery $tag}}" style="text-decoration:none;">#{{$tag}}</a>
{{end}}
</div>
</div>
{{end}}
<form method="get" action="/explore" class="uk-margin uk-margin-bottom">
<div class="uk-grid-small" uk-grid>
<div class="uk-width-expand">
<div class="uk-inline uk-width-1-1">
<button class="uk-form-icon uk-form-icon-flip" uk-icon="icon: search" type="submit"></button>
<input class="uk-input" type="text" name="q" placeholder="{{t .LangCode "explore.search"}}" value="{{.Keyword}}">
</div>
</div>
<div class="uk-width-auto">
<select name="sort" class="uk-select" onchange="this.form.submit()">
<option value="latest" {{if eq .Sort "latest"}}selected{{end}}>{{t .LangCode "explore.sortLatest"}}</option>
<option value="oldest" {{if eq .Sort "oldest"}}selected{{end}}>{{t .LangCode "explore.sortOldest"}}</option>
</select>
</div>
</div>
</form>
{{if .Users}}
<div class="uk-child-width-1-1 uk-child-width-1-2@s uk-grid-match" uk-grid>
{{range .Users}}
{{$user := .}}
<div>
<div class="uk-card uk-card-default uk-card-hover uk-card-body">
<div class="uk-flex uk-flex-top">
<div>
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar">
</div>
<div class="explore-info">
<div class="explore-name truncate">
<a href="/_/{{$user.Domain}}" title="{{$user.Name}}">{{$user.Name}}</a>
</div>
<div class="explore-intro" title="{{$user.Intro}}">
{{if $user.Intro}}{{$user.Intro}}{{else}}{{t $.LangCode "explore.lazy"}}{{end}}
</div>
<div class="explore-tags">
{{if $user.TagList}}
{{range $index, $tag := $user.TagList}}
<span class="uk-label {{index $user.TagColors $index}}" title="{{$tag}}">{{$tag}}</span>
{{end}}
{{else}}
<span class="uk-text-muted">{{t $.LangCode "explore.noTags"}}</span>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
</div>
<ul class="uk-pagination uk-flex-center uk-margin-top" uk-margin>
{{if gt .Page 1}}
<li><a href="?{{if .Keyword}}q={{.Keyword}}&{{end}}sort={{.Sort}}&page={{.PrevPage}}"><span uk-pagination-previous></span> {{t .LangCode "explore.prevPage"}}</a></li>
{{else}}
<li class="uk-disabled"><span><span uk-pagination-previous></span> {{t .LangCode "explore.prevPage"}}</span></li>
{{end}}
<li class="uk-active"><span>{{.Page}} / {{.TotalPages}}</span></li>
{{if lt .Page .TotalPages}}
<li><a href="?{{if .Keyword}}q={{.Keyword}}&{{end}}sort={{.Sort}}&page={{.NextPage}}">{{t .LangCode "explore.nextPage"}} <span uk-pagination-next></span></a></li>
{{else}}
<li class="uk-disabled"><span>{{t .LangCode "explore.nextPage"}} <span uk-pagination-next></span></span></li>
{{end}}
</ul>
{{else}}
<div class="uk-alert uk-alert-warning">{{t .LangCode "explore.noResult"}}</div>
{{end}}
</div>
<div class="explore-panel" data-explore-panel="votes" hidden>
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="explore-vote-toolbar">
<div>
<h3 class="uk-card-title uk-margin-remove">{{t .LangCode "explore.votesTitle"}}</h3>
<p class="uk-text-meta uk-text-small">{{t .LangCode "explore.votesDesc"}}</p>
</div>
<button type="button" class="vote-random-btn" id="random-vote-btn"><span uk-icon="icon: refresh; ratio: .8"></span> {{t .LangCode "explore.randomVote"}}</button>
</div>
{{if .HotVotes}}
<h4 class="uk-heading-bullet uk-margin-small-bottom">{{t .LangCode "explore.hotVotes"}}</h4>
<div class="hot-votes-list uk-margin-small-bottom">
{{range $vote := .HotVotes}}
<a href="/vote/{{$vote.LinkToken}}" class="hot-vote-item">
<div class="hot-vote-content">
<span class="hot-vote-title" title="{{$vote.Title}}">{{$vote.Title}}</span>
<span class="hot-vote-meta">
{{if eq $vote.Type "single"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{end}}
</span>
</div>
<span class="hot-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{end}}
</div>
{{end}}
<h4 class="uk-heading-bullet uk-margin-medium-top uk-margin-small-bottom">{{t .LangCode "explore.allVotes"}}</h4>
{{if .AllVotes}}
<div class="all-votes-list" id="all-votes-list">
{{range $vote := .AllVotes}}
<a href="/vote/{{$vote.LinkToken}}" class="all-vote-item">
<div class="hot-vote-content">
<span class="hot-vote-title" title="{{$vote.Title}}">{{$vote.Title}}</span>
<span class="hot-vote-meta">
{{if eq $vote.Type "single"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{end}}
<span class="uk-text-meta">{{$vote.CreatedAt.Format "2006-01-02"}}</span>
</span>
</div>
<span class="hot-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{end}}
</div>
{{else}}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "explore.noVotes"}}</div>
{{end}}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const recommendContainer = document.getElementById('recommend-list');
const refreshBtn = document.getElementById('refresh-recommend');
const tabs = document.querySelectorAll('[data-explore-tab]');
const panels = document.querySelectorAll('[data-explore-panel]');
function showExplorePanel(name) {
tabs.forEach(function(tab) {
const active = tab.dataset.exploreTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.explorePanel !== name;
});
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname + (name === 'votes' ? '#votes' : ''));
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showExplorePanel(tab.dataset.exploreTab); });
});
if (window.location.hash === '#votes') showExplorePanel('votes');
const randomVoteBtn = document.getElementById('random-vote-btn');
if (randomVoteBtn) {
randomVoteBtn.addEventListener('click', function() {
const votes = Array.from(document.querySelectorAll('#all-votes-list .all-vote-item'));
if (!votes.length) return;
const target = votes[Math.floor(Math.random() * votes.length)];
randomVoteBtn.classList.add('is-spinning');
target.style.outline = '2px solid #1e87f0';
target.style.outlineOffset = '2px';
window.setTimeout(function() {
window.location.href = target.href;
}, 350);
});
}
// 递归提取用户数组,无视包裹层数
function extractUsers(obj) {
if (Array.isArray(obj)) return obj;
if (!obj || typeof obj !== 'object') return [];
// 单个用户对象
if (obj.domain || obj.id) {
return [obj];
}
// 尝试从 data 中递归
if (obj.data) {
return extractUsers(obj.data);
}
// 遍历所有值
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const val = obj[key];
if (Array.isArray(val)) {
return val;
} else if (val && typeof val === 'object') {
const result = extractUsers(val);
if (result.length > 0) return result;
}
}
}
return [];
}
function escapeHTML(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 仅允许 http(s) 头像地址,阻断 javascript: 等伪协议。
function safeAvatarURL(url) {
if (typeof url !== 'string') return '';
if (/^https?:\/\//i.test(url)) return url;
return '';
}
function loadRandomRecommend() {
recommendContainer.innerHTML = '<div class="uk-text-center uk-padding"><div uk-spinner></div> {{t .LangCode "explore.loading"}}</div>';
fetch('/api/v1/explore/random')
.then(response => {
if (!response.ok) throw new Error(`HTTP {{t .LangCode "explore.loadFailed"}} ${response.status}`);
return response.json();
})
.then(resp => {
const users = extractUsers(resp);
if (users.length === 0) {
recommendContainer.innerHTML = '<div class="uk-text-center uk-padding">{{t .LangCode "explore.noRecommend"}}</div>';
return;
}
let html = '';
users.forEach(user => {
let tagsHtml = '';
if (user.tag_list && user.tag_list.length) {
user.tag_list.forEach((tag, idx) => {
const colorClass = (user.tag_color && user.tag_color[idx]) || 'uk-label-default';
tagsHtml += `<span class="uk-label ${escapeHTML(colorClass)}" title="${escapeHTML(tag)}">${escapeHTML(tag)}</span>`;
});
} else {
tagsHtml = '<span class="uk-text-muted">{{t .LangCode "explore.noTags"}}</span>';
}
html += `
<div>
<a href="/_/${escapeHTML(user.domain)}" class="recommend-card">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar">
<div class="recommend-info">
<div class="recommend-name truncate">${escapeHTML(user.name)}</div>
<div class="recommend-tags">${tagsHtml}</div>
</div>
</a>
</div>
`;
});
recommendContainer.innerHTML = html;
if (window.UIkit && UIkit.grid) {
UIkit.grid(recommendContainer);
}
})
.catch(error => {
console.error('Failed to load random recommends:', error);
recommendContainer.innerHTML = '<div class="uk-alert uk-alert-danger">加载失败:' + escapeHTML(error.message) + '</div>';
});
}
// 初始推荐已由服务端渲染,只有"换一批"按钮触发 AJAX
refreshBtn.addEventListener('click', function() {
// 重启旋转动画(先移再触发 reflow 再加,确保每次点击都播放)
refreshBtn.classList.remove('is-spinning');
void refreshBtn.offsetWidth;
refreshBtn.classList.add('is-spinning');
loadRandomRecommend();
});
});
</script>
{{template "base/footer" .}}
+135
View File
@@ -0,0 +1,135 @@
<!--
功能控制表单部分模板:提问 / 私聊 / 投票 三个功能面板共用。
渲染时「点」为单个功能视图(Key / Control / State / Mode / CSRFTokenHTML ...),
因此所有文案与令牌都可自取,无需访问根数据。
收集模式(mode)三选一,保存时互斥、后端同步清理未选模式的字段:
- always 长期开放:不限时间,保持开放直到手动关闭(仍受收集上限约束);
- window 定时收集:一次性开放 / 关闭时间窗口;
- recurring 循环定时:每周固定开放日 + 每日时间段循环。
私聊(chat)不提供收集上限:配额字段与重置计数按钮按 Key 条件隐藏,后端强制清零。
「保存」与「重置计数」同行:重置按钮通过 HTML5 form 属性指向独立的 reset 表单。
-->
<div class="feat-card">
<div class="feat-card-head">
<h3 class="uk-margin-remove">{{ .NameText }}</h3>
<span class="feat-status {{ if .State.Open }}is-open{{ else }}is-closed{{ end }}">{{ .StatusText }}</span>
</div>
<span class="feat-hint">{{t .LangCode (print "admin.featureHint." .Key)}}</span>
<form method="post" action="/user/profile/features/update">
{{ .CSRFTokenHTML }}
<input type="hidden" name="feature" value="{{ .Key }}">
<div class="uk-margin-small">
<label>
<input name="enabled" class="uk-checkbox" type="checkbox" {{ if .Control.Enabled }}checked{{ end }}>
<span class="uk-text-small">{{t .LangCode "admin.featureEnabled"}}</span>
</label>
</div>
<div class="uk-margin-small feat-field">
<span class="feat-field-label">{{t .LangCode "admin.featureMode"}}</span>
<div class="feat-modes">
<label class="feat-day{{ if eq .Mode "always" }} is-on{{ end }}">
<input name="mode" type="radio" value="always" {{ if eq .Mode "always" }}checked{{ end }}>
<span>{{t .LangCode "admin.featureModeAlways"}}</span>
</label>
<label class="feat-day{{ if eq .Mode "window" }} is-on{{ end }}">
<input name="mode" type="radio" value="window" {{ if eq .Mode "window" }}checked{{ end }}>
<span>{{t .LangCode "admin.featureModeWindow"}}</span>
</label>
<label class="feat-day{{ if eq .Mode "recurring" }} is-on{{ end }}">
<input name="mode" type="radio" value="recurring" {{ if eq .Mode "recurring" }}checked{{ end }}>
<span>{{t .LangCode "admin.featureModeRecurring"}}</span>
</label>
</div>
<span class="feat-hint">{{t .LangCode "admin.featureModeHint"}}</span>
</div>
<div class="uk-margin-small feat-field feat-window-fields">
<label>
<span>{{t .LangCode "admin.featureOpenAt"}}</span>
<input name="open_at" class="uk-input" type="datetime-local" value="{{ .OpenAtStr }}">
</label>
<label>
<span>{{t .LangCode "admin.featureCloseAt"}}</span>
<input name="close_at" class="uk-input" type="datetime-local" value="{{ .CloseAtStr }}">
</label>
<span class="feat-hint">{{t .LangCode "admin.featureTimeHint"}}</span>
</div>
<div class="uk-margin-small feat-field feat-recurring-fields">
<label>
<span>{{t .LangCode "admin.featureSchedule"}}</span>
</label>
<div class="feat-schedule">
<div class="feat-schedule-days">
{{ range $i := loop 7 }}
<label class="feat-day{{ if hasDay $.ScheduleDays (sub $i 1) }} is-on{{ end }}">
<input name="schedule_days" class="uk-checkbox" type="checkbox" value="{{ sub $i 1 }}" {{ if hasDay $.ScheduleDays (sub $i 1) }}checked{{ end }}>
<span>{{t $.LangCode (printf "weekday.%d" (sub $i 1))}}</span>
</label>
{{ end }}
</div>
<div class="feat-schedule-times">
<label>
<span>{{t .LangCode "admin.featureScheduleStart"}}</span>
<input name="schedule_start" class="uk-input" type="time" value="{{ .ScheduleStart }}">
</label>
<label>
<span>{{t .LangCode "admin.featureScheduleEnd"}}</span>
<input name="schedule_end" class="uk-input" type="time" value="{{ .ScheduleEnd }}">
</label>
</div>
<span class="feat-hint">{{t .LangCode "admin.featureScheduleHint"}}</span>
</div>
</div>
{{ if ne .Key "chat" }}
<div class="uk-margin-small feat-field">
<label>
<span>{{t .LangCode "admin.featureQuotaLimit"}}</span>
<input name="quota_limit" class="uk-input" type="number" min="0" max="1000000" value="{{ .Control.QuotaLimit }}">
</label>
<span class="feat-hint">{{t .LangCode "admin.featureQuotaHint"}}</span>
</div>
<div class="feat-quota-line">
{{t .LangCode "admin.featureQuotaCount"}}:{{ .State.QuotaCount }}
{{ if .Control.QuotaLimit }}/ {{ .Control.QuotaLimit }}{{ else }}<span class="uk-text-muted">({{t .LangCode "admin.featureQuotaUnlimited"}})</span>{{ end }}
{{ if .Control.QuotaResetAt }}<br>{{t .LangCode "admin.featureQuotaResetAt"}} {{ .QuotaResetAtStr }}{{ end }}
</div>
{{ end }}
<div class="feat-actions">
{{ if ne .Key "chat" }}
<button type="submit" class="uk-button uk-button-default" form="feat-reset-{{ .Key }}">{{t .LangCode "admin.featureResetQuota"}}</button>
{{ end }}
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "admin.featureSave"}}</button>
</div>
</form>
{{ if ne .Key "chat" }}
<form id="feat-reset-{{ .Key }}" method="post" action="/user/profile/features/reset-quota">
{{ .CSRFTokenHTML }}
<input type="hidden" name="feature" value="{{ .Key }}">
</form>
{{ end }}
<script>
(function () {
var card = document.currentScript && document.currentScript.closest('.feat-card');
if (!card) return;
var radios = card.querySelectorAll('input[name="mode"]');
var windowFields = card.querySelector('.feat-window-fields');
var recurringFields = card.querySelector('.feat-recurring-fields');
if (!radios.length) return;
function sync() {
var mode = '';
radios.forEach(function (r) {
var pill = r.closest('.feat-day');
if (pill) pill.classList.toggle('is-on', r.checked);
if (r.checked) mode = r.value;
});
if (windowFields) windowFields.classList.toggle('is-hidden', mode !== 'window');
if (recurringFields) recurringFields.classList.toggle('is-hidden', mode !== 'recurring');
}
radios.forEach(function (r) { r.addEventListener('change', sync); });
sync();
})();
</script>
</div>
+29
View File
@@ -0,0 +1,29 @@
<!--
全局屏蔽词表单部分模板:提问 / 私聊 / 投票 三个功能面板共用同一份词表设置。
渲染时「点」为 dict{ Root: 根数据, Action: 表单提交地址 }:
Root —— profile 页面根数据(LoggedUser / TagLimits / LangCode / CSRFTokenHTML …);
Action —— 表单 action,携带 ?tab= 来源面板参数,保存后回跳原面板。
三处展示同一份数据(users.block_words),任意一处修改保存后全部同步生效;
词表按 conf.Limits.MaxBlockWords / MaxBlockWordLen 上限校验(后端同一份配置)。
-->
{{ $root := .Root }}
<div class="uk-margin">
<form method="post" action="{{ .Action }}">
{{ $root.CSRFTokenHTML }}
<legend class="uk-legend">{{t $root.LangCode "admin.globalBlockWords"}}</legend>
<div class="uk-margin">
<p class="uk-text-meta uk-text-small">{{t $root.LangCode "admin.globalBlockWordsHint"}}</p>
<label class="uk-form-label">{{t $root.LangCode "admin.blockWords" | safe}}</label>
<input name="block_words" class="uk-input" type="text" value="{{$root.LoggedUser.BlockWords}}"
data-tb-tag-input
{{ with $root.TagLimits }}data-tb-max="{{ .MaxBlockWords }}" data-tb-max-len="{{ .MaxBlockWordLen }}"{{ end }}
data-tb-msg-too-many="{{t $root.LangCode "admin.flash.blockWordsTooMany"}}"
data-tb-msg-too-long="{{t $root.LangCode "admin.flash.blockWordTooLong"}}"
data-tb-msg-duplicate="{{t $root.LangCode "admin.tagInputDuplicate"}}"
data-tb-msg-hint="{{t $root.LangCode "admin.tagInputHint"}}">
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $root.LangCode "admin.updateBlockWords"}}</button>
</div>
</form>
</div>
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
{{template "base/header" .}}
<style>
.qlist-tabs {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
margin: 6px auto 20px;
max-width: 320px;
border: 1px solid #e5e5e5;
border-radius: 9px;
background: #fafafa;
}
.qlist-tab {
flex: 1 1 0;
border: 0;
border-radius: 6px;
padding: 8px 15px;
background: transparent;
color: #666;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.qlist-tab:hover { color: #1e87f0; }
.qlist-tab.is-active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: #1e87f0;
font-weight: 600;
}
.qlist-panel[hidden] { display: none !important; }
.all-votes-list { display: grid; gap: 10px; }
.all-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #e9e9e9;
text-decoration: none;
}
.all-vote-item:first-child { border-top: 1px solid #e9e9e9; }
.all-vote-item:hover { background: #f8fbff; text-decoration: none; }
.all-vote-item .hot-vote-title { max-width: 100%; }
.hot-vote-title {
display: block;
overflow: hidden;
color: #333;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-vote-title:hover { color: #1e87f0; }
.hot-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.hot-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
.hot-vote-action { flex: 0 0 auto; color: #1e87f0; font-size: 0.82rem; white-space: nowrap; }
/* 提问列表右上角:状态标签与操作按钮统一尺寸(等宽等高、垂直居中、完全对齐)。
每个元素显式带 .qlist-pill 类,选择器挂在 .qlist-pills 容器下提升优先级,
避免浏览器对 <button> 的默认样式或 UIkit .uk-label 造成差异。
移动端默认显示短文字标签(每语言短词,如 未读→新/New,宽度可控);
≥640px 显示完整文案(配合 title 悬浮提示,多语言安全)。 */
.qlist-pills .qlist-pill {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 40px;
padding: 0 8px;
box-sizing: border-box;
font-size: 0.72rem;
line-height: 1;
white-space: nowrap;
border-radius: 2px;
border: 0;
vertical-align: middle;
}
.qlist-pills .tb-short { display: inline; }
.qlist-pills .tb-text { display: none; }
/* 按钮额外重置浏览器默认样式,保证与 span 标签渲染完全一致 */
.qlist-pills .qlist-pill.tb-label-btn {
cursor: pointer;
font-family: inherit;
-webkit-appearance: none;
appearance: none;
background-image: none;
outline: none;
margin: 0;
}
.qlist-pills .qlist-pill.tb-label-btn:hover { opacity: 0.85; }
@media (min-width: 640px) {
/* 桌面端纯文字:隐藏移动端短词,只显示完整文案 */
.qlist-pills .qlist-pill { min-width: 62px; padding: 0 10px; }
.qlist-pills .tb-short { display: none; }
.qlist-pills .tb-text { display: inline; }
}
html[data-theme="dark"] .qlist-tabs { border-color: var(--tb-border); background: var(--tb-surface-2); }
html[data-theme="dark"] .qlist-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .qlist-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .all-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .all-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
html[data-theme="dark"] .hot-vote-title { color: var(--tb-text); }
/* 紧凑统计看板:替代原 UIkit 卡片网格。手机端 4 列(7 项=2 行)、桌面端 7 列 1 行,
轻量描边小格子、缩字号、去掉大卡片内边距,显著降低纵向高度,减少手机端滚动。
--4 修饰类用于项数较少的看板(如私聊 4 项),在所有断点固定 4 列,避免末尾留白过多。 */
.qlist-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
@media (min-width: 768px) {
.qlist-stats { grid-template-columns: repeat(7, 1fr); }
}
.qlist-stats--4 { grid-template-columns: repeat(4, 1fr); }
@media (min-width: 768px) {
.qlist-stats--4 { grid-template-columns: repeat(4, 1fr); }
}
/* --2 修饰类用于项数很少的看板(如投票 2 项),固定 2 列,避免末尾留白 */
.qlist-stats--2 { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 768px) {
.qlist-stats--2 { grid-template-columns: repeat(2, 1fr); }
}
.qlist-stat {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 9px;
padding: 8px 6px;
text-align: center;
}
.qlist-stat .qlist-stat-num {
display: block;
font-size: 1.15rem;
font-weight: 700;
line-height: 1.15;
color: #222;
}
.qlist-stat .qlist-stat-lbl {
display: block;
margin-top: 2px;
font-size: 0.7rem;
color: #888;
line-height: 1.15;
}
html[data-theme="dark"] .qlist-stat { background: var(--tb-surface); border-color: var(--tb-border); }
html[data-theme="dark"] .qlist-stat .qlist-stat-num { color: var(--tb-text); }
html[data-theme="dark"] .qlist-stat .qlist-stat-lbl { color: var(--tb-text-muted); }
</style>
<div class="uk-container">
<div class="uk-flex uk-flex-middle uk-flex-between uk-margin-small-bottom">
<h2 class="uk-margin-remove">{{t .LangCode "qlist.title"}}</h2>
<a class="uk-button uk-button-default uk-button-small" href="/user/profile">{{t .LangCode "admin.featuresEntry"}}</a>
</div>
<!-- 居中切换:提问 / 投票 / 私聊 -->
<div class="qlist-tabs" role="tablist" aria-label="{{t .LangCode "qlist.title"}}">
<button type="button" class="qlist-tab is-active" data-qlist-tab="questions" role="tab" aria-selected="true">{{t .LangCode "qlist.questionsTab"}}</button>
<button type="button" class="qlist-tab" data-qlist-tab="votes" role="tab" aria-selected="false">{{t .LangCode "qlist.votesTab"}}</button>
<button type="button" class="qlist-tab" data-qlist-tab="chats" role="tab" aria-selected="false">{{t .LangCode "qlist.chatsTab"}}</button>
</div>
<!-- 提问面板(默认展示) -->
<div class="qlist-panel" data-qlist-panel="questions">
<!-- 统一提示区:标记已读/未读、置顶等操作的成功/错误提示沿用 base/alert 样式 -->
{{template "base/alert" .}}
<!-- 提问箱统计看板(紧凑多列网格:手机4列/桌面7列,降低纵向高度) -->
<div class="qlist-stats">
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.total }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsTotal"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.answered }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsAnswered"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.unanswered }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsUnanswered"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.read }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsRead"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.unread }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsUnread"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.private }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsPrivate"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .Stats.pinned }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.statsPinned"}}</span></div>
</div>
<div class="uk-margin-medium-bottom uk-text-center">
<!-- 全文搜索自己提问箱 -->
<form method="get" action="" class="uk-form-stacked">
<div class="uk-flex uk-flex-center uk-flex-nowrap uk-overflow-auto">
<div class="uk-margin-small-right uk-width-expand" style="max-width: 420px;">
<input type="text" name="q" class="uk-input uk-form-small" value="{{ .Keyword }}" placeholder="{{t .LangCode "qlist.searchPlaceholder"}}" aria-label="{{t .LangCode "qlist.searchPlaceholder"}}">
</div>
<div class="uk-margin-small-right">
<button type="submit" class="uk-button uk-button-primary uk-button-small">{{t .LangCode "qlist.searchBtn"}}</button>
</div>
{{ if ne .Keyword "" }}
<div>
<a class="uk-button uk-button-default uk-button-small" href="/user/questions">{{t .LangCode "qlist.searchClear"}}</a>
</div>
{{ end }}
</div>
</form>
<form method="get" action="" class="uk-form-stacked uk-margin-small-top">
<div class="uk-flex uk-flex-center uk-flex-nowrap uk-overflow-auto">
<div class="uk-margin-small-right" style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.sort"}}</label>
<select name="sort" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="latest" {{if eq .SelectedSort "latest"}}selected{{end}}>{{t .LangCode "qlist.latest"}}</option>
<option value="oldest" {{if eq .SelectedSort "oldest"}}selected{{end}}>{{t .LangCode "qlist.oldest"}}</option>
</select>
</div>
<div class="uk-margin-small-right" style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.status"}}</label>
<select name="status" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="all" {{if eq .SelectedStatus "all"}}selected{{end}}>{{t .LangCode "qlist.all"}}</option>
<option value="answered" {{if eq .SelectedStatus "answered"}}selected{{end}}>{{t .LangCode "qlist.answered"}}</option>
<option value="unanswered" {{if eq .SelectedStatus "unanswered"}}selected{{end}}>{{t $.LangCode "qlist.unanswered"}}</option>
</select>
</div>
<div class="uk-margin-small-right" style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.type"}}</label>
<select name="type" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="all" {{if eq .SelectedType "all"}}selected{{end}}>{{t .LangCode "qlist.all"}}</option>
<option value="public" {{if eq .SelectedType "public"}}selected{{end}}>{{t .LangCode "qlist.public"}}</option>
<option value="private_question" {{if eq .SelectedType "private_question"}}selected{{end}}>{{t .LangCode "qlist.privateQuestion"}}</option>
</select>
</div>
<div style="width: 120px;">
<label class="uk-form-label uk-text-small">{{t .LangCode "qlist.readStatus"}}</label>
<select name="read" class="uk-select uk-form-small" onchange="this.form.submit()" style="width: 100%;">
<option value="all" {{if eq .SelectedRead "all"}}selected{{end}}>{{t .LangCode "qlist.all"}}</option>
<option value="read" {{if eq .SelectedRead "read"}}selected{{end}}>{{t .LangCode "qlist.read"}}</option>
<option value="unread" {{if eq .SelectedRead "unread"}}selected{{end}}>{{t .LangCode "qlist.unread"}}</option>
</select>
</div>
</div>
</form>
</div>
<!-- 我收到的提问(标签右浮同行时间,内容下一行) -->
<h4>{{t .LangCode "qlist.received"}}</h4>
{{ if .ReceivedItems }}
{{ range .ReceivedItems }}
<div class="uk-margin-medium-bottom" id="question-{{.QuestionID}}">
<hr>
<!-- 标签在左、按钮在右:两组分开布局;移动端状态/按钮只显示 emoji 图标,保持单行紧凑 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap qlist-pills" style="gap: 6px;">
<!-- 左:时间 + 状态标签 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 6px;">
<span class="uk-text-small uk-text-muted">{{Date .CreatedAt "Y-m-d H:i:s"}}</span>
{{ if .IsPinned }}
<span class="uk-label uk-label-warning qlist-pill" title="{{t $.LangCode "qlist.pinned"}}"><span class="tb-short">{{t $.LangCode "qlist.pinnedShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.pinned"}}</span></span>
{{ end }}
{{ if eq .Type "question_private" }}
<span class="uk-label uk-label-warning qlist-pill" title="{{t $.LangCode "qlist.private"}}"><span class="tb-short">{{t $.LangCode "qlist.privateShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.private"}}</span></span>
{{ end }}
{{ if not .IsAnswered }}
<span class="uk-label qlist-pill" title="{{t $.LangCode "qlist.unanswered"}}"><span class="tb-short">{{t $.LangCode "qlist.unansweredShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.unanswered"}}</span></span>
{{ end }}
{{ if .IsRead }}
<span class="uk-label uk-label-success qlist-pill" title="{{t $.LangCode "qlist.read"}}"><span class="tb-short">{{t $.LangCode "qlist.readShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.read"}}</span></span>
{{ else }}
<span class="uk-label qlist-pill" title="{{t $.LangCode "qlist.unread"}}"><span class="tb-short">{{t $.LangCode "qlist.unreadShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.unread"}}</span></span>
{{ end }}
</div>
<!-- 右:操作按钮(margin-left:auto 推到最右,换行后仍保持右对齐) -->
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 6px; margin-left: auto;">
{{ if not .IsRead }}
<!-- 快捷标记已读:沿用原 tb-label-btn 样式,display:contents 不破坏 flex 布局 -->
<form method="post" action="/_/{{$.LoggedUser.Domain}}/{{.QuestionID}}/read" style="margin: 0; display: contents;">
{{ $.CSRFTokenHTML }}
<button type="submit" class="uk-label tb-label-btn uk-label-success qlist-pill" title="{{t $.LangCode "qitem.markRead"}}"><span class="tb-short">{{t $.LangCode "qitem.markReadShort"}}</span><span class="tb-text">{{t $.LangCode "qitem.markRead"}}</span></button>
</form>
{{ end }}
<!-- form 用 display:contents 不产生盒子,置顶按钮直接参与 flex 对齐 -->
<form method="post" action="{{.PinLink}}" style="margin: 0; display: contents;">
{{ $.CSRFTokenHTML }}
<button type="submit" class="uk-label tb-label-btn {{ if .IsPinned }}uk-label-danger{{ else }}uk-label-primary{{ end }} qlist-pill" title="{{ if .IsPinned }}{{t $.LangCode "qlist.unpin"}}{{ else }}{{t $.LangCode "qlist.pin"}}{{ end }}"><span class="tb-short">{{ if .IsPinned }}{{t $.LangCode "qlist.unpinShort"}}{{ else }}{{t $.LangCode "qlist.pinShort"}}{{ end }}</span><span class="tb-text">{{ if .IsPinned }}{{t $.LangCode "qlist.unpin"}}{{ else }}{{t $.LangCode "qlist.pin"}}{{ end }}</span></button>
</form>
</div>
</div>
<a href="{{.Link}}">
<div class="uk-text-small uk-text-break tb-preline tb-clamp-5 uk-margin-small-top">{{MarkdownPlainText .Content}}</div>
</a>
</div>
{{ end }}
{{ if .HasMoreQuestions }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .QuestionLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
{{ else }}
<p class="uk-text-muted">{{t .LangCode "qlist.empty"}}</p>
{{ end }}
</div>
<!-- 投票面板 -->
<div class="qlist-panel" data-qlist-panel="votes" hidden>
<!-- 统一提示区:发布/结束/删除等操作的成功/错误提示沿用 base/alert 样式 -->
{{template "base/alert" .}}
<!-- 投票统计看板(与提问/私聊看板一致) -->
<div class="qlist-stats qlist-stats--2">
<div class="qlist-stat"><span class="qlist-stat-num">{{ .VoteStats.total }}</span><span class="qlist-stat-lbl">{{t .LangCode "vote.statsTotal"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .VoteStats.published }}</span><span class="qlist-stat-lbl">{{t .LangCode "vote.statsPublished"}}</span></div>
</div>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom uk-flex uk-flex-between uk-flex-middle">
<div>
<div class="uk-text-bold">{{t .LangCode "qlist.myVotes"}}</div>
<div class="uk-text-meta uk-text-small">{{t .LangCode "vote.manageDesc"}}</div>
</div>
<a class="uk-button uk-button-primary uk-button-small" href="/user/votes/new">{{t .LangCode "qlist.newVote"}}</a>
</div>
{{ if .MyVotes }}
{{ range .MyVotes }}
<div class="uk-margin-medium-bottom">
<hr>
<!-- 第一行:标题 + 类型 + 状态 + 元信息 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 8px;">
<span class="uk-text-bold">{{ if .Title }}{{ .Title }}{{ else }}{{t $.LangCode "qlist.untitledVote"}}{{ end }}</span>
{{ if eq .Type "single" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
{{ if eq .Type "multiple" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
{{ if eq .Type "rating" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
{{ if eq .Type "rank" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
{{ if eq .Type "form" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
{{ if eq .EffectiveStatus "draft" }}<span class="uk-label">{{t $.LangCode "vote.statusDraft"}}</span>{{ end }}
{{ if eq .EffectiveStatus "published" }}<span class="uk-label uk-label-success">{{t $.LangCode "vote.statusPublished"}}</span>{{ end }}
{{ if eq .EffectiveStatus "not_started" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.statusNotStarted"}}</span>{{ end }}
{{ if eq .EffectiveStatus "ended" }}<span class="uk-label uk-label-warning">{{t $.LangCode "vote.statusEnded"}}</span>{{ end }}
{{ if eq .EffectiveStatus "archived" }}<span class="uk-label uk-label-secondary">{{t $.LangCode "vote.statusArchived"}}</span>{{ end }}
<span class="uk-text-meta">{{t $.LangCode "vote.participants"}}: {{ .SubmissionCount }}</span>
{{ if .RemainingText }}<span class="uk-text-meta">{{ .RemainingText }}</span>{{ end }}
<span class="uk-text-meta">{{t $.LangCode "vote.createdAt"}}: {{ .CreatedAt.Format "2006-01-02 15:04" }}</span>
</div>
<!-- 第二行:操作按钮(统一右对齐,所有状态保持一致的两行布局) -->
<div class="uk-flex uk-flex-wrap uk-margin-small-top" style="gap: 6px; justify-content: flex-end;">
{{ if eq .EffectiveStatus "draft" }}
<form method="post" action="/user/votes/{{ .ID }}/publish" class="uk-display-inline">
{{ $.CSRFTokenHTML }}
<button class="uk-button uk-button-primary uk-button-small" onclick="return confirm('{{t $.LangCode "vote.confirmPublish"}}')">{{t $.LangCode "vote.publish"}}</button>
</form>
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/preview">{{t $.LangCode "vote.preview"}}</a>
{{ end }}
{{ if or (eq .EffectiveStatus "published") (eq .EffectiveStatus "not_started") }}
<form method="post" action="/user/votes/{{ .ID }}/close" class="uk-display-inline">
{{ $.CSRFTokenHTML }}
<button class="uk-button uk-button-warning uk-button-small" onclick="return confirm('{{t $.LangCode "vote.confirmClose"}}')">{{t $.LangCode "vote.close"}}</button>
</form>
{{ end }}
{{ if ne .EffectiveStatus "archived" }}
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/edit">{{t $.LangCode "vote.edit"}}</a>
{{ end }}
{{ if ne .EffectiveStatus "draft" }}
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/results">{{t $.LangCode "vote.results"}}</a>
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .ID }}/export">{{t $.LangCode "vote.export"}}</a>
{{ end }}
{{ if or (eq .EffectiveStatus "published") (eq .EffectiveStatus "not_started") }}
<button class="uk-button uk-button-default uk-button-small" onclick="copyShareURL('{{ .LinkToken }}', this)">{{t $.LangCode "vote.share"}}</button>
{{ end }}
<form method="post" action="/user/votes/{{ .ID }}/delete" class="uk-display-inline">
{{ $.CSRFTokenHTML }}
<button class="uk-button uk-button-danger uk-button-small" onclick="return confirm('{{t $.LangCode "vote.confirmDelete"}}')">{{t $.LangCode "common.delete"}}</button>
</form>
</div>
</div>
{{ end }}
{{ if .HasMoreVotes }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .VoteLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
{{ else }}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "vote.empty"}}</div>
{{ end }}
</div>
<!-- 私聊面板 -->
<div class="qlist-panel" data-qlist-panel="chats" hidden>
<!-- 统一提示区:私聊相关操作的成功/错误提示沿用 base/alert 样式 -->
{{template "base/alert" .}}
<!-- 私聊统计看板(与提问/投票看板一致) -->
<div class="qlist-stats qlist-stats--4">
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.total }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsTotal"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.received }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsReceived"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.initiated }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsInitiated"}}</span></div>
<div class="qlist-stat"><span class="qlist-stat-num">{{ .ChatStats.closed }}</span><span class="qlist-stat-lbl">{{t .LangCode "qlist.chatStatsClosed"}}</span></div>
</div>
<!-- 我收到的私聊 -->
<h4>{{t .LangCode "qlist.receivedChats"}}({{ .ChatStats.received }})</h4>
{{ if .ReceivedChats }}
{{ range .ReceivedChats }}
<div class="uk-margin-medium-bottom">
<hr>
<div class="uk-float-right uk-flex uk-flex-middle uk-flex-wrap" style="gap: 6px;">
<span class="uk-label uk-label-success">{{t $.LangCode "chat.title"}}</span>
{{ if .IsBlocked }}
<span class="uk-label uk-label-danger">{{t $.LangCode "qlist.blocked"}}</span>
{{ end }}
</div>
<a href="{{.Link}}">
<div class="uk-text-left uk-text-small uk-text-muted">{{Date .CreatedAt "Y-m-d H:i:s"}}</div>
<div class="uk-text-small uk-text-break tb-preline tb-clamp-5 uk-margin-small-top">{{.Content}}</div>
</a>
</div>
{{ end }}
{{ else }}
<p class="uk-text-muted">{{t .LangCode "qlist.chatEmpty"}}</p>
{{ end }}
{{ if .HasMoreChats }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .ChatLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
<!-- 我发起的私聊 -->
{{ if .ConversationDetails }}
<h4 class="uk-margin-top">{{t .LangCode "qlist.myChats"}}({{ .ChatStats.initiated }})</h4>
{{ range .ConversationDetails }}
<a href="/_/{{.OwnerDomain}}/chat/{{.Conversation.Token}}" class="uk-display-block uk-overflow-hidden uk-margin-medium-bottom">
<div>
<hr>
<span class="uk-label uk-label-warning uk-float-right uk-margin-small-right">{{t $.LangCode "chat.title"}}</span>
{{ if .IsBlocked }}
<span class="uk-label uk-label-danger uk-float-right uk-margin-small-right">{{t $.LangCode "qlist.blocked"}}</span>
{{ end }}
<div class="uk-text-left uk-text-small uk-text-muted">{{Date .Conversation.CreatedAt "Y-m-d H:i:s"}}</div>
<div class="uk-text-small uk-text-break tb-preline tb-clamp-5 uk-margin-small-top">{{.FirstMessage}}</div>
</div>
</a>
{{ end }}
{{ end }}
{{ if .HasMoreAskedChats }}
<div class="uk-text-center uk-margin-medium-top">
<a class="uk-button uk-button-default uk-button-small" href="{{ .AskedChatsLoadMoreURL }}">{{t .LangCode "qlist.loadMore"}}</a>
</div>
{{ end }}
</div>
</div>
<script>
function copyShareURL(token, btn) {
var url = window.location.origin + "/vote/" + token;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function () {
var old = btn.textContent;
btn.textContent = '{{t .LangCode "vote.copied"}}';
setTimeout(function () { btn.textContent = old; }, 1500);
});
} else {
prompt('{{t .LangCode "vote.shareURL"}}', url);
}
}
document.addEventListener('DOMContentLoaded', function() {
const tabs = document.querySelectorAll('[data-qlist-tab]');
const panels = document.querySelectorAll('[data-qlist-panel]');
function showQlistPanel(name) {
tabs.forEach(function(tab) {
const active = tab.dataset.qlistTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.qlistPanel !== name;
});
if (window.history && window.history.replaceState) {
const hash = name === 'votes' ? '#votes' : (name === 'chats' ? '#chats' : '');
window.history.replaceState({}, document.title, window.location.pathname + hash);
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showQlistPanel(tab.dataset.qlistTab); });
});
if (window.location.hash === '#votes') showQlistPanel('votes');
if (window.location.hash === '#chats') showQlistPanel('chats');
});
</script>
{{template "base/footer" .}}
+392
View File
@@ -0,0 +1,392 @@
{{template "base/header" .}}
<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-bottom">
<h3 class="uk-margin-remove">
{{ if .IsEdit }}{{t .LangCode "vote.editTitle"}}{{ else }}{{t .LangCode "vote.newTitle"}}{{ end }}
</h3>
<a class="uk-button uk-button-default uk-button-small" href="/user/questions#votes">{{t .LangCode "common.back"}}</a>
</div>
<form method="post" action="{{ if .IsEdit }}/user/votes/{{ .Vote.ID }}{{ else }}/user/votes{{ end }}" onsubmit="return validateBuilderForm(event)">
{{ .CSRFTokenHTML }}
{{template "base/alert" .}}
<!-- 基本设置 -->
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.formBasic"}}</h4>
{{ if .LockedContent }}
<div class="uk-alert uk-alert-warning uk-margin-small-bottom">{{t .LangCode "vote.formLockedHint"}}</div>
{{ end }}
<div class="uk-margin-small">
<label class="uk-form-label">{{t .LangCode "vote.formTitle"}} *</label>
<input class="uk-input" type="text" name="title" maxlength="100" required value="{{ if .Vote }}{{ .Vote.Title }}{{ end }}" placeholder="{{t .LangCode "vote.formTitlePlaceholder"}}">
</div>
<div class="uk-margin-small">
<label class="uk-form-label">{{t .LangCode "vote.formDescription"}}</label>
<textarea class="uk-textarea" name="description" rows="3" maxlength="5000" placeholder="{{t .LangCode "vote.formDescriptionPlaceholder"}}">{{ if .Vote }}{{ .Vote.Description }}{{ end }}</textarea>
</div>
<div class="uk-grid-small uk-child-width-1-2@s uk-margin-small-top" uk-grid>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formType"}}</label>
{{ if .LockedContent }}
<input class="uk-input" type="text" disabled
value="{{ if eq .Vote.Type "single" }}{{t $.LangCode "vote.typeSingle"}}{{ else if eq .Vote.Type "multiple" }}{{t $.LangCode "vote.typeMultiple"}}{{ else if eq .Vote.Type "rating" }}{{t $.LangCode "vote.typeRating"}}{{ else if eq .Vote.Type "rank" }}{{t $.LangCode "vote.typeRank"}}{{ else }}{{t $.LangCode "vote.typeForm"}}{{ end }}">
{{ else }}
<select class="uk-select" name="type" id="vote-type" onchange="toggleEditor()">
<option value="single" {{ if and .Vote (eq .Vote.Type "single") }}selected{{ end }}>{{t .LangCode "vote.typeSingle"}}</option>
<option value="multiple" {{ if and .Vote (eq .Vote.Type "multiple") }}selected{{ end }}>{{t .LangCode "vote.typeMultiple"}}</option>
<option value="rating" {{ if and .Vote (eq .Vote.Type "rating") }}selected{{ end }}>{{t .LangCode "vote.typeRating"}}</option>
<option value="rank" {{ if and .Vote (eq .Vote.Type "rank") }}selected{{ end }}>{{t .LangCode "vote.typeRank"}}</option>
<option value="form" {{ if and .Vote (eq .Vote.Type "form") }}selected{{ end }}>{{t .LangCode "vote.typeForm"}}</option>
</select>
{{ end }}
</div>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formVisibility"}} *</label>
<select class="uk-select" name="visibility">
<option value="public" {{ if and .Vote (eq .Vote.Visibility "public") }}selected{{ end }}>{{t .LangCode "vote.formVisibilityPublic"}}</option>
<option value="link_only" {{ if and .Vote (eq .Vote.Visibility "link_only") }}selected{{ end }}>{{t .LangCode "vote.formVisibilityLinkOnly"}}</option>
</select>
</div>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formVoterScope"}} *</label>
<select class="uk-select" name="voter_scope">
<option value="anyone" {{ if and .Vote (eq .Vote.VoterScope "anyone") }}selected{{ end }}>{{t .LangCode "vote.formVoterScopeAnyone"}}</option>
<option value="registered_only" {{ if and .Vote (eq .Vote.VoterScope "registered_only") }}selected{{ end }}>{{t .LangCode "vote.formVoterScopeRegistered"}}</option>
</select>
</div>
<div>
<label class="uk-form-label">{{t .LangCode "vote.formShowResult"}} *</label>
<select class="uk-select" name="show_result">
<option value="after_vote" {{ if and .Vote (eq .Vote.ShowResult "after_vote") }}selected{{ end }}>{{t .LangCode "vote.formShowResultAfterVote"}}</option>
<option value="after_end" {{ if and .Vote (eq .Vote.ShowResult "after_end") }}selected{{ end }}>{{t .LangCode "vote.formShowResultAfterEnd"}}</option>
<option value="owner_only" {{ if and .Vote (eq .Vote.ShowResult "owner_only") }}selected{{ end }}>{{t .LangCode "vote.formShowResultOwnerOnly"}}</option>
</select>
</div>
<div class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formStartAt"}}</label>
<input class="uk-input" type="datetime-local" name="start_at" value="{{ .StartAtStr }}">
</div>
<div class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formEndAt"}}</label>
<input class="uk-input" type="datetime-local" name="end_at" value="{{ .EndAtStr }}">
</div>
<div class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formQuotaLimit"}}</label>
<input class="uk-input" type="number" name="quota_limit" min="0" max="1000000" value="{{ if .Vote }}{{ .Vote.QuotaLimit }}{{ else }}0{{ end }}">
<span class="uk-text-meta uk-text-small">{{t .LangCode "vote.formQuotaHint"}}</span>
</div>
{{ if not .LockedContent }}
<div id="max-choices-field" class="uk-margin-small-top">
<label class="uk-form-label">{{t .LangCode "vote.formMaxChoices"}}</label>
<input class="uk-input" type="number" name="max_choices" min="1" max="20" value="{{ if .Vote }}{{ .Vote.MaxChoices }}{{ else }}3{{ end }}">
</div>
{{ end }}
</div>
<!-- 限投提醒:移出两列网格,桌面/移动均占满整行,避免与输入框并排显得局促 -->
<!-- registered_only 时限制维度变为"每账号",否则为"每设备",文案随之切换 -->
<div class="uk-margin-small-top">
<div id="scope-hint" class="uk-alert uk-alert-primary uk-margin-remove-bottom" role="status">
<span id="scope-hint-device"{{ if and .Vote (eq .Vote.VoterScope "registered_only") }} hidden{{ end }}>{{t .LangCode "vote.formMaxVotesPerFingerprint"}}</span><span id="scope-hint-account"{{ if and .Vote (eq .Vote.VoterScope "registered_only") }}{{ else }} hidden{{ end }}>{{t .LangCode "vote.formMaxVotesPerAccount"}}</span>
</div>
</div>
<div class="uk-margin-small-top uk-grid-small uk-child-width-1-2@s" uk-grid>
<!-- 全局"允许补充自定义选项"仅对单选/多选/排序生效;表单收集与评分类型由各自机制控制,此处隐藏避免误导 -->
<label id="global-custom-field" class="uk-form-label" {{ if and .Vote (or (eq .Vote.Type "form") (eq .Vote.Type "rating")) }}hidden{{ end }}><input class="uk-checkbox" type="checkbox" name="allow_custom" {{ if and .Vote .Vote.AllowCustom }}checked{{ end }}> {{t .LangCode "vote.formAllowCustom"}}</label>
<label class="uk-form-label"><input class="uk-checkbox" type="checkbox" name="require_captcha" checked disabled> {{t .LangCode "vote.formRequireCaptcha"}}</label>
</div>
</div>
{{ if not .LockedContent }}
<!-- 选项编辑器(单选/多选/排序;评分题无需选项) -->
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom" id="options-editor">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.formOptions"}}</h4>
<div id="rating-hint" class="uk-alert uk-alert-primary uk-margin-small-bottom" hidden>{{t .LangCode "vote.formRatingHint"}}</div>
<div id="options-list"></div>
<button type="button" class="uk-button uk-button-default uk-button-small" onclick="addOption()">{{t .LangCode "vote.addOption"}}</button>
<input type="hidden" name="options" id="options-payload">
</div>
<!-- 表单字段设计器(表单收集类型) -->
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom" id="fields-editor" hidden>
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.formFields"}}</h4>
<div id="fields-list"></div>
<button type="button" class="uk-button uk-button-default uk-button-small" onclick="addField()">{{t .LangCode "vote.addField"}}</button>
<input type="hidden" name="form_fields" id="fields-payload">
</div>
{{ end }}
<div class="uk-margin">
<input type="hidden" name="action" id="vote-action" value="save">
{{ if and .IsEdit (not .LockedContent) }}<button class="uk-button uk-button-default" type="submit" onclick="setVoteAction('preview')">{{t .LangCode "vote.preview"}}</button>{{ end }}
<button class="uk-button uk-button-primary" type="submit" onclick="setVoteAction('save')">{{ if .IsEdit }}{{t .LangCode "common.save"}}{{ else }}{{t .LangCode "vote.createDraft"}}{{ end }}</button>
{{ if not .LockedContent }}<button class="uk-button uk-button-secondary" type="submit" onclick="setVoteAction('publish')">{{t .LangCode "vote.publish"}}</button>{{ end }}
<a class="uk-button uk-button-default" href="/user/questions#votes">{{t .LangCode "common.cancel"}}</a>
</div>
<div id="builder-hint" class="uk-alert uk-alert-danger uk-margin-small-top" hidden></div>
</form>
<script>
var INITIAL_OPTIONS = {{ toJSON .InitialOptions }};
var INITIAL_FIELDS = {{ toJSON .InitialFields }};
// 站长侧单个选项文本上限,与后端 conf.Limits.VoteOptionLen([limits] 配置段)同源。
// 参与者的自定义补充文本上限是另一套 MAX_CUSTOM_LEN(conf.Limits.CustomAnswerLen),二者独立。
var MAX_OPTION_LEN = {{ .MaxVoteOptionLen }};
var optionsData = [];
var fieldsData = [];
function toggleEditor() {
var t = document.getElementById('vote-type').value;
var optEditor = document.getElementById('options-editor');
var fldEditor = document.getElementById('fields-editor');
var maxField = document.getElementById('max-choices-field');
var ratingHint = document.getElementById('rating-hint');
var globalCustom = document.getElementById('global-custom-field');
optEditor.hidden = (t === 'form');
fldEditor.hidden = (t !== 'form');
maxField.hidden = (t !== 'multiple');
ratingHint.hidden = (t !== 'rating');
if (globalCustom) { globalCustom.hidden = (t === 'form' || t === 'rating'); }
updateBuilderHint();
}
function addOption() { optionsData.push(''); renderOptions(); updateBuilderHint(); }
function removeOption(i) { optionsData.splice(i, 1); renderOptions(); updateBuilderHint(); }
function renderOptions() {
var list = document.getElementById('options-list');
list.innerHTML = '';
optionsData.forEach(function (val, i) {
var row = document.createElement('div');
row.className = 'uk-flex uk-flex-middle uk-margin-small-bottom';
row.style.gap = '6px';
row.innerHTML = '<span class="uk-text-meta">' + (i + 1) + '.</span>' +
'<input class="uk-input" style="flex:1" type="text" maxlength="' + MAX_OPTION_LEN + '" placeholder="{{t .LangCode "vote.optionPlaceholder"}}" value="' + escapeAttr(val) + '" oninput="optionsData[' + i + ']=this.value">' +
'<button type="button" class="uk-button uk-button-danger uk-button-small" onclick="removeOption(' + i + ')">{{t .LangCode "common.delete"}}</button>';
list.appendChild(row);
});
}
function addField() {
fieldsData.push({ field_type: 'radio', label: '', required: false, max_length: 0, optionsArr: [], allow_custom: false });
renderFields();
updateBuilderHint();
}
function removeField(i) { fieldsData.splice(i, 1); renderFields(); updateBuilderHint(); }
// 字段上下移:交换数组元素后重渲染;保存时 payload 顺序即新顺序(后端按数组下标写 SortOrder)
function moveField(i, dir) {
var j = i + dir;
if (j < 0 || j >= fieldsData.length) { return; }
var tmp = fieldsData[i]; fieldsData[i] = fieldsData[j]; fieldsData[j] = tmp;
renderFields();
updateBuilderHint();
}
function renderFields() {
var list = document.getElementById('fields-list');
list.innerHTML = '';
fieldsData.forEach(function (f, i) {
var box = document.createElement('div');
box.className = 'uk-card uk-card-default uk-card-small uk-card-body uk-margin-small-bottom';
box.innerHTML =
'<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-small-bottom">' +
'<span class="uk-text-bold">{{t .LangCode "vote.field"}} ' + (i + 1) + '</span>' +
'<div class="uk-flex uk-flex-middle" style="gap:6px">' +
'<button type="button" class="uk-button uk-button-default uk-button-small"' + (i === 0 ? ' disabled' : '') + ' onclick="moveField(' + i + ',-1)" title="{{t .LangCode "common.moveUp"}}">↑</button>' +
'<button type="button" class="uk-button uk-button-default uk-button-small"' + (i === fieldsData.length - 1 ? ' disabled' : '') + ' onclick="moveField(' + i + ',1)" title="{{t .LangCode "common.moveDown"}}">↓</button>' +
'<button type="button" class="uk-button uk-button-danger uk-button-small" onclick="removeField(' + i + ')">{{t .LangCode "common.delete"}}</button>' +
'</div>' +
'</div>' +
'<div class="uk-grid-small uk-child-width-1-2@s" uk-grid>' +
'<div><label class="uk-form-label">{{t .LangCode "vote.formFieldType"}}</label>' +
'<select class="uk-select" onchange="fieldsData[' + i + '].field_type=this.value;renderFields()">' +
'<option value="radio"' + (f.field_type === 'radio' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeRadio"}}</option>' +
'<option value="text"' + (f.field_type === 'text' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeText"}}</option>' +
'<option value="textarea"' + (f.field_type === 'textarea' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeTextarea"}}</option>' +
'<option value="select"' + (f.field_type === 'select' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeSelect"}}</option>' +
'<option value="checkbox"' + (f.field_type === 'checkbox' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeCheckbox"}}</option>' +
'<option value="number"' + (f.field_type === 'number' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeNumber"}}</option>' +
'<option value="rating"' + (f.field_type === 'rating' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeRating"}}</option>' +
'<option value="rank"' + (f.field_type === 'rank' ? ' selected' : '') + '>{{t .LangCode "vote.fieldTypeRank"}}</option>' +
'</select></div>' +
'<div><label class="uk-form-label">{{t .LangCode "vote.formFieldLabel"}}</label>' +
'<input class="uk-input" type="text" maxlength="100" value="' + escapeAttr(f.label) + '" oninput="fieldsData[' + i + '].label=this.value"></div>' +
'</div>' +
'<div class="uk-grid-small uk-child-width-1-3@s uk-margin-small-top" uk-grid>' +
'<label class="uk-form-label"><input class="uk-checkbox" type="checkbox"' + (f.required ? ' checked' : '') + ' onchange="fieldsData[' + i + '].required=this.checked"> {{t .LangCode "vote.formFieldRequired"}}</label>' +
'<div id="maxlen-' + i + '"' + (isTextType(f.field_type) ? '' : ' style="display:none"') + '><label class="uk-form-label">{{t .LangCode "vote.formFieldMaxLength"}}</label>' +
'<input class="uk-input" type="number" min="0" max="2000" value="' + (f.max_length || 0) + '" oninput="fieldsData[' + i + '].max_length=parseInt(this.value||0)"></div>' +
'</div>' +
'<div class="uk-margin-small-top" id="opts-' + i + '"' + (isChoiceType(f.field_type) ? '' : ' hidden') + '>' +
'<label class="uk-form-label">{{t .LangCode "vote.formFieldOptions"}}</label>' +
'<div class="uk-text-meta uk-margin-small-top">{{t .LangCode "vote.formFieldOptionsHint"}}</div>' +
'<div id="fopts-' + i + '" class="uk-margin-small-top"></div>' +
'<button type="button" class="uk-button uk-button-default uk-button-small" onclick="addFieldOption(' + i + ')">{{t .LangCode "vote.addOption"}}</button>' +
'</div>' +
'<div class="uk-margin-small-top" id="custom-' + i + '"' + (isCustomType(f.field_type) ? '' : ' hidden') + '>' +
'<label class="uk-form-label"><input class="uk-checkbox" type="checkbox"' + (f.allow_custom ? ' checked' : '') + ' onchange="fieldsData[' + i + '].allow_custom=this.checked"> {{t .LangCode "vote.fieldAllowCustom"}}</label>' +
'</div>';
list.appendChild(box);
if (isChoiceType(f.field_type)) { renderFieldOptions(i); }
});
if (typeof UIkit !== 'undefined') { UIkit.update(list); }
}
function isChoiceType(t) { return t === 'select' || t === 'radio' || t === 'checkbox' || t === 'rank'; }
// 支持"补充自定义选项"的字段类型(rank 排序题不开放)
function isCustomType(t) { return t === 'select' || t === 'radio' || t === 'checkbox'; }
function isTextType(t) { return t === 'text' || t === 'textarea'; }
// 字段选项兼容处理:后端可能返回数组(新)或逗号字符串(旧数据/回填),统一为数组。
function normalizeFieldOptions(v) {
if (Array.isArray(v)) return v.slice();
if (typeof v === 'string' && v !== '') return v.split(',').map(function (x) { return x.trim(); }).filter(function (x) { return x !== ''; });
return [];
}
// 渲染单个字段的选项行(逐条 add/remove),避免整段文本框 + 逗号分隔。
function renderFieldOptions(i) {
var box = document.getElementById('fopts-' + i);
if (!box) return;
box.innerHTML = '';
var arr = fieldsData[i].optionsArr || [];
arr.forEach(function (val, j) {
var row = document.createElement('div');
row.className = 'uk-flex uk-flex-middle uk-margin-small-bottom';
row.style.gap = '6px';
row.innerHTML = '<span class="uk-text-meta">' + (j + 1) + '.</span>' +
'<input class="uk-input" style="flex:1" type="text" maxlength="' + MAX_OPTION_LEN + '" placeholder="{{t .LangCode "vote.optionPlaceholder"}}" value="' + escapeAttr(val) + '" oninput="fieldsData[' + i + '].optionsArr[' + j + ']=this.value">' +
'<button type="button" class="uk-button uk-button-danger uk-button-small" onclick="removeFieldOption(' + i + ',' + j + ')">{{t .LangCode "common.delete"}}</button>';
box.appendChild(row);
});
}
function addFieldOption(i) { (fieldsData[i].optionsArr = fieldsData[i].optionsArr || []).push(''); renderFieldOptions(i); }
function removeFieldOption(i, j) { fieldsData[i].optionsArr.splice(j, 1); renderFieldOptions(i); }
// 设置提交动作:save=保存草稿(宽松),publish=发布(一次性完整校验)。
function setVoteAction(a) { document.getElementById('vote-action').value = a; }
// 前端即时校验:根据题型/字段返回需提示给用户的信息;空字符串表示通过。
// 锁定模式(已发布投票)下无选项/字段编辑器,直接返回通过。
function computeBuilderHint() {
if (!document.getElementById('options-payload') && !document.getElementById('fields-payload')) {
return '';
}
var t = document.getElementById('vote-type').value;
if (t === 'single' || t === 'multiple' || t === 'rank') {
var cnt = 0;
for (var i = 0; i < optionsData.length; i++) {
if (optionsData[i].trim() !== '') cnt++;
}
if (cnt < 2) return '{{t .LangCode "vote.builderHintOptions"}}';
} else if (t === 'form') {
if (fieldsData.length < 1) return '{{t .LangCode "vote.builderHintFields"}}';
for (var i = 0; i < fieldsData.length; i++) {
var f = fieldsData[i];
if (isChoiceType(f.field_type)) {
var opts = (f.optionsArr || []).map(function (x) { return x.trim(); }).filter(function (x) { return x !== ''; });
if (opts.length < (f.field_type === 'rank' ? 2 : 1)) {
return '{{t .LangCode "vote.builderHintFieldOptions"}}';
}
}
}
}
return '';
}
// 实时更新提示条(切换题型/增删选项或字段时调用)
function updateBuilderHint() {
var m = computeBuilderHint();
var el = document.getElementById('builder-hint');
if (m) { el.textContent = m; el.hidden = false; } else { el.hidden = true; }
}
// 提交时执行:先收集 payload,再按当前动作做校验。
// 发布动作下校验不通过则阻止提交;草稿动作仅提示,不阻止(草稿本就允许暂存)。
function validateBuilderForm(event) {
buildPayload();
var m = computeBuilderHint();
var el = document.getElementById('builder-hint');
if (m) {
el.textContent = m; el.hidden = false;
if (document.getElementById('vote-action').value === 'publish') {
resetSubmitState();
var form = event && event.target ? event.target : document.querySelector('form');
if (form) form.setAttribute('data-tb-skip-submit-feedback', '1');
if (event) event.preventDefault();
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
} else {
el.hidden = true;
}
return true;
}
function buildPayload() {
// 发布后锁定模式:无选项/字段编辑器,跳过 JSON 收集
var optPayload = document.getElementById('options-payload');
if (optPayload) {
var options = optionsData.map(function (v) { return v.trim(); }).filter(function (v) { return v !== ''; });
optPayload.value = JSON.stringify(options);
}
var fldPayload = document.getElementById('fields-payload');
if (fldPayload) {
var fields = fieldsData.map(function (f) {
return { field_type: f.field_type, label: f.label, required: f.required, max_length: f.max_length || 0, options: (f.optionsArr || []), allow_custom: !!f.allow_custom };
});
fldPayload.value = JSON.stringify(fields);
}
return true;
}
function escapeAttr(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 限投维度提示:仅注册用户投票时切换为"每账号",否则"每设备"。
// 与提交逻辑一致——registered_only 下指纹维度不参与,约束实为每账号一票。
function updateScopeHint() {
var sel = document.querySelector('select[name="voter_scope"]');
var dev = document.getElementById('scope-hint-device');
var acc = document.getElementById('scope-hint-account');
if (!sel || !dev || !acc) return;
var isReg = sel.value === 'registered_only';
dev.hidden = isReg;
acc.hidden = !isReg;
}
(function init() {
// 发布后锁定模式:编辑器未渲染,跳过初始化
{{ if .LockedContent }}return;{{ end }}
optionsData = INITIAL_OPTIONS.slice();
fieldsData = INITIAL_FIELDS.map(function (f) { return { field_type: f.field_type || 'text', label: f.label || '', required: !!f.required, max_length: f.max_length || 0, optionsArr: normalizeFieldOptions(f.options), allow_custom: !!f.allow_custom }; });
renderOptions();
renderFields();
toggleEditor();
updateBuilderHint();
var scopeSel = document.querySelector('select[name="voter_scope"]');
if (scopeSel) { scopeSel.addEventListener('change', updateScopeHint); }
updateScopeHint();
})();
</script>
{{if .DraftSaved}}
<script>
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname);
}
</script>
{{end}}
{{template "base/footer" .}}
+182
View File
@@ -0,0 +1,182 @@
{{template "base/header" .}}
<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-bottom">
<h3 class="uk-margin-remove">{{t .LangCode "vote.resultsTitle"}}:{{ .Vote.Title }}</h3>
<div class="uk-flex uk-flex-middle" style="gap: 8px;">
<a class="uk-button uk-button-default uk-button-small" href="/user/votes/{{ .Vote.ID }}/export">{{t .LangCode "vote.export"}}</a>
<a class="uk-button uk-button-default uk-button-small" href="/user/questions#votes">{{t .LangCode "common.back"}}</a>
</div>
</div>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom">
<div class="uk-text-large uk-text-bold">{{ .Total }}</div>
<div class="uk-text-meta">{{t .LangCode "vote.resultsTotal"}}</div>
</div>
{{ if eq .Vote.Type "form" }}
<!-- 表单收集(混合问卷)字段级聚合统计 -->
{{ if .Results.FormFieldStats }}
<h4>{{t .LangCode "vote.resultsOptions"}}</h4>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-bottom">
{{ range $si, $stat := .Results.FormFieldStats }}
<div class="uk-margin">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 8px;">
<span class="uk-label">{{ add $si 1 }}</span>
<span class="uk-label {{ fieldTypeLabelClass $stat.Field.FieldType }}">{{ fieldTypeLabel $.LangCode $stat.Field.FieldType }}</span>
<span class="uk-text-bold">{{ $stat.Field.Label }}</span>
<span class="uk-text-meta uk-text-small">{{t $.LangCode "vote.resultsAnswered"}}: {{ $stat.Total }}</span>
</div>
{{ if or (eq $stat.Field.FieldType "radio") (eq $stat.Field.FieldType "select") (eq $stat.Field.FieldType "checkbox") }}
{{ $denom := $stat.Total }}
{{ if eq $stat.Field.FieldType "checkbox" }}{{ $denom = $stat.TotalSelections }}{{ end }}
{{ range $o := $stat.OptionStats }}
<div class="uk-margin-small-top">
<div class="uk-flex uk-flex-between">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{ $o.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $denom 0 }}0{{ else }}{{ percentOf $o.Count $denom }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ if $stat.CustomStats }}
<div class="uk-margin-small-top">
<div class="uk-text-meta">{{t $.LangCode "vote.page.customStats"}}:</div>
{{ range $c := $stat.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $c.Text }}</span>
<span class="uk-text-meta">{{ $c.Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rating" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRating"}}: {{ printf "%.1f" $stat.AvgRating }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $r := $stat.RatingCounts }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $r.Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ $r.Count }} {{t $.LangCode "vote.resultsVotes"}}({{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $stat.Total 0 }}0{{ else }}{{ percentOf $r.Count $stat.Total }}{{ end }}%;"></div>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "rank" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgRank"}}: {{ printf "%.2f" $stat.AvgRank }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ range $o := $stat.OptionStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ $o.Option.Content }}</span>
<span class="uk-text-meta">{{t $.LangCode "vote.page.avgRank"}}: {{ if gt $o.Count 0 }}{{ printf "%.2f" $o.AvgRank }}{{ else }}-{{ end }} · {{t $.LangCode "vote.page.rankedTimes"}}: {{ $o.Count }}</span>
</div>
{{ end }}
{{ else if eq $stat.Field.FieldType "number" }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.avgNumber"}}: {{ printf "%.1f" $stat.AvgNumber }}({{ $stat.Total }} {{t $.LangCode "vote.resultsVotes"}})</div>
{{ else }}
<div class="uk-text-meta uk-margin-small-top">{{t $.LangCode "vote.page.fieldFilled"}}: {{ $stat.TextCount }}</div>
{{ $replies := index $.TextReplies $stat.Field.Label }}
{{ if $replies }}
<div class="uk-margin-small-top">
<div class="uk-text-small" style="cursor:pointer; user-select:none; color:#1e87f0;" onclick="toggleFieldReplies('{{ $stat.Field.ID }}')" title="{{t $.LangCode "vote.resultsToggleHint"}}">
<span id="caret-{{ $stat.Field.ID }}" style="display:inline-block; transition:transform .15s; transform:rotate(-90deg);">▾</span>
{{t $.LangCode "vote.resultsViewTexts"}}({{ len $replies }})
</div>
<div id="replies-{{ $stat.Field.ID }}" hidden>
<ul class="uk-list uk-list-divider uk-margin-small-top">
{{ range $r := $replies }}
<li>
<div class="uk-text-meta uk-text-small">{{ $r.CreatedAt.Format "2006-01-02 15:04" }}</div>
<div style="white-space:pre-wrap;">{{ $r.Text }}</div>
</li>
{{ end }}
</ul>
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
<!-- 文本类字段的逐条提交内容已内联在各题目下方(默认收起),此处无需额外区块 -->
{{ else if .Results }}
<!-- 选项 / 评分 / 排序统计 -->
<h4>{{t .LangCode "vote.resultsOptions"}}</h4>
<div class="uk-card uk-card-default uk-card-small uk-card-body">
{{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}
{{ range .Results.Options }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Option.Content }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}({{ .Percent }}%)</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ .Percent }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rating" }}
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.avgRating"}}: {{ printf "%.1f" .Results.AvgRating }}</div>
{{ range .Results.Ratings }}
<div class="uk-margin">
<div class="uk-flex uk-flex-between">
<span>{{ .Score }} {{t $.LangCode "vote.page.ratingUnit"}}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
<div class="uk-progress uk-margin-small-top">
<div class="uk-progressbar" style="width: {{ if eq $.Total 0 }}0{{ else }}{{ percentOf .Count $.Total }}{{ end }}%;"></div>
</div>
</div>
{{ end }}
{{ else if eq .Results.Type "rank" }}
<table class="uk-table uk-table-small uk-table-divider">
<thead><tr><th>{{t .LangCode "vote.page.optionColumn"}}</th><th>{{t .LangCode "vote.page.avgRank"}}</th></tr></thead>
<tbody>
{{ range .Results.Rankings }}
<tr><td>{{ .Option.Content }}</td><td>{{ if gt .AvgRank 0.0 }}{{ printf "%.2f" .AvgRank }}{{ else }}-{{ end }}</td></tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ if .Results.CustomStats }}
<div class="uk-margin-top">
<div class="uk-text-bold">{{t .LangCode "vote.page.customStats"}}</div>
{{ range .Results.CustomStats }}
<div class="uk-flex uk-flex-between uk-margin-small-top">
<span>{{ .Text }}</span>
<span class="uk-text-meta">{{ .Count }} {{t $.LangCode "vote.resultsVotes"}}</span>
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="uk-alert uk-alert-primary">{{t .LangCode "vote.resultsEmpty"}}</div>
{{ end }}
<script>
// 文本类字段:点击"查看文本提交(N)"按该字段 ID 展开/收起逐条内容
function toggleFieldReplies(id) {
var box = document.getElementById('replies-' + id);
var caret = document.getElementById('caret-' + id);
if (!box) { return; }
if (box.hasAttribute('hidden')) {
box.removeAttribute('hidden');
if (caret) { caret.style.transform = 'rotate(0deg)'; }
} else {
box.setAttribute('hidden', '');
if (caret) { caret.style.transform = 'rotate(-90deg)'; }
}
}
</script>
{{template "base/footer" .}}