Files
rond-with-you/internal/web/templates/place.html
T
tamakyi d5f6dfa381 审计修复:前端(模板 / CSS / JS 与无障碍)
- 地图脚本:localStorage 读写走 lsGet/lsSet 兜底(隐私模式下裸访问会打断整段脚本);
  preview() 在 /api/map-config 还没回来时不丢点(previewQueue 在 if (live) 之前排队);
  异步回包一律发序号作废,取消后回来的包不再画
- photo-exif.js:EXIF 时间全 0 不再变成 1899-11-30 写进到访时间;读 count 前先按版本卡宽度
- 数据编辑页:三个筛选表单都把其余条件用隐藏域原样带上,提交不再把别的列表的筛选清掉
- 补全页:sessionStorage 的读写一律包 try(脏数据/隐私模式都会抛)
- 窄屏:删掉被后面同特异性规则盖掉的死内边距;宽表堆叠类名成对、判据扩到所有 tb 表
- 无障碍:图片删除按钮补 aria-label,访客画廊图片补 alt
- report.html 补上漏掉的 </tr>,fog-mask 的宽表带 tb-stack

本批不碰数据库与导出路径:往返包 823/323/8/30/791/557/2246 全等、
坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。
2026-09-24 20:37:57 +08:00

463 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "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 .CategoryLabel}} · {{.CategoryLabel}}{{end}}
· <span class="mono">{{f1 .Place.Lat}}, {{f1 .Place.Lon}}</span>
</div>
</div>
<div class="row">
{{/* 有 back 就按它回去:从筛选过的地点库/时间线/地图点进来时,这里要回到**那份筛选结果** */}}
<a class="btn" href="{{if .Back}}{{.Back}}{{else}}/places{{end}}">← {{if .BackLabel}}{{.BackLabel}}{{else}}返回地点库{{end}}</a>
<a class="btn" href="{{.MapLink .Place.Name}}">在地图中查看</a>
{{if .IsAdmin}}<a class="btn" href="{{.EditPlaceLink .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}}" data-name="{{.Place.Name}}">
<div class="imgs-grid" id="imgs-grid">
{{range .Images}}
<figure class="imgs-cell" data-id="{{.ID}}">
<img src="{{.URL}}" alt="{{if $.Place.Name}}{{$.Place.Name}} 的图片{{else}}地点图片{{end}}" loading="lazy">
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张" aria-label="删除这张图片">×</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="{{if .Place.Name}}{{.Place.Name}} 的图片{{else}}地点图片{{end}}">
</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」按钮
* 与画廊共用同一套数据,所以服务端渲染时还没有图的话,这里现补一个。
*/
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 serverURLs = {{if .ImageURLs}}{{json .ImageURLs}}{{else}}[]{{end}};
var urls = [];
var idx = 0;
// 判据必须是「缩略图墙在不在」,而不是「墙里有几张」:站长把图删光时墙还在、只是空了,
// 这时若按「墙是空的」回退到服务端那份,删掉的图就会被数回来。
function cellURLs() {
var g = document.getElementById('imgs-grid');
if (!g) return serverURLs;
return [].map.call(g.querySelectorAll('.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 = (admin && admin.dataset.name ? admin.dataset.name + ' 的图片' : '地点图片');
var del = document.createElement('button');
del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张';
del.setAttribute('aria-label', '删除这张图片');
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}}