一、错误页
原来未知路径一律 302 到首页:用户点了个失效链接,地址栏莫名跳到首页却什么
提示都没有,分不清是链接坏了还是自己点错了;对搜索引擎是软 404,失效链接会
被当成有效页面一直占抓取预算。500 则是 flamego.Recovery() 的纯文本
"Internal Server Error",白底黑字两行英文,没有返回入口,也没法报编号。
现在是 internal/route/errorpage.go 里的 writeErrorPage:
- 404 保留状态码 + 一页能看懂的说明 + 返回首页按钮 + noindex
- panic 由自写的 recoveryMiddleware 兜住,渲染同样的 HTML 500 页,
并带上 trace id(用户反馈时报这个号就能定位日志),日志里也带完整栈
- 响应已写出时不再写头(否则 net/http 会忽略状态码并打 superfluous 警告)
为什么不套模板:f.NotFound 不经过组级中间件 —— template.Templater 与
Contexter(连同 session、CSRF)都挂在 f.Group 上,在 NotFound 里注入
template.Data 会直接 panic(实测:"value not found for type template.Data")。
而 recovery 是最外层,那时模板还没装配。错误页恰恰最不该依赖可能出错的链路,
所以用内联 HTML + i18n.T,语言从 Accept-Language 猜(拿不到 session)。
蜜罐分流仍在 NotFound 最前面:临时打开 enable_honeypot 实测 /wp-admin
仍返回假 WordPress 登录页(200),普通未知路径返回 404。
二、提问者找回自己的提问
不填邮箱时,唯一能追踪这条提问的东西是提问成功页上那条链接 —— 关掉页面、
链接冲掉了就永久失联。箱主认真答了,提问者永远不知道。这是这类产品最核心的
体验断点,而它不需要账号体系就能解决:
- 成功页把 {domain, id, token} 写进本机 localStorage(最多 30 条,按 domain+id 去重)
- 再访该箱时显示一条不打扰的横条:「这台设备上有 N 条提问记录,其中 M 条已有回答」,
点「查看」展开列表,每条可直接打开
- 新增 POST /api/v1/ask-status 批量回答「哪些已被回答」:一次 IN 查询,不逐条 N+1
它不能用来枚举:必须同时给出正确 token 才返回 ok=true,而「token 错」「id 不存在」
「不属于该箱主」三种情况的响应完全相同(ok=false / answered=false / excerpt 为空),
无法区分。token 比较走常数时间,单次最多 20 条,限流 30/分钟。用 POST 不用 GET,
因为 token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。
列表摘要先过 MarkdownPlainText 再按 rune 截断:直接截原文会露出 
这种噪音,一条带图的提问几乎只剩链接。
验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
541 lines
24 KiB
HTML
541 lines
24 KiB
HTML
{{template "base/header" .}}
|
||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目(R2 对无 Origin 请求的响应无 Vary,可缓存 31 天) -->
|
||
<link rel="shortcut icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||
<link rel="apple-touch-icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||
<link rel="icon" sizes="192x192" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||
<style>
|
||
/* 封面高度(原内联 style,改到类里便于和遮罩一起维护) */
|
||
.tb-profile-cover { min-height: 260px; }
|
||
/* 投票列表:与探索页"全部投票"一致的列表行样式 */
|
||
.profile-votes-list { display: grid; gap: 0; }
|
||
.profile-vote-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
min-width: 0;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--tb-border);
|
||
text-decoration: none;
|
||
}
|
||
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border); }
|
||
.profile-vote-item:hover { background: var(--tb-surface-hover); text-decoration: none; }
|
||
.profile-vote-content { min-width: 0; }
|
||
.profile-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;
|
||
}
|
||
.profile-vote-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-top: 8px;
|
||
}
|
||
.profile-vote-meta .uk-label { font-size: var(--tb-fs-xs); line-height: 1.6; }
|
||
.profile-vote-action {
|
||
flex: 0 0 auto;
|
||
color: var(--tb-primary);
|
||
font-size: var(--tb-fs-md);
|
||
white-space: nowrap;
|
||
}
|
||
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
|
||
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
|
||
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
|
||
/* 个人主页 Tab:与探索页一致的胶囊切换样式 */
|
||
/* 个人主页 Tab:全宽等分,置于主体顶部,作为一级导航(移动端整行可点) */
|
||
.profile-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 4px;
|
||
margin-top: 20px;
|
||
margin-bottom: 18px;
|
||
border: 1px solid var(--tb-border);
|
||
border-radius: var(--tb-radius-sm);
|
||
background: var(--tb-surface-2);
|
||
}
|
||
.profile-tab {
|
||
flex: 1;
|
||
border: 0;
|
||
border-radius: var(--tb-radius-xs);
|
||
padding: 9px 15px;
|
||
background: transparent;
|
||
color: var(--tb-text-muted);
|
||
cursor: pointer;
|
||
font-size: var(--tb-fs-md);
|
||
transition: background 0.2s ease, color 0.2s ease;
|
||
}
|
||
.profile-tab:hover { color: var(--tb-primary); }
|
||
.profile-tab.is-active {
|
||
background: var(--tb-surface);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
|
||
color: var(--tb-primary);
|
||
font-weight: 600;
|
||
}
|
||
/* 功能关闭时置灰禁用的 Tab(如投票功能关闭) */
|
||
.profile-tab.is-disabled,
|
||
.profile-tab:disabled {
|
||
cursor: not-allowed;
|
||
color: var(--tb-text-muted);
|
||
opacity: 0.7;
|
||
}
|
||
.profile-tab.is-disabled:hover { color: var(--tb-text-muted); }
|
||
.profile-panel[hidden] { display: none !important; }
|
||
html[data-theme="dark"] .profile-tabs {
|
||
border-color: var(--tb-border);
|
||
background: var(--tb-surface-2);
|
||
}
|
||
|
||
/* 提问历史提示条:仅在本机存有该箱提问记录时出现,不占固定位置、不遮挡内容 */
|
||
.tb-ask-history {
|
||
margin-bottom: 18px;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--tb-border);
|
||
border-radius: 10px;
|
||
background: var(--tb-surface);
|
||
}
|
||
.tb-ask-history-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.tb-ask-history-text { font-size: var(--tb-fs-md); color: var(--tb-text); }
|
||
.tb-ask-history-bar .uk-button-text { flex: 0 0 auto; color: var(--tb-primary); }
|
||
.tb-ask-history-list {
|
||
margin: 12px 0 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
display: grid;
|
||
gap: 2px;
|
||
}
|
||
.tb-ask-history-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 8px 0;
|
||
border-top: 1px solid var(--tb-border);
|
||
text-decoration: none;
|
||
}
|
||
.tb-ask-history-item:hover .tb-ask-history-excerpt { color: var(--tb-primary); }
|
||
.tb-ask-history-excerpt {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: var(--tb-fs-md);
|
||
color: var(--tb-text);
|
||
}
|
||
.tb-ask-history-meta { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
||
.tb-ask-history-state { font-size: var(--tb-fs-xs); white-space: nowrap; }
|
||
.tb-ask-history-state.is-answered { color: #19a974; }
|
||
.tb-ask-history-state.is-pending { color: var(--tb-text-muted); }
|
||
.tb-ask-history-note {
|
||
margin: 10px 0 0;
|
||
font-size: var(--tb-fs-xs);
|
||
color: var(--tb-text-muted);
|
||
}
|
||
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
|
||
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
|
||
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
|
||
</style>
|
||
<div class="uk-card uk-card-default uk-text-center">
|
||
<div class="tb-profile-cover uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light{{ if .PageUser.Background }} tb-cover{{ end }}" data-src="{{ .PageUser.Background }}" uk-img>
|
||
<div class="uk-card-body">
|
||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||
<div class="icon-container">
|
||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||
</a>
|
||
{{ end }}
|
||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||
</a>
|
||
{{ end }}
|
||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||
</a>
|
||
{{ end }}
|
||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||
</a>
|
||
{{ end }}
|
||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||
</a>
|
||
{{ end }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
document.getElementById("usernamecolor").style.color = "{{.PageUser.Usernamecolor}}";
|
||
document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
|
||
// 封面上的文字颜色由箱主自选,深浅不定:按用户名颜色的亮度决定遮罩方向
|
||
// (深字上面压浅色遮罩、浅字上面压深色遮罩),保证任何配色都能读。
|
||
// 无背景图时页面没有 .tb-cover,这里什么都不做,保持原样。
|
||
(function () {
|
||
var cover = document.querySelector('.tb-cover');
|
||
var el = document.getElementById('usernamecolor');
|
||
if (!cover || !el) return;
|
||
var m = window.getComputedStyle(el).color.match(/\d+/g);
|
||
if (!m || m.length < 3) return;
|
||
var lum = (0.299 * m[0] + 0.587 * m[1] + 0.114 * m[2]) / 255;
|
||
if (lum > 0.62) cover.classList.add('tb-cover-dark');
|
||
})();
|
||
</script>
|
||
<div>
|
||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||
{{ if .CanAsk }}
|
||
<!-- jQuery 已改为按需加载:defer 按文档顺序先于 easy.qrcode 执行,保证就绪 -->
|
||
<script defer src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||
<div class="uk-text-center"><div id="boxqrimg"></div></div>
|
||
<script type="text/javascript">
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
var options = {
|
||
text: window.location.href,
|
||
width: 200,
|
||
height: 200,
|
||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||
correctLevel: QRCode.CorrectLevel.H,
|
||
logo: (function () {
|
||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||
})(),
|
||
logoWidth: 60,
|
||
logoHeight: 60,
|
||
logoBackgroundTransparent: true,
|
||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||
crossOrigin: 'anonymous',
|
||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||
autoColor: false,
|
||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||
AO: '',
|
||
AI: '',
|
||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||
};
|
||
// 降级渲染:头像(logo)/ 背景图加载慢/失败时先出基础码,探针成功后再升级带 logo 版
|
||
window.tbRenderQR(document.getElementById("boxqrimg"), function () { return options; });
|
||
});
|
||
</script>
|
||
|
||
<p class="uk-text-center uk-text-small">{{t .LangCode "ask.scan" .PageUser.Name}}<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||
|
||
<div class="uk-text-center uk-margin-small-top">
|
||
<a id="share-btn" href="/_/{{ .PageUser.Domain }}/share-card" download class="uk-button uk-button-default uk-button-small">
|
||
📤 {{t .LangCode "ask.share"}}
|
||
</a>
|
||
</div>
|
||
|
||
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
|
||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
|
||
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
|
||
<p class="uk-text-large uk-text-muted">{{t .LangCode "ask.generating"}}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
var btn = document.getElementById('share-btn');
|
||
var overlay = document.getElementById('share-loading-overlay');
|
||
if (!btn || !overlay) return;
|
||
btn.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
overlay.style.display = 'block';
|
||
btn.style.pointerEvents = 'none';
|
||
fetch(btn.href)
|
||
.then(function(response) {
|
||
if (!response.ok) throw new Error('{{t .LangCode "ask.networkError"}}');
|
||
return response.blob();
|
||
})
|
||
.then(function(blob) {
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = '{{t .LangCode "ask.shareFilename"}}';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(function() {
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, 100);
|
||
})
|
||
.catch(function(err) {
|
||
alert('{{t .LangCode "ask.genFailed"}}');
|
||
console.error(err);
|
||
})
|
||
.finally(function() {
|
||
overlay.style.display = 'none';
|
||
btn.style.pointerEvents = 'auto';
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
{{ end }}
|
||
{{template "base/alert" .}}
|
||
|
||
<!-- 提问者找回自己的提问:只有本机 localStorage 里存着本箱记录时才由脚本显示 -->
|
||
<div id="tb-ask-history" class="tb-ask-history" hidden>
|
||
<div class="tb-ask-history-bar">
|
||
<span class="tb-ask-history-text" id="tb-ask-history-text"></span>
|
||
<button type="button" id="tb-ask-history-toggle" class="uk-button uk-button-text uk-button-small uk-padding-remove" aria-expanded="false"></button>
|
||
</div>
|
||
<ul class="tb-ask-history-list" id="tb-ask-history-list" hidden></ul>
|
||
<p class="tb-ask-history-note">{{ t .LangCode "askHistory.note" }}</p>
|
||
</div>
|
||
|
||
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
|
||
<div class="profile-tabs" role="tablist">
|
||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
|
||
{{ if .VoteEnabled }}
|
||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "votes" }} is-active{{ end }}" data-profile-tab="votes" role="tab" aria-selected="{{ if eq .ActiveProfileTab "votes" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.votesTab"}}</button>
|
||
{{ else }}
|
||
<!-- 投票功能关闭:保留 Tab 展示但置灰禁用,hover 提示原因 -->
|
||
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="{{ .VoteClosedMessage }}">{{t .LangCode "explore.votesTab"}} <span uk-icon="icon: lock; ratio: 0.8"></span></button>
|
||
{{ end }}
|
||
</div>
|
||
|
||
<div class="profile-panel" data-profile-panel="questions"{{ if eq .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||
{{template "question/new-question-template" .}}
|
||
|
||
{{ if ne (len .PageQuestions) 0 }}
|
||
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
|
||
{{ template "question/history-template" . }}
|
||
{{ end }}
|
||
</div>
|
||
|
||
<div class="profile-panel" data-profile-panel="votes"{{ if ne .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||
{{ if .PageVotes }}
|
||
<!-- 匿名投票板块:箱主"进行中"的公开投票(列表行样式,与探索页"全部投票"一致) -->
|
||
<div class="profile-votes-list">
|
||
{{ range .PageVotes }}
|
||
<a href="/vote/{{ .LinkToken }}" class="profile-vote-item">
|
||
<div class="profile-vote-content">
|
||
<span class="profile-vote-title" title="{{ .Title }}">{{ .Title }}</span>
|
||
<span class="profile-vote-meta">
|
||
{{ if eq .Type "single" }}<span class="tb-chip">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
|
||
{{ if eq .Type "multiple" }}<span class="tb-chip">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
|
||
{{ if eq .Type "rating" }}<span class="tb-chip">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
|
||
{{ if eq .Type "rank" }}<span class="tb-chip">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
|
||
{{ if eq .Type "form" }}<span class="tb-chip">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
|
||
<span class="uk-text-meta">{{ .CreatedAt.Format "2006-01-02" }}</span>
|
||
</span>
|
||
</div>
|
||
<span class="profile-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
|
||
</a>
|
||
{{ end }}
|
||
</div>
|
||
{{ else }}
|
||
<div class="uk-alert uk-alert-primary uk-margin-remove-top">{{t .LangCode "explore.noVotes"}}</div>
|
||
{{ end }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var tabs = document.querySelectorAll('[data-profile-tab]');
|
||
var panels = document.querySelectorAll('[data-profile-panel]');
|
||
function showPanel(name) {
|
||
tabs.forEach(function(tab) {
|
||
var active = tab.dataset.profileTab === name;
|
||
tab.classList.toggle('is-active', active);
|
||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
});
|
||
panels.forEach(function(panel) {
|
||
panel.hidden = panel.dataset.profilePanel !== name;
|
||
});
|
||
if (window.history && window.history.replaceState) {
|
||
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
|
||
}
|
||
}
|
||
tabs.forEach(function(tab) {
|
||
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
|
||
});
|
||
// 仅当投票 Tab 可点击(功能开启)时才响应 #votes hash,避免投票关闭时切到空面板
|
||
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
|
||
showPanel('votes');
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<script>
|
||
// 提问者找回自己的提问。
|
||
//
|
||
// 记录由「提问成功页」写进本机 localStorage(见 question/success.html),
|
||
// 这里只负责:有记录就显示一条提示,并问服务端哪些已经被回答。
|
||
// 服务端只做「你给的这个 token 对不对」的常数时间比对,不建任何访客档案。
|
||
(function () {
|
||
var KEY = 'tb_ask_history_v1';
|
||
var DOMAIN = '{{ .PageUser.Domain }}';
|
||
var CSRF = '{{ .CSRFToken }}';
|
||
|
||
var box = document.getElementById('tb-ask-history');
|
||
if (!box) return;
|
||
|
||
var I18N = {
|
||
banner: '{{ t .LangCode "askHistory.banner" | js }}',
|
||
answeredSuffix: '{{ t .LangCode "askHistory.answeredSuffix" | js }}',
|
||
expand: '{{ t .LangCode "askHistory.expand" | js }}',
|
||
collapse: '{{ t .LangCode "askHistory.collapse" | js }}',
|
||
answered: '{{ t .LangCode "askHistory.answered" | js }}',
|
||
pending: '{{ t .LangCode "askHistory.pending" | js }}',
|
||
open: '{{ t .LangCode "askHistory.open" | js }}'
|
||
};
|
||
|
||
function readAll() {
|
||
try {
|
||
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||
return Array.isArray(v) ? v : [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
function writeAll(list) {
|
||
try { localStorage.setItem(KEY, JSON.stringify(list)); } catch (e) {}
|
||
}
|
||
|
||
var mine = readAll().filter(function (x) {
|
||
return x && x.d === DOMAIN && x.id && x.t;
|
||
});
|
||
if (!mine.length) return;
|
||
|
||
var textEl = document.getElementById('tb-ask-history-text');
|
||
var toggleEl = document.getElementById('tb-ask-history-toggle');
|
||
var listEl = document.getElementById('tb-ask-history-list');
|
||
|
||
function renderTitle(answered) {
|
||
var t = I18N.banner.replace('{n}', mine.length);
|
||
if (answered > 0) t += I18N.answeredSuffix.replace('{m}', answered);
|
||
textEl.textContent = t;
|
||
}
|
||
|
||
renderTitle(0);
|
||
box.hidden = false;
|
||
toggleEl.textContent = I18N.expand;
|
||
|
||
toggleEl.addEventListener('click', function () {
|
||
var expanded = toggleEl.getAttribute('aria-expanded') === 'true';
|
||
toggleEl.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||
listEl.hidden = expanded;
|
||
toggleEl.textContent = expanded ? I18N.expand : I18N.collapse;
|
||
});
|
||
|
||
// 问服务端哪些已被回答。失败就只显示本地记录(不带状态),不影响主流程。
|
||
fetch('/api/v1/ask-status', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({
|
||
domain: DOMAIN,
|
||
items: mine.map(function (x) { return { id: x.id, token: x.t }; })
|
||
})
|
||
}).then(function (r) {
|
||
return r.ok ? r.json() : null;
|
||
}).then(function (payload) {
|
||
var items = (payload && payload.data && payload.data.items) || [];
|
||
var byId = {};
|
||
items.forEach(function (it) { byId[it.id] = it; });
|
||
|
||
// 服务端说 token 无效(提问被删、或记录过期):从本地清掉,避免一直挂着死链
|
||
var stale = items.filter(function (it) { return !it.ok; }).map(function (it) { return it.id; });
|
||
if (stale.length) {
|
||
mine = mine.filter(function (x) { return stale.indexOf(x.id) === -1; });
|
||
var all = readAll().filter(function (x) {
|
||
return !(x && x.d === DOMAIN && stale.indexOf(x.id) !== -1);
|
||
});
|
||
writeAll(all);
|
||
if (!mine.length) { box.hidden = true; return; }
|
||
}
|
||
|
||
var answered = 0;
|
||
mine.forEach(function (x) {
|
||
var st = byId[x.id];
|
||
if (st && st.answered) answered++;
|
||
});
|
||
renderTitle(answered);
|
||
|
||
mine.sort(function (a, b) { return (b.ts || 0) - (a.ts || 0); });
|
||
listEl.textContent = '';
|
||
mine.forEach(function (x) {
|
||
var st = byId[x.id] || {};
|
||
var a = document.createElement('a');
|
||
a.className = 'tb-ask-history-item';
|
||
a.href = '/_/' + encodeURIComponent(DOMAIN) + '/' + x.id +
|
||
'?t=' + encodeURIComponent(x.t);
|
||
|
||
var left = document.createElement('span');
|
||
left.className = 'tb-ask-history-excerpt';
|
||
// textContent:摘要来自用户自己的提问,但仍然不走 innerHTML
|
||
left.textContent = (st.excerpt || ('#' + x.id)) +
|
||
(st.created ? ' · ' + new Date(st.created * 1000).toLocaleDateString() : '');
|
||
|
||
var right = document.createElement('span');
|
||
right.className = 'tb-ask-history-meta';
|
||
var state = document.createElement('span');
|
||
state.className = 'tb-ask-history-state ' + (st.answered ? 'is-answered' : 'is-pending');
|
||
state.textContent = st.answered ? I18N.answered : I18N.pending;
|
||
var go = document.createElement('span');
|
||
go.className = 'tb-ask-history-open';
|
||
go.textContent = I18N.open;
|
||
right.appendChild(state);
|
||
right.appendChild(go);
|
||
|
||
a.appendChild(left);
|
||
a.appendChild(right);
|
||
var li = document.createElement('li');
|
||
li.appendChild(a);
|
||
listEl.appendChild(li);
|
||
});
|
||
}).catch(function () {
|
||
// 网络失败:仍然把本地记录列出来(没有状态标签),链接照样能打开
|
||
mine.sort(function (a, b) { return (b.ts || 0) - (a.ts || 0); });
|
||
listEl.textContent = '';
|
||
mine.forEach(function (x) {
|
||
var a = document.createElement('a');
|
||
a.className = 'tb-ask-history-item';
|
||
a.href = '/_/' + encodeURIComponent(DOMAIN) + '/' + x.id +
|
||
'?t=' + encodeURIComponent(x.t);
|
||
var left = document.createElement('span');
|
||
left.className = 'tb-ask-history-excerpt';
|
||
left.textContent = '#' + x.id;
|
||
var right = document.createElement('span');
|
||
right.className = 'tb-ask-history-meta';
|
||
var go = document.createElement('span');
|
||
go.className = 'tb-ask-history-state is-pending';
|
||
go.textContent = I18N.open;
|
||
right.appendChild(go);
|
||
a.appendChild(left);
|
||
a.appendChild(right);
|
||
var li = document.createElement('li');
|
||
li.appendChild(a);
|
||
listEl.appendChild(li);
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
{{template "base/footer" .}} |