现象:输入任意正常半径(3、3.5、7.25…)提交时弹 「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」。 根因:<input type="number"> 的 **step 是相对 min 递增**的,不是「step 的整数倍」。 min=0.05 + step=0.5 ⇒ 合法值只有 0.05 / 0.55 / 1.05 …,于是 3 和 3.5 全被判非法。 而这是**浏览器原生**提示:JS 与后端都拦不到(我们自己的校验根本没机会说话)。 实测对照(同一元素换 step,其余不动): step=0.5 :0.05✓ 1✗ 3✗ 3.5✗ 7.25✗ 13.37✗ step=0.05 :0.05✓ 1✓ 3✓ 3.5✓ 7.25✓ 13.37✗ ← 仍会拦任意小数 step=any :全 ✓ ← 采用 保留 min/max(真实边界,且给 spinner 范围),只把 step 放开交给 JS/后端校验, 理由与写法都记在模板注释里。 同一 bug 类加了通配守卫,防的是下次换个页面又踩: - TestNumberInputStepNeverMismatchesMin:扫**所有**模板的数字框, 凡「有 min 且有数值 step」就要求 min 是 step 的整数倍(或改用 any); - TestTopicAreaRadiusInputAllowsAnyStep:列表里的输入框是 JS 动态建的,模板扫不到, 单独钉住 step='any'。 变异验证过:把 step 改回 0.5,守卫立刻红。 实测(无头):输入 3.5 后 validationMessage 为空、form.checkValidity() 为真 ⇒ 回车提交不再弹气泡;隐藏字段正确更新为 radiusKm:3.5;越界值只给我们的中文提示 (stepMismatch=false、rangeUnderflow=true,说明「step 误判」这一类已消除)。 全站其它数字框(如迷雾遮罩的 radius_m,min=50/step=50)本来就合法,未改动。
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
/* 专题「显示区」编辑器:在地图上圈一组圆(后台专题表单用)。
|
||
*
|
||
* 交互:
|
||
* · 地图上点一下 = 以「新圆半径」加一个圆
|
||
* · 拖圆心移动、拖圆边上的把手改半径
|
||
* · 列表里可改半径、定位、删除;「清除全部」一次清空
|
||
* · 变化立刻写进隐藏字段 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);
|
||
// step 必须 any:它是相对 min 递增的,min=0.05 + step=0.5 的合法值只有
|
||
// 0.05/0.55/1.05…,填 3 或 3.5 会被浏览器判非法并弹原生提示(「两个最接近的
|
||
// 有效值分别为 3.05 和 3.55」)—— 那是浏览器行为,JS 与后端都拦不到。
|
||
inp.step = 'any';
|
||
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);
|