时间框(flatpickr allowInput 的手输体验)
- flatpickr 把 <input type=datetime-local> 就地改成 type=text,原生分段编辑器被替换掉;
allowInput 又是「整串解析」语义:实测 parseDate('2026') 只出年份、'202666' 才出月份,
于是「年份很长才跳下一位」;'20260606' 还会被静默错解成 2030-12-06 且不报错。
- 新增 static/js/datetime-input.js:自定义 parseDate(纯数字只收 4/6/8/12/14 位,
歧义与非法日期一律拒)+ 输入引导(段满即补分隔符,敲完 4 位年份立刻跳月份位)。
按标准格式不做自动补零、不前滚;越界值原样保留交报错,不静默改成合法值。
- 三处页面(编辑/照片/专题)共用 attachPicker,配置与原先一致,模板净减 5 行。
弹窗
- window.prompt 会把提示文本与默认值写进地址栏并留进历史(confirm/alert 无此问题)。
- 新增 static/js/prompt-box.js 替代:原生 <dialog>.showModal(),遮罩/Esc/焦点齐全,
旧浏览器降级成页面顶部一条。已换 report.html 复制链接兜底与 edit.html 活动改名。
- 门禁 no_prompt_test.go:静态扫模板禁止 prompt(,并钉住 JS 与 CSS 都在。
统计脚本
- 原来只能改 conf/app.ini(要重启),现在后台「站点设置」可开关 + 填写,默认关闭。
- cleanTrackScript 挡住注入面:开闭 <script> 标签数不配平(提前闭合会让页面散架)、
src 的 javascript:/data: 伪协议、超长截断。注意不能用「出现 </script 就拒」当规则——
百度统计、Umami 贴的就是完整 HTML,正常用法会被挡死。
门禁
- datetime-input.test.js:34 条断言(node 直跑,无依赖)。
- TestCleanTrackScript:逐条边界 + 定义性不变量(任何输入都不得产出含 </script 的非空结果)。
- 三项均做过变异验证,改坏实现都会被对应测试抓到。
实测:真实站点逐键 2→20→202→2026-→2026-06-→2026-06-06T;
统计脚本六个场景(默认关/只填不开/开+URL/后台与登录页不挂/危险输入/关掉恢复)全部符合预期。
全量 internal/web 204 例 0 skip,其余包全绿,gofmt 干净。
148 lines
6.6 KiB
HTML
148 lines
6.6 KiB
HTML
{{define "content"}}
|
|
<div class="wrap">
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>{{.Year}} 年度报告</h1>
|
|
<div class="sub">这一年的足迹,一目了然</div>
|
|
</div>
|
|
<div class="row no-print">
|
|
{{if .Years}}
|
|
<select onchange="location='/report?y='+this.value" style="min-width:110px">
|
|
{{range .Years}}<option value="{{.}}" {{if eq . $.Year}}selected{{end}}>{{.}} 年</option>{{end}}
|
|
</select>
|
|
{{end}}
|
|
<button class="btn" id="btn-share" type="button">复制链接</button>
|
|
<button class="btn primary" id="btn-pdf" type="button">保存为 PDF</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{if not .HasData}}
|
|
{{template "emptystate" .}}
|
|
{{else if gt .Year 0}}
|
|
<div class="kpis" style="margin-bottom:16px">
|
|
<div class="kpi"><div class="v">{{num .Overview.Visits}}<small>次</small></div><div class="k">到访记录</div></div>
|
|
<div class="kpi"><div class="v">{{num .NewPlaces}}<small>个</small></div><div class="k">新地点</div></div>
|
|
<div class="kpi"><div class="v">{{num .Overview.Cities}}<small>个</small></div><div class="k">城市</div></div>
|
|
<div class="kpi"><div class="v">{{num .Overview.Provinces}}<small>个</small></div><div class="k">省级行政区</div></div>
|
|
<div class="kpi"><div class="v">{{num .Overview.Days}}<small>天</small></div><div class="k">活跃天数</div></div>
|
|
<div class="kpi"><div class="v">{{km .Overview.DistanceKm}}<small>km</small></div><div class="k">出行里程</div></div>
|
|
</div>
|
|
|
|
{{if .Cities}}
|
|
<div class="card" style="margin-bottom:16px">
|
|
<h2>🏅 首次到达的城市</h2>
|
|
<div class="card-sub">这一年里第一次踏足的这些城市(按到达日期排序)</div>
|
|
<div class="chips">
|
|
{{range .Cities}}
|
|
<span class="chip on" title="{{.Province}} · 首次到达 {{.First}}">
|
|
📍 {{.City}}<span class="muted"> · {{.First}}</span>
|
|
</span>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>去得最多的城市</h2>
|
|
<div class="card-sub">按到访次数</div>
|
|
{{if .TopCities}}
|
|
<div class="bars">
|
|
{{range $i, $c := .TopCities}}
|
|
<div class="bar-row">
|
|
<div class="nm"><span class="muted">{{add $i 1}}.</span> {{.City}}</div>
|
|
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count (index $.TopCities 0).Count}}%;background:var(--accent)"></div></div>
|
|
<div class="vv">{{num .Count}} 次</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}<div class="empty">这一年还没有到访记录</div>{{end}}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>南北两端</h2>
|
|
<div class="card-sub">这一年到过纬度最高与最低的地点</div>
|
|
{{if .North}}
|
|
<div class="tw">
|
|
<table class="tb tb-stack">
|
|
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>最北</td><td data-label="地点">{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num" data-label="纬度">{{f1 .North.Lat}}°N</td></tr>
|
|
{{if .South}}<tr><td>最南</td><td data-label="地点">{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num" data-label="纬度">{{f1 .South.Lat}}°N</td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{else}}<div class="empty">这一年还没有到访记录</div>{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
{{if .Transports}}
|
|
<div class="card">
|
|
<h2>在路上</h2>
|
|
<div class="card-sub">这一年 {{num .Overview.Movements}} 段行程,共 {{km .Overview.DistanceKm}} km</div>
|
|
<div class="tw">
|
|
<table class="tb tb-stack">
|
|
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th></tr></thead>
|
|
<tbody>
|
|
{{range .Transports}}
|
|
<tr>
|
|
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
|
<td class="num" data-label="次数">{{num .Count}}</td>
|
|
<td class="num" data-label="里程">{{km .Distance}} km</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .Fog}}
|
|
{{/* 这张卡原来只有一句说明、没有任何内容(本来想放地图但没接上),看着像坏了。
|
|
改成与统计页一致的四个数字;完整的迷雾视图在 /stats 与地图页。 */}}
|
|
<div class="card">
|
|
<h2>世界迷雾</h2>
|
|
<div class="card-sub">迷雾是累计数据,不按年份切分。</div>
|
|
<div class="kpis">
|
|
<div class="kpi"><div class="v">{{f1 .Fog.AreaKm2}}<small>km²</small></div><div class="k">已探索面积</div></div>
|
|
<div class="kpi"><div class="v">{{pct .Fog.LitPlaces .Fog.Places}}<small>%</small></div><div class="k">地点点亮率</div></div>
|
|
<div class="kpi"><div class="v">{{num .Fog.LitPlaces}}<small>/{{num .Fog.Places}}</small></div><div class="k">点亮地点</div></div>
|
|
<div class="kpi"><div class="v">{{num .Fog.Blocks}}<small>块</small></div><div class="k">迷雾快照</div></div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<div class="row" style="justify-content:center;margin:8px 0 24px">
|
|
<a class="btn" href="/stats?from={{printf "%d-01-01" .Year}}&to={{printf "%d-12-31" .Year}}">查看这一年的完整统计 →</a>
|
|
</div>
|
|
{{else}}
|
|
<div class="card"><div class="empty">还没有数据</div></div>
|
|
{{end}}
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
var share = document.getElementById('btn-share');
|
|
var pdf = document.getElementById('btn-pdf');
|
|
if (share) share.addEventListener('click', async function () {
|
|
var url = location.href;
|
|
if (navigator.share) {
|
|
navigator.share({ title: document.title, url: url }).catch(function () {});
|
|
return;
|
|
}
|
|
// 复制失败时不再用 window.prompt 兜底(它会把整条链接写进地址栏并留进历史),
|
|
// 改成只读弹层:内容照样能选中 Ctrl+C,地址栏保持干净。
|
|
var fallback = function () { return window.rondPrompt.ask('复制下面的链接:', url, { readonly: true }); };
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(url).then(function () {
|
|
share.textContent = '已复制链接';
|
|
setTimeout(function () { share.textContent = '复制链接'; }, 1600);
|
|
}).catch(fallback);
|
|
} else {
|
|
fallback();
|
|
}
|
|
});
|
|
if (pdf) pdf.addEventListener('click', function () { window.print(); });
|
|
})();
|
|
</script>
|
|
{{end}}
|