Files
rond-with-you/internal/web/templates/layout.html
T
tamakyi d4e4257a23 启动模板自检 + 站长导航放行 + 统计脚本关口收紧
排查:/admin/settings 渲染到一半就断
- 现象:页面在「统计脚本」那张卡片处截断,没有保存按钮、没有 </form></html>,而 HTTP 仍是 200。
- 根因:跑的是旧二进制(改动前编译),磁盘上的模板却是新的。debug=true 下 pageSet()
  每次渲染都从磁盘重解析模板,于是新 settings.html 引用了旧 setting.Settings 里没有的
  TrackEnabled —— 执行期报错,而 render 只把错误写进日志,客户端只看到半张页面。
- 判据:strings <二进制> | grep -c cleanTrackScript(旧 0 / 新 2)。别用 strings | grep
  TrackScript —— config 里本来就有这个名字,旧二进制也命中,是个假信号。

启动自检(防复发)
- template_smoke.go:pageProbe 给 24 个页面各配一份最小可用数据,New() 里全部渲染一遍,
  任一页失败就拒绝启动。pageProbe 与 pageFiles 双向校验,新加页面必须补探针,
  漏配按失败处理(不是省事)。
- smokeOne 用 recover 兜住方法里的 panic,报「哪一页坏了」而不是崩栈。
- 实测:往 settings.html 插一个不存在的字段 → 启动即报
  「模板自检未通过…admin/settings: can't evaluate field …」,退出码 1。
- 门禁 template_smoke_test.go:一条跑全量自检,一条变异验证(探针换成字段更少的类型
  必须报错且点名该页)。

站长导航被页面开关误伤
- guardPage 只拦访客(站长始终能访问全部公开页),但导航写的是 {{if .Settings.HasPage "x"}},
  没有 IsAdmin 分支 ⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
  改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
- 门禁 nav_visibility_test.go:三向不变量(站长仍有全部入口 / 访客一个都没有 /
  全开放时访客也看得到),做过变异验证。
- 访客侧「只有主页」是按后台配置应有的状态,没有动数据。

统计脚本关口收紧(cleanTrackScript)
- src= 只在 <script …> 开标签内部找。原来对整段做 strings.Index,脚本体里一句
  `img.src=u` 就被命中、整段被判危险而清空 —— 页面照样提示「设置已保存」,站长看不出没挂上。
- 开闭标签从「只拒 shut > open」改成严格配平:末尾未闭合的 <script> 会把页面收尾整段吞进脚本。
- 不再静默:签名改成 (script, warn),说明码经 ?warn= 回显成人话;判为危险时整段不采纳、
  库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
- 门禁 TestCleanTrackScript 重写:逐条边界 + 定义性不变量(采纳了非空结果时,
  里面开闭标签数必定相等)+ TestTrackWarnMessage(未识别码不得回显)。变异验证做过。
- 文案订正:settings.html 的「conf/app.ini 的 track_script 会作为这里的默认值」有歧义 ——
  它不回填文本框,而是「这个框留空时、注入那一刻才回落到 ini」。

其余
- bin/ 目录删除(15 个 exe / 412MB),二进制只留仓库根 rond.exe;
  技能 rond-web-verify 与 memory details 十八里 bin/rond.exe 的写法同步改掉。
- 验收:./scripts/verify.sh 全绿且 0 skip(PASS=346 FAIL=0 SKIP=0)。
2026-10-09 11:06:41 +08:00

