first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>迷雾遮罩区</h1>
<div class="sub">这些范围内的迷雾<strong>对访客隐藏</strong>,你自己仍然看得到。在地图上点一下就是圆心,半径可调。</div>
</div>
<div class="row">
<a class="btn" href="/admin">← 返回后台</a>
</div>
</div>
<div class="card">
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div id="mask-map" style="height:380px;border-radius:10px;background:var(--line-soft)"></div>
<form method="post" action="/admin/fog/mask/save" id="mask-form" style="margin-top:12px">
<input type="hidden" name="id">
<input type="hidden" name="lat">
<input type="hidden" name="lon">
<input type="hidden" name="crs">
<div class="row">
<label class="f" style="flex:1 1 200px;margin-bottom:0">
<span>名称(可选)</span>
<input type="text" name="name" maxlength="40" placeholder="例如:家附近、公司周边">
</label>
<label class="f" style="flex:0 1 150px;margin-bottom:0">
<span>半径(米)</span>
<input type="number" name="radius_m" min="50" max="50000" step="50" value="500" required>
</label>
<button class="btn primary" type="submit" id="mask-save">保存</button>
<button class="btn" type="button" id="mask-reset">清除选择</button>
</div>
<div class="muted" id="mask-hint" style="font-size:12.5px;margin-top:6px">在下方地图上点选圆心</div>
</form>
</div>
<div class="card">
<h2>已有遮罩区({{len .Masks}})</h2>
{{if .Masks}}
<table class="tb">
<thead><tr><th>名称</th><th>圆心(纬度, 经度)</th><th>半径</th><th></th></tr></thead>
<tbody>
{{range .Masks}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
<td>{{printf "%.0f" .RadiusM}} 米</td>
<td>
<button class="btn sm" type="button" data-edit
data-id="{{.ID}}" data-name="{{.Name}}"
data-lat="{{.Lat}}" data-lon="{{.Lon}}" data-radius="{{.RadiusM}}">编辑</button>
<form method="post" action="/admin/fog/mask/delete" style="display:inline"
onsubmit="return confirm('删除后这块范围的迷雾会重新对访客显示。确认删除?')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="muted">还没有遮罩区。在地图上点选圆心、设置半径后保存即可。</div>
{{end}}
<div class="card-sub" style="margin-top:10px">遮罩只影响访客:站长登录状态下看到的始终是完整迷雾。</div>
</div>
</div>
<script>
(function () {
// Leaflet 由 layout 在 content 之后引入,脚本执行时 window.L 还没有,
// 必须等 load 事件之后再初始化,否则地图整个不出现(与后台「新增地点」同一条约定)
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
var el = document.getElementById('mask-map');
var form = document.getElementById('mask-form');
if (!el || !form) return;
var latIn = form.querySelector('[name=lat]'), lonIn = form.querySelector('[name=lon]');
var idIn = form.querySelector('[name=id]'), nameIn = form.querySelector('[name=name]');
var radiusIn = form.querySelector('[name=radius_m]'), crsIn = form.querySelector('[name=crs]');
var hint = document.getElementById('mask-hint');
var saveBtn = document.getElementById('mask-save');
// 底图走站点配置(与后台「新增地点」同一套取法):带密钥的图层由服务端转发,
// 免密钥的是浏览器直连的 {s} 模板,不能硬写 /tiles/ 路由
var fallback = {
def: { url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', maxZoom: 18, attr: '© 高德地图', crs: 'gcj02' },
subs: ['1', '2', '3', '4']
};
function init(cfg) {
var def = cfg.def;
var map = L.map(el).setView([34.5, 108.9], 4);
L.tileLayer(def.url, { subdomains: cfg.subs, maxZoom: def.maxZoom || 18, minZoom: 3, attribution: def.attr || '' }).addTo(map);
crsIn.value = def.crs || 'gcj02';
// 已有遮罩区画出来(只读),供对照
var existing = L.layerGroup().addTo(map);
[].forEach.call(document.querySelectorAll('[data-edit]'), function (b) {
var lat = parseFloat(b.dataset.lat), lon = parseFloat(b.dataset.lon), r = parseFloat(b.dataset.radius);
L.marker([lat, lon], { opacity: 0.65 }).addTo(existing);
L.circle([lat, lon], { radius: r, color: '#d9534f', weight: 1.5, fillOpacity: 0.12 }).addTo(existing);
});
var marker = null, circle = null;
function place(lat, lon) {
if (!marker) {
marker = L.marker([lat, lon], { draggable: true }).addTo(map);
marker.on('drag', function (e) { circle.setLatLng(e.target.getLatLng()); });
marker.on('dragend', function (e) { fillCoord(e.target.getLatLng()); });
} else {
marker.setLatLng([lat, lon]);
}
if (!circle) {
circle = L.circle([lat, lon], { radius: parseFloat(radiusIn.value) || 500, color: '#d9534f', fillOpacity: 0.18 }).addTo(map);
} else {
circle.setLatLng([lat, lon]);
}
}
function fillCoord(ll) { latIn.value = ll.lat.toFixed(6); lonIn.value = ll.lng.toFixed(6); }
radiusIn.addEventListener('input', function () {
if (circle) circle.setRadius(Math.max(50, parseFloat(radiusIn.value) || 500));
});
map.on('click', function (e) { place(e.latlng.lat, e.latlng.lng); fillCoord(e.latlng); });
marker = null;
// 列表里的「编辑」:把该行灌进表单,地图上跟过去
[].forEach.call(document.querySelectorAll('[data-edit]'), function (b) {
b.addEventListener('click', function () {
idIn.value = b.dataset.id;
nameIn.value = b.dataset.name || '';
radiusIn.value = Math.round(parseFloat(b.dataset.radius) || 500);
var lat = parseFloat(b.dataset.lat), lon = parseFloat(b.dataset.lon);
place(lat, lon);
fillCoord({ lat: lat, lng: lon });
map.setView([lat, lon], 13);
saveBtn.textContent = '保存修改';
hint.textContent = '正在编辑「' + (b.dataset.name || b.dataset.id) + '」,也可以在地图上点选新圆心';
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
});
document.getElementById('mask-reset').addEventListener('click', function () {
form.reset();
idIn.value = '';
radiusIn.value = 500;
if (marker) { map.removeLayer(marker); marker = null; }
if (circle) { map.removeLayer(circle); circle = null; }
saveBtn.textContent = '保存';
hint.textContent = '在下方地图上点选圆心';
});
}
whenReady(function () {
if (!window.L) return;
fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
var def = (c && c.base && c.base.length) ? c.base[0] : fallback.def;
init({ def: def, subs: (c && c.subs && c.subs.length) ? c.subs : fallback.subs });
})
.catch(function () { init(fallback); });
});
})();
</script>
{{end}}
+169
View File
@@ -0,0 +1,169 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>后台管理</h1>
<div class="sub">上传 rond 备份、切换展示的数据、调整站点设置</div>
</div>
<div class="row">
<a class="btn" href="/admin/edit">数据编辑</a>
<a class="btn" href="/admin/topics">专题管理</a>
<a class="btn" href="/admin/photos">照片导入</a>
<a class="btn" href="/admin/settings">站点设置</a>
<a class="btn" href="/admin/twofa">两步验证</a>
<a class="btn" href="/admin/notes">地点备注</a>
<a class="btn" href="/admin/password">账号设置</a>
<a class="btn" href="/" target="_blank">查看站点</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .DefaultPW}}<div class="alert err">⚠️ 您仍在使用初始口令 <span class="mono">rond-admin</span>,站点对公网开放时任何人都可以用这组口令登录后台。请尽快<a href="/admin/password">修改密码</a>,并在<a href="/admin/twofa">两步验证</a>中绑定动态口令。</div>{{end}}
<div class="card">
<h2>上传备份</h2>
<div class="card-sub">
在 rond App 中导出备份,得到 <span class="mono">.rondbackup</span> 文件后上传。
单次最大 {{size .MaxSize}}。
<b>上传是以备份为准重建数据</b>:站点里删掉的地点、改过的记录,只要下次传一份
<b>内容不同</b>的备份,就会被备份里的原样覆盖回来(重复传同一份文件不会,会提示「完全一致」)。
在站点上新加的地点属于当时那个数据集,传新备份后要看它们,可在下面的数据集列表里切回。
</div>
<form method="post" action="/admin/upload" enctype="multipart/form-data">
<label class="f" style="max-width:520px">
<span>选择文件</span>
<input type="file" name="backup" accept=".rondbackup,.zip" required
style="padding:8px;border:1px dashed var(--line);border-radius:8px;background:var(--bg-2)">
</label>
<button class="btn primary" type="submit">上传并导入</button>
</form>
</div>
<div class="card">
<h2>世界迷雾</h2>
<div class="card-sub">
导入 Fog of World 导出的 <span class="mono">.fwss</span> 快照,在足迹地图上叠加已探索区域。
迷雾网格为 WGS-84,站点会自动对高德等 GCJ-02 底图做纠偏。
迷雾是站点级数据,不属于任何数据集,删除数据集不影响迷雾。
{{if gt .FogBlocks 0}}
当前已入库 <span class="mono">{{num .FogBlocks}}</span> 块 / <span class="mono">{{num .FogCells}}</span> 格,
估算已探索 <span class="mono">{{printf "%.1f" .FogArea}}</span> km²{{if .FogUpdatedAt}},最近更新 {{dtt .FogUpdatedAt}}{{end}}。
{{else}}
<b>尚未导入迷雾</b>——把 Fog of World 的 <span class="mono">.fwss</span>(在 App 内导出,通常在 zip 里)上传即可点亮地图。
{{end}}
</div>
<form method="post" action="/admin/fog" enctype="multipart/form-data">
<label class="f" style="max-width:520px">
<span>选择快照</span>
<input type="file" name="fwss" accept=".fwss,.zip" required
style="padding:8px;border:1px dashed var(--line);border-radius:8px;background:var(--bg-2)">
</label>
<label class="f" style="max-width:520px">
<span>导入方式</span>
<select name="mode">
<option value="overlay" selected>叠加 — 保留现有迷雾,把新路线合并进来</option>
<option value="cover">覆盖 — 清空现有迷雾,整体替换</option>
</select>
</label>
<button class="btn primary" type="submit">导入迷雾</button>
</form>
{{if gt .FogBlocks 0}}
<div style="margin:10px 0 0;display:flex;gap:8px;flex-wrap:wrap">
<a class="btn" href="/admin/fog/export">导出当前迷雾(.fwss)</a>
<a class="btn" href="/admin/fog/mask">迷雾遮罩区{{if .FogMasks}}({{.FogMasks}}){{end}}</a>
<form method="post" action="/admin/fog/clear"
onsubmit="return confirm('将删除全部迷雾数据(数据库与地图图层同步清空),且不可恢复。确认清空?')">
<button class="btn danger" type="submit">清空迷雾</button>
</form>
</div>
{{end}}
</div>
<div class="card">
<h2>数据导出</h2>
<div class="card-sub">
把当前展示的数据集导出为通用格式,方便备份或迁移到其他平台。
坐标保持入库时的 GCJ-02,GeoJSON 里已注明坐标系。
</div>
<div class="row">
<a class="btn" href="/admin/export/places.geojson">导出地点(GeoJSON)</a>
<a class="btn" href="/admin/export/places.gpx">导出地点(GPX)</a>
</div>
<div class="card-sub" style="margin-top:14px">
也可导出为 rond 原始格式的 <span class="mono">.rondbackup</span>(含你在<a href="/admin/edit">数据编辑</a>里补录的记录),可直接导回 rond App。
坐标为 GCJ-02,原样写回、无需纠偏。
</div>
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup(可回导 rond)</a>
</div>
<div class="card">
<div class="row" style="justify-content:space-between;align-items:center">
<h2 style="margin:0">已导入的数据集</h2>
<form method="post" action="/admin/places/backfill"
onsubmit="return confirm('对缺地址 / 没名字的地点逐个反查高德补全?(每次处理 100 个)')">
<button class="btn sm" type="submit">补全缺失地址</button>
</form>
</div>
<div class="card-sub">同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它</div>
{{if .Datasets}}
<div class="tw">
<table class="tb">
<thead>
<tr><th>文件</th><th>导入时间</th><th class="num">到访</th><th class="num">地点</th><th>数据跨度</th><th>状态</th><th></th></tr>
</thead>
<tbody>
{{range .Datasets}}
<tr>
<td><span class="mono">{{trunc .Name 34}}</span></td>
<td class="muted">{{dt .UploadedAt}}</td>
<td class="num">{{num .Visits}}</td>
<td class="num">{{num .Places}}</td>
<td class="muted">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
<td>
{{if and $.Dataset (eq $.Dataset.ID .ID)}}
<span class="tag">展示中</span>
{{else}}
<span class="muted">未启用</span>
{{end}}
</td>
<td style="white-space:nowrap">
{{if not (and $.Dataset (eq $.Dataset.ID .ID))}}
<form method="post" action="/admin/datasets/{{.ID}}/activate" style="display:inline">
<button class="btn sm" type="submit">启用</button>
</form>
{{end}}
<form method="post" action="/admin/datasets/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('删除后该数据集的所有记录都会从数据库移除,且不可恢复。确认删除?')">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty"><div class="big">📦</div>还没有导入任何数据集</div>
{{end}}
</div>
<div class="card">
<h2>数据说明</h2>
<div class="card-sub">从备份中读取并转换的字段</div>
<div class="tw">
<table class="tb">
<thead><tr><th>来源表</th><th>内容</th><th>用途</th></tr></thead>
<tbody>
<tr><td class="mono">ZVISIT</td><td>到访记录(到达 / 离开时间、类型、地点、备注、标签)</td><td>时间线、统计、地图</td></tr>
<tr><td class="mono">ZLOCATION</td><td>地点(名称、经纬度、城市、省份、POI 分类)</td><td>地图打点、地点库</td></tr>
<tr><td class="mono">ZACTIVITY</td><td>自定义类型(颜色、图标、是否家 / 工作)</td><td>类型分布</td></tr>
<tr><td class="mono">ZMOVEMENT</td><td>位移段(交通方式、起止时间)</td><td>出行方式统计</td></tr>
<tr><td class="mono">ZTAG</td><td>自定义标签及分组</td><td>标签筛选</td></tr>
<tr><td class="mono">ZHOURLYWEATHER</td><td>逐小时天气(气温、天气现象、湿度)</td><td>天气足迹</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "login"}}<!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>登录后台 · {{.Settings.SiteTitle}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css">
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a}</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">{{.Settings.OwnerAvatar}}</div>
<h1 style="font-size:19px;margin-top:6px">{{.Settings.SiteTitle}}</h1>
<div class="muted" style="font-size:13px">后台管理</div>
</div>
<div class="card" style="margin:0">
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .Flash}}<div class="alert info">{{.Flash}}</div>{{end}}
<form method="post" action="/admin/login">
<input type="hidden" name="next" value="{{.Next}}">
<label class="f"><span>用户名</span><input type="text" name="username" autocomplete="username" autofocus required></label>
<label class="f"><span>密码</span><input type="password" name="password" autocomplete="current-password" required></label>
<label class="f"><span>验证码</span>
{{/* 用 div 而不是 span:label.f > span 会把 display 压成 block,.row 的 flex 就失效了。
输入框要显式 width:auto —— 全局的 input[type=text]{width:100%} 会把图挤到下一行。 */}}
<div class="row" style="flex-wrap:nowrap;gap:8px">
<input type="text" name="captcha" inputmode="latin" autocomplete="off"
spellcheck="false" maxlength="6" required style="flex:1 1 0;width:auto;min-width:0">
<img id="captcha-img" src="/captcha" alt="验证码" width="130" height="40"
title="看不清?点击换一张"
style="border:1px solid var(--line);border-radius:6px;cursor:pointer;flex:0 0 auto">
</div>
</label>
<button class="btn primary" type="submit" style="width:100%;justify-content:center">登录</button>
</form>
<script>
// 换一张:带上时间戳绕开缓存;服务端每次出图都会下发新的 id cookie
document.getElementById('captcha-img').addEventListener('click', function () {
this.src = '/captcha?t=' + Date.now();
});
</script>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:13px">← 返回主页</a></div>
</div>
</div>
</body>
</html>{{end}}
+189
View File
@@ -0,0 +1,189 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>地点备注</h1>
<div class="sub">列出当前数据集的全部地点,可给任意地点起别名、写备注、标「推荐 / 踩雷」;别名会替代原名显示</div>
</div>
<div class="row">
<a class="btn" href="/admin/settings">可见性设置</a>
<a class="btn" href="/admin">← 返回后台</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<div class="card-sub" style="margin:0 0 10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<span>
备注对外{{if .NotesPublic}}<b>公开</b>(访客也能看到){{else}}<b>不公开</b>(只有你能看到){{end}}
· 共 {{num .Total}} 个地点
</span>
<form method="post" action="/admin/notes/rond" style="margin:0">
<button class="btn sm" type="submit"
title="把 rond 备份里按次到访写的备注汇总成地点备注;只重写「来自 rond」那一块,你手写的内容不动">
从 rond 备份回填备注
</button>
</form>
</div>
<form method="get" action="/admin/notes" class="row" style="gap:8px;flex-wrap:wrap;align-items:flex-end;margin:0">
<label class="f" style="margin:0;flex:1 1 200px"><span>关键词</span>
<input type="text" name="q" value="{{.Query}}" placeholder="地点名 / 别名 / 备注 / 城市">
</label>
<label class="f" style="margin:0;flex:0 0 145px"><span>状态</span>
<select name="status">
<option value="all" {{if eq .Status "all"}}selected{{end}}>全部地点</option>
<option value="has" {{if eq .Status "has"}}selected{{end}}>仅有备注</option>
<option value="none" {{if eq .Status "none"}}selected{{end}}>仅无备注</option>
</select>
</label>
<label class="f" style="margin:0;flex:0 0 150px"><span>城市</span>
<select name="city">
<option value="">全部城市</option>
{{range .CityOptions}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label class="f" style="margin:0;flex:0 0 140px"><span>排序</span>
<select name="sort">
<option value="visit" {{if eq .Sort "visit"}}selected{{end}}>到访次数</option>
<option value="note" {{if eq .Sort "note"}}selected{{end}}>最近编辑</option>
<option value="talk" {{if eq .Sort "talk"}}selected{{end}}>票数</option>
<option value="name" {{if eq .Sort "name"}}selected{{end}}>名称</option>
</select>
</label>
<button class="btn primary" type="submit">筛选</button>
<a class="btn" href="/admin/notes">重置</a>
</form>
</div>
{{if .Rows}}
<form id="bulk-form" method="post" action="/admin/notes/bulk"></form>
<div class="card" style="position:sticky;top:8px;z-index:3;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="check" style="margin:0"><input type="checkbox" id="chk-all"> 全选本页</label>
<span class="muted" style="font-size:12.5px">批量:</span>
<select name="verdict" form="bulk-form" style="padding:5px 8px;border:1px solid var(--line);border-radius:8px">
<option value="1">👍 推荐</option>
<option value="-1">👎 踩雷</option>
<option value="0">取消结论</option>
</select>
<button class="btn sm" type="submit" form="bulk-form" name="action" value="verdict">设为该结论</button>
<button class="btn sm" type="submit" form="bulk-form" name="action" value="clear"
onclick="return confirm('清空所选地点的手写备注?(别名与「来自 rond」的备注都会保留)')">清空备注</button>
<button class="btn sm danger" type="submit" form="bulk-form" name="action" value="delete"
onclick="return confirm('删除所选地点的手写备注与别名?(rond 备份里的备注下次回填还会再出现)')">删除备注</button>
</div>
<div class="card" style="padding:0">
<div class="list">
{{range .Rows}}
<div class="item" style="grid-template-columns:28px 1fr;gap:10px;align-items:stretch">
<div style="display:flex;align-items:flex-start;padding-top:10px">
<input type="checkbox" name="src_pk" value="{{.SrcPK}}" form="bulk-form" class="row-chk">
</div>
<div>
<form method="post" action="/admin/place/note" style="display:grid;grid-template-columns:1fr 1.4fr auto;gap:10px;align-items:end">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<input type="hidden" name="next" value="{{$.BackURL}}">
<label class="f" style="margin:0">
<span>别名{{if .PlaceName}}(原名:{{.PlaceName}}{{if .PlaceCity}} · {{.PlaceCity}}{{end}}){{end}}</span>
<input type="text" name="alias" value="{{.Alias}}" maxlength="60" placeholder="{{.PlaceName}}">
</label>
<label class="f" style="margin:0">
<span>备注{{if .PlaceID}} <a href="/places/{{.PlaceID}}" target="_blank">查看 ↗</a>{{end}}</span>
<input type="text" name="note" value="{{.Content}}" maxlength="2000">
</label> <div class="row" style="gap:6px;flex-wrap:nowrap">
<button class="btn primary" type="submit">保存</button>
<button class="vote-btn up {{if eq .Verdict 1}}on{{end}}" type="button"
data-verdict-btn data-src="{{.SrcPK}}" data-v="1" title="标为推荐">👍</button>
<button class="vote-btn down {{if eq .Verdict -1}}on{{end}}" type="button"
data-verdict-btn data-src="{{.SrcPK}}" data-v="-1" title="标为踩雷">👎</button>
</div>
</form>
<div class="muted" style="font-size:12px;margin-top:4px">
地点 ID {{.SrcPK}}{{if .VisitCount}} · 到访 {{num .VisitCount}} 次{{end}}
<span class="vote-tip" data-verdict-status="{{.SrcPK}}">{{if eq .Verdict 1}} · 已标推荐{{else if eq .Verdict -1}} · 已标踩雷{{end}}</span>
{{if or .Up .Down}} · 访客 👍{{.Up}} 👎{{.Down}}{{end}}
{{if .Content}} · 更新于 {{dt .UpdatedAt}}{{end}}
</div>
{{if .RondNote}}<div class="note-block">来自 rond:{{.RondNote}}</div>{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
<script>
(function () {
var all = document.getElementById('chk-all');
var boxes = document.querySelectorAll('.row-chk');
if (all) {
all.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = all.checked; });
});
}
// 行内 👍/👎 用 AJAX(原来是 formaction 提交 → 整页刷新,会把同一行没保存的备注编辑一起丢掉)
document.querySelectorAll('.vote-btn[data-verdict-btn]').forEach(function (b) {
b.addEventListener('click', function () {
var src = b.getAttribute('data-src');
var v = Number(b.getAttribute('data-v'));
fetch('/api/verdict', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: 'src_pk=' + src + '&verdict=' + (b.classList.contains('on') ? 0 : v)
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
var cur = Number(d.verdict) || 0;
b.parentElement.querySelectorAll('.vote-btn').forEach(function (x) {
x.classList.toggle('on', Number(x.getAttribute('data-v')) === cur);
});
var st = document.querySelector('[data-verdict-status="' + src + '"]');
if (st) {
st.className = 'vote-tip' + (cur === 1 ? ' up' : cur === -1 ? ' down' : '');
st.textContent = cur === 1 ? ' · 已标推荐' : cur === -1 ? ' · 已标踩雷' : '';
}
}).catch(function () {});
});
});
})();
</script>
{{else}}
<div class="empty"><div class="big">📝</div>{{if .Query}}没有匹配的地点{{else}}当前数据集里还没有地点{{end}}</div>
{{end}}
{{if .Orphans}}
<div class="card" style="padding:0;margin-top:16px">
<div style="padding:12px 14px 4px"><h2 style="margin:0">已不在当前数据集的备注</h2>
<div class="card-sub">这些备注挂在当前备份里已不存在的地点上(换了快照的旧地点),可逐条清理。</div>
</div>
<div class="list">
{{range .Orphans}}
<div class="item" style="grid-template-columns:1fr auto;gap:10px;align-items:center">
<div>
<div>{{if .Alias}}{{.Alias}}{{else}}<span class="muted">(无别名)</span>{{end}}{{if .Content}} · <span class="muted">{{.Content}}</span>{{end}}{{if .RondNote}} · <span class="muted">来自 rond:{{.RondNote}}</span>{{end}}</div>
<div class="muted" style="font-size:12px">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
</div>
<form method="post" action="/admin/place/note">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<input type="hidden" name="alias" value="">
<input type="hidden" name="note" value="">
<input type="hidden" name="next" value="{{$.BackURL}}">
<button class="btn danger" type="submit" onclick="return confirm('删除这条备注?')">删除</button>
</form>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
@@ -0,0 +1,35 @@
{{define "content"}}
<div class="wrap" style="max-width:520px">
<div class="page-head">
<div>
<h1>账号设置</h1>
<div class="sub">修改密码后当前会话会失效,需要用新密码重新登录</div>
</div>
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">用户名</h2>
{{if eq .Flash "name"}}<div class="alert ok">用户名已更新</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<form method="post" action="/admin/username">
<label class="f"><span>用户名(2~32 个字符,不含空格)</span><input type="text" name="username" value="{{.Username}}" maxlength="32" autocomplete="username" required></label>
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
<div class="row"><button class="btn primary" type="submit">修改用户名</button></div>
</form>
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">密码</h2>
{{if and .Flash (ne .Flash "name")}}<div class="alert ok">密码已更新</div>{{end}}
<form method="post" action="/admin/password">
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
<label class="f"><span>新密码(至少 8 位)</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<label class="f"><span>确认新密码</span><input type="password" name="confirm" autocomplete="new-password" minlength="8" required></label>
<div class="row">
<button class="btn primary" type="submit">修改密码</button>
<a class="btn" href="/admin">返回后台</a>
</div>
</form>
</div>
</div>
{{end}}
+746
View File
@@ -0,0 +1,746 @@
{{define "content"}}
{{/* 解析器放最前:它必须在用户选完文件之前就绪,放页面底部会晚一步 */}}
<script src="/static/js/photo-exif.js?v={{.AssetV}}"></script>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
{{/* 选项数据放隐藏 select 里给脚本读:内联 JSON 会被模板的 JS 上下文转义搞坏 */}}
<select id="category-options" hidden>
<option value="">未指定</option>
{{range .Categories}}<option value="{{.Label}}">{{.Name}}</option>{{end}}
<option value="__custom__">自定义…</option>
</select>
<select id="activity-options" hidden>
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<select id="tag-options" hidden>
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<div class="wrap">
<div class="page-head">
<div>
<h1>照片导入</h1>
<div class="sub">从照片的 EXIF 读拍摄时间与坐标,反查地址后逐条确认,再写入数据库</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<h2>1. 选照片与坐标系</h2>
<div class="card-sub">
支持 JPEG 与 HEIC/HEIF。<b>EXIF 在浏览器里读取,照片不会上传到服务器</b>——
一次几十张、几百 MB 都没有上传耗时或存储压力。
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<span class="muted" style="font-size:12.5px">照片里的坐标是</span>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="wgs84" checked> WGS-84(绝大多数手机)</label>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="gcj02"> 已经是 GCJ-02(部分国产手机会这样写)</label>
</div>
{{if not .GeocodeReady}}
<div class="alert err" style="margin-bottom:10px">未配置高德 <span class="mono">geocode_key</span>,无法反查地址,只能靠坐标判断。</div>
{{end}}
<div class="row" style="align-items:center;gap:10px">
<label class="btn primary" for="photo-input" style="cursor:pointer">+ 选择照片</label>
<input type="file" id="photo-input" accept="image/*" multiple hidden>
<span class="muted" id="pick-count" style="font-size:12.5px">可多选,JPEG / HEIC 都行</span>
</div>
<div id="parse-status" class="alert info"></div>
</div>
<form method="post" action="/admin/photos/import" id="import-form" enctype="multipart/form-data">
<input type="hidden" name="n" id="row-count" value="0">
<input type="hidden" name="coord" id="coord-field" value="wgs84">
<div class="card" id="preview-card" style="display:none">
<h2>2. 确认与修改</h2>
<div class="card-sub">对照「地址」列确认点是否正确;点该行的「更多」可以改类别、省市区街道、活动、标签等——字段与<a href="/admin/edit">新增地点</a>完全一致。</div>
<div id="map-card" style="display:none">
<div id="photo-map" class="photo-map"></div>
<div class="card-sub" style="margin:6px 2px 0">地图上是所有已读取的点(坐标已按上面的选择换算);点跑到国外或海里说明坐标系选错了。<b>鼠标移到下面任意一行,地图上对应的点会跳出来</b>,点「地图」可以看单点大图。</div>
</div>
<div class="kpis" style="margin:12px 0 14px">
<div class="kpi"><div class="v" id="k-total">0</div><div class="k">读取到的照片</div></div>
<div class="kpi"><div class="v" id="k-gps">0</div><div class="k">含坐标</div></div>
<div class="kpi"><div class="v" id="k-addr">0</div><div class="k">地址反查成功</div></div>
<div class="kpi"><div class="v" id="k-todo">0</div><div class="k">待补坐标 / 时间</div></div>
</div>
{{/* 固定列宽比例:输入框跟着列宽走,表格就不会被撑宽到需要横向拖动 */}}
<div class="tw">
<table class="tb photo-table">
<colgroup>
<col style="width:4%"><col style="width:7%"><col style="width:10%"><col style="width:11%">
<col style="width:11%"><col style="width:19%"><col style="width:12%"><col style="width:12%">
<col style="width:14%">
</colgroup>
<thead>
<tr>
<th>导入</th><th>存图</th><th>文件</th><th>地点名</th><th>坐标</th><th>地址(反查)</th>
<th>到达时间</th><th>离开时间</th><th>说明</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="row" style="margin-top:12px;gap:10px">
<button class="btn primary" type="submit">写入数据库</button>
<button class="btn" type="button" id="retry-btn">重新反查未成功的</button>
<button class="btn" type="button" id="reset-btn">重新选照片</button>
</div>
<div class="req-tip" style="margin-top:8px">导入后每张照片会生成一个地点 + 一条到访(标记为「补录」),可在<a href="/admin/edit">数据编辑</a>里继续调整或删除。</div>
</div>
</form>
</div>
{{/* 单个点位的地图:总图留着看分布,这一个用来看「这个点到底在哪」 */}}
<div id="point-modal" style="display:none">
<div class="point-modal-box">
<div class="point-modal-head">
<b id="point-modal-title">点位</b>
<button class="btn sm" type="button" id="point-modal-close">关闭</button>
</div>
<div id="point-modal-map" class="point-modal-map"></div>
<div id="point-modal-meta" class="card-sub" style="margin:8px 2px 0"></div>
</div>
</div>
<script>
(function () {
var DEFAULT_STAY_MIN = 10;
var input = document.getElementById('photo-input');
var rowsBox = document.getElementById('rows');
var previewCard = document.getElementById('preview-card');
var mapCard = document.getElementById('map-card');
var status = document.getElementById('parse-status');
var rowCount = document.getElementById('row-count');
var coordField = document.getElementById('coord-field');
if (!input || !window.rondExif) return;
function coordSel() {
var el = document.querySelector('input[name=coord-sel]:checked');
return el ? el.value : 'wgs84';
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
// datetime-local 需要的本地时间串(EXIF 没有时区,按拍摄地当地时间理解)
function dtLocal(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function tInput(name, value, placeholder, width) {
var el = document.createElement('input');
el.type = 'text';
el.name = name;
el.value = value || '';
el.placeholder = placeholder || '';
// 宽度交给列宽(photo-table 是 fixed 布局,input 撑满格子即可)
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function dtInput(name, value) {
var el = document.createElement('input');
el.type = 'datetime-local';
el.name = name;
el.value = value || '';
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function fieldLabel(text, control, flex) {
var lab = document.createElement('label');
lab.className = 'f';
lab.style.cssText = flex + ';margin-bottom:0';
var sp = document.createElement('span');
sp.textContent = text;
lab.appendChild(sp);
lab.appendChild(control);
return lab;
}
// 更多里的分组小标题:一堆输入框挤在一起像锅粥,每组给一句说明
function groupCap(text) {
var d = document.createElement('div');
d.className = 'card-sub';
d.style.cssText = 'margin:0 0 4px';
d.textContent = text;
return d;
}
// 鼠标划到某一行时把图上那个点挑出来(index 为 null 表示复原)。
// 记一下当前高亮的是谁:鼠标在「主行 ↔ 更多块」之间移动会连发几次事件,
// 行号没变就不必让地图重设一遍样式(会闪)。
var hoveredRow = null;
function highlightRow(i) {
if (hoveredRow === i) return;
hoveredRow = i;
if (!window.rondMap) return;
window.rondMap.highlight('photo-map', i === null ? null : 'row-' + i);
}
// 主行和它的「更多」块是两条独立的 <tr>,各自绑 mouseleave 的话,
// 鼠标一进「更多」块就会被主行的 leave 把高亮清掉——而改字段往往就在那块里。
// 所以两行打上同一个行号,统一按「光标底下那一行是谁」来算。
rowsBox.addEventListener('mouseover', function (e) {
var tr = e.target && e.target.closest ? e.target.closest('tr') : null;
highlightRow(tr && tr.dataset.rowIndex !== undefined ? Number(tr.dataset.rowIndex) : null);
});
rowsBox.addEventListener('mouseleave', function () { highlightRow(null); });
// 按隐藏 select 的选项克隆一个独立的下拉(每行一份,互不影响)
function makeSelect(name, srcId) {
var src = document.getElementById(srcId);
var sel = document.createElement('select');
sel.name = name;
if (src) {
[].forEach.call(src.options, function (o) {
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.textContent;
sel.appendChild(opt);
});
}
return sel;
}
function setVal(name, v) {
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
if (el && v) el.value = v;
}
function valOf(idx, field) {
var el = rowsBox.querySelector('[name="' + field + '_' + idx + '"]');
return el ? el.value : '';
}
// 一行 = 主行(常用字段)+ 默认收起的「更多」行(字段与新增地点一致)
function addRow(index, file, info, errMsg) {
var tr = document.createElement('tr');
var td = document.createElement('td');
var keep = document.createElement('input');
keep.type = 'checkbox';
keep.name = 'keep_' + index;
keep.checked = !!(info && info.hasGPS); // 没读到坐标的默认不导入
td.appendChild(keep);
tr.appendChild(td);
// 存图:勾上就把这张照片在浏览器里转成 WebP 一起提交,作为该地点的配图。
// 默认不勾——一次导入几十张全存会占不少空间,也可能把相册里的截图一起带进去。
td = document.createElement('td');
var keepImg = document.createElement('input');
keepImg.type = 'checkbox';
keepImg.name = 'keepimg_' + index;
keepImg.title = '把这张照片在浏览器里转成 WebP(长边 1440px、约 100~250KB)存为该地点的图片,地点页可查看、可删';
td.appendChild(keepImg);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
td.textContent = file.name;
td.title = file.name;
tr.appendChild(td);
td = document.createElement('td');
var initialName = file.name.replace(/\.[^.]+$/, '');
var nameInput = tInput('name_' + index, initialName, '地点名', '120px');
nameInput.dataset.initial = initialName;
td.appendChild(nameInput);
tr.appendChild(td);
td = document.createElement('td');
var latEl = tInput('lat_' + index, info && info.hasGPS ? info.lat.toFixed(6) : '', '纬度', '98px');
var lonEl = tInput('lon_' + index, info && info.hasGPS ? info.lon.toFixed(6) : '', '经度', '98px');
latEl.style.marginBottom = '4px';
td.appendChild(latEl);
td.appendChild(lonEl);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:260px';
var addrEl = document.createElement('div');
addrEl.textContent = '—';
td.appendChild(addrEl);
tr.appendChild(td);
var arrival = '', departure = '';
if (info && info.hasTime && info.shot) {
arrival = dtLocal(info.shot);
departure = dtLocal(new Date(info.shot.getTime() + DEFAULT_STAY_MIN * 60000));
}
td = document.createElement('td');
td.appendChild(dtInput('arrival_' + index, arrival));
tr.appendChild(td);
td = document.createElement('td');
td.appendChild(dtInput('departure_' + index, departure));
tr.appendChild(td);
var notes = [];
if (errMsg) notes.push(errMsg);
if (info && info.hasGPS && !info.hasTime) notes.push('没有拍摄时间,请手工填写');
if (info && !info.hasGPS && info.hasTime) notes.push('没有 GPS 坐标,请手工填写');
td = document.createElement('td');
td.className = 'muted';
td.style.maxWidth = '220px';
var noteEl = document.createElement('div');
noteEl.textContent = notes.join(';');
td.appendChild(noteEl);
// 操作链接单独一行且整体不折行——中文默认能在任意字间断开,
// 「地图」会被拆成「地」「图」两行
var acts = document.createElement('div');
acts.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-top:6px';
var mapLink = document.createElement('a');
mapLink.href = 'javascript:void(0)';
mapLink.textContent = '地图';
mapLink.className = 'btn sm';
mapLink.addEventListener('click', function () {
var lat = parseFloat(valOf(index, 'lat')), lon = parseFloat(valOf(index, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) { window.alert('这一行还没有坐标'); return; }
openPointMap(lat, lon, valOf(index, 'name'), addrEl.textContent);
});
acts.appendChild(mapLink);
var moreLink = document.createElement('a');
moreLink.href = 'javascript:void(0)';
moreLink.textContent = '更多';
moreLink.className = 'btn sm';
acts.appendChild(moreLink);
td.appendChild(acts);
tr.appendChild(td);
rowsBox.appendChild(tr);
// ---- 更多:地点属性(与新增地点一致)----
var tr2 = document.createElement('tr');
tr2.style.display = 'none';
var td2 = document.createElement('td');
// 主行是 9 列(colSpan 少一列的话,这块灰底就比上面窄一格,看着很突兀)
td2.colSpan = 9;
td2.style.cssText = 'background:var(--bg-2);padding:12px 14px'
var row1 = document.createElement('div');
row1.className = 'row';
var catSel = makeSelect('category_' + index, 'category-options');
var catCustom = tInput('category_custom_' + index, '', '如 公园', '140px');
var catCustomWrap = fieldLabel('自定义类别', catCustom, 'flex:0 1 150px');
catCustomWrap.style.display = 'none';
catSel.addEventListener('change', function () {
catCustomWrap.style.display = catSel.value === '__custom__' ? '' : 'none';
});
td2.appendChild(groupCap('地点属性'));
row1.appendChild(fieldLabel('类别', catSel, 'flex:0 1 160px'));
row1.appendChild(catCustomWrap);
[
['country_' + index, '国家代码', 'CN', '90px'],
['province_' + index, '省份', '', '100px'],
['city_' + index, '城市', '', '100px'],
['district_' + index, '区县', '', '100px'],
['street_' + index, '街道', '', '140px'],
['timezone_' + index, '时区', 'Asia/Shanghai', '130px']
].forEach(function (spec) {
row1.appendChild(fieldLabel(spec[1], tInput(spec[0], spec[2], '', spec[3]), 'flex:0 1 ' + spec[3]));
});
td2.appendChild(row1);
// ---- 更多:到访属性(与新增到访一致)----
var row2 = document.createElement('div');
row2.className = 'row';
var cap2 = groupCap('到访属性');
cap2.style.marginTop = '14px';
td2.appendChild(cap2);
row2.appendChild(fieldLabel('活动', makeSelect('activity_' + index, 'activity-options'), 'flex:0 1 170px'));
// 备注不设 flex-grow:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
var favWrap = document.createElement('label');
favWrap.className = 'check';
favWrap.style.cssText = 'margin:0;flex:0 0 auto;align-self:flex-end;padding-bottom:9px';
var fav = document.createElement('input');
fav.type = 'checkbox';
fav.name = 'bookmarked_' + index;
favWrap.appendChild(fav);
favWrap.appendChild(document.createTextNode(' 标记为收藏'));
row2.appendChild(favWrap);
td2.appendChild(row2);
// ---- 更多:标签(多选 chips)----
var tagSrc = document.getElementById('tag-options');
if (tagSrc && tagSrc.options.length) {
var cap3 = groupCap('标签');
cap3.style.marginTop = '14px';
td2.appendChild(cap3);
var row3 = document.createElement('div');
row3.className = 'chips';
[].forEach.call(tagSrc.options, function (o) {
var chip = document.createElement('label');
chip.className = 'chip';
chip.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.name = 'tags_' + index;
cb.value = o.value;
cb.style.marginRight = '5px';
chip.appendChild(cb);
chip.appendChild(document.createTextNode(o.textContent));
row3.appendChild(chip);
});
td2.appendChild(row3);
}
tr2.appendChild(td2);
rowsBox.appendChild(tr2);
moreLink.addEventListener('click', function () {
var show = tr2.style.display === 'none';
tr2.style.display = show ? '' : 'none';
moreLink.textContent = show ? '收起' : '更多';
});
// 行号打在两行 <tr> 上(主行 + 「更多」行),rowsBox 的 mouseover 靠它找回来
tr.dataset.rowIndex = index;
tr2.dataset.rowIndex = index;
return { addrEl: addrEl, file: file, keepImg: keepImg };
}
function initPickers() {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
if (el._flatpickr) return;
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
}
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
function lookup(coords) {
return fetch('/api/regeo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coord: coordSel(), points: coords })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) throw new Error(d.error);
return d.items || [];
});
}
// 世代号:连续选两次文件时,旧那一轮的解析结果一律丢弃,避免表格出现重复行
var gen = 0;
// rowRefs 提到外层:applyOne 与「重新反查」按钮都要按行号找回那一行的地址元素
var rowRefs = {};
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
if (!files.length) return;
var my = ++gen;
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = '';
mapCard.style.display = 'none';
status.textContent = '正在读取 ' + files.length + ' 张照片的 EXIF…';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '已选 ' + files.length + ' 张';
rowRefs = {};
var done = 0, withGPS = 0;
var chain = Promise.resolve();
files.forEach(function (file, i) {
chain = chain.then(function () {
if (my !== gen) return;
return window.rondExif.read(file).then(function (info) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, info, null);
if (info.hasGPS) withGPS++;
}).catch(function (err) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, null, (err && err.message) || '读取失败');
}).then(function () {
if (my !== gen) return;
done++;
status.textContent = '正在读取 ' + done + '/' + files.length + '…';
initPickers();
});
});
});
chain.then(function () {
if (my !== gen) return;
rowCount.value = String(files.length);
// EXIF 一读完就把数字亮出来(此时还没反查,地址数为 0);后面每条路径都会再刷
updateKpis();
if (!withGPS) {
status.textContent = '已读取 ' + files.length + ' 张,但都没有可用的 GPS 坐标。';
return;
}
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),正在反查地址…';
// 收集坐标:key 用行号,服务端去重后仍能配回每一行
var coords = [], owner = [];
files.forEach(function (file, i) {
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
coords.push({ lat: lat, lon: lon, key: String(i) });
owner.push(i);
});
if (!coords.length) {
status.textContent = '已读取 ' + files.length + ' 张,没有可反查的坐标。';
return;
}
lookup(coords).then(function (items) {
if (my !== gen) return;
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
owner.forEach(function (i) {
var it = byKey[String(i)];
if (!it || !it.address) return;
hit++;
applyOne(i, it);
});
finishLookup(files.length, withGPS, hit, owner.length);
}).catch(function (err) {
if (my !== gen) return;
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),但地址反查失败:' +
(err.message || err) + '。可以点「重新反查未成功的」再试,或只看坐标判断。';
drawMap();
});
});
});
// applyOne 把一条反查结果写回它的行:地址、店名、坐标、省市区街道、地点名
function applyOne(i, it) {
if (rowRefs[i]) {
// 有店名时显示成「店名 · 完整地址」,比干巴巴的街道好认
var text = it.address || '';
if (it.poi) {
text = it.poi + (it.poiDist ? '(' + Math.round(it.poiDist) + 'm)' : '') + ' · ' + text;
}
rowRefs[i].addrEl.textContent = text;
rowRefs[i].addrEl.title = text;
}
// 服务端返回的是换算后的 GCJ-02 坐标,写回表格保证导入的就是它
if (it.lat && it.lon) {
var latEl = rowsBox.querySelector('input[name=lat_' + i + ']');
var lonEl = rowsBox.querySelector('input[name=lon_' + i + ']');
if (latEl) latEl.value = it.lat.toFixed(6);
if (lonEl) lonEl.value = it.lon.toFixed(6);
}
setVal('province_' + i, it.province);
setVal('city_' + i, it.city);
setVal('district_' + i, it.district);
setVal('street_' + i, it.street);
if (it.country) setVal('country_' + i, it.country);
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
if (nameEl && nameEl.value === nameEl.dataset.initial) {
// 名称还停在文件名(用户没改过)时优先用店名,其次「区县 + 街道/乡镇」
var better = it.poi || [it.district, it.street || it.township].filter(Boolean).join('');
if (better) nameEl.value = better;
}
}
// 四个数字比一行长句子好扫。待补=没有坐标或没有到达时间的行(这两项不补就导不进去)
function kpi(id, v) { var el = document.getElementById(id); if (el) el.textContent = String(v); }
// 全部从 DOM 现算,不靠调用方传参:谁改了表格谁就调一次 updateKpis() 即可。
// 之前是「哪些路径记得传参才更新」,漏了「全都没 GPS」「没有可反查的坐标」「重新反查」
// 这几条,数字会停在上一批的值上——比显示 0 更容易误导。
function updateKpis() {
var total = 0, gps = 0, addr = 0, todo = 0;
Object.keys(rowRefs).forEach(function (k) {
var i = Number(k);
total++;
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
var hasPoint = !isNaN(lat) && !isNaN(lon) && !!(lat || lon);
if (hasPoint) gps++;
// 地址列还停在占位符 = 这一行没反查成功(与「重新反查」挑行的判据同源)
var ref = rowRefs[i];
if (ref && ref.addrEl && ref.addrEl.textContent !== '—') addr++;
if (!hasPoint || !valOf(i, 'arrival')) todo++;
});
kpi('k-total', total); kpi('k-gps', gps); kpi('k-addr', addr); kpi('k-todo', todo);
}
// 手工改过坐标/时间之后数字要跟着变,不然一直挂在旧值上
rowsBox.addEventListener('change', updateKpis);
function finishLookup(total, withGPS, hit, asked) {
status.textContent = '已读取 ' + total + ' 张(' + withGPS + ' 张含坐标),' +
hit + '/' + asked + ' 个点位反查成功' +
(hit < asked ? ',可点「重新反查未成功的」再试' : '') + '。确认无误后点下面的按钮写入数据库。';
drawMap();
}
// ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ----
var modal = document.getElementById('point-modal');
var modalInited = false;
function openPointMap(lat, lon, name, addr) {
document.getElementById('point-modal-title').textContent = name || '点位';
document.getElementById('point-modal-meta').textContent =
(addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6);
modal.style.display = 'flex';
var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }];
if (!modalInited) {
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
window.rondMap.preview({ id: 'point-modal-map', points: pts });
modalInited = true;
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
var pin = function () {
var st = window.rondMap._state['point-modal-map'];
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
};
setTimeout(pin, 400);
setTimeout(pin, 1200);
} else {
var st = window.rondMap._state['point-modal-map'];
if (st && st.setPoints) st.setPoints(pts);
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
}
}
function closePointMap() { modal.style.display = 'none'; }
document.getElementById('point-modal-close').addEventListener('click', closePointMap);
modal.addEventListener('click', function (e) { if (e.target === modal) closePointMap(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePointMap(); });
function drawMap() {
// 放最前面:这是「表格改完了」的统一出口,KPI 在这里收口就不会再漏路径
updateKpis();
if (!window.rondMap) return;
var points = [];
[].forEach.call(rowsBox.querySelectorAll('input[name^=lat_]'), function (el) {
var i = el.name.replace('lat_', '');
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
var lat = parseFloat(el.value), lon = parseFloat(valOf(i, 'lon'));
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
points.push({ lat: lat, lon: lon, n: nameEl ? nameEl.value : '', v: 1, key: 'row-' + i });
});
if (!points.length) { mapCard.style.display = 'none'; return; }
mapCard.style.display = '';
window.rondMap.preview({ id: 'photo-map', points: points });
syncStuck();
}
// 图粘住时收矮(.stuck):不收的话 200px 的浮层会挡住从下面滚过去的半个表格。
// 容器高度变了必须告诉 Leaflet 重新量一次,否则底图会缺一块。
// 导航高度只在窗口尺寸变化时才会变,所以在滚动路径上不能每帧去解析 --nav-h
// (读自定义属性会触发一次样式计算);滚动里只留必须实时的 getBoundingClientRect。
var stuckOffset = 0;
function measureNav() {
stuckOffset = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h') || '54') + 8;
}
function syncStuck() {
var stuck = mapCard.getBoundingClientRect().top <= stuckOffset + 2;
if (stuck === mapCard.classList.contains('stuck')) return;
mapCard.classList.toggle('stuck', stuck);
var st = window.rondMap && window.rondMap._state['photo-map'];
if (st && st.map) setTimeout(function () { st.map.invalidateSize(); }, 260);
}
measureNav();
window.addEventListener('scroll', syncStuck, { passive: true });
window.addEventListener('resize', function () { measureNav(); syncStuck(); });
document.getElementById('retry-btn').addEventListener('click', function () {
var todo = [];
Object.keys(rowRefs).forEach(function (k) {
var ref = rowRefs[k];
// 地址列还是占位符说明这行没查到(或查失败)
if (!ref || !ref.addrEl || ref.addrEl.textContent !== '—') return;
var lat = parseFloat(valOf(k, 'lat')), lon = parseFloat(valOf(k, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
todo.push({ lat: lat, lon: lon, key: String(k) });
});
if (!todo.length) { status.textContent = '没有需要重新反查的行。'; return; }
status.textContent = '正在重新反查 ' + todo.length + ' 个点位…';
lookup(todo).then(function (items) {
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
todo.forEach(function (c) {
var it = byKey[c.key];
if (!it || !it.address) return;
hit++;
applyOne(Number(c.key), it);
});
status.textContent = '重新反查完成:' + hit + '/' + todo.length + ' 个成功' +
(hit < todo.length ? '(其余的周边可能确实没有收录,或坐标不在境内)' : '') + '。';
drawMap();
}).catch(function (err) {
status.textContent = '重新反查失败:' + ((err && err.message) || err);
});
});
document.getElementById('reset-btn').addEventListener('click', function () {
gen++;
input.value = '';
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = 'none';
mapCard.style.display = 'none';
status.textContent = '';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '可多选,JPEG / HEIC 都行';
window.scrollTo(0, 0);
});
[].forEach.call(document.querySelectorAll('input[name=coord-sel]'), function (el) {
el.addEventListener('change', function () { coordField.value = coordSel(); });
});
/* ---------- 提交前把勾了「存图」的照片转成 WebP ----------
* 转码是异步的,所以先拦下这次提交,转完再 form.submit() 放行;
* form.submit() 不触发 submit 事件,不会自己套自己。
* 转好的字节塞进隐藏的 <input type=file>,随表单一起以 multipart 发出去。
*/
var form = document.getElementById('import-form');
var submitBtn = form.querySelector('button[type=submit]');
form.addEventListener('submit', function (e) {
if (form.dataset.ready === '1') return; // 放行那一趟
var picked = [];
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
if (!cb.checked) return;
var i = Number(cb.name.slice('keepimg_'.length));
var ref = rowRefs[i];
if (ref && ref.file) picked.push({ i: i, file: ref.file, cb: cb });
});
if (!picked.length) { form.dataset.ready = '1'; return; }
// 服务端对整包有 60MB 上限:按转码后的体积(约 100~250KB)估,这里保守按 200 张提醒一次
if (picked.length > 200 && !window.confirm('这次勾了 ' + picked.length + ' 张要存为图片,' +
'总量可能超过服务端 60MB 的上限导致整包提交失败。建议分两次导入,是否继续?')) {
if (submitBtn) submitBtn.disabled = false;
return;
}
e.preventDefault();
if (submitBtn) submitBtn.disabled = true;
var at = 0, failed = 0;
function next() {
if (at >= picked.length) {
if (failed) {
// 有转码失败的:提示后仍然继续导入地点,只是那几张不带图
status.textContent = '已跳过 ' + failed + ' 张转码失败的图片,正在写入数据库…';
} else {
status.textContent = '图片已就绪,正在写入数据库…';
}
form.dataset.ready = '1';
form.submit();
return;
}
var it = picked[at++];
status.textContent = '正在转码图片 ' + at + '/' + picked.length + '…';
window.rondImage.toWebP(it.file).then(function (out) {
var holder = document.createElement('input');
holder.type = 'file';
holder.name = 'image_' + it.i;
holder.style.display = 'none';
var dt = new DataTransfer();
dt.items.add(new File([out.blob], 'image.webp', { type: 'image/webp' }));
holder.files = dt.files;
form.appendChild(holder);
// 宽高随文件一起发:服务端不解码图片,只记账
[['image_w_', out.width], ['image_h_', out.height]].forEach(function (pair) {
var h = document.createElement('input');
h.type = 'hidden';
h.name = pair[0] + it.i;
h.value = pair[1];
form.appendChild(h);
});
}).catch(function (err) {
failed++;
it.cb.checked = false;
status.textContent = '第 ' + (it.i + 1) + ' 行的图片转码失败(已跳过):' +
((err && err.message) || err);
}).then(next);
}
next();
});
})();
</script>
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>站点设置</h1>
<div class="sub">控制主页展示的模块、隐私处理与地图底图</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">设置已保存</div>{{end}}
<form method="post" action="/admin/settings">
<div class="card">
<h2>基本信息</h2>
<div class="card-sub">展示在主页顶部与浏览器标题</div>
<div class="grid g2">
<label class="f"><span>站点标题</span><input type="text" name="site_title" value="{{.Settings.SiteTitle}}" maxlength="40"></label>
<label class="f"><span>副标题</span><input type="text" name="site_subtitle" value="{{.Settings.SiteSubtitle}}" maxlength="80" placeholder="记录我去过的每一个地方"></label>
<label class="f"><span>署名</span><input type="text" name="owner_name" value="{{.Settings.OwnerName}}" maxlength="30"></label>
<label class="f"><span>头像(单个 emoji)</span><input type="text" name="owner_avatar" value="{{.Settings.OwnerAvatar}}" maxlength="8"></label>
</div>
<label class="f"><span>主色调</span>
<div class="row">
<input type="color" name="accent_color" value="{{.Settings.AccentColor}}" style="width:56px;height:34px;padding:2px">
<span class="muted">用于按钮、图表与选中状态</span>
</div>
</label>
</div>
<div class="card">
<h2>主页展示模块</h2>
<div class="card-sub">取消勾选的模块不会出现在主页上;一个都不选时按全部展示处理</div>
<div class="grid g2">
{{range .Modules}}
<label class="check">
<input type="checkbox" name="modules" value="{{.Key}}" {{if $.Settings.Has .Key}}checked{{end}}>
<span><b style="font-weight:500">{{.Name}}</b> <span class="muted">— {{.Desc}}</span></span>
</label>
{{end}}
</div>
</div>
<div class="card">
<h2>公开页面</h2>
<div class="card-sub">控制访客能在导航里看到并访问哪些页面;你登录后始终可以访问全部页面</div>
<div class="grid g2">
{{range .Pages}}
<label class="check">
<input type="checkbox" name="pages" value="{{.Key}}" {{if $.Settings.HasPage .Key}}checked{{end}}>
<span><b style="font-weight:500">{{.Name}}</b> <span class="muted">— {{.Desc}}</span></span>
</label>
{{end}}
</div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
关闭的页面在访客导航中隐藏,直接输入网址也会得到 404。主页不受此开关影响。
</div>
<label class="f" style="max-width:320px;margin-top:14px">
<span>访客默认页面</span>
<select name="visitor_home">
<option value="home" {{if eq .Settings.VisitorHome "home"}}selected{{end}}>主页(默认)</option>
{{range .Pages}}<option value="{{.Key}}" {{if eq $.Settings.VisitorHome .Key}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<div class="muted" style="font-size:12.5px;margin-top:6px">
访客访问站点根路径时直接落到这里;你登录后访问根路径仍然看主页。
选择了一个没对访客开放的页面时不会跳转(会留在主页)。
</div>
</div>
<div class="card">
<h2>访客可见区域</h2>
<div class="card-sub">
一行一个,省 / 市 / 区都认(例如「广东省」「贵阳市」「南明区」)。
留空表示不限制,访客能看到全部数据;你自己登录后始终是全量。
</div>
<label class="check"><input type="radio" name="regions_mode" value="whitelist" {{if eq .Settings.RegionsMode "whitelist"}}checked{{end}}> 白名单:访客<span class="nobr">只能看到这些区域</span>的记录</label>
<label class="check"><input type="radio" name="regions_mode" value="blacklist" {{if eq .Settings.RegionsMode "blacklist"}}checked{{end}}> 黑名单:这些区域的记录<span class="nobr">对访客隐藏</span>,其余照常开放</label>
<label class="f" style="max-width:520px;margin-top:10px">
<span>区域列表</span>
<textarea name="regions" rows="5" placeholder="广东省&#10;贵阳市&#10;南明区">{{range .Settings.Regions}}{{.}}
{{end}}</textarea>
</label>
</div>
<div class="card">
<h2>隐私处理</h2>
<div class="card-sub">「家」和「工作」通常是敏感位置,默认已隐藏</div>
<label class="check"><input type="checkbox" name="hide_home" {{if .Settings.HideHome}}checked{{end}}> 隐藏类型为「家」的记录</label>
<label class="check"><input type="checkbox" name="hide_work" {{if .Settings.HideWork}}checked{{end}}> 隐藏类型为「工作」的记录</label>
<label class="check"><input type="checkbox" name="notes_public" {{if .Settings.NotesPublic}}checked{{end}}> 地点备注对访客公开(不勾选则只有你自己能看到)</label>
<label class="check"><input type="checkbox" name="marks_public" {{if .Settings.MarksPublic}}checked{{end}}> 「推荐 / 踩雷」标记与票数对访客公开</label>
<div class="muted" style="font-size:12.5px">
隐藏后匿名访客看不到这两类记录。你登录后可在任意公开页面用筛选器上的
<span class="mono">含家/工作</span> 开关临时显示给自己看。
</div>
<label class="f" style="max-width:280px;margin-top:12px">
<span>忽略停留时间过短的记录</span>
<select name="min_dwell_min">
<option value="0" {{if eq .Settings.MinDwellMin 0}}selected{{end}}>不过滤</option>
<option value="2" {{if eq .Settings.MinDwellMin 2}}selected{{end}}>少于 2 分钟</option>
<option value="5" {{if eq .Settings.MinDwellMin 5}}selected{{end}}>少于 5 分钟</option>
<option value="10" {{if eq .Settings.MinDwellMin 10}}selected{{end}}>少于 10 分钟</option>
</select>
</label>
<div class="muted" style="font-size:12.5px">该设置作用于全站统计与地图,可有效过滤路过点。</div>
</div>
<div class="card">
<h2>地图底图</h2>
<div class="card-sub">在 <span class="mono">conf/app.ini</span> 中配置,改完重启生效</div>
<div class="kv"><span class="k">当前生效</span><span class="v">{{.MapInfo}}</span></div>
{{if .MapLayers}}
<label class="f" style="max-width:340px;margin-top:10px"><span>默认用哪张底图</span>
<select name="map_default_base">
<option value="" {{if not .Settings.MapDefaultBase}}selected{{end}}>跟随第一张({{(index .MapLayers 0).Label}})</option>
{{range .MapLayers}}<option value="{{.Key}}" {{if eq .Key $.Settings.MapDefaultBase}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<div class="card-sub" style="margin-top:0">放大后底图自带的 POI 与路名标注太密、看不清自己的点时,换成<b>纯路网</b>。
访客在地图上手动切换过的话,浏览器会记住他自己的选择。</div>
{{end}}
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div><span class="mono">map_provider</span>:<span class="mono">amap</span>(默认,免密钥)/ <span class="mono">tianditu</span>(需 <span class="mono">map_key</span>)/ <span class="mono">custom</span>(自备 <span class="mono">map_tile_url</span>)</div>
<div><span class="mono">map_key</span>:填写后瓦片改由服务端代理,密钥不会出现在页面源码里。</div>
<div><span class="mono">map_crs</span>:仅 <span class="mono">custom</span> 使用,填 <span class="mono">gcj02</span> 或 <span class="mono">wgs84</span>;站点数据本身是 GCJ-02,换成 WGS-84 底图(天地图 / OSM)时会自动纠偏。</div>
</div>
</div>
<div class="card">
<h2>图片存储</h2>
<div class="card-sub">地点图片存到哪儿。图片在<b>浏览器里</b>转成 WebP 后才上传,服务器只负责收下与转存</div>
<label class="check"><input type="radio" name="media_backend" value="local" {{if ne .Settings.MediaBackend "s3"}}checked{{end}}> 服务器本地磁盘</label>
<label class="check"><input type="radio" name="media_backend" value="s3" {{if eq .Settings.MediaBackend "s3"}}checked{{end}}> 对象存储(S3 兼容,如 Cloudflare R2)</label>
<div class="kv" style="margin-top:8px"><span class="k">对象存储</span><span class="v">{{.S3Info}}</span></div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div>凭据放在 <span class="mono">conf/app.ini</span>,不进数据库、也不会下发到前端:</div>
<div><span class="mono">s3_endpoint</span>(R2 形如 <span class="mono">https://&lt;账号&gt;.r2.cloudflarestorage.com</span>)、<span class="mono">s3_bucket</span>、<span class="mono">s3_access_key</span>、<span class="mono">s3_secret_key</span>、<span class="mono">s3_region</span>(R2 填 <span class="mono">auto</span>)。</div>
<div><span class="mono">s3_public_base</span>:桶的公开域名(自定义域或 r2.dev),拼上对象名就是 <span class="mono">&lt;img src&gt;</span>;不填的话图片存得进去但浏览器取不到。</div>
<div>改了 <span class="mono">conf/app.ini</span> 需要重启;这里的「存哪边」保存后立即生效,已经存过的老图仍按各自原来的位置取,不受影响。</div>
</div>
</div>
<div class="row">
<button class="btn primary" type="submit">保存设置</button>
<a class="btn" href="/admin">取消</a>
</div>
</form>
</div>
{{end}}
+105
View File
@@ -0,0 +1,105 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>专题管理</h1>
<div class="sub">把某段时间、某个区域里的足迹打包成一个可对外展示的页面(地点 + 世界迷雾)</div>
</div>
<div class="row">
<a class="btn" href="/admin">← 返回后台</a>
<a class="btn" href="/topics" target="_blank">查看前台</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<h2>{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}</h2>
<div class="card-sub">时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选。</div>
<form method="post" action="/admin/topics/save">
{{if not .IsNew}}<input type="hidden" name="id" value="{{.Form.ID}}">{{end}}
<div class="row">
<label class="f" style="flex:2 1 240px"><span>标题<b class="req">*</b></span><input type="text" name="title" value="{{.Form.Title}}" placeholder="如:2026 大理之行"></label>
<label class="f" style="flex:2 1 240px"><span>副标题</span><input type="text" name="subtitle" value="{{.Form.Subtitle}}" placeholder="如:环洱海骑行四天"></label>
</div>
<label class="f"><span>说明</span><textarea name="description" rows="3" placeholder="可选:这段行程的来龙去脉">{{.Form.Description}}</textarea></label>
<div class="row">
<label class="f" style="flex:1 1 200px"><span>开始时间<b class="req">*</b></span><input type="datetime-local" name="start_at" value="{{.Form.StartDate}}"></label>
<label class="f" style="flex:1 1 200px"><span>结束时间<b class="req">*</b></span><input type="datetime-local" name="end_at" value="{{.Form.EndDate}}"></label>
<label class="f" style="flex:1 1 120px"><span>排序</span><input type="number" name="sort_order" value="{{.Form.SortOrder}}" placeholder="小的在前"></label>
</div>
<div class="card-sub" style="margin:0 0 8px">区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域</div>
<div class="row">
<label class="f" style="flex:1 1 150px"><span>中心纬度</span><input type="text" name="center_lat" value="{{if .Form.CenterLat}}{{printf "%.6f" .Form.CenterLat}}{{end}}" placeholder="25.606486"></label>
<label class="f" style="flex:1 1 150px"><span>中心经度</span><input type="text" name="center_lon" value="{{if .Form.CenterLon}}{{printf "%.6f" .Form.CenterLon}}{{end}}" placeholder="100.267638"></label>
<label class="f" style="flex:1 1 120px"><span>半径(公里)</span><input type="text" name="radius_km" value="{{if .Form.RadiusKm}}{{printf "%.1f" .Form.RadiusKm}}{{end}}" placeholder="0 或留空=不限"></label>
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<label class="check" style="margin:0"><input type="checkbox" name="show_fog" {{if .Form.ShowFog}}checked{{end}}> 展示世界迷雾</label>
<label class="check" style="margin:0"><input type="checkbox" name="enabled" {{if .Form.Enabled}}checked{{end}}> 对外展示(取消勾选则访客看不到)</label>
</div>
<button class="btn primary" type="submit">{{if .IsNew}}创建专题{{else}}保存修改{{end}}</button>
{{if not .IsNew}}<a class="btn" href="/admin/topics">取消编辑</a>{{end}}
</form>
</div>
<div class="card">
<h2>专题列表</h2>
<div class="card-sub">共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览</div>
{{if .Topics}}
<div class="tw">
<table class="tb">
<thead><tr><th>标题</th><th>时间范围</th><th class="num">地点</th><th class="num">到访</th><th>区域</th><th>对访客</th><th></th></tr></thead>
<tbody>
{{range .Topics}}
<tr>
<td><a href="/topics/{{.ID}}" target="_blank">{{.Title}}</a>{{if .Subtitle}} <span class="muted">· {{.Subtitle}}</span>{{end}}</td>
<td class="muted mono">{{.RangeLabel}}</td>
<td class="num">{{num .PlaceCount}}</td>
<td class="num">{{num .VisitCount}}</td>
<td class="muted">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
<td>
<form method="post" action="/admin/topics/toggle" style="display:inline">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
</form>
</td>
<td style="white-space:nowrap">
<a class="btn sm" href="/admin/topics?edit={{.ID}}">编辑</a>
<form method="post" action="/admin/topics/delete" style="display:inline"
onsubmit="return confirm('删除专题「{{.Title}}」?地点数据不受影响。')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">还没有专题,用上面的表单建一个吧</div>
{{end}}
</div>
</div>
<script>
(function () {
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
whenReady(function () {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
});
})();
</script>
{{end}}
@@ -0,0 +1,39 @@
{{define "login2"}}<!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>两步验证 · {{.Settings.SiteTitle}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css">
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a}</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">🔐</div>
<h1 style="font-size:19px;margin-top:6px">两步验证</h1>
<div class="muted" style="font-size:13px">输入验证器 App 中的 6 位验证码</div>
</div>
<div class="card" style="margin:0">
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<form method="post" action="/admin/login/twofa">
<input type="hidden" name="pending" value="{{.Pending}}">
<input type="hidden" name="next" value="{{.Next}}">
<label class="f"><span>验证码</span>
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6"
autocomplete="one-time-code" autofocus required
style="text-align:center;font-size:20px;letter-spacing:8px">
</label>
<button class="btn primary" type="submit" style="width:100%;justify-content:center">验证并登录</button>
</form>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/admin/login" style="font-size:13px">← 重新登录</a></div>
</div>
</div>
</body>
</html>{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>两步验证</h1>
<div class="sub">为后台登录追加一层动态验证码(TOTP,兼容 Google Authenticator 等 App)</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .Enabled}}
<div class="card">
<h2>状态:已开启</h2>
<div class="card-sub">每次登录后台,除口令外还需输入验证器 App 中的 6 位验证码</div>
<form method="post" action="/admin/twofa/disable" style="max-width:360px">
<label class="f"><span>输入当前验证码以关闭</span>
<input type="text" name="code" inputmode="numeric" maxlength="6" required
style="text-align:center;letter-spacing:6px;font-size:16px">
</label>
<button class="btn danger" type="submit">关闭两步验证</button>
</form>
</div>
{{else if .Pending}}
<div class="card">
<h2>绑定验证器</h2>
<div class="card-sub">用验证器 App 扫描二维码,或手动输入秘钥,然后输入 App 显示的 6 位验证码确认</div>
<div class="row" style="align-items:flex-start;gap:20px;flex-wrap:wrap">
{{if .QR}}<img src="data:image/png;base64,{{.QR}}" alt="TOTP 二维码"
style="width:220px;height:220px;border:1px solid var(--line);border-radius:10px;background:var(--surface)">{{end}}
<div style="min-width:240px">
<label class="f"><span>秘钥(手动输入用)</span>
<input type="text" readonly value="{{.Secret}}" onclick="this.select()"
style="font-family:monospace;letter-spacing:1px">
</label>
<div class="muted" style="font-size:12.5px">账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒</div>
</div>
</div>
<form method="post" action="/admin/twofa/confirm" style="max-width:360px;margin-top:6px">
<label class="f"><span>输入验证码确认开启</span>
<input type="text" name="code" inputmode="numeric" maxlength="6" required
style="text-align:center;letter-spacing:6px;font-size:16px">
</label>
<button class="btn primary" type="submit">确认开启</button>
</form>
</div>
{{else}}
<div class="card">
<h2>状态:未开启</h2>
<div class="card-sub">开启后,登录 = 口令 + 动态验证码,即使口令泄露账户也不会被直接攻破</div>
<form method="post" action="/admin/twofa/setup">
<button class="btn primary" type="submit">开始绑定验证器</button>
</form>
<div class="muted" style="font-size:12.5px;margin-top:10px">
需要一个 TOTP 验证器 App(Google Authenticator、Microsoft Authenticator、1Password、Aegis 等均可)。
</div>
</div>
{{end}}
</div>
{{end}}
+182
View File
@@ -0,0 +1,182 @@
{{define "content"}}
{{if not .HasData}}
<div class="wrap">{{template "emptystate" .}}</div>
{{else}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{.Settings.OwnerAvatar}} {{.Settings.OwnerName}}</h1>
<div class="sub">{{if .Settings.SiteSubtitle}}{{.Settings.SiteSubtitle}} · {{end}}{{.RangeLabel}}</div>
</div>
<div class="row">
<a class="btn" href="/map">全屏地图</a>
<a class="btn" href="/stats">数据统计</a>
</div>
</div>
{{if .Settings.Has "overview"}}
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Overview.Places}}<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">{{num .Overview.Visits}}<small>次</small></div><div class="k">累计到访</div></div>
<div class="kpi"><div class="v">{{dur .Overview.DwellMin}}</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 class="kpi"><div class="v">{{if .Overview.FirstAt}}{{since .Overview.FirstAt}}{{else}}—{{end}}</div><div class="k">开始记录于</div></div>
</div>
{{end}}
{{if and (.Settings.Has "map") .Points}}
<div class="card">
<h2>足迹地图</h2>
<div class="card-sub">共 {{num (len .Points)}} 个地点{{if .Settings.HideHome}} · 已隐藏「家」{{end}}{{if .Settings.HideWork}} · 已隐藏「工作」{{end}}</div>
<div id="home-map" class="map-preview"></div>
<div style="margin-top:10px;font-size:12.5px" class="muted">滚轮被禁用,点击地图进入全屏模式查看细节与筛选。</div>
</div>
{{end}}
<div class="grid g2">
{{if and (.Settings.Has "types") .ActivityStats}}
<div class="card">
<h2>去过的类型</h2>
<div class="card-sub">按到访次数排序</div>
<div class="bars">
{{range .ActivityStats}}
<div class="bar-row">
<div class="nm" title="{{.Name}}">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if and (.Settings.Has "cities") .CityStats}}
<div class="card">
<h2>城市足迹</h2>
<div class="card-sub">按到访次数排序</div>
<div class="bars">
{{range .CityStats}}
<div class="bar-row">
<div class="nm"><a href="{{$.CityLink .City}}">{{.City}}</a></div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxCity}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
<div style="margin-top:12px" class="muted">共覆盖 {{num .Overview.Cities}} 座城市、{{num .Overview.Provinces}} 个省级行政区。</div>
</div>
{{end}}
</div>
{{if .Monthly}}
<div class="card">
<h2>月度活跃度</h2>
<div class="card-sub">每月到访次数</div>
<div class="cols">
{{range .Monthly}}<div class="col" style="height:{{.Pct}}%" title="{{.Key}}:{{.Count}} 次"></div>{{end}}
</div>
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
</div>
{{end}}
{{if and (.Settings.Has "heatmap") .Calendar}}
<div class="card">
<h2>活跃日历</h2>
<div class="card-sub">每一列是一周,颜色越深记录越多</div>
<div class="heat">
{{range .Calendar}}<div class="wk">{{range .Days}}<div class="d {{if gt .Level 0}}l{{.Level}}{{end}}" title="{{.Title}}"></div>{{end}}</div>{{end}}
</div>
<div class="heat-legend">少 <span class="d"></span><span class="d l1"></span><span class="d l2"></span><span class="d l3"></span><span class="d l4"></span> 多</div>
</div>
{{end}}
<div class="grid g2">
{{if and (.Settings.Has "transport") .Transports}}
<div class="card">
<h2>出行方式</h2>
<div class="card-sub">按次数排序,里程为两点间直线距离估算</div>
<div class="bars">
{{range .Transports}}
<div class="bar-row">
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTransport}}%;background:{{colorof .Color}}"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if and (.Settings.Has "tags") .Tags}}
<div class="card">
<h2>我的标签</h2>
<div class="card-sub">记录时打上的自定义标签</div>
<div class="chips">
{{range .Tags}}
<a class="chip" href="{{$.TagLink .ID}}" title="{{num .Count}} 次">
<span class="dot" style="background:{{colorof .Color}}"></span>{{.Name}}
<span class="muted">{{num .Count}}</span>
</a>
{{end}}
</div>
</div>
{{end}}
</div>
{{if and (.Settings.Has "weather") .Weather}}
<div class="card">
<h2>天气足迹</h2>
<div class="card-sub">记录期间经历过的天气</div>
<div class="tw">
<table class="tb">
<thead><tr><th>天气</th><th class="num">记录小时数</th><th class="num">平均气温</th><th class="num">范围</th></tr></thead>
<tbody>
{{range .Weather}}
<tr>
<td>{{condition .Condition}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{f1 .AvgTemp}} ℃</td>
<td class="num">{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{if and (.Settings.Has "timeline") .Recent}}
<div class="card">
<h2>最近足迹</h2>
<div class="card-sub">最新 {{num (len .Recent)}} 条到访记录</div>
<div class="list">
{{range .Recent}}
<div class="item">
<div class="ic" style="background:{{colorof .Color}}22">{{iconor .Icon "📍"}}</div>
<div>
<div class="t">{{if .PlaceID}}<a href="/places/{{.PlaceID}}">{{.PlaceName}}</a>{{else}}未命名地点{{end}}</div>
<div class="m">{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}<span class="tag">{{$t}}</span>{{end}}{{end}}</div>
</div>
<div class="r">{{dur .DurationMin}}<br><span class="muted">{{since .Arrival}}</span></div>
</div>
{{end}}
</div>
<div style="margin-top:12px"><a class="btn sm" href="/timeline">查看完整时间线 →</a></div>
</div>
{{end}}
</div>
{{if and (.Settings.Has "map") .Points}}
<script>
window.addEventListener('load', function () {
window.rondMap.preview({ id: 'home-map', points: {{json .Points}}, clickToFull: true });
});
</script>
{{end}}
{{end}}
{{end}}
+295
View File
@@ -0,0 +1,295 @@
{{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">
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
{{if .Settings.HasPage "map"}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
{{if .Settings.HasPage "places"}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
{{if .Settings.HasPage "timeline"}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
{{if .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 .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>
{{end}}
{{define "filters"}}
<form class="card" method="get" action="{{.FilterBase}}" id="filters-form">
<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>
{{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}}
{{if .Dataset}} · 数据截至 {{d .Dataset.Last}}{{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>
{{end}}
{{/* 第三方统计脚本:只在公开页面挂,后台/登录页不挂(免得把自己的操作也统计进去) */}}
{{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}}
+333
View File
@@ -0,0 +1,333 @@
{{define "content"}}
{{if not .HasData}}
<div class="wrap">{{template "emptystate" .}}</div>
{{else}}
<div class="map-page">
<div id="map"></div>
{{/* 时间轴回放:拖动即可按「首次到访时间」累加显示点位。
注意它和左侧的日期区间是两回事——区间是「只看这段时间」,
时间轴是「一路走到这个时刻为止」,所以放在地图底部而不是筛选面板里。 */}}
<div class="timeline-bar" id="timeline-bar" hidden>
<button class="tl-play" type="button" id="tl-play" aria-label="播放时间轴">▶</button>
<input type="range" id="tl-range" min="0" max="100" value="100" aria-label="时间轴">
<span class="tl-label" id="tl-label"></span>
<button class="tl-reset" type="button" id="tl-reset">全部</button>
</div>
{{/* 加载失败时的提示条:绝不重新加载页面,只在地图上说清楚并给一个重试按钮 */}}
<div class="map-status" id="map-status" hidden>
<span id="map-status-text"></span>
<button class="btn sm" type="button" id="map-retry" hidden>重试</button>
</div>
<div class="map-panel">
<form id="filter-form" method="get" action="/map">
<h3>时间范围</h3>
<div class="chips" style="margin-bottom:10px">
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
{{if .IsAdmin}}
<a class="chip {{if .ShowHomeWork}}on{{end}}" href="{{.HWLink}}" title="仅登录后的你自己可见,访客看不到这两类记录">含家/工作</a>
{{end}}
</div>
<div class="row" style="gap:8px;margin-bottom:10px">
<input type="date" name="from" value="{{.From}}" style="flex:1">
<input type="date" name="to" value="{{.To}}" style="flex:1">
</div>
{{if .Activities}}
<h3>地点类型</h3>
<div class="chips" style="margin-bottom:10px">
{{range $i, $a := .Activities}}{{if or (lt $i 12) ($.IsAct $a.ID)}}
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
</a>
{{end}}{{end}}
</div>
{{if gt (len .Activities) 12}}
<details class="more-types">
<summary>展开其余 {{len .Activities}} 个类型</summary>
<div class="chips">
{{range $i, $a := .Activities}}{{if and (ge $i 12) (not ($.IsAct $a.ID))}}
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}</a>
{{end}}{{end}}
</div>
</details>
{{end}}
{{end}}
<h3>其它条件</h3>
<div class="row" style="gap:8px;margin-bottom:10px">
<select name="city" style="flex:1">
<option value="">全部城市</option>
{{range .CityList}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
<select name="tag" style="flex:1">
<option value="0">全部标签</option>
{{range .TagOptions}}<option value="{{.ID}}" {{if eq .ID $.TagID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="row" style="gap:8px;margin-bottom:12px">
<select name="min" style="flex:1">
<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>
<input type="text" name="q" value="{{.Keyword}}" placeholder="关键词" style="flex:1">
</div>
{{range .ActivityIDs}}<input type="hidden" name="act" value="{{.}}">{{end}}
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
<div class="row">
<button class="btn" type="submit" style="flex:1">查询</button>
<a class="btn" href="/map">重置</a>
</div>
<div class="chips" style="margin-top:10px">
<label class="chip" style="user-select:none"><input type="checkbox" id="track-toggle" style="accent-color:var(--accent)"> 行程轨迹</label>
<label class="chip" style="user-select:none"><input type="checkbox" id="heat-toggle" style="accent-color:var(--accent)"> 到访热力</label>
<label class="chip" style="user-select:none"><input type="checkbox" id="city-toggle" style="accent-color:var(--accent)"> 城市聚合</label>
<label class="chip" style="user-select:none"><input type="checkbox" id="gpx-toggle" style="accent-color:var(--accent)"> GPX 轨迹</label>
</div>
<div class="muted" style="margin-top:9px;font-size:12px">当前显示 <b id="pt-count">{{num (len .Points)}}</b> 个地点</div>
</form>
</div>
{{if .Legend}}
<div class="map-legend">
<div style="font-weight:500;color:var(--text-2)">类型分布</div>
{{range .Legend}}
<div class="lg"><i style="background:{{colorof .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
{{end}}
</div>
{{end}}
</div>
<script>
window.addEventListener('load', function () {
// 首屏不带地点数据(全量内联会让 HTML 膨胀到 ~58KB),
// 页面先出壳,地点走 /api/points 并行拉取后再填充。
window.__map = window.rondMap.full({ id: 'map', points: [] });
var form = document.getElementById('filter-form');
var ptCount = document.getElementById('pt-count');
var statusBox = document.getElementById('map-status');
var statusText = document.getElementById('map-status-text');
var retryBtn = document.getElementById('map-retry');
function qs() {
return new URLSearchParams(new FormData(form)).toString();
}
function showStatus(msg, withRetry) {
statusText.textContent = msg;
retryBtn.hidden = !withRetry;
statusBox.hidden = false;
}
function hideStatus() { statusBox.hidden = true; }
// HTTP 500 时 fetch 本身是成功的,紧接着 r.json() 才抛错——统一在这里判掉,
// 调用方就只有一个失败分支要处理。
function loadJSON(url) {
return fetch(url, { headers: { 'Accept': 'application/json' } }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
/* 以前这里是 `.catch(function () { form.submit(); })`:接口一失败就整页重提交,
而重提交后的新页面又走同一条路 —— 实测 8 秒内自刷 17 次、接口被拒 16 次、
访客看不到任何解释,服务器被反复打(1C1G 上尤其难受)。
现在改成:有限次自动重试 + 明确提示 + 手动重试按钮,**任何情况下都不重新加载页面**。 */
var RETRY_DELAYS = [1000, 3000];
// 返回的 promise 一定 resolve(true/false),不会 reject——避免未处理的 rejection,
// 也让调用方只需判断「这次到底加载成功了没」。
function loadPoints(q, attempt) {
attempt = attempt || 0;
return loadJSON('/api/points' + (q ? '?' + q : '')).then(function (d) {
var pts = d.points || [];
// 先交给时间轴:它自己会在「有时间的点」上做过滤再决定画什么
setupTimeline(pts);
window.rondMap.setPoints(pts);
ptCount.textContent = pts.length;
hideStatus();
return true;
}).catch(function (err) {
var why = (err && err.message) ? err.message : '网络异常';
if (attempt < RETRY_DELAYS.length) {
showStatus('地点加载失败(' + why + '),正在重试…', false);
return new Promise(function (res) { setTimeout(res, RETRY_DELAYS[attempt]); })
.then(function () { return loadPoints(q, attempt + 1); });
}
showStatus('地点加载失败(' + why + ')。地图上暂时没有点,可以点右侧重试。', true);
return false;
});
}
function reloadPoints(q) {
// 地图页的筛选是 AJAX 换数据、不跳页,所以顶部进度条要自己收
if (window.rondProgress) window.rondProgress.start();
return loadPoints(q).then(function (ok) {
if (ok && document.getElementById('track-toggle').checked) refreshTrack();
if (window.rondProgress) window.rondProgress.finish();
return ok;
});
}
// 与列表页保持一致:下拉 / 日期改动即生效,不用再点「应用筛选」。
// 关键词仍然回车才查(每敲一个字都发请求很难受)。
// 这里走的是本页自己的 submit 处理器(AJAX 换数据),所以用 requestSubmit。
form.addEventListener('change', function (e) {
var el = e.target;
if (!el || !el.name) return;
if (el.tagName === 'SELECT' || el.type === 'date') {
if (form.requestSubmit) form.requestSubmit();
else form.submit();
}
});
retryBtn.addEventListener('click', function () {
retryBtn.hidden = true;
hideStatus();
reloadPoints(qs());
});
// 从后台切回来时顺手重试一次:多半是刚才断网/休眠导致的失败
document.addEventListener('visibilitychange', function () {
if (!document.hidden && !statusBox.hidden) retryBtn.click();
});
/* ---------- 时间轴回放 ----------
* 每个点带着「首次到访时间」(ft,Unix 秒),拖动滑块就把 ft ≤ 截止时刻的点留下。
* 这样得到的是「一路走到这个时刻,足迹长什么样」的累加效果。
* 实现上不去逐点改样式,而是复用 setPoints 整体重画——点位规模是几百,
* 且只在「日期真的变了」时才重画(拖动一次可能跨很多像素但只跨几天)。
*/
var tlBar = document.getElementById('timeline-bar');
var tlRange = document.getElementById('tl-range');
var tlLabel = document.getElementById('tl-label');
var tlPlay = document.getElementById('tl-play');
var allPoints = []; // 最近一次拉取到的全部点(时间轴始终在它上面过滤)
var tlMin = 0, tlMax = 0, tlDay = 86400, tlLastDay = null, tlTimer = null;
function fmtDay(sec) {
var d = new Date(sec * 1000);
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
}
function applyTimeline() {
if (!allPoints.length) return;
var cut = Number(tlRange.value);
// 只跟着「天」变化重画:拖动时像素变化远多于天数变化,不然一帧要重画好几次
var day = Math.floor(cut / tlDay);
if (day === tlLastDay) return;
tlLastDay = day;
var sub = [], shown = 0;
for (var i = 0; i < allPoints.length; i++) {
var p2 = allPoints[i];
// 「截至某日」要把那一天也算进去,否则拖到最左会是一个空地图
if (!p2.ft || p2.ft < cut + tlDay) { sub.push(p2); shown++; }
}
// keepView:拖动/播放时不要重新 fit 视野,不然地图会跟着不停缩放,很晕
window.rondMap.setPoints(sub, { keepView: true });
tlLabel.textContent = fmtDay(cut) + ' · ' + shown + '/' + allPoints.length + ' 个点';
}
function setupTimeline(pts) {
allPoints = pts;
var withTime = pts.filter(function (p2) { return p2.ft; });
if (withTime.length < 2) { tlBar.hidden = true; return; }
tlMin = withTime[0].ft;
tlMax = withTime[0].ft;
for (var i = 1; i < withTime.length; i++) {
if (withTime[i].ft < tlMin) tlMin = withTime[i].ft;
if (withTime[i].ft > tlMax) tlMax = withTime[i].ft;
}
// 两端都对齐到「天」:input[type=range] 的可取值为 min + k*step,
// max 不是 step 的整数倍时**根本取不到 max**,全量时就会少算几个点。
tlMin = Math.floor(tlMin / tlDay) * tlDay;
tlMax = Math.ceil(tlMax / tlDay) * tlDay;
tlRange.min = tlMin;
tlRange.max = tlMax;
tlRange.step = tlDay;
tlRange.value = tlMax;
tlLastDay = null;
tlBar.hidden = false;
applyTimeline();
}
tlRange.addEventListener('input', function () {
if (tlTimer) return; // 一帧只重画一次,拖起来才不卡
tlTimer = requestAnimationFrame(function () { tlTimer = null; applyTimeline(); });
});
tlRange.addEventListener('change', function () { tlLastDay = null; applyTimeline(); });
document.getElementById('tl-reset').addEventListener('click', function () {
stopPlay();
tlRange.value = tlMax;
tlLastDay = null;
applyTimeline();
});
// 播放:约 12 秒走完整个跨度(按天推进,和拖动走同一条路径)
function stopPlay() {
if (tlTimer) { cancelAnimationFrame(tlTimer); tlTimer = null; }
if (tlPlay._t) { clearInterval(tlPlay._t); tlPlay._t = null; }
tlPlay.textContent = '▶';
tlPlay.setAttribute('aria-label', '播放时间轴');
}
tlPlay.addEventListener('click', function () {
if (tlPlay._t) { stopPlay(); return; }
if (Number(tlRange.value) >= tlMax) { tlRange.value = tlMin; tlLastDay = null; }
tlPlay.textContent = '⏸';
tlPlay.setAttribute('aria-label', '暂停');
var steps = Math.max(1, Math.round((tlMax - Number(tlRange.value)) / tlDay));
var perTick = Math.max(1, Math.ceil(steps / 240)); // 最多约 240 帧,约 12 秒
tlPlay._t = setInterval(function () {
var next = Number(tlRange.value) + perTick * tlDay;
if (next >= tlMax) { next = tlMax; stopPlay(); }
tlRange.value = next;
applyTimeline();
}, 50);
});
reloadPoints(qs());
form.addEventListener('submit', function (e) {
e.preventDefault();
var q = qs();
history.replaceState(null, '', '/map' + (q ? '?' + q : ''));
reloadPoints(q);
});
var trackOn = false;
function refreshTrack() {
if (!trackOn) { window.rondMap.setTrack(null, null); return; }
var q = qs();
loadJSON('/api/track' + (q ? '?' + q : ''))
.then(function (d) { window.rondMap.setTrack(d.segs || [], d.modes || []); })
// 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态
.catch(function (err) {
trackOn = false;
document.getElementById('track-toggle').checked = false;
window.rondMap.setTrack(null, null);
showStatus('行程轨迹加载失败(' + ((err && err.message) || '网络异常') + '),已取消勾选。', false);
setTimeout(hideStatus, 4000);
});
}
document.getElementById('track-toggle').addEventListener('change', function () {
trackOn = this.checked;
refreshTrack();
});
document.getElementById('heat-toggle').addEventListener('change', function () {
window.rondMap.setHeat(this.checked);
});
document.getElementById('city-toggle').addEventListener('change', function () {
window.rondMap.setCityAgg(this.checked);
});
document.getElementById('gpx-toggle').addEventListener('change', function () {
window.rondMap.setGpx(this.checked);
});
});
</script>
{{end}}
{{end}}
+454
View File
@@ -0,0 +1,454 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{if .Place.Name}}{{.Place.Name}}{{else}}未命名地点{{end}}</h1>
<div class="sub">
{{if .Place.City}}{{.Place.City}}{{end}}
{{if .Place.Province}} · {{.Place.Province}}{{end}}
{{if .Place.District}} · {{.Place.District}}{{end}}
{{if .Place.POICategory}} · {{poiname .Place.POICategory}}{{end}}
· <span class="mono">{{f1 .Place.Lat}}, {{f1 .Place.Lon}}</span>
</div>
</div>
<div class="row">
<a class="btn" href="/places">← 返回地点库</a>
<a class="btn" href="/map?q={{.Place.Name}}">在地图中查看</a>
{{if .IsAdmin}}<a class="btn" href="/admin/edit?open={{.Place.SrcPK}}" title="在数据编辑页里直接打开这个地点的编辑框">编辑</a>{{end}}
{{if .Images}}<button class="btn" type="button" id="imgs-open">图片 <span class="muted">{{len .Images}}</span></button>{{end}}
</div>
</div>
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Place.VisitCount}}<small>次</small></div><div class="k">到访次数</div></div>
<div class="kpi"><div class="v">{{dur .Place.DwellMinutes}}</div><div class="k">累计停留</div></div>
<div class="kpi"><div class="v">{{if .Place.FirstAt}}{{d .Place.FirstAt}}{{else}}—{{end}}</div><div class="k">首次到访</div></div>
<div class="kpi"><div class="v">{{if .Place.LastAt}}{{since .Place.LastAt}}{{else}}—{{end}}</div><div class="k">最近到访</div></div>
</div>
{{if .IsAdmin}}
<div class="card" style="margin-bottom:16px">
<h2>备注与别名</h2>
<div class="card-sub">
别名会替代原名显示在所有页面上;备注{{if .Settings.NotesPublic}}对访客公开{{else}}只有你自己能看到{{end}}。
「推荐 / 踩雷」是单独的按钮,不用写进备注。按 rond 的地点 ID 保存,重新上传备份也不会丢。
</div>
{{if .Flash}}<div class="alert ok" id="note-flash">{{.Flash}}</div>{{end}}
{{if .RondNote}}
<div class="card-sub" style="margin-bottom:10px">
<div style="margin-bottom:4px">
rond 备份里给这个地点写过的备注(按次到访记录,只读;展示时会拼在你的备注之前):
</div>
<div class="note-block">{{.RondNote}}</div>
</div>
{{end}}
<form method="post" action="/admin/place/note" id="note-form">
<input type="hidden" name="src_pk" value="{{.Place.SrcPK}}">
<input type="hidden" name="next" value="/places/{{.Place.ID}}">
<label class="f" style="max-width:420px">
<span>别名</span>
<input type="text" name="alias" value="{{.Place.Alias}}" maxlength="60" placeholder="{{.Place.Name}}">
</label>
<label class="f" style="max-width:640px">
<span>备注</span>
<textarea name="note" rows="3" maxlength="2000" placeholder="例如:和谁来的、什么印象">{{.MyNote}}</textarea>
</label>
<button class="btn primary" type="submit">保存</button>
</form>
</div>
{{else if .Place.Note}}
<div class="card" style="margin-bottom:16px"><h2>备注</h2><div class="note-block">{{.Place.Note}}</div></div>
{{end}}
{{if .IsAdmin}}
<div class="card" style="margin-bottom:16px">
<h2>地点图片</h2>
<div class="card-sub">
选好图就<b>自动上传并立即生效</b>,不用再点保存。
上传前会<b>在浏览器里转成 WebP</b>(长边 2000px),服务器只负责收下与转存,不做转码。
当前存储:<b>{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}}</b>
,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。
</div>
<div id="imgs-admin" data-src="{{.Place.SrcPK}}">
<div class="imgs-grid" id="imgs-grid">
{{range .Images}}
<figure class="imgs-cell" data-id="{{.ID}}">
<img src="{{.URL}}" alt="" loading="lazy">
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张">×</button>
</figure>
{{end}}
</div>
<div class="row" style="margin-top:12px;gap:10px">
<label class="btn primary" for="imgs-input" id="imgs-pick" style="cursor:pointer">+ 选择图片</label>
<input type="file" id="imgs-input" accept="image/*" multiple hidden>
<span class="muted" id="imgs-count" style="font-size:12.5px"></span>
</div>
<div class="imgs-hint" id="imgs-hint"></div>
</div>
</div>
{{end}}
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="card" style="margin-bottom:16px">
<h2>推荐 / 踩雷</h2>
{{if .IsAdmin}}
{{/* 站长这里只放自己的结论;访客票数只读展示,避免同一块地方出现两套投票按钮 */}}
<div class="card-sub">值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。</div>
<div class="vote-row" data-verdict-row data-src="{{.Place.SrcPK}}">
<span class="vote-label">我的结论</span>
<span class="vote-btns">
<button class="vote-btn up {{if eq .Verdict 1}}on{{end}}" type="button" data-verdict="1">👍 推荐</button>
<button class="vote-btn down {{if eq .Verdict -1}}on{{end}}" type="button" data-verdict="-1">👎 踩雷</button>
</span>
<span class="vote-tip" data-cur="{{.Verdict}}"></span>
</div>
<div class="vote-row">
<span class="vote-label">访客票数</span>
<span class="vote-btns">
<span class="vote-btn up ro">👍 <span class="up-n">{{num .Votes.Up}}</span></span>
<span class="vote-btn down ro">👎 <span class="down-n">{{num .Votes.Down}}</span></span>
</span>
<span class="vote-tip">只读</span>
</div>
{{else}}
<div class="card-sub">值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。</div>
<div class="vote-row" data-visitor-row data-src="{{.Place.SrcPK}}">
<span class="vote-label">大家怎么说</span>
<span class="vote-btns">
<button class="vote-btn up" type="button" data-vote="1">👍 <span class="up-n">{{.Votes.Up}}</span></button>
<button class="vote-btn down" type="button" data-vote="-1">👎 <span class="down-n">{{.Votes.Down}}</span></button>
</span>
<span class="vote-tip" data-mine="{{.MyVote}}"></span>
</div>
{{end}}
</div>
<script>
(function () {
var POST = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'same-origin' };
// 站长结论:AJAX 提交,避免整页刷新把还没保存的备注冲掉
var vrow = document.querySelector('.vote-row[data-verdict-row]');
if (vrow) {
var cur = Number(vrow.querySelector('.vote-tip').getAttribute('data-cur')) || 0;
var vtip = vrow.querySelector('.vote-tip');
var paintVerdict = function () {
vrow.querySelectorAll('.vote-btn').forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-verdict')) === cur);
});
vtip.textContent = cur === 1 ? '已标记 · 推荐' : cur === -1 ? '已标记 · 踩雷' : '未标记';
};
paintVerdict();
vrow.querySelectorAll('.vote-btn').forEach(function (b) {
b.addEventListener('click', function () {
var v = Number(b.getAttribute('data-verdict'));
fetch('/api/verdict', Object.assign({
body: 'src_pk=' + vrow.getAttribute('data-src') + '&verdict=' + (cur === v ? 0 : v)
}, POST)).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
cur = Number(d.verdict) || 0;
paintVerdict();
}).catch(function () {});
});
});
}
// 访客票:一人一票,再点一次撤回
var row = document.querySelector('.vote-row[data-visitor-row]');
if (!row) return;
var src = row.getAttribute('data-src');
var mine = Number(row.querySelector('.vote-tip').getAttribute('data-mine')) || 0;
function paint(m) {
mine = m;
row.querySelectorAll('.vote-btn').forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-vote')) === mine);
});
var tip = row.querySelector('.vote-tip');
if (tip) tip.textContent = mine === 1 ? '你投了推荐' : mine === -1 ? '你投了踩雷' : '';
}
paint(mine);
row.querySelectorAll('.vote-btn').forEach(function (b) {
b.addEventListener('click', function () {
var v = Number(b.getAttribute('data-vote'));
b.disabled = true;
fetch('/api/vote', Object.assign({
body: 'src_pk=' + encodeURIComponent(src) + '&vote=' + v
}, POST)).then(function (r) { return r.json(); }).then(function (d) {
b.disabled = false;
if (d.error) { alert(d.error); return; }
row.querySelector('.up-n').textContent = d.up;
row.querySelector('.down-n').textContent = d.down;
paint(Number(d.mine) || 0);
}).catch(function () { b.disabled = false; });
});
});
})();
</script>
{{end}}
<div class="grid g2">
<div class="card">
<h2>位置</h2>
<div class="card-sub">底图:GCJ-02(与记录坐标同系,无需纠偏)</div>
<div id="place-map" class="map-preview" style="height:280px"></div>
</div>
<div class="card">
<h2>到访记录</h2>
<div class="card-sub">最近 {{num (len .Visits)}} 次</div>
<div class="list" style="max-height:280px;overflow-y:auto">
{{range .Visits}}
<div class="item" style="grid-template-columns:34px 1fr auto">
<div class="ic" style="background:{{colorof .Color}}22;width:30px;height:30px;font-size:15px">{{iconor .Icon "📍"}}</div>
<div>
<div class="t" style="font-weight:400">{{day .Arrival}}</div>
<div class="m">{{hm .Arrival}} – {{if .Departure}}{{hm .Departure}}{{else}}进行中{{end}}{{if .Remark}} · {{.Remark}}{{end}}</div>
</div>
<div class="r">{{dur .DurationMin}}</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
<script>
window.addEventListener('load', function () {
window.rondMap.preview({ id: 'place-map', points: {{json .Points}} });
});
</script>
{{/* 全屏画廊:单张查看 + 左右切换 + 双击放大。
以前是把所有图竖着堆在一个滚动页里,手机上既不能左右滑、也不能放大。
容器常驻(内容由 JS 从缩略图墙同步),所以这里不用 if .Images 包着。 */}}
<div class="imgs-modal" id="imgs-modal" hidden>
<div class="imgs-count" id="imgs-counter"></div>
<button class="imgs-close" type="button" id="imgs-close" aria-label="关闭">×</button>
<button class="imgs-nav imgs-prev" type="button" id="imgs-prev" aria-label="上一张" hidden>‹</button>
<button class="imgs-nav imgs-next" type="button" id="imgs-next" aria-label="下一张" hidden>›</button>
<div class="imgs-stage" id="imgs-stage">
<img id="imgs-img" alt="">
</div>
</div>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
<script>
window.addEventListener('load', function () {
var grid = document.getElementById('imgs-grid');
var modal = document.getElementById('imgs-modal');
var maxPer = {{.ImageMaxPer}};
var admin = document.getElementById('imgs-admin');
/* ---------- 全屏画廊 ----------
* 内容是「缩略图墙的镜像」:传图/删图之后不用刷新页面也能看到最新的,
* 页头那个「图片 N」按钮也一样(服务端渲染时还没有图的话,这里现补一个)。
* 所以下面统一由 syncGallery() 从 grid 反推,而不是各写一套。
*/
var stage = document.getElementById('imgs-stage');
var viewImg = document.getElementById('imgs-img');
var counter = document.getElementById('imgs-counter');
var prevBtn = document.getElementById('imgs-prev');
var nextBtn = document.getElementById('imgs-next');
var urls = [];
var idx = 0;
function cellURLs() {
return [].map.call(document.querySelectorAll('#imgs-grid .imgs-cell img'), function (i) {
return i.getAttribute('src');
});
}
function paint() {
if (!urls.length) return;
viewImg.src = urls[idx];
counter.textContent = (idx + 1) + ' / ' + urls.length;
var many = urls.length > 1;
prevBtn.hidden = nextBtn.hidden = !many;
// 换图后回到「适应屏幕」状态,否则下一张会继承上一张的放大
stage.classList.remove('zoomed');
stage.scrollTop = stage.scrollLeft = 0;
}
function goto(i) {
if (!urls.length) return;
idx = (i % urls.length + urls.length) % urls.length;
paint();
}
function openGallery(on) {
modal.hidden = !on;
// 打开时锁住背景滚动,否则滑到尽头会把后面的页面一起带着滚
document.body.style.overflow = on ? 'hidden' : '';
if (on) {
urls = cellURLs();
idx = Math.min(idx, Math.max(0, urls.length - 1));
paint();
} else {
stage.classList.remove('zoomed');
}
}
function syncGallery() {
urls = cellURLs();
var btn = document.getElementById('imgs-open');
if (!urls.length) {
if (btn) btn.remove();
if (modal) modal.hidden = true;
return;
}
if (!btn) {
btn = document.createElement('button');
btn.className = 'btn';
btn.type = 'button';
btn.id = 'imgs-open';
var head = document.querySelector('.page-head .row');
if (head) head.appendChild(btn);
}
btn.innerHTML = '图片 <span class="muted">' + urls.length + '</span>';
if (idx >= urls.length) idx = 0;
counter.textContent = urls.length + ' 张';
}
modal.addEventListener('click', function (e) {
var cls = (e.target.className || '').indexOf('imgs-close') >= 0;
// 点空白或关闭按钮都关掉;点图片本身不关(可能是想放大)
if (e.target === modal || e.target === stage || cls) openGallery(false);
});
prevBtn.addEventListener('click', function (e) { e.stopPropagation(); goto(idx - 1); });
nextBtn.addEventListener('click', function (e) { e.stopPropagation(); goto(idx + 1); });
document.addEventListener('click', function (e) {
if (e.target && e.target.id === 'imgs-open') { idx = 0; openGallery(true); }
});
document.addEventListener('keydown', function (e) {
if (modal.hidden) return;
if (e.key === 'Escape') openGallery(false);
else if (e.key === 'ArrowLeft') goto(idx - 1);
else if (e.key === 'ArrowRight') goto(idx + 1);
});
// 手势:横向滑动=切换,轻点两下=放大/还原。两者必须在**同一个** touchend 里分流——
// 分成两个监听器时,滑动结束后那个监听器会把它当成一次「点按」,连滑两次就误触发双击放大。
var tx = 0, ty = 0, tracking = false, panning = false, lastTap = 0;
function toggleZoom() {
stage.classList.toggle('zoomed');
if (!stage.classList.contains('zoomed')) stage.scrollTop = stage.scrollLeft = 0;
}
stage.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
tracking = true;
// 放大状态下拖动是「看局部」,不要再当成切换
panning = stage.classList.contains('zoomed');
tx = e.touches[0].clientX;
ty = e.touches[0].clientY;
}, { passive: true });
stage.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
var t = e.changedTouches[0];
var dx = t.clientX - tx, dy = t.clientY - ty;
if (!panning && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
goto(idx + (dx < 0 ? 1 : -1));
return;
}
// 手指基本没动才算「点按」,避免拖动松手被误判成双击
if (Math.abs(dx) > 12 || Math.abs(dy) > 12) return;
var now = Date.now();
if (now - lastTap < 300) { lastTap = 0; toggleZoom(); } else { lastTap = now; }
}, { passive: true });
stage.addEventListener('dblclick', function (e) { e.preventDefault(); toggleZoom(); });
{{if .IsAdmin}}
/* ---------- 站长:传图 / 删图 ---------- */
if (!admin || !window.rondImage) { syncGallery(); return; }
var srcPK = admin.getAttribute('data-src');
var input = document.getElementById('imgs-input');
var pick = document.getElementById('imgs-pick');
var hint = document.getElementById('imgs-hint');
var countEl = document.getElementById('imgs-count');
var ready = {{if .ImageReady}}true{{else}}false{{end}};
function setHint(msg, kind) {
hint.textContent = msg || '';
hint.className = 'imgs-hint' + (kind ? ' ' + kind : '');
}
function refreshCount() {
var n = grid.querySelectorAll('.imgs-cell').length;
countEl.textContent = n ? ('已有 ' + n + ' / ' + maxPer + ' 张') : ('还没有图片,最多 ' + maxPer + ' 张');
}
function cell(id, url) {
var fig = document.createElement('figure');
fig.className = 'imgs-cell';
fig.setAttribute('data-id', id);
var img = document.createElement('img');
img.src = url; img.loading = 'lazy'; img.alt = '';
var del = document.createElement('button');
del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张';
del.setAttribute('data-id', id);
fig.appendChild(img); fig.appendChild(del);
return fig;
}
refreshCount();
if (!ready) {
setHint('当前图片存储配置有问题,先修好再上传(见 conf/app.ini 的 s3_* 与后台「图片存储」)。', 'err');
pick.classList.add('is-disabled');
pick.removeAttribute('for');
pick.title = '图片存储未就绪';
}
// 选中即上传:没有「保存」这一步,所以进度和结果必须在页面上说清楚
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
input.value = ''; // 允许连着选同一张
if (!files.length) return;
admin.classList.add('imgs-busy');
var done = 0, failed = 0;
var finish = function () {
if (done + failed < files.length) return;
admin.classList.remove('imgs-busy');
refreshCount();
syncGallery();
if (failed) {
setHint('完成:成功 ' + done + ' 张,失败 ' + failed + ' 张。' +
(done ? '成功的那几张已经生效,刷新页面也在。' : ''), 'err');
} else {
setHint('已上传 ' + done + ' 张,已即时生效(访客刷新就能看到,不用再点保存)。', 'ok');
}
};
files.forEach(function (f, idx) {
setHint('正在处理 ' + (idx + 1) + '/' + files.length + ':' + f.name + '(浏览器转码 → 上传)…');
window.rondImage.toWebP(f).then(function (out) {
return window.rondImage.upload(srcPK, out.blob, out.width, out.height)
.then(function (d) {
if (d.error) throw new Error(d.error);
grid.appendChild(cell(d.id, d.url));
done++;
refreshCount();
});
}).catch(function (err) {
failed++;
setHint('「' + f.name + '」失败:' + (err && err.message ? err.message : err), 'err');
}).then(finish);
});
});
grid.addEventListener('click', function (e) {
var del = e.target.closest('.imgs-del');
if (!del) return;
var id = del.getAttribute('data-id');
del.disabled = true;
window.rondImage.remove(id).then(function (d) {
if (d.error) { setHint('删除失败:' + d.error, 'err'); del.disabled = false; return; }
var fig = grid.querySelector('.imgs-cell[data-id="' + id + '"]');
if (fig) fig.remove();
refreshCount();
syncGallery();
setHint('已删除该张,立即生效。', 'ok');
}).catch(function () { setHint('删除失败', 'err'); del.disabled = false; });
});
{{else}}
syncGallery();
{{end}}
});
</script>
{{end}}
+84
View File
@@ -0,0 +1,84 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>地点库</h1>
<div class="sub">
共 {{num .Total}} 个地点 · {{.RangeLabel}}{{if .City}} · 城市:{{.City}}{{end}}{{if .Keyword}} · 关键词:{{.Keyword}}{{end}}
</div>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{template "filters" .}}
<div class="card">
<div class="row" style="margin-bottom:12px">
<span class="muted" style="font-size:12.5px">排序</span>
<div class="chips">
<a class="chip {{if eq .Sort ""}}on{{end}}" href="{{.Link .FilterBase "sort" ""}}">到访次数</a>
<a class="chip {{if eq .Sort "dwell"}}on{{end}}" href="{{.Link .FilterBase "sort" "dwell"}}">停留时长</a>
<a class="chip {{if eq .Sort "recent"}}on{{end}}" href="{{.Link .FilterBase "sort" "recent"}}">最近到访</a>
<a class="chip {{if eq .Sort "first"}}on{{end}}" href="{{.Link .FilterBase "sort" "first"}}">首次到访</a>
<a class="chip {{if eq .Sort "name"}}on{{end}}" href="{{.Link .FilterBase "sort" "name"}}">名称</a>
</div>
</div>
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="row" style="margin-bottom:12px">
<span class="muted" style="font-size:12.5px">标记</span>
<div class="chips">
<a class="chip {{if eq .Verdict 0}}on{{end}}" href="{{.VerdictLink 0}}">全部</a>
<a class="chip {{if eq .Verdict 1}}on{{end}}" href="{{.VerdictLink 1}}">👍 推荐</a>
<a class="chip {{if eq .Verdict -1}}on{{end}}" href="{{.VerdictLink -1}}">👎 踩雷</a>
</div>
</div>
{{end}}
{{if .Places}}
<div class="tw">
<table class="tb">
<thead>
<tr>
<th>地点</th><th>城市</th><th>主要类型</th>
<th class="num">到访</th><th class="num">累计停留</th>
<th>首次</th><th>最近</th>
</tr>
</thead>
<tbody>
{{range .Places}}
<tr>
<td>
<a href="/places/{{.ID}}">{{if .Name}}{{.Name}}{{else}}未命名地点{{end}}</a>
{{if .IsHome}}<span class="tag">家</span>{{end}}
{{if .IsWork}}<span class="tag">工作</span>{{end}}
{{if eq .Verdict 1}}<span class="tag vote-up">👍 推荐</span>{{else if eq .Verdict -1}}<span class="tag vote-down">👎 踩雷</span>{{end}}
{{if or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
</td>
<td class="muted">{{if .City}}{{.City}}{{else}}—{{end}}</td>
<td>
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{colorof .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="num">{{num .VisitCount}}</td>
<td class="num">{{dur .DwellMinutes}}</td>
<td class="muted">{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}</td>
<td class="muted">{{if .LastAt}}{{d .LastAt}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .}}
{{else}}
<div class="empty">
<div class="big">🔍</div>
<div>没有符合条件的地点。</div>
<div style="margin-top:12px"><a class="btn" href="{{.FilterBase}}">清空筛选条件</a></div>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
+144
View File
@@ -0,0 +1,144 @@
{{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 g2">
<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">
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
<tbody>
<tr><td>最北</td><td>{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num">{{f1 .North.Lat}}°N</td></tr>
{{if .South}}<tr><td>最南</td><td>{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num">{{f1 .South.Lat}}°N</td>{{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">
<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">{{num .Count}}</td>
<td class="num">{{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', function () {
var url = location.href;
if (navigator.share) {
navigator.share({ title: document.title, url: url }).catch(function () {});
return;
}
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(function () {
share.textContent = '已复制链接';
setTimeout(function () { share.textContent = '复制链接'; }, 1600);
}).catch(function () { window.prompt('复制下面的链接:', url); });
} else {
window.prompt('复制下面的链接:', url);
}
});
if (pdf) pdf.addEventListener('click', function () { window.print(); });
})();
</script>
{{end}}
+245
View File
@@ -0,0 +1,245 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>数据统计</h1>
{{/* 备份文件名只对站长有意义,访客看到的是噪音(也是没必要暴露的信息) */}}
<div class="sub">{{.RangeLabel}}{{if .IsAdmin}}{{if .Dataset}} · 数据来自 {{.Dataset.Name}}{{end}}{{end}}</div>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{template "filters" .}}
<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 .Overview.Places}}<small>个</small></div><div class="k">不同地点</div></div>
<div class="kpi"><div class="v">{{dur .Overview.DwellMin}}</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 or .IsAdmin .Settings.MarksPublic}}
<div class="card" style="margin-bottom:16px">
<h2>推荐 &amp; 踩雷</h2>
<div class="card-sub">
站长结论 推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}} | 访客票数 👍 {{num .VoteUp}} · 👎 {{num .VoteDown}}
</div>
<div class="grid g2">
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 推荐的地点</div>
{{if .Recommended}}
<div class="chips">
{{range .Recommended}}<a class="chip" href="/places/{{.ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
</div>
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
</div>
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 踩雷的地点</div>
{{if .Avoided}}
<div class="chips">
{{range .Avoided}}<a class="chip" href="/places/{{.ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
</div>
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
</div>
</div>
</div>
{{end}}
<div class="card">
<h2>月度分布</h2>
<div class="card-sub">每月到访次数,最高 {{num .MaxMonthly}} 次</div>
<div class="cols">
{{/* 点柱条直接筛出那个月;title 是悬停读数 */}}
{{range .Monthly}}<a class="col" href="{{$.MonthLink .Key}}" title="{{.Key}}:{{.Count}} 次到访。点一下只看这个月" style="height:{{.Pct}}%"></a>{{end}}
</div>
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
</div>
<div class="grid g2">
<div class="card">
<h2>类型分布</h2>
<div class="card-sub">按到访次数排序</div>
<div class="bars">
{{range .ActivityStats}}
<a class="bar-row" href="{{$.ActLink .ID}}" title="{{.Name}}:{{num .Count}} 次,累计 {{dur .Dwell}}。点一下按这个类型筛选">
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
<div class="vv">{{num .Count}} 次 · {{dur .Dwell}}</div>
</a>
{{end}}
</div>
</div>
<div class="card">
<h2>一天中的分布</h2>
<div class="card-sub">按到访开始时间统计</div>
<div class="cols" style="height:130px">
{{range $h, $c := .Hourly}}<div class="col" style="height:{{pct $c $.MaxHourly}}%" title="{{$h}} 时:{{$c}} 次"></div>{{end}}
</div>
<div class="cols-x">
{{range $h, $c := .Hourly}}<span>{{if eq $h 0}}0{{else if eq $h 6}}6{{else if eq $h 12}}12{{else if eq $h 18}}18{{else}}&nbsp;{{end}}</span>{{end}}
</div>
</div>
</div>
<div class="grid g2">
<div class="card">
<h2>城市排行</h2>
<div class="card-sub">到访次数</div>
<div class="bars">
{{range .CityStats}}
<a class="bar-row" href="{{$.CityLink .City}}" title="{{.City}}:{{num .Count}} 次到访、{{num .Places}} 个地点。点一下按这个城市筛选">
<div class="nm">{{.City}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxCity}}%"></div></div>
<div class="vv">{{num .Count}} 次 · {{num .Places}} 地点</div>
</a>
{{end}}
</div>
</div>
<div class="card">
<h2>省级行政区</h2>
<div class="card-sub">覆盖情况</div>
<div class="tw">
<table class="tb">
<thead><tr><th>省份</th><th class="num">城市</th><th class="num">地点</th><th class="num">到访</th></tr></thead>
<tbody>
{{range .ProvinceStats}}
<tr>
<td>{{.Province}}</td>
<td class="num">{{num .Cities}}</td>
<td class="num">{{num .Places}}</td>
<td class="num">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="grid g2">
<div class="card">
<h2>星期分布</h2>
<div class="card-sub">一周中的到访规律</div>
<div class="bars">
{{range .Weekday}}
{{/* 星期没有对应的筛选维度(筛选只有日期区间),所以只补悬停读数 */}}
<div class="bar-row" title="{{.Label}}:{{num .Count}} 次到访">
<div class="nm">{{.Label}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxWeekday}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
</div>
<div class="card">
<h2>出行方式</h2>
<div class="card-sub">次数与估算里程</div>
{{if .Transports}}
<div class="tw">
<table class="tb">
<thead><tr><th>方式</th><th class="num">次数</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">{{num .Count}}</td>
<td class="num">{{km .Distance}} km</td>
<td class="num">{{dur .DwellMin}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<div class="empty">暂无位移记录</div>{{end}}
</div>
</div>
<div class="grid g2">
{{if .Tags}}
<div class="card">
<h2>标签统计</h2>
<div class="card-sub">自定义标签的使用情况</div>
<div class="bars">
{{range .Tags}}
<a class="bar-row" href="{{$.TagLink .ID}}" title="#{{.Name}}:{{num .Count}} 次到访。点一下按这个标签筛选">
<div class="nm"><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTag}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</a>
{{end}}
</div>
</div>
{{end}}
{{if .Weather}}
<div class="card">
<h2>天气与气温</h2>
<div class="card-sub">记录期间经历的小时级天气</div>
<div class="tw">
<table class="tb">
<thead><tr><th>天气</th><th class="num">小时数</th><th class="num">平均气温</th></tr></thead>
<tbody>
{{range .Weather}}
<tr>
<td>{{condition .Condition}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{f1 .AvgTemp}} ℃</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{/* 逐月平均气温:这份数据以前一直算着却没渲染(白跑一条查询),这里把它用上。
横向可滚动,十几根柱子挤在窄屏上也不会把月份标签压成一团。 */}}
{{if .Temps}}
<div class="temp-chart">
<div class="temp-head">逐月平均气温</div>
<div class="temp-strip">
{{range .Temps}}
<div class="temp-col" title="{{.Key}}:平均 {{f1 .AvgC}} ℃,{{num .Hours}} 小时有记录">
<span class="tc-v">{{f1 .AvgC}}°</span>
<div class="tc-track"><div class="tc-bar" style="height:{{.Pct}}%;background:{{.Color}}"></div></div>
<span class="tc-m">{{.Label}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{if .Fog}}
<div class="card">
<h2>世界迷雾覆盖</h2>
<div class="card-sub">
迷雾快照 {{num .Fog.Blocks}} 块 / {{num .Fog.Cells}} 格,估算已探索
<b>{{f1 .Fog.AreaKm2}}</b> km²;地点落在已探索格内的比例即「点亮率」
</div>
<div class="kpis" style="margin-bottom:12px">
<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>
{{if .Fog.Cities}}
<div class="bars">
{{range .Fog.Cities}}
<a class="bar-row" href="{{$.CityLink .Name}}" title="{{.Name}}:{{.Lit}}/{{.Total}} 个地点落在已探索区。点一下按这个城市筛选">
<div class="nm">{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Lit .Total}}%;background:var(--accent)"></div></div>
<div class="vv">{{.Lit}}/{{.Total}}</div>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>时间线</h1>
<div class="sub">共 {{num .Total}} 条到访记录 · {{.RangeLabel}}</div>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{template "filters" .}}
{{if .Days}}
<div class="card">
<div class="row" style="margin-bottom:6px">
<a class="btn sm {{if eq .Sort ""}}primary{{end}}" href="{{.Link .FilterBase "sort" ""}}">最新在前</a>
<a class="btn sm {{if eq .Sort "asc"}}primary{{end}}" href="{{.Link .FilterBase "sort" "asc"}}">最早在前</a>
</div>
</div>
{{range .Days}}
<div class="timeline-day">
<div class="dh">
<span>{{.Label}}</span>
<span class="muted">{{num (len .Items)}} 条 · 停留 {{dur .Dwell}}</span>
</div>
{{range .Items}}
<div class="tl-item">
<div class="tm">{{hm .Arrival}}</div>
<div class="ln"><i style="background:{{colorof .Color}}"></i></div>
<div class="bd">
<div>
{{if .PlaceID}}<a href="/places/{{.PlaceID}}">{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}</a>{{else}}未命名地点{{end}}
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:11.5px"><span class="dot" style="background:{{colorof .Color}}"></span>{{.ActivityName}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
</div>
<div class="meta">
{{if .Departure}}{{hm .Arrival}} – {{hm .Departure}} · {{end}}停留 {{dur .DurationMin}}
{{if .City}} · {{.City}}{{end}}
{{if .Remark}} · {{.Remark}}{{end}}
</div>
</div>
</div>
{{end}}
</div>
{{end}}
{{template "pager" .}}
{{else}}
<div class="card"><div class="empty">
<div class="big">📭</div>
<div>没有符合条件的到访记录。</div>
<div style="margin-top:12px"><a class="btn" href="{{.FilterBase}}">清空筛选条件</a></div>
</div></div>
{{end}}
{{end}}
</div>
{{end}}
+97
View File
@@ -0,0 +1,97 @@
{{define "content"}}
{{if not .HasData}}
<div class="wrap">{{template "emptystate" .}}</div>
{{else}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{.Topic.Title}}</h1>
{{if .Topic.Subtitle}}<div class="sub">{{.Topic.Subtitle}}</div>{{end}}
{{/* 时间范围独立成行:塞进 KPI 网格里会被挤成好几行 */}}
<div class="topic-range">{{.Topic.RangeLabel}}{{if .Topic.DayCount}} · 共 {{num .Topic.DayCount}} 天{{end}}</div>
</div>
<div class="row">
<a class="btn" href="/topics">← 全部专题</a>
{{if .IsAdmin}}<a class="btn" href="/admin/topics?edit={{.Topic.ID}}">编辑</a>{{end}}
</div>
</div>
{{if not .Topic.Enabled}}<div class="alert err">这个专题尚未对外展示,只有你自己能看到。</div>{{end}}
{{if .Topic.Description}}<div class="card topic-desc">{{.Topic.Description}}</div>{{end}}
<div class="card">
<div class="kpis">
<div class="kpi"><div class="k">{{if .Topic.HasArea}}区域内地点{{else}}地点{{end}}</div><div class="v">{{num .Topic.PlaceCount}}</div></div>
<div class="kpi"><div class="k">到访次数</div><div class="v">{{num .Topic.VisitCount}}</div></div>
<div class="kpi"><div class="k">涉及城市</div><div class="v">{{num .Topic.CityCount}}</div></div>
<div class="kpi"><div class="k">累计停留</div><div class="v">{{dur .Topic.DwellMin}}</div></div>
</div>
</div>
<div class="card" style="padding:10px">
{{/* 高度写 inline:站点里 #map{height:100%} 是 ID 选择器,只靠外部 CSS 类
容易在别处规则变动/缓存不一致时把容器算成 0 高,地图就整块消失 */}}
<div id="map" class="topic-map" style="height:520px">
<div id="map-fallback" class="map-fallback">地图加载中……若一直不出现,请强制刷新(Ctrl+F5)或检查浏览器是否拦截了地图脚本</div>
</div>
</div>
<div class="card">
<h2>去过的地方</h2>
<div class="card-sub">共 {{num .Topic.PlaceCount}} 个,按到访次数排序</div>
{{if .Points}}
<div class="tw">
<table class="tb">
<thead><tr><th>地点</th><th>城市</th><th>类型</th><th class="num">到访</th><th class="num">停留</th><th>最近一次</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="muted">{{.City}}</td>
<td class="muted">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
<td class="num">{{num .Count}}</td>
<td class="num muted">{{dur .Dwell}}</td>
<td class="muted mono">{{.LastAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">这个时间 / 区域范围内没有记录</div>
{{end}}
</div>
</div>
<script>
(function () {
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
whenReady(function () {
var el = document.getElementById('map');
// 移动端矮一些(inline 高度优先于外部 CSS,只能在这里按视口改)
if (el && window.matchMedia('(max-width: 620px)').matches) el.style.height = '360px';
if (window.rondMap) {
window.rondMap.full({
id: 'map',
points: {{.PointsJSON}},
fitCircle: {{.FitCircleJSON}},
fitBBox: {{.FitBBoxJSON}},
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}}
});
}
// 地图脚本没就绪(被拦 / 加载失败)时,把兜底提示留在原位,用户至少知道发生了什么
setTimeout(function () {
var fb = document.getElementById('map-fallback');
if (fb && el && el.classList.contains('leaflet-container')) fb.style.display = 'none';
}, 1200);
});
})();
</script>
{{end}}
{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>专题</h1>
<div class="sub">把某段时间、某个区域的足迹整理成一个个小专题</div>
</div>
{{if .IsAdmin}}<div class="row"><a class="btn" href="/admin/topics">专题管理</a></div>{{end}}
</div>
{{if .Topics}}
<div class="grid g2">
{{range .Topics}}
<a class="card topic-card" href="/topics/{{.ID}}">
<div class="topic-head">
<h2>{{.Title}}</h2>
{{if not .Enabled}}<span class="tag">未展示</span>{{end}}
</div>
{{if .Subtitle}}<div class="card-sub">{{.Subtitle}}</div>{{end}}
<div class="topic-range">{{.RangeLabel}}</div>
<div class="kpis kpis-3" style="margin-top:10px">
<div class="kpi"><div class="k">地点</div><div class="v">{{num .PlaceCount}}</div></div>
<div class="kpi"><div class="k">到访</div><div class="v">{{num .VisitCount}}</div></div>
<div class="kpi"><div class="k">天数</div><div class="v">{{num .DayCount}}</div></div>
</div>
</a>
{{end}}
</div>
{{else}}
<div class="card"><div class="empty">还没有对外展示的专题</div></div>
{{end}}
</div>
{{end}}