Files
tamabox/templates/user/explore.html
T
tamakyi 03cb5ddcf0 fix(ui): 提交防重复覆盖验证码路径、waline 按页加载、懒加载、safe-area、硬编码文案接字典
1) 防重复点击原来漏了最主要的一条路径
   footer 的全局 submit 监听会禁用按钮,但**程序化 form.submit() 不触发 submit 事件** ——
   走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,而默认配置
   captcha_type = recaptcha,也就是主提问流一直没有防重复。登录、注册、找回密码、
   投票、注销账号同样走这个回调。
   → 把「切提交中状态」抽成 tbBeginSubmit(form, trigger) 挂到 window,
     验证码回调先调它再 submit。7 个 data-callback="onSubmit" 的模板全部受益。
     顺带加了 data-tb-restore 的重复覆盖保护,避免 bfcache 复位后按钮卡在转圈。

2) waline.js / waline.css 从全站改按页加载
   只有 home.html 与 item.html 用评论区,但此前挂在 header 里 —— 登录、后台、投票、
   私聊每页都下载解析 waline.js(2.15 未压缩约 200KB)。实测 /login 已无引用。

3) 图片懒加载:explore 列表头像与题目配图加 loading="lazy"(首屏推荐位不加,避免影响 LCP)。

4) 移动端底栏:body 只预留固定 56px,而底栏 padding 含 safe-area-inset-bottom。
   带 Home Indicator 的 iPhone 上 inset 约 34px、底栏实际约 88px,最后约 32px
   (页脚备案、存活计时)被压在栏下,滚到底也看不到。改成 calc(56px + env(...))。

5) 硬编码中文接字典(en/ja 用户此前直接看到中文):
   history-template 的「查看回答 / 加载更多 / 加载中... / 无更多留言」、
   单题页「@xxx 以前回答过的问题」(字典里本来就有 ask.history)、
   投票页「(最多 N 项)」改为带参数的整句(原先前半走字典、后半手写中文)。
   新增 4 个 key × 4 语言。

实测:箱主语言设为 en 后箱主页显示 View answer ×2 / Load more / No more posts,中文零残留。
2026-10-29 16:28:53 +08:00

621 lines
21 KiB
HTML

{{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: var(--tb-fs-lg);
font-weight: bold;
margin-bottom: 5px;
}
.explore-name a {
text-decoration: none;
color: var(--tb-text);
}
.explore-name a:hover {
color: var(--tb-primary);
}
.explore-intro {
color: var(--tb-text-secondary);
margin-bottom: 10px;
font-size: var(--tb-fs-md);
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: var(--tb-surface-2);
border-radius: var(--tb-radius-sm);
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: var(--tb-fs-lg);
}
.recommend-name a {
text-decoration: none;
color: var(--tb-text);
}
.recommend-tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.recommend-tags .uk-label {
font-size: var(--tb-fs-xs);
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 var(--tb-border);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface-2);
text-decoration: none;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.hot-vote-item:hover {
border-color: var(--tb-primary);
background: #f5faff;
text-decoration: none;
transform: translateY(-1px);
}
.hot-vote-content {
min-width: 0;
}
.hot-vote-title {
display: block;
overflow: hidden;
color: var(--tb-text);
font-size: var(--tb-fs-md);
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: var(--tb-fs-xs);
line-height: 1.6;
}
.hot-vote-action {
flex: 0 0 auto;
color: var(--tb-primary);
font-size: var(--tb-fs-md);
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 var(--tb-border);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface-2);
}
.explore-tab {
border: 0;
border-radius: var(--tb-radius-xs);
padding: 8px 15px;
background: transparent;
color: var(--tb-text-secondary);
cursor: pointer;
font-size: var(--tb-fs-md);
transition: background 0.2s ease, color 0.2s ease;
}
.explore-tab:hover { color: var(--tb-primary); }
.explore-tab.is-active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: var(--tb-primary);
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: var(--tb-radius-sm);
padding: 7px 11px;
color: var(--tb-primary);
background: #f5faff;
cursor: pointer;
font-size: var(--tb-fs-xs);
}
.vote-random-btn:hover { border-color: var(--tb-primary); 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 var(--tb-border);
text-decoration: none;
}
.all-vote-item:first-child { border-top: 1px solid var(--tb-border); }
.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 var(--tb-primary);
padding: 15px 20px;
margin-bottom: 20px;
border-radius: var(--tb-radius-xs);
color: var(--tb-text);
font-size: var(--tb-fs-md);
line-height: 1.6;
}
.explore-notice strong {
color: var(--tb-primary);
}
/* 极简「换一批」按钮:描边胶囊 + 悬停浅蓝底色 + 点击旋转反馈 */
.tb-shuffle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
font-size: var(--tb-fs-md);
line-height: 1.4;
color: var(--tb-primary);
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: var(--tb-primary);
}
.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="tb-chip" href="/explore?q={{urlquery $tag}}">#{{$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" loading="lazy">
</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="tb-chip">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="tb-chip">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="tb-chip">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="tb-chip">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="tb-chip">{{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="tb-chip">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="tb-chip">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="tb-chip">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="tb-chip">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="tb-chip">{{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 var(--tb-primary)';
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" loading="lazy">
<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" .}}