360 lines
18 KiB
HTML
Raw 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.
{{define "nav"}}
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="/" title="{{.Settings.SiteTitle}}"><span class="avatar">{{.Settings.OwnerAvatar}}</span><span class="t">{{.Settings.SiteTitle}}</span></a>
<div class="nav-links">
{{/* 页面开关只管访客:站长登录后始终能访问全部页面(guardPage 也是这么放的),
导航必须跟着一起放行,否则关掉页面开关后站长自己也只剩「主页」一个入口,
明明能访问却点不到。 */}}
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
{{if or .IsAdmin (.Settings.HasPage "map")}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "places")}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "timeline")}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "stats")}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "report")}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
</div>
<div class="nav-right">
{{if .User}}
<span class="who">{{.User.DisplayName}}</span>
<a class="btn sm" href="/admin"><span class="hide-sm">后台</span><span class="only-sm">后台</span></a>
<form method="post" action="/admin/logout" style="display:inline"><button class="btn sm" type="submit">退出</button></form>
{{else}}
<a class="btn sm" href="/admin"><span class="hide-sm">登录后台</span><span class="only-sm">登录</span></a>
{{end}}
</div>
</div>
</nav>
<script>
/* 手机上导航装不下这几项,可以横向滑动(滚动条已隐藏)——右侧加一层渐隐,提示「还能往右滑」;
滑到末端就收掉,免得让人以为后面还有。桌面端没有横向溢出,这段等于不做事。 */
(function () {
var el = document.querySelector('.nav-links');
if (!el) return;
var update = function () {
el.classList.toggle('overflow-right', el.scrollWidth - el.clientWidth - el.scrollLeft > 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
})();
</script>
{{end}}
{{define "filters"}}
<form class="card filters-card" method="get" action="{{.FilterBase}}" id="filters-form">
{{/* 窄屏折叠开关,与地图页筛选面板同一套(.panel-fold):桌面端它 display:none、表单照旧常开;
手机上默认收成这一条,只留「筛选 + 当前范围 + 箭头」。收起只是 CSS 隐藏,
表单里的控件都还在,筛选条件照旧随提交带上(act/hw/sort 那几个 hidden 也在)。 */}}
<button class="panel-fold" id="filters-fold" type="button" aria-controls="filters-form" aria-expanded="true">
<span>筛选</span>
<span class="pf-n">{{.RangeLabel}}{{if .ActiveChips}} · {{len .ActiveChips}} 个条件{{end}}</span>
<span class="pf-c" id="filters-fold-caret" aria-hidden="true">▾</span>
</button>
<div class="chips" style="margin-bottom:12px">
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
<span class="chip" style="background:transparent;color:var(--text-3)">{{.RangeLabel}}</span>
{{if .IsAdmin}}
<a class="chip {{if .ShowHomeWork}}on{{end}}" href="{{.HWLink}}" title="仅登录后的你自己可见,访客看不到这两类记录">含家/工作</a>
{{end}}
</div>
{{/* 已选条件:以前只能靠「重置」全清,想撤掉其中一个得手动改回「全部」再点应用 */}}
{{$chips := .ActiveChips}}
{{if $chips}}
<div class="chips active-filters">
<span class="af-label">已选</span>
{{range $chips}}<a class="chip on" href="{{.RemoveURL}}" title="去掉这个条件">{{.Label}}<span class="af-x">×</span></a>{{end}}
<a class="chip af-clear" href="{{.FilterBase}}">全部清空</a>
</div>
{{end}}
{{if .Activities}}
<div class="chips" style="margin-bottom:{{if gt (len .Activities) 14}}8{{else}}12{{end}}px">
{{range $i, $a := .Activities}}{{if or (lt $i 14) ($.IsAct $a.ID)}}
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}" title="{{num $a.Count}} 次到访">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="muted">{{num $a.Count}}</span>
</a>
{{end}}{{end}}
</div>
{{if gt (len .Activities) 14}}
<details class="more-types">
<summary>展开其余 {{len .Activities}} 个类型</summary>
<div class="chips">
{{range $i, $a := .Activities}}{{if and (ge $i 14) (not ($.IsAct $a.ID))}}
<a class="chip" href="{{$.ActLink $a.ID}}">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="muted">{{num $a.Count}}</span>
</a>
{{end}}{{end}}
</div>
</details>
{{end}}
{{end}}
<div class="row" style="align-items:flex-end">
<label class="f" style="flex:1 1 140px;margin-bottom:0"><span>起始日期</span><input type="date" name="from" value="{{.From}}"></label>
<label class="f" style="flex:1 1 140px;margin-bottom:0"><span>结束日期</span><input type="date" name="to" value="{{.To}}"></label>
<label class="f" style="flex:1 1 130px;margin-bottom:0"><span>城市</span>
<select name="city">
<option value="">全部城市</option>
{{range .CityList}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label class="f" style="flex:1 1 130px;margin-bottom:0"><span>标签</span>
<select name="tag">
<option value="0">全部标签</option>
{{range .TagOptions}}<option value="{{.ID}}" {{if eq .ID $.TagID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label class="f" style="flex:1 1 120px;margin-bottom:0"><span>最短停留</span>
<select name="min">
<option value="0" {{if eq .MinDwell 0}}selected{{end}}>不限</option>
<option value="5" {{if eq .MinDwell 5}}selected{{end}}>≥ 5 分钟</option>
<option value="15" {{if eq .MinDwell 15}}selected{{end}}>≥ 15 分钟</option>
<option value="30" {{if eq .MinDwell 30}}selected{{end}}>≥ 30 分钟</option>
<option value="60" {{if eq .MinDwell 60}}selected{{end}}>≥ 1 小时</option>
</select>
</label>
<label class="f" style="flex:2 1 180px;margin-bottom:0"><span>关键词</span><input type="text" name="q" value="{{.Keyword}}" placeholder="地点 / 城市 / 类型,回车即可"></label>
<div style="display:flex;gap:8px">
{{/* 下拉与日期改动即生效(见下面的脚本);这个按钮是给「关键词」和
关了 JS 的浏览器兜底的,所以不再是主按钮样式 */}}
<button class="btn" type="submit">查询</button>
<a class="btn" href="{{.FilterBase}}">重置</a>
</div>
</div>
{{range .ActivityIDs}}<input type="hidden" name="act" value="{{.}}">{{end}}
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
{{if .Sort}}<input type="hidden" name="sort" value="{{.Sort}}">{{end}}
</form>
{{/* 窄屏折叠:脚本必须紧跟这个表单 —— 放到 body 末尾时,手机上会先按「全部展开」画一帧
再收起,看着就是闪一下整张大卡(地图页那块面板也是贴在面板后面)。收起态记在本地,
翻页/换页之后不该又自己弹开。 */}}
<script>
(function () {
var form = document.getElementById('filters-form');
var btn = document.getElementById('filters-fold');
if (!form || !btn) return;
var caret = document.getElementById('filters-fold-caret');
var KEY = 'rond-filters-fold';
var desk = window.matchMedia('(min-width: 621px)');
var saved = null;
try { saved = localStorage.getItem(KEY); } catch (e) { /* 隐私模式禁用了,忽略 */ }
function apply(collapsed) {
form.classList.toggle('collapsed', collapsed);
btn.setAttribute('aria-expanded', String(!collapsed));
btn.title = collapsed ? '展开筛选' : '收起筛选';
if (caret) caret.textContent = collapsed ? '▸' : '▾';
}
// 没记过状态时窄屏默认收起。桌面端这条 class 不生效(显示规则整条在窄屏媒体查询里),
// 所以同一个浏览器在手机上收起过、回桌面照样看得见筛选。
apply(saved === null ? !desk.matches : saved === '1');
btn.addEventListener('click', function () {
var collapsed = !form.classList.contains('collapsed');
apply(collapsed);
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* 同上 */ }
});
})();
</script>
{{end}}
{{define "emptystate"}}
<div class="card">
<div class="empty">
<div class="big">🗺️</div>
<div style="font-size:15px;color:var(--text);margin-bottom:6px">还没有导入任何数据</div>
<div>登录后台,上传 rond 导出的 <span class="mono">.rondbackup</span> 备份文件即可生成足迹地图。</div>
<div style="margin-top:14px"><a class="btn primary" href="/admin">前往后台导入</a></div>
</div>
</div>
{{end}}
{{define "pager"}}
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.Link .FilterBase "page" (printf "%d" (sub .PageNo 1))}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.Link .FilterBase "page" (printf "%d" (add .PageNo 1))}}">下一页</a>{{end}}
</div>
{{end}}
{{end}}
{{define "layout"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{.Title}} · {{.Settings.SiteTitle}}</title>
<script>window.__assetV="{{.AssetV}}"</script>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.webmanifest">
<meta name="theme-color" content="{{.Settings.AccentColor}}">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
{{if .HasMap}}
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{.AssetV}}">
{{if or (eq .Nav "map") (eq .Nav "topics")}}
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.css?v={{.AssetV}}">
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.Default.css?v={{.AssetV}}">
{{end}}
{{end}}
{{if .Flatpickr}}
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css?v={{.AssetV}}">
{{end}}
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetV}}">
{{/* accent-base 是站长选的主色原值:暗色模式会用 color-mix 把它提亮,否则深色底上的链接会看不清(原来的深绿在深底上只有 3.3:1)。 */}}
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a;--accent-base:{{.Settings.AccentColor}}}</style>
</head>
<body>
{{template "nav" .}}
{{template "content" .}}
{{if ne .Nav "map"}}
<footer class="ft">
{{.Settings.SiteTitle}}{{if .Settings.SiteSubtitle}} · {{.Settings.SiteSubtitle}}{{end}}
{{/* 到访被删光的数据集 last_visit_at 是 NULL,而 d 收的是 time.Time —— 少判这一层就会
nil 解引用、页脚之后整段不渲染(客户端还拿到 200)。 */}}
{{if .Dataset}}{{if .Dataset.Last}} · 数据截至 {{d .Dataset.Last}}{{end}}{{end}}
</footer>
{{end}}
{{if .HasMap}}
<script src="/static/vendor/leaflet/leaflet.js?v={{.AssetV}}"></script>
{{if or (eq .Nav "map") (eq .Nav "topics")}}<script src="/static/vendor/markercluster/leaflet.markercluster.js?v={{.AssetV}}"></script>{{end}}
<script src="/static/js/rond-map.js?v={{.AssetV}}"></script>
{{end}}
{{if .Flatpickr}}
<script src="/static/vendor/flatpickr/flatpickr.min.js?v={{.AssetV}}"></script>
<script src="/static/vendor/flatpickr/l10n/zh.js?v={{.AssetV}}"></script>
{{/* 时间框的解析器与输入引导。必须在 flatpickr 之后、页面内联脚本之前:
它不自己调 flatpickr,只提供 parseDate 与 attach,由各页 whenReady 调 attachPicker。 */}}
<script src="/static/js/datetime-input.js?v={{.AssetV}}"></script>
{{end}}
{{/* 输入弹层:替代 window.prompt(它会把提示文本与默认值写进地址栏)。
全站都可能用到(报告页复制链接、后台活动改名),所以无条件引入。 */}}
<script src="/static/js/prompt-box.js?v={{.AssetV}}"></script>
{{/* 第三方统计脚本:只在公开页面挂,后台/登录页不挂(免得把自己的操作也统计进去) */}}
{{if and .TrackScript (ne .Nav "admin")}}
{{.TrackScript}}
{{end}}
<script>
// 后台的表单提交 / 翻页 / 搜索都是整页刷新,浏览器会回到顶部,滚动位置就丢了。
// 这里在离开前把位置记进 sessionStorage,加载完再滚回去。
// 只在 /admin 下生效:前台页面(主页进详情之类)本来就该从头看。
(function () {
if (location.pathname.indexOf("/admin") !== 0) return;
var KEY = "rond:scroll:" + location.pathname;
function record() {
try {
sessionStorage.setItem(KEY, Math.round(window.scrollY || window.pageYOffset || 0) + ":" + Date.now());
} catch (e) {}
}
function restore() {
try {
var raw = sessionStorage.getItem(KEY);
if (!raw) return;
sessionStorage.removeItem(KEY);
var parts = raw.split(":"), y = parseInt(parts[0], 10), ts = parseInt(parts[1], 10);
// 10 秒内才认:确认框被取消时留下的记录不该在下次打开时把视图拽走
if (isFinite(y) && y > 0 && Date.now() - ts < 10000) window.scrollTo(0, y);
} catch (e) {}
}
// 等 load:解析阶段就滚的话文档可能还不够高,scrollTo 会被截断到 0
if (document.readyState === "complete") restore();
else window.addEventListener("load", restore);
document.addEventListener("submit", record, true);
// 翻页 / 重置这类是同路径的链接跳转,也要记(新开标签、锚点、跨页不记)
document.addEventListener("click", function (ev) {
var a = ev.target && ev.target.closest ? ev.target.closest("a") : null;
if (!a) return;
var href = a.getAttribute("href") || "";
if (!href || href.charAt(0) === "#" || a.target === "_blank") return;
var u;
try { u = new URL(a.href, location.href); } catch (e) { return; }
if (u.pathname === location.pathname) record();
}, true);
})();
</script>
{{/* 长列表(地点库 300+ 行、时间线 800+ 条)划到底后能一键回来。
地图页整页不滚动,所以这个按钮在那里不会出现。 */}}
{{/* 顶部细进度条:筛选是整页 GET,慢网下点了没反应会让人以为卡住 */}}
<div class="load-bar" id="load-bar" hidden></div>
<button class="to-top" id="to-top" type="button" aria-label="回到顶部" hidden>↑</button>
<script>
(function () {
/* ---------- 顶部加载进度条 ---------- */
var bar = document.getElementById('load-bar'), timer = null, safety = null, w = 0;
function start() {
bar.hidden = false;
w = 8;
bar.style.width = w + '%';
bar.style.opacity = '1';
clearInterval(timer);
timer = setInterval(function () {
w = Math.min(90, w + Math.max(0.4, (90 - w) / 14));
bar.style.width = w + '%';
}, 180);
// 兜底:万一这次「跳转」被阻止了(比如地图页是 AJAX 换数据),别让进度条一直挂着
clearTimeout(safety);
safety = setTimeout(finish, 8000);
}
function finish() {
clearInterval(timer);
clearTimeout(safety);
if (bar.hidden) return;
bar.style.width = '100%';
setTimeout(function () {
bar.style.opacity = '0';
setTimeout(function () { bar.hidden = true; bar.style.width = '0'; }, 260);
}, 140);
}
window.rondProgress = { start: start, finish: finish };
document.addEventListener('submit', start, true);
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest ? e.target.closest('a') : null;
if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
var u;
try { u = new URL(a.href, location.href); } catch (err) { return; }
// 只对「真的会跳走」的站内链接亮进度条
if (u.origin !== location.origin) return;
if (u.pathname === location.pathname && u.search === location.search) return;
start();
}, true);
window.addEventListener('pageshow', finish); // 从 bfcache 回来时也要收掉
/* ---------- 筛选:改动即生效 ---------- */
var form = document.getElementById('filters-form');
if (form) {
// 下拉与日期改了就提交,不用再点「查询」。
// 关键词不自动提交(每敲一个字都跳转会很难受)——回车即可。
function apply() {
// 用 requestSubmit 而不是 form.submit():前者会**触发 submit 事件**,
// 于是地图页自己那个「拦下提交、改走 AJAX」的处理器仍然生效;
// 用 submit() 会绕过它,把地图页的筛选变成整页刷新。
if (form.requestSubmit) form.requestSubmit();
else form.submit();
}
form.addEventListener('change', function (e) {
var el = e.target;
if (!el || !el.name) return;
if (el.tagName === 'SELECT' || el.type === 'date') apply();
});
}
var b = document.getElementById('to-top');
function sync() { b.hidden = window.scrollY < 600; }
window.addEventListener('scroll', sync, { passive: true });
sync();
b.addEventListener('click', function () {
// 尊重「减少动态效果」偏好:开了就别做平滑滚动
var smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
});
})();
</script>
</body>
</html>{{end}}