Files
tamakyi 5def41d0f8 feat(ux): 真 404 / HTML 500 页,以及「提问者找回自己的提问」
一、错误页

原来未知路径一律 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 截断:直接截原文会露出 ![图片](https://...)
这种噪音,一条带图的提问几乎只剩链接。

验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
  响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
  注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
2026-10-29 17:18:39 +08:00

541 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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" .}}