Files
rond-with-you/internal/web/templates/layout.html
T
tamakyi b19f27ab69 日期框补上日历弹层:flatpickr 改为无条件加载
用户实测:筛选区日期框换成文本框后手机上没了日历弹层、不好操作;多 18KB gzip 可以接受。

- layout.html 里 flatpickr(js + css + l10n)与 datetime-input.js 都改为**无条件加载**,
  不再用 {{if .Flatpickr}} 按页开关 —— 「按页开开关」正是 2026-10-09 日期框 bug 的根源
  (只给 type=datetime-local 挂了控件,后来加的 type=date 没人管)。
- 顺手删掉 `Page.Flatpickr` 字段与三处 `p.Flatpickr = true`(开关没了就不可能再漏设)。
- 守卫 TestDateInputsGoThroughAttachAll 相应改成:钉住「layout 无条件引入 flatpickr +
  datetime-input + attachAll」、并禁止再出现 {{if .Flatpickr}}。

实测(展开筛选面板后):390/320 下 /stats /map /places 的日历都在最上层(z-index 99999)、
完全在视口内、elementFromPoint 命中日历本身;打字 20260606 → 2026-06-06 不变。

回归:./scripts/verify.sh PASS=349 FAIL=0 SKIP=0;JS 单测 45/45;320/390/1440 溢出与改前一致。
2026-10-09 19:09:42 +08:00

394 lines
20 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.
{{/* navitems 是导航链接本体,抽出来是因为它要渲染两遍:桌面上在 .nav-links 里平铺,
窄屏整行一定放不下(实测 390 溢 63px、320 溢 133px,最深处的「统计」会被切成半个字),
所以另收进 .nav-menu 那个折叠菜单。两份靠 display 二选一,DOM 里不会同时可见。
页面开关只管访客:站长登录后始终能访问全部页面(guardPage 也是这么放的),
导航必须跟着一起放行,否则关掉页面开关后站长自己也只剩「主页」一个入口。 */}}
{{define "navitems"}}
<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}}
{{end}}
{{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">{{template "navitems" .}}</div>
<details class="nav-menu">
<summary aria-label="导航菜单">菜单</summary>
<nav class="nav-menu-body">{{template "navitems" .}}</nav>
</details>
<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>
/* 1) 620px 以上:链接行万一还是放不下(以后加了入口),横向可滑 + 右缘渐隐提示。
2) 620px 及以下:整行已经收进 .nav-menu,这里只管菜单开合——原生 <details> 不会
点外面自动收、也不响应 Esc,补上;点链接后也收掉,免得盖着刚打开的页面。 */
(function () {
var el = document.querySelector('.nav-links');
if (el) {
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);
}
var menu = document.querySelector('.nav-menu');
if (!menu) return;
document.addEventListener('click', function (e) { if (!menu.contains(e.target)) menu.open = false; });
menu.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('a')) menu.open = false;
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') menu.open = false; });
})();
</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:{{csscolor $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:{{csscolor $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:var(--fs-md);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}}
{{/* 日期选择器的样式。**无条件加载**:筛选区(公共页)也有日期框,按页开关会被漏掉
(见下面 datetime-input 那段的长注释)。 */}}
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css?v={{.AssetV}}">
<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}}
{{/* 日期选择器 + 手输引导。**无条件加载**(js+css+l10n 约 18KB gzip),两个理由:
① 筛选区在公共页上也有日期框(/stats /places /timeline 的起止日期、/map 自己的),
「按页开开关」这种写法已经被证明会漏 —— 2026-10-09 的日期框 bug 就是只给
type=datetime-local 挂了控件,后来加的 type=date 没人管;
② 原生控件本身不可靠:Chrome 的年段能吞 6 位数字,敲 20260606 会变成 202606/06
且 el.value 为空(筛选条件静默丢掉)。
datetime-input.js 必须在 flatpickr 之后、页面内联脚本之前。 */}}
<script src="/static/vendor/flatpickr/flatpickr.min.js?v={{.AssetV}}"></script>
<script src="/static/vendor/flatpickr/l10n/zh.js?v={{.AssetV}}"></script>
<script src="/static/js/datetime-input.js?v={{.AssetV}}"></script>
<script>
/* 每一页都把日期输入改造一次(见 datetime-input.js 顶部说明)。
页面自己的 whenReady 里还会再调一次 attachAll —— 那是为了覆盖**动态创建**的输入
(照片页是分批建行的);重复调用安全,元素上有 _rondAttached 守卫。 */
(function () {
var run = function () { if (window.rondDatetime) window.rondDatetime.attachAll(); };
/* 这个脚本在 body 末尾,内容都已经解析出来了 —— 立刻改一次,别等 DOMContentLoaded:
否则用户会先看到一个原生日期框、过一会儿才变成文本框(本机实测 DCL 在 1.18s,
网络慢时更明显)。DCL 再补一次是为了兜住别的脚本往页面里塞的输入。 */
run();
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
})();
</script>
{{/* 输入弹层:替代 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}}