first release
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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="广东省 贵阳市 南明区">{{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://<账号>.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"><img src></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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>推荐 & 踩雷</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}} {{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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user