From 7ab33227a56de175740c128c72c3d4b12e9c15cc Mon Sep 17 00:00:00 2001 From: tamakyi Date: Mon, 12 Oct 2026 17:35:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=93=E9=A2=98=E6=98=BE=E7=A4=BA=E5=8C=BA?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=9C=A8=E5=9C=B0=E5=9B=BE=E4=B8=8A=E7=82=B9?= =?UTF-8?q?=E9=80=89=EF=BC=88=E5=90=8E=E5=8F=B0=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框 换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/ 定位/删除、可一次清空;改动立刻写进隐藏字段 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 报错。 --- internal/web/frontend_fixes_test.go | 15 +- internal/web/handlers_topics.go | 32 ++ internal/web/static/css/app.css | 23 ++ internal/web/static/js/topic-areas.js | 364 +++++++++++++++++++++ internal/web/static/js/topic-areas.test.js | 129 ++++++++ internal/web/templates/admin/topics.html | 39 ++- internal/web/topic_areas_test.go | 103 ++++++ internal/web/view.go | 3 + 8 files changed, 690 insertions(+), 18 deletions(-) create mode 100644 internal/web/static/js/topic-areas.js create mode 100644 internal/web/static/js/topic-areas.test.js diff --git a/internal/web/frontend_fixes_test.go b/internal/web/frontend_fixes_test.go index 0bc2a91..eb26708 100644 --- a/internal/web/frontend_fixes_test.go +++ b/internal/web/frontend_fixes_test.go @@ -272,13 +272,14 @@ func TestDateInputsGoThroughAttachAll(t *testing.T) { // (迷雾一直是全局瓦片,完全不认区域),这种「说明写了、实现没做」最容易蒙混过关。 // // 这条守卫钉住两端与两处顺序: -// ① topic.html 必须把专题显示区传给地图(且**无条件**传,不限区域时是空数组); -// ② rond-map.js 必须实现收口(单独 pane + clip-path,不能裁默认 tilePane——底图在里面) -// 且多圆要交给 fog-clip.js 的 pathFor; -// ③ fog-clip.js 必须在 rond-map.js **之前**加载(否则 window.rondFogClip 还没有); -// ④ 裁剪的注册必须排在 applyFit() 之后:视图还没定时 latLngToContainerPoint() 会抛 -// 「Set map center and zoom first.」,那行异常会把后面的图层添加一起打断 -// (实测:pane 建好了、迷雾瓦片一张都没有)。 +// +// ① topic.html 必须把专题显示区传给地图(且**无条件**传,不限区域时是空数组); +// ② rond-map.js 必须实现收口(单独 pane + clip-path,不能裁默认 tilePane——底图在里面) +// 且多圆要交给 fog-clip.js 的 pathFor; +// ③ fog-clip.js 必须在 rond-map.js **之前**加载(否则 window.rondFogClip 还没有); +// ④ 裁剪的注册必须排在 applyFit() 之后:视图还没定时 latLngToContainerPoint() 会抛 +// 「Set map center and zoom first.」,那行异常会把后面的图层添加一起打断 +// (实测:pane 建好了、迷雾瓦片一张都没有)。 func TestTopicFogIsClippedToAreas(t *testing.T) { tpl, err := tmplFS.ReadFile("templates/topic.html") if err != nil { diff --git a/internal/web/handlers_topics.go b/internal/web/handlers_topics.go index 0b25dbe..e9a78fd 100644 --- a/internal/web/handlers_topics.go +++ b/internal/web/handlers_topics.go @@ -421,6 +421,9 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) { s.serverError(w, r, err) return } + // 显示区编辑器要用 Leaflet:布局按这个标记引入它,漏了就是「地图整块不出现」 + // (实测踩过:脚本加载了、rondTopicAreas 也在,但 window.L 一直是 undefined)。 + p.HasMap = true d := TopicAdminData{Page: p} d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err") u := userFrom(ctx) @@ -441,6 +444,12 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) { if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil { log.Printf("专题统计失败: %v", err) } + // 显示区编辑器的初始视野落在站长自己的地界上(拿不到就退回编辑器自带的兜底视野) + if b, err := s.placesBounds(ctx, ds.ID); err != nil { + log.Printf("取地点包围盒失败: %v", err) + } else { + d.MapHomeJSON = b + } } d.Topics = list // 编辑模式:?edit= @@ -462,6 +471,29 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) { s.render(w, "admin/topics", d) } +// placesBounds 是生效数据集里可用地点的包围盒,给显示区编辑器当初始视野。 +// 「可用」=有坐标且不是 (0,0)(脏数据会被填成 0,0,落进去会把视野拉到几内亚湾)。 +// 没有地点时返回 null,编辑器自己有兜底视野。 +func (s *Server) placesBounds(ctx context.Context, datasetID int64) (template.JS, error) { + var minLat, maxLat, minLon, maxLon sql.NullFloat64 + if err := s.db.QueryRowContext(ctx, `SELECT min(lat), max(lat), min(lon), max(lon) FROM places + WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat <> 0 OR lon <> 0)`, + datasetID).Scan(&minLat, &maxLat, &minLon, &maxLon); err != nil { + return "", err + } + if !minLat.Valid { + return "null", nil + } + b, err := json.Marshal(map[string]any{ + "minLat": minLat.Float64, "maxLat": maxLat.Float64, + "minLon": minLon.Float64, "maxLon": maxLon.Float64, + }) + if err != nil { + return "", err + } + return template.JS(b), nil +} + func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u := userFrom(ctx) diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index 6049eb2..a775230 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -1161,6 +1161,26 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten #parse-status { margin: 12px 0 0; } #parse-status:empty { display: none; } +/* ---------- 专题显示区编辑器(后台专题表单) ---------- + 地图 + 圆列表。半径/个数的上下限与后端一致,由 topic-areas.js 统一校验。 */ +#area-map { height: 380px; border-radius: var(--r-md); background: var(--line-soft); } +.area-tools { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; margin: 10px 0; } +.area-tools #area-hint { flex: 1 1 240px; font-size: var(--fs-sm); } +#area-hint.err { color: var(--err-fg); } +#area-list { margin-top: 4px; } +#area-list:empty { display: none; } +.area-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 7px 0; border-top: 1px solid var(--line-soft); } +.area-tag { color: var(--text-3); font-family: var(--mono); font-size: var(--fs-sm); min-width: 2.4em; } +.area-row .mono { flex: 1 1 180px; } +.area-km { display: flex; align-items: center; gap: 6px; margin: 0; } +/* 定宽容器:站点的全局 input 规则是 width:100% 且带 10 个 :not()(特异性 (0,11,1)), + 直接给 input 写宽度压不过,用容器定宽就绕开了。 */ +.area-km-box { flex: 0 0 96px; display: block; } +/* divIcon 默认没有背景,不补的话圆心和把手既看不见也抓不住 */ +.area-dot { background: var(--accent); border: 2px solid #fff; border-radius: 50%; box-shadow: var(--sh-1); } +.area-handle { background: var(--surface); border: 2px solid var(--accent); border-radius: 50%; box-shadow: var(--sh-1); } + /* ---------- 窄屏触控与控件字号(必须放在最后) ---------- 这几组选择器(.chip / .btn / .check input)的基础规则写在文件中部, 而 CSS 同特异性下靠后的赢 —— 把它们写在前面那份「窄屏触控」块里是被盖掉的 @@ -1170,6 +1190,9 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten @media (max-width: 620px) { .chip { padding: 9px 12px; } .btn, .btn.sm { padding: 10px 14px; } + /* 专题显示区编辑器:地图矮一点;坐标那列太长会挤掉按钮,让它独占一行排到最后 */ + #area-map { height: 300px; } + .area-row .mono { flex: 1 1 100%; order: 3; } /* 勾选框:原来 15px,手指很难点中(尤其后台设置页一屏几十个开关)。 有效目标仍是外面那圈 label,但控件本身大一点更好瞄。 */ .check input, input[type=checkbox], input[type=radio] { width: 20px; height: 20px; } diff --git a/internal/web/static/js/topic-areas.js b/internal/web/static/js/topic-areas.js new file mode 100644 index 0000000..3fb979c --- /dev/null +++ b/internal/web/static/js/topic-areas.js @@ -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); diff --git a/internal/web/static/js/topic-areas.test.js b/internal/web/static/js/topic-areas.test.js new file mode 100644 index 0000000..a090016 --- /dev/null +++ b/internal/web/static/js/topic-areas.test.js @@ -0,0 +1,129 @@ +/* topic-areas.js 纯函数部分的单元测试(node 直接跑,无依赖)。 + * 用法:node internal/web/static/js/topic-areas.test.js + * + * 这里只测「不改 DOM 的那半」:校验规则、序列化、增删改的不可变性、几何换算。 + * 校验规则必须与后端 normalizeTopicAreas 对齐(放行/拒绝不一致,用户只会看到一句红字 + * 却不知道改哪里),Go 侧另有一条守卫直接比对两边写死的上限数字。 + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const mod = {}; +const sandbox = { window: {}, module: mod }; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(path.join(__dirname, 'topic-areas.js'), 'utf8'), sandbox); +const api = sandbox.window.rondTopicAreas; + +let pass = 0, fail = 0; +function ok(cond, label, extra) { + if (cond) { pass++; console.log(' ok ' + label); } + else { fail++; console.log(' FAIL ' + label + (extra ? '\n ' + extra : '')); } +} +function eq(got, want, label) { + const g = JSON.stringify(got), w = JSON.stringify(want); + if (g === w) { pass++; console.log(' ok ' + label); } + else { fail++; console.log(' FAIL ' + label + '\n got ' + g + '\n want ' + w); } +} + +console.log('=== 归一化:坐标 6 位、半径 3 位 ==='); +(function () { + eq(api.normalize({ lat: 25.1234567891, lon: 100.9876543219, radiusKm: 3.14159265 }), + { lat: 25.123457, lon: 100.987654, radiusKm: 3.142 }, '超长小数被截到 6/3 位'); + eq(api.normalize({ lat: '25.5', lon: '100.25', radiusKm: '3' }), + { lat: 25.5, lon: 100.25, radiusKm: 3 }, '字符串数字照收'); + ok(!isFinite(api.normalize({ lat: 'abc', lon: 1, radiusKm: 1 }).lat), '非数字 → NaN(交给校验去拦)'); + ok(!isFinite(api.normalize(null).radiusKm), 'null 输入不抛异常'); +})(); + +console.log('=== 校验:空 = 不限区域,是合法的 ==='); +(function () { + eq(api.validate([]), '', '空数组通过'); + eq(api.validate(null), '', 'null 通过(当作空)'); + eq(api.validate([{ lat: 25, lon: 100, radiusKm: 3 }]), '', '单个正常圆通过'); + + ok(api.validate([{ lat: NaN, lon: 100, radiusKm: 3 }]).indexOf('数值') >= 0, 'NaN 纬度被拦'); + ok(api.validate([{ lat: 91, lon: 100, radiusKm: 3 }]).indexOf('经纬度') >= 0, '纬度越界被拦'); + ok(api.validate([{ lat: 25, lon: 181, radiusKm: 3 }]).indexOf('经纬度') >= 0, '经度越界被拦'); + ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MIN_KM / 2 }]).indexOf('半径') >= 0, '半径过小被拦'); + ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MAX_KM + 1 }]).indexOf('半径') >= 0, '半径过大被拦'); + ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MAX_KM }]) === '', '半径正好等于上限:放行'); + ok(api.validate([{ lat: 25, lon: 100, radiusKm: api.MIN_KM }]) === '', '半径正好等于下限:放行'); + + const many = []; + for (let i = 0; i < api.MAX_AREAS + 1; i++) many.push({ lat: 20 + i / 100, lon: 100, radiusKm: 3 }); + ok(api.validate(many).indexOf('最多') >= 0, '超过个数上限被拦'); + ok(api.validate(many.slice(0, api.MAX_AREAS)) === '', '正好等于个数上限:放行'); + // 报错要指出是第几个,否则用户得自己数 + ok(api.validate([{ lat: 25, lon: 100, radiusKm: 3 }, { lat: 91, lon: 100, radiusKm: 3 }]).indexOf('第 2 个') >= 0, + '报错指明是第几个圆'); +})(); + +console.log('=== 序列化 / 解析 ==='); +(function () { + eq(api.serialize([{ lat: 25.1234567, lon: 100.2, radiusKm: 3.14159 }]), + '[{"lat":25.123457,"lon":100.2,"radiusKm":3.142}]', '序列化顺带归一化'); + eq(api.serialize([]), '[]', '空数组序列化成 [](不是 null,后端列上有 NOT NULL)'); + eq(api.serialize(null), '[]', 'null 也序列化成 []'); + + const round = api.parse(api.serialize([{ lat: 25.5, lon: 100.25, radiusKm: 3 }])); + eq(round, [{ lat: 25.5, lon: 100.25, radiusKm: 3 }], '往返一致'); + + eq(api.parse(''), [], '空串 → 空数组'); + eq(api.parse('[{}'), [], '坏 JSON → 空数组(不让编辑页整个炸掉)'); + eq(api.parse('{"lat":25}'), [], '不是数组 → 空数组'); +})(); + +console.log('=== 增删改必须返回新数组(不原地改) ==='); +(function () { + const base = [{ lat: 25, lon: 100, radiusKm: 3 }]; + const snapshot = JSON.stringify(base); + + const added = api.add(base, { lat: 26, lon: 101, radiusKm: 4 }); + eq(added.length, 2, 'add 之后有两个'); + eq(JSON.stringify(base), snapshot, 'add 没动原数组'); + ok(added !== base, 'add 返回的是新数组'); + + const removed = api.remove(added, 0); + eq(removed.length, 1, 'remove 之后剩一个'); + eq(removed[0].lat, 26, 'remove 删的是指定下标'); + eq(JSON.stringify(added), '[{"lat":25,"lon":100,"radiusKm":3},{"lat":26,"lon":101,"radiusKm":4}]', 'remove 没动原数组'); + eq(api.remove(base, 5).length, 1, '越界下标是空操作而不是抛错'); + eq(api.remove(base, -1).length, 1, '负下标也是空操作'); + + const moved = api.setCenter(base, 0, 30.5, 110.25); + eq(moved[0], { lat: 30.5, lon: 110.25, radiusKm: 3 }, 'setCenter 只改圆心、半径保留'); + eq(JSON.stringify(base), snapshot, 'setCenter 没动原数组'); + + const bigger = api.setRadius(base, 0, 7.25); + eq(bigger[0], { lat: 25, lon: 100, radiusKm: 7.25 }, 'setRadius 只改半径、圆心保留'); + eq(JSON.stringify(base), snapshot, 'setRadius 没动原数组'); + + // 个数上限 + const full = []; + for (let i = 0; i < api.MAX_AREAS; i++) full.push({ lat: 20 + i / 100, lon: 100, radiusKm: 3 }); + eq(api.add(full, { lat: 25, lon: 100, radiusKm: 3 }).length, api.MAX_AREAS, '到上限后 add 不再增长(配合地图点击处的提示)'); +})(); + +console.log('=== 几何换算 ==='); +(function () { + ok(Math.abs(api.latDelta(111.32) - 1) < 1e-9, '111.32 公里 ≈ 1 度纬度'); + ok(Math.abs(api.lonDelta(0, 111.32) - 1) < 1e-9, '赤道上 111.32 公里 ≈ 1 度经度'); + ok(api.lonDelta(60, 111.32) > api.lonDelta(0, 111.32), + '纬度越高,同样的公里数换来的**经度越大**(经线越挤)'); + ok(isFinite(api.lonDelta(90, 1)), '极点处不会除出无穷(余弦被夹住)'); + + const d = api.kmBetween([25, 100], [26, 100]); + ok(Math.abs(d - 111.19) < 0.5, '同经度差 1 度 ≈ 111.2 公里,实得 ' + d.toFixed(2)); + eq(api.kmBetween([25, 100], [25, 100]), 0, '同一点距离为 0'); + // 拖把手反推半径用的就是这个:把手摆在东侧 lonDelta 处,量回来应当等于原半径 + const km = 5; + const lat = 24.7; + const dx = api.lonDelta(lat, km); + ok(Math.abs(api.kmBetween([lat, 108], [lat, 108 + dx]) - km) < 0.05, + '半径换算自洽:按 lonDelta 摆的把手量回来仍是 ' + km + ' 公里'); +})(); + +console.log('\n=== 汇总 ==='); +console.log(' pass=' + pass + ' fail=' + fail); +process.exit(fail === 0 ? 0 : 1); diff --git a/internal/web/templates/admin/topics.html b/internal/web/templates/admin/topics.html index 1ff091a..c29b260 100644 --- a/internal/web/templates/admin/topics.html +++ b/internal/web/templates/admin/topics.html @@ -16,8 +16,8 @@

{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}

-
时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
-
+
时间范围可精确到分钟;不圈显示区就是「时间范围内的地点画到哪算哪」。
+ {{if not .IsNew}}{{end}}
@@ -29,20 +29,27 @@
-
区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域
-
- - - +
显示区(可选):在图上圈一组圆,落在任一圆内的足迹才算这个专题的内容; + 不圈就是「时间范围内的地点画到哪算哪」。圈了之后,迷雾也只显示在这些圆的并集里。
+ {{/* areas 的初值直接渲染在这里:脚本没加载上时提交的仍是原值,不会把已有显示区静默清空。 + crs 由编辑器按底图坐标系填(天地图那类是 WGS-84),后端再换算成 GCJ-02。 */}} + + +
+ + + +
+
+
+
+ 迷雾是一份累积快照、没有时间维度,所以只按上面的显示区收口,不按时间范围收口。
-
- 填了中心与半径时,迷雾只在这个圆内显示;留空则显示全局迷雾。 - (迷雾是一份累积快照,没有时间维度,所以不收口到上面的时间范围。) -
{{if not .IsNew}}取消编辑{{end}} @@ -88,12 +95,22 @@
+