审计修复:前端(模板 / 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 块一致。
This commit is contained in:
tamakyi
2026-09-24 20:37:57 +08:00
parent b4c2a8452f
commit d5f6dfa381
18 changed files with 675 additions and 77 deletions
+13 -20
View File
@@ -27,6 +27,12 @@
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */
--nav-h: 54px;
/* 地图页浮层的层级:Leaflet 自己的 z-index 封顶在 1000(.leaflet-top/.leaflet-bottom,
见 vendor/leaflet/leaflet.css),而 #map 那一层没有建立层叠上下文 ——
站点的浮层排不到 1000 之上,就会被左上角的缩放按钮、右下的比例尺压住。
分两档是为了保住原有次序:加载状态条要压在所有浮层之上。 */
--z-map-overlay: 1100;
--z-map-status: 1150;
/* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */
--safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px);
@@ -76,7 +82,6 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
/* ---------- 布局 ---------- */
.wrap { max-width: 1120px; margin: 0 auto;
padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); }
.wrap-wide { max-width: 100%; padding: 0; }
.page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 21px; }
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; }
@@ -124,7 +129,8 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.nav-links a { padding: 5px 7px; font-size: 13px; }
/* 内边距由下面「窄屏触控」那块给(同特异性、写在后面才生效);这里只留字号。 */
.nav-links a { font-size: 13px; }
.nav-right .who { display: none; }
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
}
@@ -159,7 +165,6 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。
flex 会让最后一行自己撑满,任何个数都不会有空格。 */
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); }
@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; }
/* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */
.kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
@@ -282,7 +287,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); box-sha
.btn.danger { color: #b42318; border-color: #f0cecb; }
.btn.danger:hover { background: var(--err-bg); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
/* ---------- 表格 ---------- */
table.tb { width: 100%; border-collapse: collapse; font-size: 13px; }
@@ -317,7 +321,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l);
}
.map-panel {
position: absolute; top: 12px; left: 12px; z-index: 400; width: 286px; max-height: calc(100% - 24px);
position: absolute; top: 12px; left: 12px; z-index: var(--z-map-overlay); width: 286px; max-height: calc(100% - 24px);
overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px);
border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
}
@@ -354,16 +358,12 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
.adv-fold > h3 { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
.map-legend {
/* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */
position: absolute; bottom: 26px; right: 12px; z-index: 400; background: var(--glass-strong);
position: absolute; bottom: 26px; right: 12px; z-index: var(--z-map-overlay); background: var(--glass-strong);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px;
max-height: 40%; overflow-y: auto; box-shadow: var(--shadow);
}
.map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); }
.map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; }
.maplink { display: block; padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.maplink b { font-weight: 500; }
.maplink span { color: var(--text-3); font-size: 12px; display: block; }
.dot-marker { border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.16); }
/* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签——
不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */
.fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; }
@@ -380,7 +380,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
/* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */
.to-top {
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: 900;
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: var(--z-map-status);
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line);
background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1;
cursor: pointer; box-shadow: var(--shadow);
@@ -427,7 +427,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */
.timeline-bar {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: 12px; z-index: 650;
bottom: 12px; z-index: var(--z-map-overlay);
display: flex; align-items: center; gap: 10px;
max-width: min(560px, calc(100% - 24px));
padding: 7px 12px; border-radius: 10px;
@@ -689,7 +689,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; }
.timeline-day { margin-bottom: 20px; }
.timeline-day .dh {
position: sticky; top: 54px; background: var(--bg); z-index: 5; padding: 6px 0 7px;
position: sticky; top: var(--nav-h); background: var(--bg); z-index: 5; padding: 6px 0 7px;
font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
}
.timeline-day .dh .muted { font-size: 12px; }
@@ -857,11 +857,6 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.geo-name { display: block; font-size: 13.5px; }
.geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; }
/* 「同时创建到访」折叠区 */
.opt-visit { margin: 0 0 12px; }
.opt-visit > summary { cursor: pointer; color: var(--text-3); font-size: 12.5px; margin-bottom: 8px; }
.opt-visit .row { margin-bottom: 8px; }
/* ---------- 专题 ---------- */
/* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器,
单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */
@@ -871,8 +866,6 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); }
.topic-head { display: flex; align-items: center; gap: 8px; }
.topic-head h2 { margin: 0; }
.topic-card .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 620px) { .topic-card .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */
.map-fallback {
+11 -2
View File
@@ -113,8 +113,14 @@
if (sub[0x9003]) {
var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim());
if (m) {
res.shot = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
res.hasTime = true;
// 全 0 的「0000:00:00 00:00:00」会被 Date 归一成 1899-11-30,然后被当成拍摄时间
// 写进到访时间;字段超范围一律当「没有时间」,别把垃圾时间带进库。
var Y = +m[1], Mo = +m[2], D = +m[3], H = +m[4], Mi = +m[5], S = +m[6];
if (Y >= 1900 && Y <= 2200 && Mo >= 1 && Mo <= 12 && D >= 1 && D <= 31 &&
H <= 23 && Mi <= 59 && S <= 59) {
res.shot = new Date(Y, Mo - 1, D, H, Mi, S);
res.hasTime = true;
}
}
}
}
@@ -171,6 +177,9 @@
if (start + 4 > u8.length) return 0;
var ver = u8[start];
var p = start + 4;
// 末尾恰好相等时(start+4 == length)上面那条守卫会放行,紧接着的 getUint16/32
// 就越界抛 RangeError(用户看到一句英文的 DataView 报错)。读之前把宽度也卡住。
if (ver === 0 ? p + 2 > u8.length : p + 4 > u8.length) return 0;
var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p);
p += ver === 0 ? 2 : 4;
for (var i = 0; i < count; i++) {
+6 -1
View File
@@ -96,7 +96,12 @@
function post(url, form) {
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
.then(function (r) { return r.json(); });
.then(function (r) {
// 服务端出错时回的是 HTML,直接 r.json() 会抛一句英文 SyntaxError;
// 先判状态码,调用方才能拿到人话错误。
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
+45 -25
View File
@@ -26,8 +26,8 @@
modes.forEach(function (m) {
var row = L.DomUtil.create('div');
row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' +
'background:' + (m.color || '#30b0c7') + ';margin-right:6px"></span>' +
m.name + ' <span style="color:#8a9199">' + m.count + '</span>';
'background:' + esc(m.color || '#30b0c7') + ';margin-right:6px"></span>' +
esc(m.name) + ' <span style="color:#8a9199">' + esc(m.count) + '</span>';
box.appendChild(row);
});
L.DomEvent.disableClickPropagation(box);
@@ -89,7 +89,7 @@
L.DomEvent.on(slider, 'input', function () {
var v = parseInt(slider.value, 10) / 100;
layer.setOpacity(v);
localStorage.setItem('rond.fogOpacity', String(v));
lsSet('rond.fogOpacity', String(v));
});
return box;
}
@@ -155,6 +155,7 @@
};
var maps = {};
var previewQueue = {}; // id -> 最近一次 preview 请求的点集(配置就绪后补画)
var cfgCache = null;
function register(id, map) {
@@ -211,20 +212,27 @@
*/
var BASE_PICK = 'rond.base';
/* localStorage 在隐私模式 / 禁用 Cookie 的浏览器里**读写都会抛异常**,
裸调用会把整段脚本打断(地图白屏、图层不出来)。统一走这两个兜底,失败当没有。 */
function lsGet(k) {
try { return window.localStorage.getItem(k); } catch (e) { return null; }
}
function lsSet(k, v) {
try { window.localStorage.setItem(k, v); } catch (e) { /* 隐私模式,忽略 */ }
}
function pickBase(cfg) {
var byKey = {};
cfg.base.forEach(function (b) { byKey[b.key] = b; });
try {
var saved = window.localStorage.getItem(BASE_PICK);
if (saved && byKey[saved]) return byKey[saved];
} catch (e) { /* 隐私模式禁用了 localStorage,忽略即可 */ }
var saved = lsGet(BASE_PICK);
if (saved && byKey[saved]) return byKey[saved];
if (cfg.default && byKey[cfg.default]) return byKey[cfg.default];
return cfg.base[0];
}
function rememberBase(key) {
if (!key) return;
try { window.localStorage.setItem(BASE_PICK, key); } catch (e) { /* 同上 */ }
lsSet(BASE_PICK, key);
}
function esc(s) {
@@ -379,7 +387,7 @@
// maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行
m.bindPopup(function () { return popup(p, vote); },
{ closeButton: true, maxWidth: 360, minWidth: 240 });
m.bindTooltip(p.n || '未命名', { direction: 'top', offset: [0, -4] });
m.bindTooltip(esc(p.n || '未命名'), { direction: 'top', offset: [0, -4] });
m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」
return m;
}
@@ -543,12 +551,18 @@
// 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」:
// 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。
// 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。
// 记下这一轮要画的点:配置就绪后以「最新一次请求」为准。
// 原来只在 _state 已建好时才处理;配置还没回来时 _state 里还没有条目,
// 第二次 preview 的点会被静默丢掉——地图上少一批点,且完全不报错。
previewQueue[opts.id] = points;
var live = maps[opts.id];
if (live) {
live.invalidateSize();
var prev = window.rondMap._state[opts.id];
if (prev && prev.setPoints) prev.setPoints(points);
else if (prev) prev.previewPending = points; // 配置还没回来,等 setPoints 建好再补画
if (prev && prev.setPoints) {
delete previewQueue[opts.id];
prev.setPoints(points);
}
return;
}
var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false });
@@ -557,6 +571,9 @@
if (vote) loadVotes();
loadConfig().then(function (cfg) {
var def = pickBase(cfg);
// 配置期间又来了一次 preview 的话,以那一次的点为准
if (previewQueue[opts.id]) points = previewQueue[opts.id];
delete previewQueue[opts.id];
tileLayer(def).addTo(map);
var byId = {};
var group = markers(points, def.crs, false, vote, byId);
@@ -587,12 +604,6 @@
applyFit();
};
window.rondMap._state[opts.id] = st;
// 配置就绪前有别的调用挂上来的点,现在补画一次
if (st.previewPending) {
var queued = st.previewPending;
st.previewPending = null;
st.setPoints(queued);
}
});
},
@@ -701,7 +712,7 @@
var overlayDefs = {};
if (fogLayer) {
overlayDefs['世界迷雾'] = fogLayer;
var savedFog = parseFloat(localStorage.getItem('rond.fogOpacity'));
var savedFog = parseFloat(lsGet('rond.fogOpacity'));
if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog);
var fogCtl = addFogOpacityControl(map, fogLayer, corner);
// 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了;
@@ -710,9 +721,9 @@
var on = map.hasLayer(fogLayer);
var box = fogCtl.getContainer();
if (box) box.style.display = on ? '' : 'none';
localStorage.setItem('rond.fogOff', on ? '0' : '1');
lsSet('rond.fogOff', on ? '0' : '1');
};
if (localStorage.getItem('rond.fogOff') === '1') syncFog();
if (lsGet('rond.fogOff') === '1') syncFog();
else fogLayer.addTo(map);
map.on('overlayadd overlayremove', function (e) {
if (e.name === '世界迷雾') syncFog();
@@ -764,12 +775,15 @@
// 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影
var trackLayer = null, trackSegs = null, heatLayer = null;
var cityLayer = null, gpxLayer = null, modeLegend = null;
// 图层开关与异步回包的竞态:勾选后立刻取消时请求还在飞,回包若直接 addTo(map),
// 开关明明关着图层却画了上去(切底图重取也走同一条路)。递增序号作废过期回包。
var gpxSeq = 0, heatSeq = 0, citySeq = 0;
function buildTrack(segs, crs) {
var g = L.layerGroup();
segs.forEach(function (s) {
var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs);
L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 })
.bindTooltip((s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true })
.bindTooltip(esc(s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(g);
});
return g;
@@ -802,9 +816,11 @@
// 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑
window.rondMap.setCityAgg = function (on) {
var seq = ++citySeq;
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; }
if (!on) return;
fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) {
if (seq !== citySeq) return; // 期间又切过开关/底图,这次回包作废
var cities = d.cities || [];
if (!cities.length) return;
var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; }));
@@ -815,10 +831,10 @@
L.circleMarker(pos, {
radius: r, color: '#2f7d6e', weight: 1.5,
fillColor: '#2f7d6e', fillOpacity: 0.18
}).bindPopup('<b>' + c.city + '</b>' +
(c.province ? ' · ' + c.province : '') +
}).bindPopup('<b>' + esc(c.city) + '</b>' +
(c.province ? ' · ' + esc(c.province) : '') +
'<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' +
(c.first ? '<br>首次到达 ' + c.first : '')).addTo(cityLayer);
(c.first ? '<br>首次到达 ' + esc(c.first) : '')).addTo(cityLayer);
});
cityLayer.addTo(map);
});
@@ -826,24 +842,28 @@
// GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好
window.rondMap.setGpx = function (on) {
var seq = ++gpxSeq;
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; }
if (!on) return;
ensureGpx(st.crs, function (tracks) {
if (seq !== gpxSeq) return; // 期间又切过开关/底图,这次回包作废
gpxLayer = L.layerGroup();
tracks.forEach(function (t) {
if (!t.points || t.points.length < 2) return;
L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 })
.bindTooltip(t.name + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true })
.bindTooltip(esc(t.name) + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(gpxLayer);
});
gpxLayer.addTo(map);
});
};
window.rondMap.setHeat = function (on) {
var seq = ++heatSeq;
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
if (!on) return;
// heat 库只在第一次开热力图时才下载,平时不进首屏
ensureHeat(function () {
if (seq !== heatSeq) return; // 期间又切过开关/底图,这次回包作废
if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map);
});
};