专题显示区改成在地图上点选(后台编辑器)

用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框
换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/
定位/删除、可一次清空;改动立刻写进隐藏字段 areas(JSON) 与 crs,随专题表单
一起提交。

- 复用 /admin/fog-mask 那套约定:/api/map-config 取底图、底图坐标系随表单带上、
  后端换算成 GCJ-02。不另起炉灶。
- areas 的初值**渲染进隐藏字段**(不是只塞给 JS):脚本没加载上时提交的仍是原值,
  否则一次保存就把已有显示区静默清空。
- 地图初始视野取生效数据集的地点包围盒(placesBounds),一上来就落在自己的地界上,
  而不是全国视野。
- 上限(20 个 / 0.05~2000 km)前端也校验一份,并**禁用提交**;两边数字用守卫比对,
  免得出现「前端放行、后端拒绝,用户不知道改哪里」。

实测抓到并修掉的 bug:/admin/topics 没设 p.HasMap ⇒ 布局不引入 Leaflet,
表现是脚本加载了、rondTopicAreas 也在,就是 window.L 永远 undefined,init 静默
返回、控制台不报错、页面像「什么都没发生」。已加守卫钉住这一点。

其余:
- topic-areas.js 只暴露纯函数(校验/序列化/增删改的不可变性/几何换算)供 node 单测,
  另加一个最小测试钩子 api._map 供无头验证(同 window.rondMap._state 惯例)。
- CSS 用定宽容器装半径输入框:站点全局 input 规则是 width:100% 且带 10 个 :not()
  (特异性 (0,11,1)),直接给 input 写宽度压不过。
- gofmt:文档注释里的缩进列表按 Go 1.19 的规则重排;顺带把工作区的 CRLF 归一到 LF
  (.gitattributes 早就声明 eol=lf,blob 本来也是 LF,所以 git diff 无额外噪声)。

验证:
- topic-areas.test.js 44 条(含「增删改返回新数组不原地改」、半径换算自洽性)。
- 三个新守卫:两边上限一致、模板契约(旧输入框必须没了 + 挂点齐全 + 初值来源)、
  topicsAdminPage 必须设 HasMap。
- 无头端到端:两击成两圆 → 改半径 7.5 → 删第 1 个 → 越界半径被拦且禁用提交 →
  提交 200 且落库 areas=[{lat:24.72,lon:108.1,radiusKm:7.5}](废弃列镜像 7.5);全程无 JS 报错。
