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