This commit is contained in:
tamakyi
2026-10-12 17:35:00 +08:00
parent 48938e6ae1
commit 7ab33227a5
8 changed files with 690 additions and 18 deletions
+364
View File
@@ -0,0 +1,364 @@
/* 专题「显示区」编辑器:在地图上圈一组圆(后台专题表单用)。
*
* 交互:
* · 地图上点一下 = 以「新圆半径」加一个圆
* · 拖圆心移动、拖圆边上的把手改半径
* · 列表里可改半径、定位、删除;「清除全部」一次清空
* · 变化立刻写进隐藏字段 areas(JSON) 与 crs(底图坐标系),随专题表单一起提交
* · 初值读隐藏字段自身(不是另传一份),脚本没加载上时提交的仍是服务端渲染的原值
*
* 坐标系:地图上的一切都在**底图坐标系**里(天地图那类底图是 WGS-84),
* 提交时把 crs 一起带上,由后端换算成站点统一的 GCJ-02 再存 —— 与 /admin/fog-mask 同一套约定。
*
* 纯函数(validate / serialize / add / remove / setCenter / setRadius)单独暴露,
* 供 node 单测;DOM 部分只在 init() 里跑,且等 load 之后再初始化
* (Leaflet 由 layout 在 content 之后引入,脚本执行时 window.L 还没有)。
*/
(function (global) {
'use strict';
// 这几个上限必须与后端 normalizeTopicAreas 一致:前端放行、后端拒绝的话,
// 用户只会看到一句红字却不知道改哪里。
var MAX_AREAS = 20;
var MIN_KM = 0.05;
var MAX_KM = 2000;
function num(v) { var n = parseFloat(v); return isFinite(n) ? n : NaN; }
// 坐标 6 位、半径 3 位:够用,且不让 JSON 里出现一长串浮点尾巴
function round6(v) { return Math.round(v * 1e6) / 1e6; }
function round3(v) { return Math.round(v * 1e3) / 1e3; }
function normalize(a) {
a = a || {};
return { lat: round6(num(a.lat)), lon: round6(num(a.lon)), radiusKm: round3(num(a.radiusKm)) };
}
// 返回错误信息;空串 = 通过。「空数组」是合法的(不限区域),不是错误。
function validate(areas) {
areas = areas || [];
if (!areas.length) return '';
if (areas.length > MAX_AREAS) return '显示区最多 ' + MAX_AREAS + ' 个';
for (var i = 0; i < areas.length; i++) {
var a = areas[i], who = '第 ' + (i + 1) + ' 个圆';
if (!isFinite(a.lat) || !isFinite(a.lon) || !isFinite(a.radiusKm)) return who + '的数值不合法';
if (Math.abs(a.lat) > 90 || Math.abs(a.lon) > 180) return who + '的圆心超出经纬度范围';
if (a.radiusKm < MIN_KM || a.radiusKm > MAX_KM) {
return who + '的半径要在 ' + MIN_KM + ' ~ ' + MAX_KM + ' 公里之间';
}
}
return '';
}
function serialize(areas) { return JSON.stringify((areas || []).map(normalize)); }
function parse(raw) {
var v;
try { v = JSON.parse(raw || '[]'); } catch (e) { return []; }
return Array.isArray(v) ? v.map(normalize) : [];
}
function add(areas, a) {
var out = (areas || []).slice();
if (out.length < MAX_AREAS) out.push(normalize(a));
return out;
}
function remove(areas, i) {
var out = (areas || []).slice();
if (i >= 0 && i < out.length) out.splice(i, 1);
return out;
}
function setCenter(areas, i, lat, lon) {
var out = (areas || []).slice();
if (i >= 0 && i < out.length) out[i] = normalize({ lat: lat, lon: lon, radiusKm: out[i].radiusKm });
return out;
}
function setRadius(areas, i, km) {
var out = (areas || []).slice();
if (i >= 0 && i < out.length) out[i] = normalize({ lat: out[i].lat, lon: out[i].lon, radiusKm: km });
return out;
}
// 半径(公里)换算成经纬度增量:摆「改半径」把手、以及把圆心移回视野用得上
function latDelta(km) { return km / 111.32; }
function lonDelta(lat, km) {
var c = Math.cos(lat * Math.PI / 180);
if (c < 0.01) c = 0.01;
return km / (111.32 * c);
}
// 两点距离(公里),球面公式;拖把手时用它反推半径
function kmBetween(a, b) {
var dLat = (b[0] - a[0]) * Math.PI / 180, dLon = (b[1] - a[1]) * Math.PI / 180;
var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(a[0] * Math.PI / 180) * Math.cos(b[0] * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(s)));
}
var api = {
MAX_AREAS: MAX_AREAS, MIN_KM: MIN_KM, MAX_KM: MAX_KM,
normalize: normalize, validate: validate, serialize: serialize, parse: parse,
add: add, remove: remove, setCenter: setCenter, setRadius: setRadius,
latDelta: latDelta, lonDelta: lonDelta, kmBetween: kmBetween
};
// ---------- 以下需要 DOM 与 Leaflet ----------
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
global.addEventListener('load', cb);
}
// 兜底底图:与 /admin/fog-mask 同一套(带密钥的图层由服务端转发,免密钥的是浏览器直连的 {s} 模板)
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'
};
var FALLBACK_SUBS = ['1', '2', '3', '4'];
api.init = function (opts) {
opts = opts || {};
var el = document.getElementById(opts.mapId || 'area-map');
var form = document.getElementById(opts.formId || 'topic-form');
if (!el || !form) return;
var hidden = form.querySelector('input[name=areas]');
var crsIn = form.querySelector('input[name=crs]');
var listBox = document.getElementById(opts.listId || 'area-list');
var hint = document.getElementById(opts.hintId || 'area-hint');
var radiusIn = document.getElementById(opts.radiusId || 'area-new-km');
var saveBtn = form.querySelector('button[type=submit]');
if (!hidden || !listBox) return;
// 初值就取自隐藏字段本身:这样脚本没加载上时,表单提交的仍是服务端渲染进去的原值,
// 不会把已有的显示区「静默清空」。
var areas = parse(hidden.value);
whenReady(function () {
if (!global.L) return;
fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
start((c && c.base && c.base.length) ? c.base[0] : FALLBACK_DEF,
(c && c.subs && c.subs.length) ? c.subs : FALLBACK_SUBS);
})
.catch(function () { start(FALLBACK_DEF, FALLBACK_SUBS); });
});
function start(def, subs) {
var map = L.map(el);
L.tileLayer(def.url, {
subdomains: subs, maxZoom: def.maxZoom || 18, minZoom: 3, attribution: def.attr || ''
}).addTo(map);
if (crsIn) crsIn.value = def.crs || 'gcj02';
var layer = L.layerGroup().addTo(map);
var views = []; // 与 areas 同下标:{circle, dot, handle}
// 供无头验证用的入口(与 window.rondMap._state 同一惯例):
// 不给个把手,就只能靠真的鼠标点地图,CDP 下很难稳定复现。
api._map = map;
api._areas = function () { return areas; };
function newRadius() {
var v = parseFloat(radiusIn && radiusIn.value);
if (!isFinite(v) || v < MIN_KM || v > MAX_KM) return 3;
return v;
}
// 整块重画:圆最多 20 个,重建比增量对齐简单得多,也不会漏掉「删了中间一个」的下标错位
function redraw() {
layer.clearLayers();
views = [];
for (var i = 0; i < areas.length; i++) {
(function (idx) {
var a = areas[idx];
var circle = L.circle([a.lat, a.lon], {
radius: a.radiusKm * 1000, color: '#d9534f', weight: 1.5, fillOpacity: 0.12
}).addTo(layer);
var dot = L.marker([a.lat, a.lon], {
draggable: true, icon: dotIcon(),
title: '拖动移动第 ' + (idx + 1) + ' 个显示区'
}).addTo(layer);
var handle = L.marker([a.lat, a.lon + lonDelta(a.lat, a.radiusKm)], {
draggable: true, icon: handleIcon(),
title: '拖动改半径'
}).addTo(layer);
dot.on('drag', function (e) {
var ll = e.target.getLatLng();
circle.setLatLng(ll);
handle.setLatLng([ll.lat, ll.lng + lonDelta(ll.lat, areas[idx].radiusKm)]);
});
dot.on('dragend', function (e) {
var ll = e.target.getLatLng();
areas = setCenter(areas, idx, ll.lat, ll.lng);
sync();
});
handle.on('drag', function (e) {
var ll = e.target.getLatLng();
var km = kmBetween([areas[idx].lat, areas[idx].lon], [ll.lat, ll.lng]);
if (!isFinite(km) || km < MIN_KM) km = MIN_KM;
circle.setRadius(km * 1000);
});
handle.on('dragend', function (e) {
var ll = e.target.getLatLng();
var km = kmBetween([areas[idx].lat, areas[idx].lon], [ll.lat, ll.lng]);
if (!isFinite(km) || km < MIN_KM) km = MIN_KM;
areas = setRadius(areas, idx, km);
sync();
});
views.push({ circle: circle, dot: dot, handle: handle });
})(i);
}
}
function dotIcon() {
return L.divIcon({ className: 'area-dot', iconSize: [14, 14], iconAnchor: [7, 7] });
}
function handleIcon() {
return L.divIcon({ className: 'area-handle', iconSize: [16, 16], iconAnchor: [8, 8] });
}
function sync() {
hidden.value = serialize(areas);
redraw();
renderList();
var msg = validate(areas);
if (hint) {
if (msg) {
hint.textContent = msg;
hint.className = 'err';
} else {
hint.className = '';
hint.textContent = areas.length
? '已圈 ' + areas.length + ' 个显示区;点地图继续加,拖圆心移动、拖圆边改半径'
: '在图上点一下就是圆心;不圈就是「时间范围内的地点画到哪算哪」';
}
}
// 校验不过就别让提交(后端也会再拦一道,这里只是别浪费一次往返)
if (saveBtn) saveBtn.disabled = !!msg;
}
function renderList() {
listBox.textContent = '';
if (!areas.length) {
var empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = '还没有显示区。';
listBox.appendChild(empty);
return;
}
areas.forEach(function (a, idx) {
var row = document.createElement('div');
row.className = 'area-row';
var tag = document.createElement('span');
tag.className = 'area-tag';
tag.textContent = '#' + (idx + 1);
row.appendChild(tag);
var coord = document.createElement('span');
coord.className = 'mono muted';
coord.textContent = a.lat.toFixed(5) + ', ' + a.lon.toFixed(5);
row.appendChild(coord);
var lab = document.createElement('label');
lab.className = 'area-km';
// 半径输入套一层定宽容器:站点的全局规则给所有 input 上了 width:100% 且带 10 个
// :not()(特异性 (0,11,1)),直接给 input 写宽度是压不过的 —— 用容器定宽就绕开了。
var box = document.createElement('span');
box.className = 'area-km-box';
var inp = document.createElement('input');
inp.type = 'number';
inp.min = String(MIN_KM);
inp.max = String(MAX_KM);
inp.step = '0.5';
inp.value = String(a.radiusKm);
inp.addEventListener('input', function () {
areas = setRadius(areas, idx, parseFloat(inp.value));
sync();
focusList(idx);
});
box.appendChild(inp);
lab.appendChild(box);
var unit = document.createElement('span');
unit.className = 'muted';
unit.textContent = '公里';
lab.appendChild(unit);
row.appendChild(lab);
var go = document.createElement('button');
go.type = 'button';
go.className = 'btn sm';
go.textContent = '定位';
go.addEventListener('click', function () {
map.setView([a.lat, a.lon], Math.max(map.getZoom(), 12));
});
row.appendChild(go);
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn sm';
del.textContent = '删除';
del.addEventListener('click', function () {
areas = remove(areas, idx);
sync();
});
row.appendChild(del);
listBox.appendChild(row);
});
}
// 改半径会整块重画,输入框会失焦 —— 把焦点还给同一行,否则连打第二个数字都困难
function focusList(idx) {
var inputs = listBox.querySelectorAll('.area-km input');
if (inputs[idx]) {
inputs[idx].focus();
var v = inputs[idx].value;
try { inputs[idx].setSelectionRange(v.length, v.length); } catch (e) { /* number 型不支持,忽略 */ }
}
}
map.on('click', function (e) {
if (areas.length >= MAX_AREAS) {
if (hint) { hint.textContent = '显示区最多 ' + MAX_AREAS + ' 个'; hint.className = 'err'; }
return;
}
areas = add(areas, { lat: e.latlng.lat, lon: e.latlng.lng, radiusKm: newRadius() });
sync();
});
var clearBtn = document.getElementById(opts.clearId || 'area-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
if (!areas.length) return;
if (!global.confirm('清空全部显示区(等于不限区域)。确认?')) return;
areas = [];
sync();
});
}
// 初始视野:有圆就框住所有圆,否则落在站长自己的地点范围上
if (areas.length) {
var b = null;
areas.forEach(function (a) {
var dLat = latDelta(a.radiusKm), dLon = lonDelta(a.lat, a.radiusKm);
var box = L.latLngBounds([[a.lat - dLat, a.lon - dLon], [a.lat + dLat, a.lon + dLon]]);
b = b ? b.extend(box) : box;
});
map.fitBounds(b, { padding: [20, 20] });
} else if (opts.home) {
map.fitBounds(L.latLngBounds(
[[opts.home.minLat, opts.home.minLon], [opts.home.maxLat, opts.home.maxLon]]),
{ padding: [20, 20] });
} else {
map.setView([34.5, 108.9], 4);
}
sync();
}
};
if (typeof module !== 'undefined' && module.exports) module.exports = api;
global.rondTopicAreas = api;
})(typeof window !== 'undefined' ? window : globalThis);