Compare commits
19
Commits
3077250a82
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3321c6e6 | ||
|
|
862160c537 | ||
|
|
6aee7e441b | ||
|
|
9cf3d89c3c | ||
|
|
bf7121f11b | ||
|
|
d795c6b579 | ||
|
|
9c76437a76 | ||
|
|
829a318400 | ||
|
|
7ab33227a5 | ||
|
|
48938e6ae1 | ||
|
|
2d46b8a0ca | ||
|
|
b40160867e | ||
|
|
44763dfc3f | ||
|
|
1bb35dbbc9 | ||
|
|
cc37c9d1c9 | ||
|
|
eb35abb7f2 | ||
|
|
cd69efe8f1 | ||
|
|
b19f27ab69 | ||
|
|
b69b827d6e |
@@ -217,3 +217,56 @@ at <.Settings.TrackEnabled>: can't evaluate field TrackEnabled in type setting.S
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三批:日期框(用户报的硬 bug)+ 两处观感
|
||||||
|
|
||||||
|
**用户报**:① 逐月平均气温图在桌面端很小;② 日期框敲 `20260606` 变成 `202606/06`(要「永远解决」);
|
||||||
|
③ 类型分布与一天中的分布同行高度不齐、下面一大片空白。
|
||||||
|
|
||||||
|
### ② 日期框:**实测复现**后定位(写了真实按键工具 `storage/_type.py`)
|
||||||
|
技能里的 `--press` 只给 Enter 带 `text`,数字敲不进去(Chrome 不执行默认动作),所以单独写了一个
|
||||||
|
CDP 打字脚本(每字符 keyDown(text) + keyUp)。用它复现:
|
||||||
|
- 敲之前 `INPUT|date|`、`attached=false`;敲完 `202606/06`、`value=""` —— 截图为证。
|
||||||
|
- **根因**:筛选区的「起始/结束日期」是原生 `<input type="date">`,而挂载代码只写了
|
||||||
|
`querySelectorAll('input[type=datetime-local]')` ⇒ **type=date 从没人管**。
|
||||||
|
Chrome 的年段允许 >4 位数字(年份上限 275760),8 位被切成 年=202606 + 月=06。
|
||||||
|
原生分段状态读不到、`setSelectionRange` 对 date 类输入直接抛 ⇒ 修不了,只能换控件。
|
||||||
|
|
||||||
|
**做法**:`datetime-input.js` 加只日期模式(`SPEC.date`:年/月/日,输出 `Y-m-d`)+ **统一入口
|
||||||
|
`attachAll(root)`** 按 `el.type` 分派;`attach` 里先把控件换成 `text`(引导会写 `2026-06-` 这种半成品,
|
||||||
|
原生控件对非法值一律置空)。`datetime-input.js` 改为**无条件引入**(公共页筛选区也要用),
|
||||||
|
挂载在 body 末尾**立刻**跑一次(别只等 DOMContentLoaded,否则先露原生框再变文本框)。
|
||||||
|
三个后台页改用 `attachAll`(顺带覆盖 `mfrom/mto`,且 date 类走 `enableTime:false`)。
|
||||||
|
|
||||||
|
**验证**:真实按键敲 20260606 → `value="2026-06-06"` ✓;端到端 `from=2025-08-01&to=2025-08-31`
|
||||||
|
筛出 **186 次**,与月度图峰值月(186)完全吻合 ✓(排除「解析成空导致恒 0」)。
|
||||||
|
守卫 `TestDateInputsGoThroughAttachAll`(模板出现按 type 挑的选择器即红;变异验证过)。
|
||||||
|
|
||||||
|
### ① 气温图:10 个月只用了 1042px 里的 396px
|
||||||
|
`.temp-col` 固定 36×84。改成宽屏 `flex: 1 1 36px; max-width: 96px` + 轨道 130px + 字号提到 `--fs-sm`;
|
||||||
|
窄屏保持固定宽 + 横滚(否则月份标签糊成一团)。
|
||||||
|
|
||||||
|
### ③ 同行卡片高度不齐:grid 默认 stretch
|
||||||
|
「类型分布」1411px 把「一天中的分布」也拉成 1411px,后者内部一大片空白。
|
||||||
|
`.grid { align-items: start }` 后两项各按自然高(256px)。
|
||||||
|
|
||||||
|
**回归**:verify.sh 348 例 0 skip、JS 单测 **45/45**(新增 11 条逐键模拟)、移动端 390 / 桌面 1440 审计溢出与改前一致。
|
||||||
|
⚠️ **纪律(又踩一次)**:验收跑着的时候别并发跑别的(我并发跑了变异测试,verify 当场把注入的旧写法抓成 FAIL ——
|
||||||
|
守卫是对的,但那轮结果得作废重跑)。这条上次已写进 details 三十二,这次是实锤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补:日历弹层也要(用户确认手机上好操作、多 18KB 可以接受)
|
||||||
|
|
||||||
|
前面把筛选区的 `type=date` 换成了纯手输文本框、没有日历弹层;用户要日历。最终决定:
|
||||||
|
**flatpickr 与 datetime-input.js 都改成无条件加载**(js+css+l10n 约 18KB gzip),
|
||||||
|
`Page.Flatpickr` 字段与三处 `p.Flatpickr = true` 一并删掉 —— 不再用「按页开开关」,
|
||||||
|
因为那正是这次日期框 bug 的根源(只挂了 datetime-local、type=date 没人管)。
|
||||||
|
|
||||||
|
实测(展开筛选面板后):390/320 下 /stats /map /places 的日历都在最上层(z-index 99999)、
|
||||||
|
完全在视口内、`elementFromPoint` 命中日历本身;打字 20260606 → 2026-06-06 不变。
|
||||||
|
守卫 `TestDateInputsGoThroughAttachAll` 相应改成钉「无条件加载 + 不许再出现 {{if .Flatpickr}}」。
|
||||||
|
|
||||||
|
回归:verify.sh **PASS=349 FAIL=0 SKIP=0**、JS 45/45、320/390/1440 溢出与改前一致。
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-10-10
|
||||||
|
|
||||||
|
## 日历弹层返工 + 统计页排版(承接 10-09 的日期框改造)
|
||||||
|
|
||||||
|
用户反馈两点:① 日历「空间显示异常」且是英文、和项目割裂;② 类型分布与一天中的分布仍是
|
||||||
|
一左一右、右边下面一大片空白。
|
||||||
|
|
||||||
|
### ① 日历:两个独立的坑
|
||||||
|
|
||||||
|
**a) 英文文案**:`flatpickr.localize(l10ns.zh)` 原先只写在三个后台页的 `whenReady` 里;
|
||||||
|
昨天把 flatpickr 放到公共页(筛选区)之后就漏了 ⇒ 日历全是 Sun/Mon/January…
|
||||||
|
修法:移到 `datetime-input.js` 的 `attachPicker` 里(`localizeOnce()`,全局只调一次),
|
||||||
|
三个后台页里那三行重复调用删掉。
|
||||||
|
|
||||||
|
**b) 头部重叠(用户说的「空间异常」)**:月份下拉被撑满整行、压在年份输入上。
|
||||||
|
根因是**站点全局的 `input/select` 样式扫进了日历内部** —— 那条规则带 `width:100%`、
|
||||||
|
7px 内边距、边框与 16px 控件字号(`input.numInput` 是 `type=number`,`select.flatpickr-monthDropdown-months`
|
||||||
|
是 select,都躲不过)。
|
||||||
|
⚠️ 第一版修法是错的:我在后面补了一条 `.flatpickr-calendar input{...}` 去对冲,
|
||||||
|
**压不过**——全局选择器带 10 个 `:not([type=…])`,特异性 (0,10,1),实测年份框仍是站点样式。
|
||||||
|
正确做法是在**全局规则里排除**:`input…:not(.numInput)`、`select:not(.flatpickr-monthDropdown-months)`,
|
||||||
|
`input:focus` 的焦点环同理(否则年份框上套一圈主色光圈)。
|
||||||
|
实测日历内部只有 4 个控件:`select.flatpickr-monthDropdown-months` +
|
||||||
|
`input.numInput.cur-year / .flatpickr-hour / .flatpickr-minute`。
|
||||||
|
|
||||||
|
实测结果:星期「周日 周一 …」、月份「十月」、`overlap=0`(月份 82px、年份紧接其后)、无 JS 报错。
|
||||||
|
|
||||||
|
### ② 统计页:体量差太多的卡片别并排
|
||||||
|
|
||||||
|
`align-items: start` 只消掉了**卡内**空白,消不掉并排本身的空 —— 类型分布 1411px 与
|
||||||
|
一天中的分布 256px 并排,右侧下面必然空一大片。
|
||||||
|
改成 `<div class="grid">`(单列):两张卡各占整行(1080px),24 根柱终于有宽度可用,
|
||||||
|
列表也像「城市排行」那样横铺。实测 top=1183 → 2625,都全宽。
|
||||||
|
|
||||||
|
**回归**:verify.sh **PASS=349 FAIL=0 SKIP=0**、JS 单测 45/45、移动端 390 溢出全 0、
|
||||||
|
桌面 1440 与改前一致(−15 是滚动条宽)。
|
||||||
|
|
||||||
|
## 专题的迷雾按区域收口
|
||||||
|
|
||||||
|
用户问「我创建了一个专题,但是迷雾好像无法根据选择显示范围?」——查下来是**实现缺口**:
|
||||||
|
专题的中心/半径只用来 `fitCircle` 定视图,迷雾图层一直是全局的;管理页那句
|
||||||
|
「区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选」属于**文档与实现不符**(把「没做」写成「不用做」)。
|
||||||
|
用户选定口径:**按专题区域收口**(迷雾是累积快照、无时间维度,所以不按时间范围收口)。
|
||||||
|
|
||||||
|
改 4 个文件:`rond-map.js`(新建 `rondFog` pane + `clip-path: circle()`)、`topic.html`(`.Topic.HasArea` 时下发
|
||||||
|
`fogArea`)、`admin/topics.html`(把那句错提示删掉、补正确说明)、`frontend_fixes_test.go`(守卫)。
|
||||||
|
细节与三个坑(不能裁 `tilePane`、圆心要 `project()`、注册必须排在 `applyFit()` 之后)见 details 四十五。
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 2026-10-12
|
||||||
|
|
||||||
|
## 专题的「迷雾显示区域」改成一组圆(多圆 + 地图点选)
|
||||||
|
|
||||||
|
用户提出两点:① 显示区不该只有一个圆,应该能圈多个;② 应该在**地图上点选**,
|
||||||
|
而不是让他手填经纬度和半径。
|
||||||
|
|
||||||
|
### 方案(先出清单获批,分批落地)
|
||||||
|
|
||||||
|
先摸清了现状:单圆硬编码在 6 处(`topics` 三列 / `HasArea` / `topicAreaCond` /
|
||||||
|
`fitParams` / 后台三个输入框 / `topic.html` 的 `fogArea`)。同时发现项目里**已经有**
|
||||||
|
一套现成的地图选圆交互(`/admin/fog-mask` 那个页面:点选圆心 + 拖拽 + 半径可调 +
|
||||||
|
多圆列表),可以直接抄它的模式。而 `fog_masks` 是**另一个语义**(圈内迷雾对访客隐藏),
|
||||||
|
用户明确这轮不动它。
|
||||||
|
|
||||||
|
三个岔路口问过用户,都选了推荐项:只改专题(不做站点级)、后台做成**专题表单内嵌地图**
|
||||||
|
(不是独立管理页)、不动遮罩区。
|
||||||
|
|
||||||
|
原计划 B1(存 areas)/B2(查询认多圆)之间会留一个「存了但只认第一个圆」的陷阱状态,
|
||||||
|
所以合并成一批:数据层与查询层一起做,后台先用兼容桥(仍手填单圆,落进 areas)。
|
||||||
|
|
||||||
|
### 落地(三个提交)
|
||||||
|
|
||||||
|
- `1bb35db` 后端与查询层:`topics.areas` jsonb 数组 + 存量回填;`Topic.AreaList()`
|
||||||
|
归一(改为空格时退回老的三列,未回填的老行与直接构造 Topic 的调用方都继续工作);
|
||||||
|
`topicAreaCond` 改成 N 个圆的 OR;`fitParams` 改用所有圆的并集外接矩形。
|
||||||
|
- `44763df` 前台渲染:`clip-path` 从 `circle()` 换成 `path()`(多子路径、同绕向 ⇒
|
||||||
|
nonzero 下自然并集);拼路径抽成 `fog-clip.js` 以便跑 node 单测;顺带清掉已死的
|
||||||
|
`fitCircle`;`verify.sh` 挂进前端单测。
|
||||||
|
- 待办(B3):**后台地图编辑器**还没做(专题表单目前仍是三个手填输入框)——
|
||||||
|
这是用户最初诉求的第二半,下一批做。
|
||||||
|
|
||||||
|
### 关键取舍
|
||||||
|
|
||||||
|
- **为什么不单开一张表**:圆是专题定义的组成部分、与专题一起读写、没有独立生命周期;
|
||||||
|
单开表还要过一遍「能不能从备份重导」的归属口径、改 `dataset_ownership_test` 白名单、
|
||||||
|
注意迁移块顺序。jsonb 一次全绕开。
|
||||||
|
- **废弃列保留并镜像第一个圆**:回滚旧二进制时退化成「第一个圆」总比「没有区域」强。
|
||||||
|
- **`fitCircle` 删掉**:多圆表达不了「圆心 + 半径」,留着就是两套并行的贴合口径。
|
||||||
|
|
||||||
|
### 只在这轮踩到的坑
|
||||||
|
|
||||||
|
- **CSSOM 会把 `clip-path` 规范化**:`style.clipPath` 读回来被补上空格
|
||||||
|
(`path("M 405 296 a 87 87 …")`),且位置不固定(`296 a87`)、第一个子路径尾部
|
||||||
|
还可能多个空格。想解析回几何必须每处分隔都用 `\s*`/`\s+` 容忍,别写死 ——
|
||||||
|
我连着写错两版正则(先是 `Z$` 卡在尾部空格)。
|
||||||
|
- **`--clip` 截图的 `pad=10`**(×scale)又咬了一次:这次用**自校准**绕开的 ——
|
||||||
|
用单圆掩码反解出 scale 与 offset(实测 2.0 / 19.2),不再手算元素坐标。
|
||||||
|
|
||||||
|
## 后台的显示区编辑器(B3,用户诉求的后半)
|
||||||
|
|
||||||
|
三个手填输入框(中心纬度/经度/半径)换成**地图 + 圆列表**:点地图加圆、拖圆心移动、
|
||||||
|
拖圆边改半径、列表里可改半径/定位/删除、可一次清空;改动立刻写进隐藏字段
|
||||||
|
`areas`(JSON) 与 `crs`,随专题表单一起提交。复用 `/admin/fog-mask` 那套约定
|
||||||
|
(`/api/map-config` 取底图、底图坐标系随表单带上、后端换算成 GCJ-02),不另起炉灶。
|
||||||
|
|
||||||
|
### 实测抓到的 bug(探针一跑就现形)
|
||||||
|
|
||||||
|
`/admin/topics` **没设 `p.HasMap = true`** ⇒ 布局不引入 Leaflet。表现极隐蔽:
|
||||||
|
脚本加载了、`rondTopicAreas` 也在,就是 `window.L` 永远 undefined,`init` 静默返回、
|
||||||
|
**控制台不报错**、页面看着像「什么都没发生」。已加守卫
|
||||||
|
`TestTopicsAdminPageLoadsLeaflet`(按函数体截取后断言含 `HasMap = true`)。
|
||||||
|
记忆里那条「带地图页须 `p.HasMap=true`」是真的会咬人。
|
||||||
|
|
||||||
|
### 两个设计上的取舍
|
||||||
|
|
||||||
|
- **areas 的初值渲染进隐藏字段**(`value="{{.Form.AreasJSON}}"`),JS 也从隐藏字段读初值,
|
||||||
|
而不是另传一份。这样脚本没加载上时,表单提交的仍是服务端渲染的原值 ——
|
||||||
|
否则一次保存就把已有显示区**静默清空**。守卫
|
||||||
|
`TestTopicAdminUsesMapEditorNotCoordInputs` 钉住了「初值来源」与「旧输入框必须没了」。
|
||||||
|
- **上限两边各写一份**(前端 `MAX_AREAS/MIN_KM/MAX_KM` vs 后端 Go 常量),并加守卫
|
||||||
|
`TestTopicAreaLimitsMatchBetweenGoAndJS` 直接比对数字。不一致的表现很别扭:
|
||||||
|
前端放行、后端拒绝,用户看到一句红字却不知道改哪里。
|
||||||
|
|
||||||
|
### 小坑
|
||||||
|
|
||||||
|
- 半径输入框套了一层**定宽容器**:站点全局 input 规则是 `width:100%` 且带 10 个 `:not()`
|
||||||
|
(特异性 (0,11,1)),直接给 input 写宽度压不过。
|
||||||
|
- `topic-areas.js` 只把**纯函数**(校验/序列化/增删改/几何换算)暴露出来供 node 单测,
|
||||||
|
另加一个最小测试钩子 `api._map` 供无头验证(同 `window.rondMap._state` 惯例)——
|
||||||
|
不给把手就只能真拿鼠标点地图,CDP 下很难稳定复现。
|
||||||
|
|
||||||
|
### gofmt / 行尾
|
||||||
|
|
||||||
|
`gofmt -l` 报的整文件重写有两层原因,已一并处理:
|
||||||
|
① Go 1.19 起 gofmt 会重排**文档注释**里的缩进列表(我写的 `// ① …` 被要求改成 tab 缩进块);
|
||||||
|
② 工作区是 CRLF 而 `.gitattributes` 声明 `eol=lf`。
|
||||||
|
`gofmt -w` 归一后 `git diff` 没有额外噪声 —— 说明 **blob 本来就是 LF,CRLF 只是检出产物**,
|
||||||
|
之前担心的「模板 CRLF 与 .gitattributes 冲突」不是仓库内容问题。
|
||||||
|
|
||||||
|
### 记忆压缩
|
||||||
|
|
||||||
|
`MEMORY.md` 压到 **8919**(原 9285):合并「归属口径/防复发」与「跨备份/附属数据」两节
|
||||||
|
(同属归属)、若干长条目的**理由**下沉 details(规矩一字未删)、收紧自己新加的那条。
|
||||||
|
12 节 → 11 节。
|
||||||
|
|
||||||
|
## 修 bug:半径输入框被浏览器原生校验误判
|
||||||
|
|
||||||
|
用户反馈「输入半径总是提示『请输入一个有效的值,最接近的两个有效值为 XX 和 XX』」。
|
||||||
|
|
||||||
|
**根因**:`<input type="number">` 的 **`step` 是相对 `min` 递增**的,不是「`step` 的整数倍」。
|
||||||
|
`min=0.05` + `step=0.5` ⇒ 合法值只有 `0.05 / 0.55 / 1.05 …`,于是 3、3.5、7.25 全被判非法。
|
||||||
|
关键是**这是浏览器原生提示**:JS 与后端都拦不到,我们自己的校验根本没机会说话。
|
||||||
|
|
||||||
|
实测对照(同一元素只换 step):
|
||||||
|
`step=0.5` → 0.05✓ 1✗ 3✗ 3.5✗ 7.25✗ 13.37✗;`step=0.05` → 13.37 仍✗;`step=any` → 全✓。
|
||||||
|
采用 `step="any"`,保留 `min/max`(真实边界 + spinner 范围),把校验交给 JS/后端。
|
||||||
|
|
||||||
|
**加了一条通配守卫**(`TestNumberInputStepNeverMismatchesMin`):扫**所有**模板的数字框,
|
||||||
|
凡「有 `min` 且有数值 `step`」就要求 `min` 是 `step` 的整数倍(或 `step="any"`);
|
||||||
|
JS 动态建的输入框模板扫不到,另用 `TestTopicAreaRadiusInputAllowsAnyStep` 钉住。
|
||||||
|
变异验证过(step 改回 0.5 ⇒ 守卫立刻红)。全站其它数字框(`radius_m` min=50/step=50)
|
||||||
|
本来就合法,未动。
|
||||||
|
|
||||||
|
**顺带**:把 `admin/topics.html` 的工作区行尾归一到 LF。`git add` 之后**零改动** ⇒
|
||||||
|
再次确认 blob 本来就是 LF,CRLF 只是检出产物(那条 CRLF 警告是 stat 缓存假象)。
|
||||||
|
|
||||||
|
## 推送这个仓库要认证(非交互环境会卡住)
|
||||||
|
|
||||||
|
用户让「提交一次」时本地其实已经全部提交完了(每批做完就提交),工作区干净;
|
||||||
|
唯一没做的是推送。结果 `git push origin master` **卡了 7 分钟没有任何输出**。
|
||||||
|
|
||||||
|
诊断(下次直接照这个查):
|
||||||
|
|
||||||
|
- 远端 `git.shiroko.one` 的 **读是匿名的**(`upload-pack` HTTP 200)、
|
||||||
|
**写要认证**(`receive-pack` HTTP 401)⇒ 所以 `git fetch` 一切正常、唯独 push 卡住。
|
||||||
|
- `tasklist` 里能看到 `git-credential-helper-selector`(**有可见窗口**,标题
|
||||||
|
`CredentialHelperSelector`),`git config credential.helper` = `helper-selector` +
|
||||||
|
PortableGit 自带的 `git-credential-manager.exe`。本机从未推过这个仓库 ⇒ 触发选择器弹窗等人。
|
||||||
|
- 判据:`netstat` 里到远端的连接**根本没建立**(ESTABLISHED 全是本机 postgres),
|
||||||
|
说明停在认证之前,不是网络慢。
|
||||||
|
- ⚠️ `git push | tail -N` 会把进度缓冲住,看起来「一片空白」——排查时别用管道。
|
||||||
|
|
||||||
|
处置:结束掉 push 那几个进程(不动本地提交,也不会留 `.git/*.lock`),
|
||||||
|
**由用户在自己的终端里推**(交互式提示看得见)。
|
||||||
|
**结论:以后不要在这个环境里代跑 `git push`,直接把命令交给用户。**
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 2026-10-13
|
||||||
|
|
||||||
|
## 排查「专题里的圆大小不随缩放变化」(结论:未能复现,行为是对的)
|
||||||
|
|
||||||
|
用户反馈:专题里的圆无论怎么缩放,屏幕上的大小都一样;而且「新圆半径 25km 居然覆盖了整个南宁」。
|
||||||
|
库里实际存的是 3 个 `radiusKm: 20`(北海 / 南宁 22.815547,108.367767 / 柳州)。
|
||||||
|
|
||||||
|
四处实测(无头 CDP,全部换算回地面公里):
|
||||||
|
|
||||||
|
| 位置 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 编辑器 `L.circle`(半径 25) | z9=178px / z11=710px / z13=2842px(每级翻倍),反算始终 25 km,期望 177/711/2847 —— 吻合 |
|
||||||
|
| 编辑器「改半径」把手 vs 圆心 | 20.038 km(该圆半径 20 km)—— 位置准确 |
|
||||||
|
| 前台专题页的迷雾裁剪圆 | z7→z10 像素半径 17→141(应 142),换算始终 ≈20 km |
|
||||||
|
| 同一缩放改「新圆半径」 | 3→42px、10→142、25→356、50→710,与反算 43/142/355/711 一致 |
|
||||||
|
|
||||||
|
⇒ **三处画圆全都是地理锚定的、都随缩放正确变化,半径值也确实生效**,复现不出「大小不变」。
|
||||||
|
|
||||||
|
「20 km 覆盖整个南宁」是**对的**——用他自己的数据验证:圆心 20 km 内落了他 477 个地点中的
|
||||||
|
**45 个**,最远的是西乡塘的广西民族大学相思湖校区(14.5 km)与良庆的五象万象汇(10.2 km),
|
||||||
|
也就是从相思湖一直盖到五象新区,正是南宁建成区的跨度(约 20~25 km)。
|
||||||
|
20 km 半径 = 40 km 直径;只想圈市中心得用 5~8 km。
|
||||||
|
|
||||||
|
### 找到一处真实缺口
|
||||||
|
|
||||||
|
**后台编辑器的地图没有比例尺**(`L.control.scale`):`scaleBar=0`(前台专题页 `=1`)。
|
||||||
|
画圆时没有任何长度参照,很容易得出现「这个圆怎么这么大/怎么没变化」的判断 ——
|
||||||
|
大概率就是这次困惑的来源。待办:给它加比例尺;圆列表里把半径的后果写出来(如「20 公里(约 40 公里宽)」)。
|
||||||
|
|
||||||
|
### 排查时的两个自坑(记下来省时间)
|
||||||
|
|
||||||
|
- 地图与迷雾 pane 在 `load` 之后还要等 `/api/map-config` 回来才初始化:`--wait 5` 会拍到
|
||||||
|
「pane 不存在」,`--wait 8` 才稳。别把「还没初始化」当成「功能没生效」。
|
||||||
|
- `--eval-file` 里写 `(async function(){...})()` 却**不加 `--await`**,CDP 拿到的是 Promise,
|
||||||
|
`returnByValue` 序列化成 `{}` —— 看着像脚本没执行,其实是漏了参数。
|
||||||
|
|
||||||
|
### 处置:补「参照物」(提交 6aee7e4)
|
||||||
|
|
||||||
|
用户选定「加比例尺 + 标注直径」。几何逻辑**一行没动** —— 因为问题本来就不在几何:
|
||||||
|
|
||||||
|
- 编辑器地图加 `L.control.scale`(公制,`position:'bottomleft'`,跟前台一致)。
|
||||||
|
- 新增纯函数 `spanLabel(radiusKm)`:半径 →「约 40 公里宽」(直径);<1 公里改用米,
|
||||||
|
免得出现「约 0.1 公里宽」。圆列表每行标,**「新圆半径」旁边也实时跟着变**(落点前就能判断)。
|
||||||
|
- 守卫 `TestTopicAreaEditorGivesSizeReference` 钉住比例尺与跨度标注(变异验证过)。
|
||||||
|
⚠️ 它当场抓到我自己的疏漏:模板的 init 调用漏传 `spanId`,靠脚本默认值兜着 ——
|
||||||
|
已改成显式传参(**新增 opts 时记得在模板里也写出来**,否则守卫和外人都看不出这层耦合)。
|
||||||
|
|
||||||
|
教训:这次「bug」的本质是**界面缺参照物**,不是算错。以后遇到「大小看起来不对」类反馈,
|
||||||
|
先问「用户手上有什么可以量」,而不只是去验算几何。
|
||||||
@@ -539,6 +539,12 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块
|
|||||||
2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉**单独跑 4.1s 就过**(环境抖动,不是代码问题)。
|
2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉**单独跑 4.1s 就过**(环境抖动,不是代码问题)。
|
||||||
另外这条测试失败时会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` **成功之后**才注册),
|
另外这条测试失败时会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` **成功之后**才注册),
|
||||||
跑完记得手动 `DROP DATABASE` 清掉,别当成「库被占用 / 测试有 bug」。
|
跑完记得手动 `DROP DATABASE` 清掉,别当成「库被占用 / 测试有 bug」。
|
||||||
|
同理:**变异实验也别和验收并发**(2026-10-09 实锤:变异期间正好被验收抓到,那轮结果直接作废)。
|
||||||
|
- ⚠️ **变异验证「内嵌文件」(`templates/`、`static/`)之后必须 `go clean -cache` 再跑验收**。
|
||||||
|
`//go:embed` 的内容虽然参与构建,但「改坏 → 还原」之后构建缓存会继续给出**变异期间编译的那份内嵌内容**:
|
||||||
|
2026-10-09 实测同一条守卫 `go test ./internal/web/` 绿、`go test ./...` 红(同一份磁盘文件、同一个测试);
|
||||||
|
`go clean -testcache` **不管用**(它只清测试结果),`go clean -cache` 之后立刻一致。
|
||||||
|
判据:两种调用方式结果不一致时,先清构建缓存再下结论。**只变异 `.go` 文件没有这个问题**(Go 源码参与缓存键)。
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1025,5 +1031,227 @@ HTML,一行里必然带 `</script>`,一拒就把正常用法挡死了(我
|
|||||||
- ⚠️ 顶栏 `--z-nav: 1200`,**必须高于 `--z-map-overlay`(1100)**:菜单是 `.nav` 的子元素、
|
- ⚠️ 顶栏 `--z-nav: 1200`,**必须高于 `--z-map-overlay`(1100)**:菜单是 `.nav` 的子元素、
|
||||||
自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(`/map` 上用 `elementFromPoint` 实测验证过)。
|
自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(`/map` 上用 `elementFromPoint` 实测验证过)。
|
||||||
|
|
||||||
|
## 四十四、日期控件:原生 `type=date` 也不能用(2026-10-09)
|
||||||
|
|
||||||
|
⚠️ **`<input type="date">` 与 `datetime-local` 一样不可靠。** Chrome 的年段允许超过 4 位数字
|
||||||
|
(年份上限 275760),所以敲 `20260606` 会被切成 **年=202606 + 月=06** —— zh-CN 下显示 `202606/06`,
|
||||||
|
而且 `el.value` 是**空**(非法)⇒ 筛选条件静默丢掉。原生分段状态读不到、
|
||||||
|
`setSelectionRange` 对 date 类输入直接抛,所以「修原生」这条路不通,只能换控件。
|
||||||
|
|
||||||
|
**规矩**:
|
||||||
|
|
||||||
|
- 日期字段一律走 `rondDatetime.attachAll(root)`(在 `datetime-input.js` 里):它按 `el.type`
|
||||||
|
自动分派只日期(输出 `Y-m-d`)或带时间(输出 `Y-m-dTH:i`)。
|
||||||
|
⚠️ **不要手写 `querySelector('input[type=...]')`** —— 2026-10-09 的 bug 就是
|
||||||
|
「只挂了 datetime-local,后来加的 type=date 没人管」。
|
||||||
|
- flatpickr 与 `datetime-input.js` **都无条件加载**(js+css+l10n 约 18KB gzip):
|
||||||
|
筛选区在公共页上也有日期框,而且「按页开开关」已经被证明会漏,所以干脆**不设开关**
|
||||||
|
(`Page.Flatpickr` 字段也删了)。用户明确接受这点资源增量(手机上有日历弹层比省这 18KB 重要)。
|
||||||
|
挂载要在 body 末尾**立刻**跑一次,别只等 `DOMContentLoaded`,否则先露出原生框再变文本框。
|
||||||
|
- 门禁 `TestDateInputsGoThroughAttachAll`:模板里出现 `input[type=date]` /
|
||||||
|
`input[type=datetime-local]` 这种选择器即红;并钉住「layout 无条件引入 flatpickr + datetime-input +
|
||||||
|
attachAll」、以及「不许再出现 {{if .Flatpickr}} 这种开关」。
|
||||||
|
- JS 单测:`layoutTo(spec, raw)` 是纯函数,逐键模拟敲 `20260606` 必须得 `2026-06-06`
|
||||||
|
(`datetime-input.test.js`,node 直跑,45 条)。
|
||||||
|
|
||||||
|
**同批修的两处观感问题**(都属于「只有量才看得见」):
|
||||||
|
|
||||||
|
- `.grid` 必须 `align-items: start`。grid 默认 `stretch`,同一行里内容少的那张卡会被拉到与最高的
|
||||||
|
一样高,卡片内部留一大片空白(/stats 的「类型分布」1411px vs「一天中的分布」256px)。
|
||||||
|
- `.temp-strip` 的柱子窄屏固定 36px + 横滚;**宽屏要 `flex: 1 1 36px; max-width: 96px` 并把轨道加高**。
|
||||||
|
原来固定 36×84,10 个月只占 1042px 里的 396px,右边 600 多像素全空 —— 看着就是「图表很小」。
|
||||||
|
|
||||||
|
### 日历弹层的两处坑(2026-10-10)
|
||||||
|
|
||||||
|
- **中文文案要在共享入口设一次**:以前是各页 `whenReady` 里自己调 `flatpickr.localize(l10ns.zh)`,
|
||||||
|
公共页开始加载 flatpickr 之后就漏了 —— 日历是英文的(Sun/Mon/January…),跟中文界面明显割裂。
|
||||||
|
现在放在 `attachPicker` 里(`localizeOnce()`,全局只调一次)。
|
||||||
|
- ⚠️ **站点全局的 `input/select` 样式会扫进日历内部**:那条规则带 `width:100%`、内边距与边框,
|
||||||
|
落到日历里就是「月份下拉被撑满整行、压在年份输入上」(实测重叠成一团),时间格也变成方框输入框。
|
||||||
|
修法是**在全局规则里排除** `input.numInput` 与 `select.flatpickr-monthDropdown-months`
|
||||||
|
(实测日历内部只有这 4 个控件:`select.flatpickr-monthDropdown-months` + `input.numInput.cur-year`
|
||||||
|
/ `.flatpickr-hour` / `.flatpickr-minute`)。
|
||||||
|
⚠️ **不要**试图在 `.flatpickr-calendar input{...}` 里对冲 —— 那条全局选择器带 10 个 `:not([type=…])`,
|
||||||
|
特异性 (0,10,1),普通对冲压不过(实测年份框仍是站点样式)。
|
||||||
|
`input:focus` 的焦点环同理要一起排除,否则年份框上会套一圈主色光圈。
|
||||||
|
- **体量差太多的卡片别并排**:类型分布(60 多行)与一天中的分布(24 根柱)并排时,右侧下面必然
|
||||||
|
一大片空白 —— `align-items: start` 只能消掉**卡内**的空白,消不掉并排本身的空。各占整行才对
|
||||||
|
(列表横铺、24 根柱也终于有宽度)。`align-items: start` 仍然保留:其它同行卡片也会体量不等。
|
||||||
|
|
||||||
|
## 四十五、专题的迷雾按区域收口(2026-10-10)
|
||||||
|
|
||||||
|
**现象**:用户建了专题问「迷雾好像无法根据选择显示范围?」。查下来是**实现缺口**,不是配置问题 ——
|
||||||
|
迷雾图层一直是全局的,专题的区域(中心+半径)只用来 `fitCircle` 定视图,跟迷雾完全无关。
|
||||||
|
管理页那句「区域内的世界迷雾由地图按视野自动呈现,**不需要单独圈选**」纯属**文档与实现不符**,
|
||||||
|
把「没做」写成了「不用做」。用户选的口径:**按专题区域收口**(不按时间收口)。
|
||||||
|
|
||||||
|
**实现**(`rond-map.js` + `topic.html`):`opts.fogArea = {lat, lon, radiusKm}` 由 `topic.html`
|
||||||
|
在 `.Topic.HasArea` 时下发;脚本给迷雾**单独建一个 pane** 再对该 pane 做 `clip-path: circle()`。
|
||||||
|
|
||||||
|
⚠️ 三个必踩的坑:
|
||||||
|
|
||||||
|
- **不能裁默认 `tilePane`** —— 底图瓦片也在那里面,裁下去地图跟着一起被挖空。所以
|
||||||
|
`map.createPane('rondFog')`(`zIndex=250`:底图 200 之上、覆盖物 400 之下,`pointerEvents='none'`),
|
||||||
|
迷雾 tileLayer 走 `pane: fogPane ? 'rondFog' : 'tilePane'`。
|
||||||
|
- **圆心必须 `project()` 到底图坐标系**(跟 `fitCircle` 一样),直接拿经纬度当容器坐标会整体偏出几百米。
|
||||||
|
半径用「同经度上偏移 radiusKm/111320 纬度」再量容器像素距离,随缩放自然变化。
|
||||||
|
- 🔴 **注册点必须排在 `applyFit()` 之后**。`latLngToContainerPoint()` 在视图未定时会抛
|
||||||
|
「Set map center and zoom first.」—— 实测放早了:pane 建好了、**迷雾瓦片一张都不出来**
|
||||||
|
(那行异常把后面所有图层添加一起打断,且不报错给用户,页面看着只是「迷雾没了」)。
|
||||||
|
正确写法:`applyFit(); if (applyFogClip) { map.on('move zoom viewreset resize', applyFogClip); applyFogClip(); }`
|
||||||
|
|
||||||
|
- 迷雾是**累积快照、没有时间维度** ⇒ 只按区域收口,不按专题的时间范围收口(管理页文案已写明)。
|
||||||
|
- 老浏览器不认 `clip-path` 只是「不裁剪」(退回全局迷雾),不会报错。
|
||||||
|
- 门禁 `TestTopicFogIsClippedToArea`:钉住两端 —— `topic.html` 得发 `fogArea:`、`rond-map.js` 得认
|
||||||
|
`opts.fogArea`/`createPane('rondFog')`/`clipPath`,且注册点下标 > `var applyFit = function` 的下标
|
||||||
|
(防的就是上面那个「放早了整片迷雾消失」)。
|
||||||
|
- 实测:临时专题(中心 24.698/108.075、半径 3km)→ `clip = circle(173px at 529px 260px)`,
|
||||||
|
反解半径正好 **3000 m**,`setZoom(9)` 后仍正确;`/map`、`/stats` 不受影响(它们没有 `fogArea`)。
|
||||||
|
- ⚠️ 用户自己的专题 `id=15`「2026年国庆」`radius_km=0` ⇒ 收口不生效,要在后台把中心与半径填上。
|
||||||
|
|
||||||
|
### 怎么证「裁剪真的生效」(2026-10-10 实测方法)
|
||||||
|
|
||||||
|
`clip-path` 读得到 ≠ 画出来生效,而且**目测会骗人**:截图上那片「灰色」可能是底图不是迷雾。
|
||||||
|
可靠判据是一组**像素对比**,四张图同一个视图、同一个(临时)专题:
|
||||||
|
|
||||||
|
| 图 | 迷雾瓦片 | 裁剪 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | 显示 | 有 |
|
||||||
|
| B | 显示 | 无(`pane.style.clipPath='none'`)|
|
||||||
|
| D | 隐藏(`img.style.display='none'`)| 有 |
|
||||||
|
|
||||||
|
- **迷雾掩码** = `|A−D| > 阈值`(裁剪态实际画出来的迷雾)、`|B−D|`(无裁剪态)。
|
||||||
|
- 结论(半径 1km 那张,阈值 60):裁剪态 **83,185** 个迷雾像素、**圆外 0 个**(最远半径 462.4 / 圆 462.1);
|
||||||
|
无裁剪态 222,510 个、**62.3% 溢出圆外**(最远 1,117.8)。⇒ 裁剪精准,边界正好落位。
|
||||||
|
- 想知道**裁剪圆到底画在哪**,别用红色探针(底图有红路/红 POI 会污染掩码)——
|
||||||
|
在 pane 里塞一个 `background:#ff00ff` 的巨型子元素(子元素一并被裁剪),品红掩码即裁剪区域,
|
||||||
|
`填满率=π r² 反解`能同时校出圆心与半径。实测圆心/半径与 CSS 标称差 < 1px。
|
||||||
|
- 🔴 **图像坐标不能用元素坐标直接换算**:`site_cdp.py --clip` 会把裁切框每边外扩 `pad=10` CSS px
|
||||||
|
(×`--scale`),图像原点在元素左上角**左上方 20px**。差这一步会得到「圆整体偏 (18.5,19)px」的
|
||||||
|
假 bug,还会把边界抗锯齿当成「圆外有 1830 个迷雾像素」。正确做法:**在同一张图里量一个已知参照定标**
|
||||||
|
(品红探针就是为此)。已写进技能 `rond-web-verify` 的「四个必踩的坑」第 4 条。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 四十六、专题显示区:一组圆 + 地图点选(2026-10-12)
|
||||||
|
|
||||||
|
**语义变了**:专题的「区域」从**一个圆**变成**一组圆**(并集——落在任一圆内即算在区域内)。
|
||||||
|
起因是用户提的两点:不该只有一个圆;应该在**地图上点选**而不是手填经纬度和半径。
|
||||||
|
(原管理页那句「不需要单独圈选」是文档与实现不一致,见四十五。)
|
||||||
|
|
||||||
|
### 存储:`topics.areas JSONB`,不单开表
|
||||||
|
|
||||||
|
`areas` 是 `[{lat, lon, radiusKm}, ...]`,坐标 **GCJ-02**(地点过滤是拿它跟
|
||||||
|
`places.lat/lon` 直接比球面距离的)。
|
||||||
|
|
||||||
|
为什么用 jsonb 而不是一张 `topic_areas` 表:这些圆是专题定义的组成部分、与专题一起
|
||||||
|
读写、**没有独立生命周期**;单开表还要过一遍「能不能从备份重导」的归属口径、改
|
||||||
|
`dataset_ownership_test.go` 的表白名单、注意迁移块必须排在建表之后 —— jsonb 一次全绕开。
|
||||||
|
|
||||||
|
- 存量回填在 schema.sql 里:`UPDATE topics SET areas = jsonb_build_array(...) WHERE radius_km > 0 AND areas = '[]'`。
|
||||||
|
- ⚠️ `center_lat/center_lon/radius_km` **已废弃但保留**,写入时镜像第 1 个圆:回滚旧二进制时
|
||||||
|
退化成「第一个圆」总比「没有区域」强。判据是三列的注释,别当成还有用的列。
|
||||||
|
- ⚠️ **出库值必须是 `[]` 不是 `null`**:列上有 NOT NULL,而 `json.Marshal(nil slice)` 恰好给
|
||||||
|
`"null"`。用 `topicAreasColumn()` 收敛这一处。
|
||||||
|
- `Topic.AreaList()` 做归一:`Areas` 为空时退回老的三列 ⇒ 未回填的老行与**测试里直接构造
|
||||||
|
Topic 的写法**都继续工作(`topic_stats_test.go` 就是这么写的)。`HasArea()`/`AreasJSON()`
|
||||||
|
都走它,保证「HasArea 为真」与「脚本拿得到圆」永远是同一份判据。
|
||||||
|
|
||||||
|
### 查询:N 个圆的 OR
|
||||||
|
|
||||||
|
`topicAreaCond` 每个圆占 **3 个占位符**(纬度、经度、半径),条件用 ` AND (... OR ...)`。
|
||||||
|
⚠️ 占位符纪律照旧(写错就是「专题内容全变 0」那种难查的事故)。判据写成**定义性不变量**:
|
||||||
|
本段引用的 `$n` 必须**恰好**是 `[prefix+1, len(args)]` 这个连续区间,且最大值 == `len(args)`
|
||||||
|
(列表页那条 UNION ALL 是靠 `len(args)` 自己算编号的,错位就靠这个抓)。注意断言的是
|
||||||
|
「本段自己那 3n 个」,前缀那几个由查询别处引用,**不该**出现在区域条件里。
|
||||||
|
|
||||||
|
### 贴合:`fitCircle` 已删除,统一 `fitBBox`
|
||||||
|
|
||||||
|
多圆之后「圆心 + 半径」表达不了,留着 `fitCircle` 就是两套并行的贴合口径。`fitParams`
|
||||||
|
改为返回所有圆的并集外接矩形(`topicAreasBounds`,高纬处夹一下余弦)——
|
||||||
|
单圆时外接矩形正是那个圆的外接正方形,与原路径等价。
|
||||||
|
|
||||||
|
### 前台:`clip-path: path()`(circle() 只表达得了一个圆)
|
||||||
|
|
||||||
|
- 每个圆一段圆弧子路径、**同绕向(sweep=0)** ⇒ nonzero 填充规则下多段自然取并集,
|
||||||
|
重叠处**不会**互相挖洞。绕向写反的话重叠区会被抵消成一个洞。
|
||||||
|
- 拼路径抽成独立文件 `static/js/fog-clip.js`(`rond-map.js` 是个大 IIFE,函数取不出来,
|
||||||
|
抽出来才能跑 node 单测);⚠️ layout 里**必须排在 rond-map.js 之前**加载。
|
||||||
|
- 给迷雾单独 pane 再裁它(不能裁 `tilePane`,底图在里面);注册时机仍要在 `applyFit()`
|
||||||
|
之后(视图未定时 `latLngToContainerPoint()` 会抛错并把后续图层添加一起打断,见四十五)。
|
||||||
|
- `topic.html` 的 `fogAreas` **无条件下发**(空数组 = 不限区域),别包在 `{{if}}` 里 ——
|
||||||
|
条件分支是「有区域忘了新增分支」这类漏传的温床。
|
||||||
|
- ⚠️ **CSSOM 会把 `clip-path` 规范化**:`style.clipPath` 读回来会被补空格
|
||||||
|
(`path("M 405 296 a 87 87 …")`),而且空格位置不固定(可能出现 `296 a87`)、
|
||||||
|
第一个子路径尾部还可能多一个空格。任何「解析回几何」的代码必须每处分隔都用
|
||||||
|
`\s*`/`\s+` 容忍 —— 写死就等着被咬(本轮连错两版正则)。
|
||||||
|
|
||||||
|
### 前端单测接进 `verify.sh`
|
||||||
|
|
||||||
|
`internal/web/static/js/*.test.js` 以前只靠人记得手动跑 = 等于没有。现在挂在
|
||||||
|
「第 1.5 段」,且**找不到 node 直接失败而不是跳过** —— 跳过会让验收看着全绿、
|
||||||
|
实际一行前端脚本都没验。
|
||||||
|
|
||||||
|
### 验证方法:自校准(别再手算元素坐标)
|
||||||
|
|
||||||
|
`site_cdp.py --clip` 会把裁切框每边外扩 `pad=10` CSS px(×`--scale`),所以
|
||||||
|
「CSS 容器坐标 → 图像像素」不能直接换算。做法是:先拍一张**只画一个圆**的品红探针图,
|
||||||
|
用已知圆心/半径反解出 `scale` 与 `offset`(实测 2.0 / ~19.2),再用它给别处定标。
|
||||||
|
两张图反解出的 scale/offset 必须互相吻合,否则说明取景变了、比较无意义。
|
||||||
|
|
||||||
|
本轮实测(两圆,重叠):A 独有点只有 A 亮、B 独有点只有 B 亮、**重叠中心两圆都亮且
|
||||||
|
并集也亮(无洞)**,整体集合差异 0.013%;迷雾掩码 86,776 px 按并集解析式判**圆外 0 个**,
|
||||||
|
两个圆内分别 52,609 / 46,757 px ⇒ 第二个圆真的被认了。
|
||||||
|
|
||||||
|
### 后台编辑器:地图点选(2026-10-12 当天补完)
|
||||||
|
|
||||||
|
三个手填输入框(中心纬度/经度/半径)换成**地图 + 圆列表**(`static/js/topic-areas.js`:
|
||||||
|
点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/定位/删除/清空)。改动立刻写进
|
||||||
|
隐藏字段 `areas`(JSON) 与 `crs`,随专题表单一起提交。复用 `/admin/fog-mask` 那套约定
|
||||||
|
(`/api/map-config` 取底图、底图坐标系随表单带上、后端换算 GCJ-02)。
|
||||||
|
地图初始视野取生效数据集的地点包围盒(`placesBounds`),一上来就落在自己的地界上。
|
||||||
|
|
||||||
|
⚠️ **三条容易再踩的**:
|
||||||
|
|
||||||
|
- 🔴 **`/admin/topics` 必须 `p.HasMap = true`**(`topicsAdminPage`)。漏了的表现极隐蔽:
|
||||||
|
脚本加载了、`rondTopicAreas` 也在,就是 `window.L` 永远 undefined,`init` 静默返回、
|
||||||
|
**控制台不报错**、页面看着像「什么都没发生」。守卫 `TestTopicsAdminPageLoadsLeaflet`
|
||||||
|
(按函数体截取后断言含 `HasMap = true`)。
|
||||||
|
- 🔴 **`areas` 的初值要渲染进隐藏字段**(`value="{{.Form.AreasJSON}}"`),JS 也从隐藏字段
|
||||||
|
读初值而不是另传一份:脚本没加载上时提交的仍是服务端渲染的原值,否则**一次保存就把
|
||||||
|
已有显示区静默清空**。守卫 `TestTopicAdminUsesMapEditorNotCoordInputs` 钉住初值来源、
|
||||||
|
挂点 id 齐全、以及「三个旧输入框必须没了」(留着就是第二套只写第 1 个圆的入口)。
|
||||||
|
- **上限两边各写一份**(JS `MAX_AREAS/MIN_KM/MAX_KM` vs Go `maxTopicAreas` 等),
|
||||||
|
守卫 `TestTopicAreaLimitsMatchBetweenGoAndJS` 直接比对数字;前端还据此**禁用提交**。
|
||||||
|
不一致的表现是「前端放行、后端拒绝,用户看到红字却不知道改哪里」。
|
||||||
|
|
||||||
|
小坑:半径输入框得套一层**定宽容器**(`.area-km-box`)——站点全局 input 规则是
|
||||||
|
`width:100%` 且带 10 个 `:not()`(特异性 (0,11,1)),直接给 input 写宽度压不过。
|
||||||
|
|
||||||
|
⚠️ **给几何参数配「参照物」**:2026-10-13 用户反馈「圆无论怎么缩放大小都一样、20km 却覆盖了
|
||||||
|
整个南宁」,实测三处画圆(编辑器 `L.circle`、改半径把手、前台迷雾裁剪圆)**全是地理锚定、
|
||||||
|
随缩放正确变化**,半径值也确实生效 —— 复现不出「大小不变」。真正缺的是**参照物**:
|
||||||
|
编辑器地图以前没有比例尺(前台一直有),圈圆时只能靠地名猜。
|
||||||
|
⇒ 规矩:**编辑器地图必须有 `L.control.scale`,且半径旁要标出跨度**(`spanLabel()`:
|
||||||
|
半径 20 →「约 40 公里宽」;<1 公里改用米,别写「约 0.1 公里宽」)。
|
||||||
|
守卫 `TestTopicAreaEditorGivesSizeReference`。教训:遇到「大小看起来不对」,
|
||||||
|
先问「用户手上有什么可以量」,而不是只去验算几何。
|
||||||
|
|
||||||
|
⚠️ **`<input type="number">` 的 `step` 是相对 `min` 递增的**,不是「`step` 的整数倍」:
|
||||||
|
`min=0.05` + `step=0.5` 的合法值只有 0.05/0.55/1.05…,用户填 3 或 3.5 会被 Chrome 判非法、
|
||||||
|
提交时弹「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」。**这是浏览器原生提示,
|
||||||
|
JS 与后端都拦不到**(我们的校验根本没机会说话)——只能在源头避免。
|
||||||
|
⇒ 要么 `step="any"`(交给 JS/后端校验),要么让 `min` 是 `step` 的整数倍。
|
||||||
|
`min`/`max` 可以留着(真实边界 + spinner 范围)。守卫 `TestNumberInputStepNeverMismatchesMin`
|
||||||
|
扫**所有**模板的数字框(不是只盯出问题那个),JS 动态建的另有
|
||||||
|
`TestTopicAreaRadiusInputAllowsAnyStep`。
|
||||||
|
`topic-areas.js` 只把**纯函数**暴露给 node 单测,另加最小测试钩子 `api._map`
|
||||||
|
供无头验证(同 `window.rondMap._state` 惯例)—— 不留把手就只能真拿鼠标点地图。
|
||||||
|
|
||||||
|
### gofmt / 行尾(同一天的附带结论)
|
||||||
|
|
||||||
|
`gofmt -l` 报「整文件重写」有两层原因:① Go 1.19 起 gofmt 会重排**文档注释**里的缩进列表
|
||||||
|
(`// ① …` 要改成 tab 缩进块);② 工作区 CRLF 而 `.gitattributes` 声明 `eol=lf`。
|
||||||
|
`gofmt -w` 归一后 `git diff` **没有额外噪声** ⇒ **blob 本来就是 LF,CRLF 只是检出产物**,
|
||||||
|
而不是仓库内容问题(之前担心的「模板 CRLF 冲突」可以放下了)。
|
||||||
|
|||||||
+17
-17
@@ -1,8 +1,8 @@
|
|||||||
# rond-with-you 硬规矩(索引)
|
# rond-with-you 硬规矩(索引)
|
||||||
|
|
||||||
> 全文:`.workbuddy/memory/MEMORY-details.md`(38 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。
|
> 全文:`.workbuddy/memory/MEMORY-details.md`(47 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。
|
||||||
>
|
>
|
||||||
> ⚠️ 本文件**按字符数**注入,硬上限 `MAX_MEMORY_CHARS = 1e4`(WorkBuddy `app.asar` 里 `getWorkingMemoryContent`;中文 1 字算 1,不是按字节)。超了会被 `substring(0,1e4)` 截断并写明「先整理再干活」——即整段尾部静默丢失。日常日志与本 details **不参与注入**,可以随便写。现值见 `wc -m`,留 20% 余量。
|
> ⚠️ 本文件**按字符数**注入,硬上限 1e4(`MAX_MEMORY_CHARS`;中文 1 字算 1,不是按字节)。超了会被 `substring` 截断=**尾部静默丢失**。日常日志与 details **不参与注入**,可随便写。现值见 `wc -m`,留 20% 余量。
|
||||||
|
|
||||||
## 落库 / 精度(details 一、四、五、十六、十九、廿八A)
|
## 落库 / 精度(details 一、四、五、十六、十九、廿八A)
|
||||||
- 建地点+到访**同一事务**;新行号只走 `nextSrcPK`+`clearStaleRaw`,**禁 `MAX(src_pk)+1`**。`seq_test.go`
|
- 建地点+到访**同一事务**;新行号只走 `nextSrcPK`+`clearStaleRaw`,**禁 `MAX(src_pk)+1`**。`seq_test.go`
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
- rows 收尾必须 `drainRows`(先 `Err()` 再 `Close()`)。`parse_rows_test.go`
|
- rows 收尾必须 `drainRows`(先 `Err()` 再 `Close()`)。`parse_rows_test.go`
|
||||||
- **扫进裸 `bool` 的列必须先 `COALESCE`**:`weather.is_daylight` 漏写 ⇒ NULL ⇒ 整包拒收(details 卅C)。
|
- **扫进裸 `bool` 的列必须先 `COALESCE`**:`weather.is_daylight` 漏写 ⇒ NULL ⇒ 整包拒收(details 卅C)。
|
||||||
- 核对导出包**别按留档 UUID 找行**:站点新建行会被补派生 UUID(`coreDataUUID`)。
|
- 核对导出包**别按留档 UUID 找行**:站点新建行会被补派生 UUID(`coreDataUUID`)。
|
||||||
- **换基线**(底座换代,`/admin/baseline`):站点数据一行不动,只换底稿。判据=到访 UUID 重合率(`matched==0` 硬拒)+ 干跑导出;记档 `datasets.baseline_*`,`ensureBaseline` **必须先看记档**;留档名**必须防重名**(秒级时间戳会让回滚覆盖掉刚生成的留档)。`baseline_swap_test.go`(details 卅三)
|
- **换基线**(`/admin/baseline`):站点数据一行不动,只换底稿。判据=到访 UUID 重合率(`matched==0` 硬拒)+ 干跑导出;记档 `datasets.baseline_*`,`ensureBaseline` **必须先看记档**;留档名**必须防重名**。`baseline_swap_test.go`(卅三)
|
||||||
|
|
||||||
## 迷雾 / 遮罩(details 三、九、十、廿二)
|
## 迷雾 / 遮罩(details 三、九、十、廿二)
|
||||||
- `.fwss`=zip、条目 zlib;`Model/*/`=头 32768B+每块 515B;`Model/~/`、`Model/#/` 原样写回;清空/替换都要连 `fog_raw_files`。
|
- `.fwss`=zip、条目 zlib;`Model/*/`=头 32768B+每块 515B;`Model/~/`、`Model/#/` 原样写回;清空/替换都要连 `fog_raw_files`。
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
- 瓦片须与底图同 `crs`;带地图页须 `p.HasMap=true`。
|
- 瓦片须与底图同 `crs`;带地图页须 `p.HasMap=true`。
|
||||||
- `tileDigits` 补前导 0;`tileName` 尾缀 `len(s)-2` 单数字编号时为 -1 ⇒ panic;解码守卫 `len(name)<8`。
|
- `tileDigits` 补前导 0;`tileName` 尾缀 `len(s)-2` 单数字编号时为 -1 ⇒ panic;解码守卫 `len(name)<8`。
|
||||||
- `fogIndexV` 必须 `atomic.Pointer`(普通指针=真 data race)。`fog_race_test.go`(-race)
|
- `fogIndexV` 必须 `atomic.Pointer`(普通指针=真 data race)。`fog_race_test.go`(-race)
|
||||||
|
- **专题显示区=一组圆(并集)**:存 `topics.areas` jsonb(不单开表);SQL 是 N 个圆的 **OR**(每圆 3 个占位符,编号须恰好覆盖 `[prefix+1,len(args)]`)。裁剪=**单独 pane**(不能裁 `tilePane`)+ `clip-path: path()`,每圆一段**同绕向**子路径(反了重叠处会挖成洞);圆心须 `project()`;⚠️ 注册必须排在 `applyFit()` **之后**(视图未定会抛「Set map center and zoom first.」并打断后续图层=迷雾整片消失且不报错)。后台=地图点选(`topic-areas.js`);⚠️ areas 初值必须渲染进隐藏字段(脚本没加载就静默清空)、上限两边写死须一致(四十五、四十六)
|
||||||
|
|
||||||
## 前端 / 模板 / CSS(details 六~八、十七、廿三、廿八D、卅七)
|
## 前端 / 模板 / CSS(details 六~八、十七、廿三、廿八D、卅七)
|
||||||
- ⚠️ **对访客公开的块,措辞必须按身份分支**(`{{if .IsAdmin}}我的…{{else}}站长…{{end}}`):
|
- ⚠️ **对访客公开的块,措辞必须按身份分支**(`{{if .IsAdmin}}我的…{{else}}站长…{{end}}`):
|
||||||
@@ -38,7 +39,8 @@
|
|||||||
(以访客 ctx 渲染 `/`、`/stats`、`/report` 逐个断言禁用词,并确认站长那份仍说「我的」)。
|
(以访客 ctx 渲染 `/`、`/stats`、`/report` 逐个断言禁用词,并确认站长那份仍说「我的」)。
|
||||||
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。⚠️ **新模板+旧二进制**=「渲染到一半仍 200」;启动自检 `smokeRender` 会拒启(四十一)。
|
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。⚠️ **新模板+旧二进制**=「渲染到一半仍 200」;启动自检 `smokeRender` 会拒启(四十一)。
|
||||||
- 字阶/圆角/阴影**只走令牌**,裸 `font-size: 13px` 有静态守卫 `TestNoBareFontSizePx`;卡片三档 `.card--hero/--quiet`。⚠️ 改**根字号**要查 Leaflet(`.75rem` 会跟着漂),(四十二)。
|
- 字阶/圆角/阴影**只走令牌**,裸 `font-size: 13px` 有静态守卫 `TestNoBareFontSizePx`;卡片三档 `.card--hero/--quiet`。⚠️ 改**根字号**要查 Leaflet(`.75rem` 会跟着漂),(四十二)。
|
||||||
- 类型色走 `csscolor/csssoft`(**必须返回 `template.CSS`**,否则 `var()` 被换成 `ZgotmplZ` 且不报错);这套色连 3:1 都不到,**只能当圆点标记、别当文字色**。窄屏导航收进 `.nav-menu`,顶栏 `--z-nav` 必须高于地图浮层(四十三)。
|
- 类型色走 `csscolor/csssoft`(**必须返回 `template.CSS`**,否则 `var()` 被换成 `ZgotmplZ` 且不报错);这套色连 3:1 都不到,**只能当圆点标记**。窄屏导航收进 `.nav-menu`,顶栏 `--z-nav` 必须高于地图浮层(四十三)。
|
||||||
|
- 日期控件一律走 `attachAll`(原生 `type=date` 年段会吞 6 位数字 ⇒ 敲 20260606 变 `202606/06` 且值为空),模板里有守卫。`.grid` 必须 `align-items:start`(四十四)。前端单测(`static/js/*.test.js`)已挂进 verify.sh,**node 缺失直接失败不跳过**;可测的纯函数要抽成独立文件(`rond-map.js` 是大 IIFE,取不出函数)。数字框 `step` 须 `any` —— 它相对 `min` 递增,写死小数会把正常值判非法,且那是**浏览器原生**提示、JS/后端都拦不到。(四十六)
|
||||||
- 拼查询串走返回 `template.URL` 的方法(`PlaceLink`/`EditPlaceLink`/`MapLink`/`EditLink`),勿手工转义。
|
- 拼查询串走返回 `template.URL` 的方法(`PlaceLink`/`EditPlaceLink`/`MapLink`/`EditLink`),勿手工转义。
|
||||||
- 窄屏覆盖写 `app.css` **末尾那块**;宽表堆叠类名**成对** `class="tb tb-stack"`;触控 44px。`tb_stack_test.go`
|
- 窄屏覆盖写 `app.css` **末尾那块**;宽表堆叠类名**成对** `class="tb tb-stack"`;触控 44px。`tb_stack_test.go`
|
||||||
- 加表格列:`PlaceCols`/`scanPlace` **成对**、新列追加最后;CJK 挤换行只抬该表 `min-width`。
|
- 加表格列:`PlaceCols`/`scanPlace` **成对**、新列追加最后;CJK 挤换行只抬该表 `min-width`。
|
||||||
@@ -58,35 +60,33 @@
|
|||||||
- 站内新建行 `entity_raw.raw IS NULL`;`ZCATEGORY_` 真机只有 `MKPOICategoryXxx` 枚举。
|
- 站内新建行 `entity_raw.raw IS NULL`;`ZCATEGORY_` 真机只有 `MKPOICategoryXxx` 枚举。
|
||||||
- 类别候选:编辑/照片页=库里在用 `GROUP BY`;补全页=在用 ∪ 静态表;下拉留「其它(自己写)」,默认「不动」。
|
- 类别候选:编辑/照片页=库里在用 `GROUP BY`;补全页=在用 ∪ 静态表;下拉留「其它(自己写)」,默认「不动」。
|
||||||
|
|
||||||
## 归属口径 / 防复发(details 卅五)
|
## 归属口径 / 跨备份 / 附属数据(details 十九~廿一、卅四、卅五)
|
||||||
- ⚠️ **铁律:这份记录能不能从备份重新导入?** 能 ⇒ 可挂 `dataset_id … ON DELETE CASCADE`
|
- ⚠️ **铁律:这份记录能不能从备份重新导入?** 能 ⇒ 可挂 `dataset_id … ON DELETE CASCADE`
|
||||||
(places/visits/movements/weather/activities/tags/entity_raw);**不能 ⇒ 只留 dataset_id 当出处、不许挂外键**
|
(places/visits/movements/weather/activities/tags/entity_raw);**不能 ⇒ 只留 dataset_id 当出处、不许挂外键**
|
||||||
(place_images/place_notes/place_votes/topics)。挂在备份里没有的表上 = 删数据集就永久丢失。
|
(place_images/place_notes/place_votes/topics)。挂在备份里没有的表上 = 删数据集就永久丢失。
|
||||||
- 两道门禁都钉着它:运行时查 `pg_constraint`(表必须在白名单里,反向也查)+ **静态扫 `schema.sql`**
|
- 两道门禁都钉着它:运行时查 `pg_constraint`(表必须在白名单里,反向也查)+ **静态扫 `schema.sql`**
|
||||||
(新建的库走 CREATE TABLE,只看运行时会有盲点)。`dataset_ownership_test.go`
|
(新建的库走 CREATE TABLE,只看运行时会有盲点)。`dataset_ownership_test.go`(卅五)
|
||||||
- **体检常驻后台首页**:库里 N 张 / 本地盘上 M 个 / 落单 K 个(`imageHealth`)——
|
- **体检常驻后台首页**:库里 N 张 / 本地盘上 M 个 / 落单 K 个(`imageHealth`)——
|
||||||
事故的本质是「行与对象悄悄对不上、没人发现」,让它当场可见而不是等照片消失才追。
|
事故的本质是「行与对象悄悄对不上、没人发现」,让它当场可见而不是等照片消失才追。
|
||||||
|
|
||||||
## 跨备份 / 附属数据(details 十九~廿一、卅四)
|
|
||||||
- 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。
|
- 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。
|
||||||
- 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。
|
- 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。
|
||||||
- **站长结论(`place_votes`)与访客票(`place_visitor_votes`)是两码事**:各存各表、各看各榜;
|
- **站长结论(`place_votes`)与访客票(`place_visitor_votes`)是两码事**:各存各表、各看各榜(`votes.TopVisitorVoted`,`HAVING` 只算有票的方向)。
|
||||||
统计页拆成「我的结论」与「访客点赞」两块(`votes.TopVisitorVoted`,`HAVING` 只算有票的那个方向)。
|
⚠️ 票表也按坐标键跨数据集读 ⇒ 用例必须按 `place_key` 清历史票,否则票数越跑越多。(卅六)
|
||||||
⚠️ 票表也按坐标键跨数据集读 ⇒ 用例必须按 `place_key` 清历史票,否则票数越跑越多。(details 卅六)
|
- ⚠️ **附属数据不能挂数据集外键**(`place_images` 曾按 `dataset_id` CASCADE ⇒ 删数据集=删行、**文件还在盘上**);
|
||||||
- ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/<id>.sqlite`(**别清 baselines 目录**);⚠️ 来源必须**全部读进来合并**(places → entity_raw 留档 → 该 sqlite 及**每份 `.bak`**)——换基线会覆盖底座文件,只看当前那份会整片认不出(实测 40 张只挂回 7 张)。`image_recovery_test.go`(details 卅四)
|
恢复走后台「图片挂载恢复」,认地点靠 `baselines/<id>.sqlite`(**别清该目录**);
|
||||||
|
⚠️ 来源必须**全部读进来合并**(places → entity_raw 留档 → 该 sqlite 及**每份 `.bak`**),只看当前那份会整片认不出。`image_recovery_test.go`
|
||||||
- ⚠️ 恢复**同时扫对象存储**(R2/S3):`media.ListObjects`(ListObjectsV2 + SigV4,查询串先编码再签),
|
- ⚠️ 恢复**同时扫对象存储**(R2/S3):`media.ListObjects`(ListObjectsV2 + SigV4,查询串先编码再签),
|
||||||
需 `s3:ListBucket` 权限;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集
|
需 `s3:ListBucket`;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集(`user_id` 过滤,内容仍照生效数据集算)。(卅四)
|
||||||
(`user_id` 过滤,内容仍照生效数据集算)。用户级数据靠 `Dataset.UserID` 定位。(details 卅四)
|
|
||||||
- ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。
|
- ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。
|
||||||
- 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。
|
- 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。
|
||||||
|
|
||||||
## 后端 / 性能(details 十、廿八C)
|
## 后端 / 性能(details 十、廿八C)
|
||||||
- `findSourceArchive` 先按 `size_bytes` 筛再算 sha256(`pickArchiveBySha`)。池 8/8 + `db.WarmUp(d,6)`;页面内查询 `runJobs`(≤6);别用 `fog.Index.All()`;改遮罩须 `masks.invalidate()`+`fogTiles.clear()`。
|
- `findSourceArchive` 先按 `size_bytes` 筛再算 sha256。池 8/8 + `db.WarmUp(d,6)`;页面内查询 `runJobs`(≤6);别用 `fog.Index.All()`;改遮罩须 `masks.invalidate()`+`fogTiles.clear()`。
|
||||||
- `placeCols` 子查询必须带 `vN.dataset_id=p.dataset_id`。`stats/placecols_test.go`(按子查询分段判)
|
- `placeCols` 子查询必须带 `vN.dataset_id=p.dataset_id`。`stats/placecols_test.go`(按子查询分段判)
|
||||||
- multipart handler 直接 `ParseMultipartForm`(`r.ParseForm()` 毒化字段);**之前必须 `MaxBytesReader`**。五个上传口:备份/GPX/迷雾/照片/地点图片。
|
- multipart handler 直接 `ParseMultipartForm`(`r.ParseForm()` 毒化字段);**之前必须 `MaxBytesReader`**。五个上传口:备份/GPX/迷雾/照片/地点图片。
|
||||||
- 包了 `ResponseWriter` 必须实现 `Unwrap()`(`gzipWriter`/`headerSent`);`recover` 写错误页前须看 `headerSent`。`server_error_test.go`
|
- 包了 `ResponseWriter` 必须实现 `Unwrap()`(`gzipWriter`/`headerSent`);`recover` 写错误页前须看 `headerSent`。`server_error_test.go`
|
||||||
- 「先落盘再解析」的上传 handler,落盘后每条失败路径都要删半成品。`upload_cleanup_test.go`
|
- 「先落盘再解析」的上传 handler,落盘后每条失败路径都要删半成品。`upload_cleanup_test.go`
|
||||||
- 批量勾选走 `*Bulk` + `p.src_pk = ANY($3::bigint[])`(pgx 直接吃 `[]int64`);**全局内存缓存必须带上限**(`fillupCache` FIFO 淘汰最旧 1/4);**同一查询别在一个 handler 里查两遍**。`admin_notes_bulk_test.go`+`fillup_cache_test.go`+`stats_weekday_once_test.go`
|
- 批量勾选走 `*Bulk` + `p.src_pk = ANY($3::bigint[])`;**全局内存缓存必须带上限**(`fillupCache` FIFO 淘汰最旧 1/4);**同一查询别在一个 handler 里查两遍**。`admin_notes_bulk_test.go`+`fillup_cache_test.go`+`stats_weekday_once_test.go`
|
||||||
- 加 `setting` 字段改三处:`Default()`/`Load()`/`settingsSubmit()`。
|
- 加 `setting` 字段改三处:`Default()`/`Load()`/`settingsSubmit()`。
|
||||||
|
|
||||||
## 安全 / 跳转(details 廿八B)
|
## 安全 / 跳转(details 廿八B)
|
||||||
@@ -108,4 +108,4 @@
|
|||||||
- 地点配对只有一份实现 `rond.MatchPlaces`(差异与合并共用);同名兜底必须有距离上限(`nameMatchMaxKm=1.0`)。
|
- 地点配对只有一份实现 `rond.MatchPlaces`(差异与合并共用);同名兜底必须有距离上限(`nameMatchMaxKm=1.0`)。
|
||||||
- 差异页默认 radio 是「**用实机**」;写合并测试要照这个提交(抓 `value="backup"`),否则测的不是真实点击行为。
|
- 差异页默认 radio 是「**用实机**」;写合并测试要照这个提交(抓 `value="backup"`),否则测的不是真实点击行为。
|
||||||
- 自检不变量:合并后没有任何地点被搬动 >1km、也没有任何到访离它的实机地点 >1km。
|
- 自检不变量:合并后没有任何地点被搬动 >1km、也没有任何到访离它的实机地点 >1km。
|
||||||
- **三条铁律**:① app 只靠 `Z_PK` 解引用地点/活动 ⇒ 导出的编号必须与**数据集基线**同一套 ⇒ **基线必须是手机包**;② rond 导入是**整库替换** ⇒ 导入前先清空 app 数据;③ 站点数据是超集 ⇒ 用「手机导出作新数据集 + 旧站点导出当备份合并进去」对齐;`ZRAW` 这类从源包抄来的计数会越界,靠导出侧夹取兜住。
|
- **三条铁律**:① app 只靠 `Z_PK` 解引用 ⇒ 导出编号必须与**数据集基线**同一套 ⇒ **基线必须是手机包**;② rond 导入是**整库替换** ⇒ 导入前先清空 app 数据;③ 站点数据是超集 ⇒ 用「手机导出作新数据集 + 旧站点导出当备份合并进去」;`ZRAW` 这类从源包抄来的计数会越界,靠导出侧夹取兜住。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module rond-with-you
|
module rond-with-you
|
||||||
|
|
||||||
go 1.25
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgx/v5 v5.7.6
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
|||||||
+17
-1
@@ -383,7 +383,7 @@ BEGIN
|
|||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
|
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
|
||||||
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
|
-- 「区域」是**一组圆**(areas,见下),空数组表示不限区域(时间范围内的地点画到哪算哪)。
|
||||||
--
|
--
|
||||||
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE)。
|
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE)。
|
||||||
-- 专题是「一段时间 + 一块区域」的**定义**,不是备份里的数据:换数据集之后它照旧成立
|
-- 专题是「一段时间 + 一块区域」的**定义**,不是备份里的数据:换数据集之后它照旧成立
|
||||||
@@ -409,6 +409,22 @@ CREATE TABLE IF NOT EXISTS topics (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id);
|
CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id);
|
||||||
|
|
||||||
|
-- 专题的显示区:**一组圆**,jsonb 数组,元素形如 {"lat":..,"lon":..,"radiusKm":..}。
|
||||||
|
-- 坐标是 GCJ-02(与 places 同系);前台由 rond-map.js 的 project() 换算到底图坐标系。
|
||||||
|
-- 落在**任一**圆内就算在区域内(并集)——地点过滤是 SQL 里的 OR,迷雾裁剪是 clip-path: path()
|
||||||
|
-- 的多段子路径(同绕向 ⇒ nonzero 填充规则下自然取并集)。
|
||||||
|
--
|
||||||
|
-- 为什么用 jsonb 而不是单开一张表:这些圆是专题定义的组成部分、跟专题一起读写、没有独立
|
||||||
|
-- 生命周期,也就免了「能不能从备份重导」那道归属口径(topics 本来就只归人、不挂数据集)。
|
||||||
|
--
|
||||||
|
-- ⚠️ center_lat / center_lon / radius_km 三列**已废弃**(早期只支持一个圆),保留只为两件事:
|
||||||
|
-- 一是回滚旧二进制时还能退化显示成「第一个圆」,二是下面这段存量回填的输入。
|
||||||
|
-- 新代码一律只读 areas;写入时把第一个圆镜像进这三列。
|
||||||
|
ALTER TABLE topics ADD COLUMN IF NOT EXISTS areas JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
UPDATE topics SET areas = jsonb_build_array(
|
||||||
|
jsonb_build_object('lat', center_lat, 'lon', center_lon, 'radiusKm', radius_km))
|
||||||
|
WHERE radius_km > 0 AND areas = '[]'::jsonb;
|
||||||
|
|
||||||
|
|
||||||
-- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
|
-- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
|
||||||
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
|
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
|
||||||
|
|||||||
@@ -191,6 +191,151 @@ func TestNarrowNavCollapsesToMenu(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 日期输入必须走统一入口 attachAll ----------
|
||||||
|
//
|
||||||
|
// 2026-10-09 用户报的 bug:筛选区是原生 <input type=date>,而挂载代码只写了
|
||||||
|
// querySelectorAll('input[type=datetime-local]') —— **类型变体没人管**,于是 Chrome 的年段
|
||||||
|
// 吞下 6 位数字,敲 20260606 变成 202606/06、el.value 还是空的(筛选条件静默丢掉)。
|
||||||
|
//
|
||||||
|
// 守卫三件事:
|
||||||
|
//
|
||||||
|
// ① 模板里不许再出现按 type 挑日期框的选择器 —— 那个选择器只能待在 datetime-input.js 里;
|
||||||
|
// ② layout.html 必须**无条件**引入 datetime-input.js(公共页的筛选框也要用,那边不加载 flatpickr);
|
||||||
|
// ③ 统一入口 attachAll 必须存在,且四类页面都调它。
|
||||||
|
func TestDateInputsGoThroughAttachAll(t *testing.T) {
|
||||||
|
js, err := staticFS.ReadFile("static/js/datetime-input.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 datetime-input.js 失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(js), "function attachAll(") {
|
||||||
|
t.Fatal("datetime-input.js 里没有 attachAll —— 统一入口没了,以后新加的输入类型又会被漏掉")
|
||||||
|
}
|
||||||
|
|
||||||
|
layout, err := tmplFS.ReadFile("templates/layout.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 layout.html 失败: %v", err)
|
||||||
|
}
|
||||||
|
lay := string(layout)
|
||||||
|
// 日期控件与它的日历弹层必须**无条件**加载:筛选区在公共页上也有日期框,而
|
||||||
|
// 「按页开开关」这种写法漏过一次(2026-10-09 的日期框 bug 就是只挂了 datetime-local)。
|
||||||
|
for _, needle := range []string{
|
||||||
|
"/static/vendor/flatpickr/flatpickr.min.js",
|
||||||
|
"/static/vendor/flatpickr/flatpickr.min.css",
|
||||||
|
"/static/js/datetime-input.js",
|
||||||
|
"rondDatetime.attachAll(",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(lay, needle) {
|
||||||
|
t.Errorf("layout.html 里缺 %s —— 那一页的日期框会退回原生控件(没有日历弹层)", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(lay, "{{if .Flatpickr}}") {
|
||||||
|
t.Error("layout.html 里又出现了 {{if .Flatpickr}} 这种按页开关 —— 日期控件已改成无条件加载," +
|
||||||
|
"开关一旦漏设,那一页的日期框就没有日历弹层(别再引入会漏的东西)")
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := fs.Glob(tmplFS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列举模板失败: %v", err)
|
||||||
|
}
|
||||||
|
adminNames, err := fs.Glob(tmplFS, "templates/admin/*.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列举后台模板失败: %v", err)
|
||||||
|
}
|
||||||
|
sels := []string{"input[type=datetime-local]", "input[type=date]"}
|
||||||
|
for _, name := range append(names, adminNames...) {
|
||||||
|
b, err := tmplFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
for _, sel := range sels {
|
||||||
|
if strings.Contains(string(b), sel) {
|
||||||
|
t.Errorf("%s 里又出现手写的日期选择器 %q —— 一律走 rondDatetime.attachAll(),"+
|
||||||
|
"否则新加的输入类型会被漏掉(2026-10-09 的日期框 bug 就是这么来的)", name, sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"templates/admin/edit.html", "templates/admin/photos.html", "templates/admin/topics.html"} {
|
||||||
|
b, err := tmplFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "attachAll(") {
|
||||||
|
t.Errorf("%s 没有调用 attachAll(它自己那批日期框会退回原生控件)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 专题的迷雾要和「显示区」成对:模板传 fogAreas、地图脚本认 fogAreas ----------
|
||||||
|
//
|
||||||
|
// 2026-10-10 用户实测发现:专题圈了区域,迷雾却还是全局的。而管理页原本的说明写的是
|
||||||
|
// 「区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选」—— **文档与实现不一致**
|
||||||
|
// (迷雾一直是全局瓦片,完全不认区域),这种「说明写了、实现没做」最容易蒙混过关。
|
||||||
|
//
|
||||||
|
// 这条守卫钉住两端与两处顺序:
|
||||||
|
//
|
||||||
|
// ① 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 {
|
||||||
|
t.Fatalf("读取 topic.html 失败: %v", err)
|
||||||
|
}
|
||||||
|
body := string(tpl)
|
||||||
|
if !strings.Contains(body, "fogAreas:") {
|
||||||
|
t.Error("topic.html 没把专题显示区(fogAreas)传给地图 —— 迷雾不会被收口")
|
||||||
|
}
|
||||||
|
// 无条件传(不要包在 {{if .Topic.HasArea}} 里):条件分支是「有区域忘了新增分支」
|
||||||
|
// 这类漏传的温床,而空数组对脚本来说就是「不限区域」,没必要分两条路。
|
||||||
|
if !strings.Contains(body, "fogAreas: {{.Topic.AreasJSON}}") {
|
||||||
|
t.Error("fogAreas 应当无条件下发 {{.Topic.AreasJSON}}(不限区域时为空数组),别包在条件里")
|
||||||
|
}
|
||||||
|
|
||||||
|
js, err := staticFS.ReadFile("static/js/rond-map.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 rond-map.js 失败: %v", err)
|
||||||
|
}
|
||||||
|
src := string(js)
|
||||||
|
for _, needle := range []string{"opts.fogAreas", "createPane('rondFog')", "clipPath", "rondFogClip.pathFor"} {
|
||||||
|
if !strings.Contains(src, needle) {
|
||||||
|
t.Errorf("rond-map.js 里缺 %q —— 迷雾的收口没实现或被改掉了", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 顺序:注册点必须排在 `var applyFit = function` 之后 —— 放早了(比如塞在迷雾图层那段里)
|
||||||
|
// 就是在视图定下来之前算裁剪圆,会抛「Set map center and zoom first.」并打断后续图层添加。
|
||||||
|
reg := strings.Index(src, "map.on('move zoom viewreset resize', applyFogClip)")
|
||||||
|
fitDef := strings.Index(src, "var applyFit = function")
|
||||||
|
if reg < 0 || fitDef < 0 || reg < fitDef {
|
||||||
|
t.Error("applyFogClip 的注册必须排在 applyFit 定义(也就是视图设定)之后 —— " +
|
||||||
|
"视图未定时算裁剪圆会抛错,并把后面的图层添加一起打断")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多圆路径拼在 fog-clip.js 里,它必须在 rond-map.js 之前加载
|
||||||
|
clip, err := staticFS.ReadFile("static/js/fog-clip.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 fog-clip.js 失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(clip), "rondFogClip") {
|
||||||
|
t.Error("fog-clip.js 没有暴露 rondFogClip —— rond-map.js 拿不到路径构造函数")
|
||||||
|
}
|
||||||
|
layout, err := tmplFS.ReadFile("templates/layout.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 layout.html 失败: %v", err)
|
||||||
|
}
|
||||||
|
lay := string(layout)
|
||||||
|
iClip := strings.Index(lay, "/static/js/fog-clip.js")
|
||||||
|
iMap := strings.Index(lay, "/static/js/rond-map.js")
|
||||||
|
if iClip < 0 {
|
||||||
|
t.Error("layout.html 没引入 fog-clip.js —— 专题的多圆裁剪会静默退化成「不裁剪」")
|
||||||
|
} else if iMap < 0 || iClip > iMap {
|
||||||
|
t.Error("fog-clip.js 必须排在 rond-map.js 之前加载")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ----------
|
// ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ----------
|
||||||
//
|
//
|
||||||
// 改前 app.css 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 /
|
// 改前 app.css 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 /
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.HasMap = true
|
p.HasMap = true
|
||||||
p.Flatpickr = true
|
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
d := EditData{Page: p, Query: strings.TrimSpace(q.Get("q")), SourceCounts: map[string]int{}}
|
d := EditData{Page: p, Query: strings.TrimSpace(q.Get("q")), SourceCounts: map[string]int{}}
|
||||||
d.VisitQuery = strings.TrimSpace(q.Get("vq"))
|
d.VisitQuery = strings.TrimSpace(q.Get("vq"))
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ func (s *Server) adminPhotosPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.Flatpickr = true
|
|
||||||
p.HasMap = true
|
p.HasMap = true
|
||||||
d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""}
|
d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""}
|
||||||
catNames := s.categoryLabels(r.Context())
|
catNames := s.categoryLabels(r.Context())
|
||||||
|
|||||||
+214
-34
@@ -9,10 +9,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"rond-with-you/internal/geo"
|
||||||
"rond-with-you/internal/stats"
|
"rond-with-you/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +41,131 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
|||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 显示区(一组圆) ----------
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxTopicAreas 限制显示区个数:每个圆在 SQL 里占 3 个占位符、在 clip-path 里是一段子路径,
|
||||||
|
// 都不是能无限堆的东西;后台地图编辑器用同一个上限。
|
||||||
|
maxTopicAreas = 20
|
||||||
|
// 半径 50 米 ~ 2000 公里:再小在地图上拖不出来,再大没有意义。
|
||||||
|
topicAreaMinRadiusKm = 0.05
|
||||||
|
topicAreaMaxRadiusKm = 2000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTopicAreas 解析 areas 列。坏数据要上抛而不是吞掉——
|
||||||
|
// 显示区决定了专题「有哪些地点」,静默退化成「全都要」比报错危险得多。
|
||||||
|
func parseTopicAreas(raw []byte) ([]TopicArea, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var areas []TopicArea
|
||||||
|
if err := json.Unmarshal(raw, &areas); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return areas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTopicAreas 校验并收敛显示区。crs 是**点选时所处底图**的坐标系:
|
||||||
|
// 天地图那类 WGS-84 底图上点的圆心必须先换算成 GCJ-02 再存 ——
|
||||||
|
// 地点过滤是拿它跟 places 的 p.lat/p.lon(GCJ-02)直接比球面距离的,不换算会整体偏几百米。
|
||||||
|
func normalizeTopicAreas(areas []TopicArea, crs string) ([]TopicArea, error) {
|
||||||
|
if len(areas) > maxTopicAreas {
|
||||||
|
return nil, fmt.Errorf("显示区最多 %d 个", maxTopicAreas)
|
||||||
|
}
|
||||||
|
out := make([]TopicArea, 0, len(areas))
|
||||||
|
for _, a := range areas {
|
||||||
|
if math.IsNaN(a.Lat) || math.IsNaN(a.Lon) || math.IsNaN(a.RadiusKm) ||
|
||||||
|
math.IsInf(a.Lat, 0) || math.IsInf(a.Lon, 0) || math.IsInf(a.RadiusKm, 0) {
|
||||||
|
return nil, fmt.Errorf("显示区里有非法数值")
|
||||||
|
}
|
||||||
|
if math.Abs(a.Lat) > 90 || math.Abs(a.Lon) > 180 {
|
||||||
|
return nil, fmt.Errorf("显示区的圆心超出经纬度范围")
|
||||||
|
}
|
||||||
|
if a.RadiusKm < topicAreaMinRadiusKm || a.RadiusKm > topicAreaMaxRadiusKm {
|
||||||
|
return nil, fmt.Errorf("显示区的半径要在 %g ~ %g 公里之间", topicAreaMinRadiusKm, topicAreaMaxRadiusKm)
|
||||||
|
}
|
||||||
|
if crs == "wgs84" {
|
||||||
|
a.Lat, a.Lon = geo.WGS84ToGCJ02(a.Lat, a.Lon)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicAreasColumn 是写进 areas 列的值。**空必须是 `[]` 而不是 `null`**:
|
||||||
|
// 列上有 NOT NULL,而 json.Marshal(nil slice) 给出来的正好是 "null"。
|
||||||
|
func topicAreasColumn(areas []TopicArea) []byte {
|
||||||
|
if len(areas) == 0 {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(areas)
|
||||||
|
if err != nil {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicAreasFromForm 从表单取显示区:优先读地图编辑器提交的 areas(JSON);
|
||||||
|
// 没给才退回老的 center_lat / center_lon / radius_km 三字段(合成一个圆)。
|
||||||
|
// 两条路走同一套校验与换算,免得「新入口有校验、老入口没有」。
|
||||||
|
func topicAreasFromForm(r *http.Request) ([]TopicArea, error) {
|
||||||
|
crs := strings.TrimSpace(r.PostFormValue("crs"))
|
||||||
|
raw := strings.TrimSpace(r.PostFormValue("areas"))
|
||||||
|
if raw == "" {
|
||||||
|
lat, ok1 := parseFloat(r.PostFormValue("center_lat"))
|
||||||
|
lon, ok2 := parseFloat(r.PostFormValue("center_lon"))
|
||||||
|
rkm, ok3 := parseFloat(r.PostFormValue("radius_km"))
|
||||||
|
// 老表单里「不限区域」就是留空或 0,不是错误
|
||||||
|
if !ok1 || !ok2 || !ok3 || rkm <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if lat == 0 && lon == 0 {
|
||||||
|
return nil, fmt.Errorf("限定区域时要在图上点选圆心")
|
||||||
|
}
|
||||||
|
return normalizeTopicAreas([]TopicArea{{Lat: lat, Lon: lon, RadiusKm: rkm}}, crs)
|
||||||
|
}
|
||||||
|
var areas []TopicArea
|
||||||
|
if err := json.Unmarshal([]byte(raw), &areas); err != nil {
|
||||||
|
return nil, fmt.Errorf("显示区数据无法解析")
|
||||||
|
}
|
||||||
|
return normalizeTopicAreas(areas, crs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicAreasBounds 求所有圆的并集外接矩形(GCJ-02 度)。
|
||||||
|
// 只有一个圆时它就是这个圆的外接正方形,与早先那条 fitCircle(toBounds(2r km))等价,
|
||||||
|
// 所以单圆专题的视野不变;多圆只有外接矩形才有意义。
|
||||||
|
func topicAreasBounds(areas []TopicArea) (minLat, maxLat, minLon, maxLon float64, ok bool) {
|
||||||
|
if len(areas) == 0 {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
minLat, maxLat = math.MaxFloat64, -math.MaxFloat64
|
||||||
|
minLon, maxLon = math.MaxFloat64, -math.MaxFloat64
|
||||||
|
for _, a := range areas {
|
||||||
|
dLat := a.RadiusKm / 111.32
|
||||||
|
// 经度方向 1 度比纬度短,按纬度余弦放大;高纬处夹一下,避免除到极大
|
||||||
|
cos := math.Cos(a.Lat * math.Pi / 180)
|
||||||
|
if cos < 0.01 {
|
||||||
|
cos = 0.01
|
||||||
|
}
|
||||||
|
dLon := a.RadiusKm / (111.32 * cos)
|
||||||
|
minLat, maxLat = math.Min(minLat, a.Lat-dLat), math.Max(maxLat, a.Lat+dLat)
|
||||||
|
minLon, maxLon = math.Min(minLon, a.Lon-dLon), math.Max(maxLon, a.Lon+dLon)
|
||||||
|
}
|
||||||
|
if minLat < -90 {
|
||||||
|
minLat = -90
|
||||||
|
}
|
||||||
|
if maxLat > 90 {
|
||||||
|
maxLat = 90
|
||||||
|
}
|
||||||
|
if minLon < -180 {
|
||||||
|
minLon = -180
|
||||||
|
}
|
||||||
|
if maxLon > 180 {
|
||||||
|
maxLon = 180
|
||||||
|
}
|
||||||
|
return minLat, maxLat, minLon, maxLon, true
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 查询 ----------
|
// ---------- 查询 ----------
|
||||||
|
|
||||||
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
|
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
|
||||||
@@ -50,7 +175,7 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
|||||||
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
|
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
|
||||||
func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) {
|
func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) {
|
||||||
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
|
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
|
||||||
radius_km, show_fog, enabled, sort_order
|
radius_km, show_fog, enabled, sort_order, areas
|
||||||
FROM topics WHERE user_id=$1`
|
FROM topics WHERE user_id=$1`
|
||||||
if onlyEnabled {
|
if onlyEnabled {
|
||||||
q += ` AND enabled`
|
q += ` AND enabled`
|
||||||
@@ -64,10 +189,15 @@ func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool
|
|||||||
var out []Topic
|
var out []Topic
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Topic
|
var t Topic
|
||||||
|
var rawAreas []byte
|
||||||
if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder); err != nil {
|
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder,
|
||||||
|
&rawAreas); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||||
|
return nil, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err)
|
||||||
|
}
|
||||||
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
||||||
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
||||||
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
|
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
|
||||||
@@ -100,16 +230,23 @@ const topicHaversineSQL = `6371 * 2 * asin(sqrt(
|
|||||||
power(sin(radians((%s - p.lat) / 2)), 2) +
|
power(sin(radians((%s - p.lat) / 2)), 2) +
|
||||||
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
||||||
|
|
||||||
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
|
// topicAreaCond 追加显示区的参数并返回区域条件:落在**任一**圆内就算在区域内(并集)。
|
||||||
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
|
// 批量统计要重复拼这段,抽出来保证两条路径的占位符编号逐字一致 ——
|
||||||
|
// 这里少算一个 $n,表现就是「专题内容全变 0」。每个圆占 3 个参数(纬度、经度、半径)。
|
||||||
func topicAreaCond(t *Topic, args *[]any) string {
|
func topicAreaCond(t *Topic, args *[]any) string {
|
||||||
if !t.HasArea() {
|
areas := t.AreaList()
|
||||||
|
if len(areas) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
conds := make([]string, 0, len(areas))
|
||||||
n := len(*args)
|
for _, a := range areas {
|
||||||
return fmt.Sprintf(" AND %s <= $%d",
|
*args = append(*args, a.Lat, a.Lon, a.RadiusKm)
|
||||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
n := len(*args)
|
||||||
|
// 球面距离表达式的三个 %s 依次是 中心纬度 / 中心纬度 / 中心经度,最后再比半径
|
||||||
|
conds = append(conds, fmt.Sprintf("%s <= $%d",
|
||||||
|
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n))
|
||||||
|
}
|
||||||
|
return " AND (" + strings.Join(conds, " OR ") + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
||||||
@@ -258,14 +395,15 @@ func parseTopicForm(r *http.Request) (Topic, error) {
|
|||||||
return t, fmt.Errorf("结束时间不能早于开始时间")
|
return t, fmt.Errorf("结束时间不能早于开始时间")
|
||||||
}
|
}
|
||||||
t.StartAt, t.EndAt = start, end
|
t.StartAt, t.EndAt = start, end
|
||||||
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
|
areas, err := topicAreasFromForm(r)
|
||||||
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
|
if err != nil {
|
||||||
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
|
return t, err
|
||||||
if t.RadiusKm < 0 {
|
|
||||||
t.RadiusKm = 0
|
|
||||||
}
|
}
|
||||||
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
|
t.Areas = areas
|
||||||
return t, fmt.Errorf("限定区域时要填中心点坐标")
|
// 镜像第一个圆进那三列废弃列:万一回滚旧二进制,退化成「第一个圆」总比「没有区域」强。
|
||||||
|
t.CenterLat, t.CenterLon, t.RadiusKm = 0, 0, 0
|
||||||
|
if len(areas) > 0 {
|
||||||
|
t.CenterLat, t.CenterLon, t.RadiusKm = areas[0].Lat, areas[0].Lon, areas[0].RadiusKm
|
||||||
}
|
}
|
||||||
t.ShowFog = r.PostFormValue("show_fog") == "on"
|
t.ShowFog = r.PostFormValue("show_fog") == "on"
|
||||||
t.Enabled = r.PostFormValue("enabled") == "on"
|
t.Enabled = r.PostFormValue("enabled") == "on"
|
||||||
@@ -283,7 +421,9 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.Flatpickr = true
|
// 显示区编辑器要用 Leaflet:布局按这个标记引入它,漏了就是「地图整块不出现」
|
||||||
|
// (实测踩过:脚本加载了、rondTopicAreas 也在,但 window.L 一直是 undefined)。
|
||||||
|
p.HasMap = true
|
||||||
d := TopicAdminData{Page: p}
|
d := TopicAdminData{Page: p}
|
||||||
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
||||||
u := userFrom(ctx)
|
u := userFrom(ctx)
|
||||||
@@ -304,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 {
|
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
|
||||||
log.Printf("专题统计失败: %v", err)
|
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
|
d.Topics = list
|
||||||
// 编辑模式:?edit=<id>
|
// 编辑模式:?edit=<id>
|
||||||
@@ -325,6 +471,29 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "admin/topics", d)
|
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) {
|
func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
u := userFrom(ctx)
|
u := userFrom(ctx)
|
||||||
@@ -346,10 +515,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id > 0 {
|
if id > 0 {
|
||||||
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
|
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
|
||||||
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
|
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
|
||||||
show_fog=$11, enabled=$12, sort_order=$13
|
show_fog=$11, enabled=$12, sort_order=$13, areas=$14::jsonb
|
||||||
WHERE id=$1 AND user_id=$2`,
|
WHERE id=$1 AND user_id=$2`,
|
||||||
id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
|
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder,
|
||||||
|
topicAreasColumn(t.Areas)); err != nil {
|
||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,10 +531,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
|
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
|
||||||
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
||||||
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
||||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
|
center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb) RETURNING id`,
|
||||||
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder).Scan(&newID); err != nil {
|
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder,
|
||||||
|
topicAreasColumn(t.Areas)).Scan(&newID); err != nil {
|
||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -444,11 +615,13 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var t Topic
|
var t Topic
|
||||||
|
var rawAreas []byte
|
||||||
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
|
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
|
||||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
|
center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas
|
||||||
FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID).
|
FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID).
|
||||||
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
|
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder,
|
||||||
|
&rawAreas)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||||
return
|
return
|
||||||
@@ -457,6 +630,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||||
|
s.serverError(w, r, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
||||||
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
||||||
if !t.Enabled && !p.IsAdmin() {
|
if !t.Enabled && !p.IsAdmin() {
|
||||||
@@ -489,10 +666,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fitCircle, fitBBox := fitParams(t, points)
|
fitBBox := fitParams(t, points)
|
||||||
d := TopicData{
|
d := TopicData{
|
||||||
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
|
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
|
||||||
PointsJSON: template.JS(js), FitCircleJSON: fitCircle, FitBBoxJSON: fitBBox,
|
PointsJSON: template.JS(js), FitBBoxJSON: fitBBox,
|
||||||
}
|
}
|
||||||
s.render(w, "topic", d)
|
s.render(w, "topic", d)
|
||||||
}
|
}
|
||||||
@@ -513,17 +690,20 @@ func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fitParams 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
|
// fitParams 生成把地图视野框到专题范围的脚本参数:限定显示区时用**所有圆的并集外接矩形**,
|
||||||
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
|
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
|
||||||
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
|
//
|
||||||
if t.HasArea() {
|
// 早先这里还给过一个 fitCircle(中心 + 半径),多圆之后放弃:它只表达得了一个圆,
|
||||||
|
// 留着就是两套并行的贴合口径。单圆时外接矩形正是那个圆的外接正方形,视野与原路径等价。
|
||||||
|
func fitParams(t Topic, points []MapPoint) template.JS {
|
||||||
|
if minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(t.AreaList()); ok {
|
||||||
b, _ := json.Marshal(map[string]any{
|
b, _ := json.Marshal(map[string]any{
|
||||||
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
|
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
||||||
})
|
})
|
||||||
return template.JS(b), template.JS("null")
|
return template.JS(b)
|
||||||
}
|
}
|
||||||
if len(points) == 0 {
|
if len(points) == 0 {
|
||||||
return template.JS("null"), template.JS("null")
|
return template.JS("null")
|
||||||
}
|
}
|
||||||
minLat, maxLat := points[0].Lat, points[0].Lat
|
minLat, maxLat := points[0].Lat, points[0].Lat
|
||||||
minLon, maxLon := points[0].Lon, points[0].Lon
|
minLon, maxLon := points[0].Lon, points[0].Lon
|
||||||
@@ -534,7 +714,7 @@ func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
|
|||||||
b, _ := json.Marshal(map[string]any{
|
b, _ := json.Marshal(map[string]any{
|
||||||
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
||||||
})
|
})
|
||||||
return template.JS("null"), template.JS(b)
|
return template.JS(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。
|
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。
|
||||||
|
|||||||
@@ -244,7 +244,10 @@ b, strong { font-weight: 600; }
|
|||||||
font: inherit; font-size: var(--glyph-xs); line-height: 1;
|
font: inherit; font-size: var(--glyph-xs); line-height: 1;
|
||||||
}
|
}
|
||||||
.card[data-card] > h2 .card-toggle:hover { color: var(--text); }
|
.card[data-card] > h2 .card-toggle:hover { color: var(--text); }
|
||||||
.grid { display: grid; gap: 16px; }
|
/* align-items: start 是必须的:grid 默认 stretch,同一行里内容少的那张卡会被拉到与
|
||||||
|
最高的一样高,卡片内部留一大片空白(/stats 的「类型分布」60 多行 vs「一天中的分布」
|
||||||
|
24 根柱就是这么来的)。改成各按自然高度,短的那张下面露的是页面底色,而不是空白卡面。 */
|
||||||
|
.grid { display: grid; gap: 16px; align-items: start; }
|
||||||
/* grid 子项默认 min-width:auto(= 不小于自身 min-content),而卡片里常常放着
|
/* grid 子项默认 min-width:auto(= 不小于自身 min-content),而卡片里常常放着
|
||||||
带 min-width 的表格——不加这条,卡片会拒绝收缩、把**整页**顶宽,出现横向滚动条
|
带 min-width 的表格——不加这条,卡片会拒绝收缩、把**整页**顶宽,出现横向滚动条
|
||||||
(/stats、/report 在手机上整页 692px 就是这么来的)。压到 0 之后由卡片内部的
|
(/stats、/report 在手机上整页 692px 就是这么来的)。压到 0 之后由卡片内部的
|
||||||
@@ -355,6 +358,14 @@ a.col:hover { opacity: 1; filter: brightness(1.12); }
|
|||||||
/* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */
|
/* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */
|
||||||
.temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
|
.temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
|
||||||
.temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; }
|
.temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; }
|
||||||
|
/* 宽屏反过来:柱子要跟着卡片长、轨道也要加高。原来固定 36×84,10 个月只占 1042px 里的
|
||||||
|
396px —— 右边 600 多像素全空着,整张图看着就「很小」。上限 96px 是防止只有两三个月时
|
||||||
|
柱子宽得不像柱状图。 */
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.temp-col { flex: 1 1 36px; max-width: 96px; }
|
||||||
|
.temp-col .tc-track { height: 130px; }
|
||||||
|
.temp-col .tc-v, .temp-col .tc-m { font-size: var(--fs-sm); }
|
||||||
|
}
|
||||||
.temp-col .tc-v { font-size: var(--fs-xs); color: var(--text-2); font-variant-numeric: tabular-nums; }
|
.temp-col .tc-v { font-size: var(--fs-xs); color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||||
.temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; }
|
.temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; }
|
||||||
.temp-col .tc-bar { width: 100%; border-radius: var(--r-xs) var(--r-xs) 0 0; min-height: 3px; }
|
.temp-col .tc-bar { width: 100%; border-radius: var(--r-xs) var(--r-xs) 0 0; min-height: 3px; }
|
||||||
@@ -418,13 +429,22 @@ label.f > span { display: block; font-size: var(--fs-sm); color: var(--text-2);
|
|||||||
/* 按「排除掉不该套文本框样式的类型」来写,而不是逐个枚举 text/number/date——
|
/* 按「排除掉不该套文本框样式的类型」来写,而不是逐个枚举 text/number/date——
|
||||||
枚举漏了 datetime-local(后台编辑页有 160 多个),那些框就一直是浏览器默认外观:
|
枚举漏了 datetime-local(后台编辑页有 160 多个),那些框就一直是浏览器默认外观:
|
||||||
没有圆角、没有内边距、13.33px 的字,跟旁边的输入框明显不是一套。 */
|
没有圆角、没有内边距、13.33px 的字,跟旁边的输入框明显不是一套。 */
|
||||||
input:not([type=checkbox]):not([type=radio]):not([type=color]):not([type=range]):not([type=file]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=hidden]),
|
/* ⚠️ 还要排除 flatpickr **内部**的控件:`input.numInput`(年 / 时 / 分)与
|
||||||
select, textarea {
|
`select.flatpickr-monthDropdown-months`(月份下拉)。它们是 type=number / select,
|
||||||
|
会被下面这条扫中 —— width:100% 把月份下拉撑满整行、压在年份输入上面(实测重叠成一团),
|
||||||
|
内边距与边框还会把时间格变成站点的方块输入框。
|
||||||
|
⚠️ 别指望「后面补一条 `.flatpickr-calendar input{...}` 去对冲」:这条选择器带 10 个
|
||||||
|
`:not([type=…])`,特异性是 (0,10,1),普通对冲压不过(实测年份框仍是站点样式)。
|
||||||
|
要排除就得在**这里**排除。 */
|
||||||
|
input:not([type=checkbox]):not([type=radio]):not([type=color]):not([type=range]):not([type=file]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=hidden]):not(.numInput),
|
||||||
|
select:not(.flatpickr-monthDropdown-months), textarea {
|
||||||
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-sm); font: inherit;
|
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-sm); font: inherit;
|
||||||
font-size: var(--field-fs, inherit);
|
font-size: var(--field-fs, inherit);
|
||||||
background: var(--surface); color: var(--text); outline: none;
|
background: var(--surface); color: var(--text); outline: none;
|
||||||
}
|
}
|
||||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
input:not(.numInput):focus, select:not(.flatpickr-monthDropdown-months):focus, textarea:focus {
|
||||||
|
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
.check { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
|
.check { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
|
||||||
.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
||||||
.nobr { white-space: nowrap; }
|
.nobr { white-space: nowrap; }
|
||||||
@@ -1141,6 +1161,28 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
|
|||||||
#parse-status { margin: 12px 0 0; }
|
#parse-status { margin: 12px 0 0; }
|
||||||
#parse-status:empty { display: none; }
|
#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); }
|
||||||
|
/* 半径旁边的「约 N 公里宽」:不许折行,否则圆列表每行高度会跳 */
|
||||||
|
.area-span { white-space: nowrap; 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)的基础规则写在文件中部,
|
这几组选择器(.chip / .btn / .check input)的基础规则写在文件中部,
|
||||||
而 CSS 同特异性下靠后的赢 —— 把它们写在前面那份「窄屏触控」块里是被盖掉的
|
而 CSS 同特异性下靠后的赢 —— 把它们写在前面那份「窄屏触控」块里是被盖掉的
|
||||||
@@ -1150,6 +1192,9 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
|
|||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.chip { padding: 9px 12px; }
|
.chip { padding: 9px 12px; }
|
||||||
.btn, .btn.sm { padding: 10px 14px; }
|
.btn, .btn.sm { padding: 10px 14px; }
|
||||||
|
/* 专题显示区编辑器:地图矮一点;坐标那列太长会挤掉按钮,让它独占一行排到最后 */
|
||||||
|
#area-map { height: 300px; }
|
||||||
|
.area-row .mono { flex: 1 1 100%; order: 3; }
|
||||||
/* 勾选框:原来 15px,手指很难点中(尤其后台设置页一屏几十个开关)。
|
/* 勾选框:原来 15px,手指很难点中(尤其后台设置页一屏几十个开关)。
|
||||||
有效目标仍是外面那圈 label,但控件本身大一点更好瞄。 */
|
有效目标仍是外面那圈 label,但控件本身大一点更好瞄。 */
|
||||||
.check input, input[type=checkbox], input[type=radio] { width: 20px; height: 20px; }
|
.check input, input[type=checkbox], input[type=radio] { width: 20px; height: 20px; }
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
/* datetime-input:修掉 flatpickr allowInput 的「整串解析」毛病。
|
/* datetime-input:把 <input type="date" / type="datetime-local"> 换成**我们完全掌控**的
|
||||||
|
* 文本输入 + 手输引导。
|
||||||
*
|
*
|
||||||
* 背景(实测 flatpickr 4.6.13,见 memory/2026-10-08.md):
|
* 为什么不用原生控件(两次踩过,都是实测):
|
||||||
* 1. flatpickr 会把 <input type="datetime-local"> 就地改成 type="text"
|
* 1. flatpickr 会把 type=datetime-local 就地改成 type=text(altInput 默认 false、不新建节点),
|
||||||
* (altInput 默认 false、不新建节点),原生那套分段编辑器被整个替换掉,
|
* 原生那套分段编辑器被整个替换掉;而 allowInput 的语义是「拿到整串再 parseDate」,
|
||||||
* 所以「敲满一段自动跳下一段」这个行为根本不存在。
|
* 实测 parseDate('2026') → 1 月、'202666' → 2026-06、'20260606' → 2030-12(静默错解)。
|
||||||
* 2. allowInput 的语义是「拿到整串再 parseDate」,不是「逐段消费」。实测:
|
* 2. **原生 type=date 自己也不可靠**:Chrome 的年段允许超过 4 位数字(年份上限 275760),
|
||||||
* parseDate('2026') → 2026 年 1 月
|
* 于是敲 20260606 会被切成 年=202606 + 月=06,框里显示 202606/06(zh-CN 用 / 分隔),
|
||||||
* parseDate('202666') → 2026 年 6 月 ← 用户要打到这个长度才「看到月份」
|
* 而 el.value 是**空**=非法 ⇒ 筛选条件静默丢掉。2026-10-09 用户报的就是这条。
|
||||||
* parseDate('20260606') → 2030 年 12 月 ← 静默错解,且不报错
|
* 原生分段状态读不到、光标也设不了(setSelectionRange 对 date 类输入直接抛),
|
||||||
* 年份得吃满 4 位才被认,之后月份位还要再吃 2 位 ⇒ 正是「年份很长才跳下一位」。
|
* 所以「修原生」这条路走不通,只能换掉控件。
|
||||||
*
|
*
|
||||||
* 这里做两件事:
|
* 于是这里做三件事:
|
||||||
* parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、
|
* parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、2026 等,
|
||||||
* 2026 等写法;解析不出来就是 null,绝不猜。
|
* 解析不出来就是 null,绝不猜(交给 flatpickr 的 parseDate 钩子)。
|
||||||
* attach 按键引导:年份满 4 位自动补分隔符并把光标推到月份段,
|
* attach 输入引导:年满 4 位自动补分隔符并把光标推到月份段,其余段满 2 位同理。
|
||||||
* 月/日/时/分满 2 位同理,让手输 behave 像原生分段。
|
* attachAll **统一入口**:一次覆盖 type=datetime-local 与 type=date 两种输入。
|
||||||
|
* 2026-10-09 的事故根因就是「只挂了 datetime-local」,后来加的 type=date
|
||||||
|
* 筛选框没人管 —— 以后加日期字段一律走 attachAll,别在页面里散着手写选择器。
|
||||||
*
|
*
|
||||||
* 产出格式恒为 'Y-m-dTH:i'(即 2026-06-06T09:30),与服务端
|
* 产出格式:datetime 恒为 'Y-m-dTH:i'(2026-06-06T09:30),只日期恒为 'Y-m-d'(2026-06-06),
|
||||||
* handlers_edit.go / handlers_topics.go 里的 "2006-01-02T15:04" 同一形状。
|
* 分别与服务端 handlers_edit.go 的 "2006-01-02T15:04" 和筛选区的 "2006-01-02" 同一形状。
|
||||||
*/
|
*/
|
||||||
(function (global) {
|
(function (global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var DATE_FMT = 'Y-m-dTH:i';
|
var DATE_FMT = 'Y-m-dTH:i';
|
||||||
|
var DAY_FMT = 'Y-m-d';
|
||||||
|
|
||||||
|
/* 两种版式。段长/分隔符/输出格式只差这几个数,算法完全共用。 */
|
||||||
|
var SPEC = {
|
||||||
|
dt: { LEN: [4, 2, 2, 2, 2], SEP: ['-', '-', 'T', ':', ''], fmt: DATE_FMT },
|
||||||
|
date: { LEN: [4, 2, 2], SEP: ['-', '-', ''], fmt: DAY_FMT }
|
||||||
|
};
|
||||||
|
|
||||||
|
function specFor(opts) { return (opts && opts.dateOnly) ? SPEC.date : SPEC.dt; }
|
||||||
|
|
||||||
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||||||
|
|
||||||
@@ -137,6 +149,7 @@
|
|||||||
* 严格分段格式:年 4 位、月/日/时/分各 2 位,一律补零,敲满一段立刻补分隔符并跳下一段。
|
* 严格分段格式:年 4 位、月/日/时/分各 2 位,一律补零,敲满一段立刻补分隔符并跳下一段。
|
||||||
* 于是「2026」+「06」+「06」+「09」+「30」会边敲边变成
|
* 于是「2026」+「06」+「06」+「09」+「30」会边敲边变成
|
||||||
* 2026-06-06T09:30,敲完 4 位年份光标就已经在月份位(这正是原版做不到的那件事)。
|
* 2026-06-06T09:30,敲完 4 位年份光标就已经在月份位(这正是原版做不到的那件事)。
|
||||||
|
* 只日期版少掉时分两段,输出 2026-06-06。
|
||||||
*
|
*
|
||||||
* 不做的事(有意为之):
|
* 不做的事(有意为之):
|
||||||
* - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。
|
* - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。
|
||||||
@@ -144,8 +157,6 @@
|
|||||||
* - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。
|
* - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。
|
||||||
* 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。
|
* 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。
|
||||||
*/
|
*/
|
||||||
var SEG_LEN = [4, 2, 2, 2, 2]; // 年月日时分
|
|
||||||
var SEP_AFTER = ['-', '-', 'T', ':', ''];
|
|
||||||
|
|
||||||
// 各段填满后的合法范围(只判能明确非法的:月 13、日 32、时 24、分 60)
|
// 各段填满后的合法范围(只判能明确非法的:月 13、日 32、时 24、分 60)
|
||||||
var SEG_FULL_MAX = [
|
var SEG_FULL_MAX = [
|
||||||
@@ -172,68 +183,91 @@
|
|||||||
return new Date(y, mo, 0).getDate();
|
return new Date(y, mo, 0).getDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 原始输入里,紧跟第 n 位数字之后的那个分隔符是什么(没有就返回空)。
|
||||||
|
* 用来在「段未填满」时把用户已经敲好的分隔符原样带回来,
|
||||||
|
* 避免把 '2026-6' 硬补零成 '2026-06'(用户 1 月就该敲 01,但
|
||||||
|
* 6 月可以只敲 6 —— 这两种都得能往下走)。 */
|
||||||
|
function sepAfterDigits(raw, n) {
|
||||||
|
var seen = 0;
|
||||||
|
for (var k = 0; k < raw.length; k++) {
|
||||||
|
var ch = raw.charAt(k);
|
||||||
|
if (ch >= '0' && ch <= '9') {
|
||||||
|
seen++;
|
||||||
|
if (seen === n) {
|
||||||
|
var nx = raw.charAt(k + 1);
|
||||||
|
if (nx && (nx === '-' || nx === 'T' || nx === ':')) return nx;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按位切分并排出标准版式。返回 null 表示「用户敲的这段已越界,原样保留」。
|
||||||
|
* 年份固定 4 位;月/日(时/分)固定 2 位 —— 不猜、不前滚、不自动补零。
|
||||||
|
*
|
||||||
|
* 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时
|
||||||
|
* 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020),
|
||||||
|
* 否则位次会整体错位一格——这是实测踩出来的。
|
||||||
|
*
|
||||||
|
* 导出成纯函数(layoutTo)是为了能在 node 里直接跑断言,不用起浏览器。 */
|
||||||
|
function layoutTo(spec, raw) {
|
||||||
|
var chars = String(raw).split('');
|
||||||
|
var digits = '';
|
||||||
|
for (var k = 0; k < chars.length; k++) {
|
||||||
|
var ch = chars[k];
|
||||||
|
if (ch >= '0' && ch <= '9') digits += ch;
|
||||||
|
}
|
||||||
|
if (!digits) return { value: '', caret: 0 };
|
||||||
|
if (digits.length > spec.LEN.reduce(function (a, b) { return a + b; }, 0)) return null;
|
||||||
|
var out = '', pos = 0, year = 0, mon = 0;
|
||||||
|
for (var i = 0; i < spec.LEN.length; i++) {
|
||||||
|
var want = spec.LEN[i];
|
||||||
|
var have = digits.slice(pos, pos + want);
|
||||||
|
var full = have.length === want;
|
||||||
|
var check = full ? SEG_FULL_MAX[i] : SEG_PREFIX_MAX[i];
|
||||||
|
if (check && check(have, year, mon)) return null;
|
||||||
|
// 本段还没填满 → 保持原样让用户继续敲。
|
||||||
|
// 若原始输入里这一段后面已经带了分隔符(用户手敲 '2026-6'),
|
||||||
|
// 就把它带回来,光标停在分隔符之后,而不是硬补零。
|
||||||
|
if (!full) {
|
||||||
|
var tailSep = sepAfterDigits(raw, pos + have.length);
|
||||||
|
var val = out + have + tailSep;
|
||||||
|
return { value: val, caret: val.length };
|
||||||
|
}
|
||||||
|
// 填满 → 结算本段
|
||||||
|
if (i === 0) year = +have;
|
||||||
|
if (i === 1) mon = +have;
|
||||||
|
out += have;
|
||||||
|
pos += want;
|
||||||
|
var sep = spec.SEP[i] || '';
|
||||||
|
// 数字用完了:本段后面还有段要填就立刻补分隔符、把光标推到下一段起点。
|
||||||
|
// 这一步就是「敲完 4 位年份自动跳到月份位」的实现。
|
||||||
|
if (pos >= digits.length) {
|
||||||
|
if (!sep) return { value: out, caret: out.length };
|
||||||
|
return { value: out + sep, caret: out.length + sep.length };
|
||||||
|
}
|
||||||
|
out += sep;
|
||||||
|
}
|
||||||
|
return { value: out, caret: out.length };
|
||||||
|
}
|
||||||
|
|
||||||
function attach(el, opts) {
|
function attach(el, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
if (el._rondAttached) return;
|
||||||
|
el._rondAttached = true;
|
||||||
|
var spec = specFor(opts);
|
||||||
|
|
||||||
/* 按位切分并排出标准版式。返回 null 表示「用户敲的这段已越界,原样保留」。
|
/* 先把控件换成 text。引导过程会往框里写 '2026-06-' 这种**半成品**,
|
||||||
* 年份固定 4 位;月/日/时/分固定 2 位 —— 不猜、不前滚、不自动补零。
|
而 type=date / datetime-local 对非法值一律置空(值当场消失),
|
||||||
*
|
只有 text 我们才拿得住。提交格式不变(date → Y-m-d、datetime → Y-m-dTH:i)。 */
|
||||||
* 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时
|
if (el.type !== 'text') {
|
||||||
* 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020),
|
el.type = 'text';
|
||||||
* 否则位次会整体错位一格——这是实测踩出来的。 */
|
el.setAttribute('inputmode', 'numeric'); // 手机上直接给数字键盘
|
||||||
function layout(raw) {
|
el.setAttribute('autocomplete', 'off');
|
||||||
// 只保留数字。必须跳过分隔符:用户敲到 '2026-0' 时数字是 20260 而不是 2020,
|
if (!el.placeholder) el.placeholder = opts.dateOnly ? '2026-06-06' : '2026-06-06T09:30';
|
||||||
// 否则位次整体错位一格 —— 这是实测踩出来的。
|
|
||||||
var chars = String(raw).split('');
|
|
||||||
var digits = '';
|
|
||||||
for (var k = 0; k < chars.length; k++) {
|
|
||||||
var ch = chars[k];
|
|
||||||
if (ch >= '0' && ch <= '9') digits += ch;
|
|
||||||
}
|
|
||||||
if (!digits) return { value: '', caret: 0 };
|
|
||||||
if (digits.length > 14) return null;
|
|
||||||
var out = '', pos = 0, year = 0, mon = 0;
|
|
||||||
for (var i = 0; i < SEG_LEN.length; i++) {
|
|
||||||
var want = SEG_LEN[i];
|
|
||||||
var have = digits.slice(pos, pos + want);
|
|
||||||
var full = have.length === want;
|
|
||||||
var check = full ? SEG_FULL_MAX[i] : SEG_PREFIX_MAX[i];
|
|
||||||
if (check && check(have, year, mon)) return null;
|
|
||||||
// 本段还没填满 → 保持原样让用户继续敲。
|
|
||||||
// 若原始输入里这一段后面已经带了分隔符(用户手敲 '2026-6'),
|
|
||||||
// 就把它带回来,光标停在分隔符之后,而不是硬补零。
|
|
||||||
if (!full) {
|
|
||||||
var tailSep = sepAfterDigits(raw, pos + have.length);
|
|
||||||
var val = out + have + tailSep;
|
|
||||||
return { value: val, caret: val.length };
|
|
||||||
}
|
|
||||||
// 填满 → 结算本段
|
|
||||||
if (i === 0) year = +have;
|
|
||||||
if (i === 1) mon = +have;
|
|
||||||
out += have;
|
|
||||||
pos += want;
|
|
||||||
var sep = SEP_AFTER[i] || '';
|
|
||||||
// 数字用完了:本段后面还有段要填就立刻补分隔符、把光标推到下一段起点。
|
|
||||||
// 这一步就是「敲完 4 位年份自动跳到月份位」的实现。
|
|
||||||
if (pos >= digits.length) {
|
|
||||||
if (!sep) return { value: out, caret: out.length };
|
|
||||||
return { value: out + sep, caret: out.length + sep.length };
|
|
||||||
}
|
|
||||||
out += sep;
|
|
||||||
}
|
|
||||||
return { value: out, caret: out.length };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。
|
|
||||||
*
|
|
||||||
* 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate,
|
|
||||||
* 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走,
|
|
||||||
* 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。
|
|
||||||
* 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。
|
|
||||||
*
|
|
||||||
* 交接判据是「值里除我们自己补的以外还有没有别的分隔符」:
|
|
||||||
* 一旦用户亲手敲了 / 或 :,立刻彻底放手(否则会出现 '2026-/6/6' 这种
|
|
||||||
* 我们补的 '-' 和用户的 '/' 混排的乱串),改由 fromSeparated 解他的写法。 */
|
|
||||||
/* 分隔符是否全是标准套(- T :)。'2026-6-6' 算(横杠是我们和用户通用的),
|
/* 分隔符是否全是标准套(- T :)。'2026-6-6' 算(横杠是我们和用户通用的),
|
||||||
* '2026/6/6' 不算(外来写法,交给 fromSeparated 原样解)。 */
|
* '2026/6/6' 不算(外来写法,交给 fromSeparated 原样解)。 */
|
||||||
function isStandardSep(v) {
|
function isStandardSep(v) {
|
||||||
@@ -246,6 +280,12 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。
|
||||||
|
*
|
||||||
|
* 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate,
|
||||||
|
* 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走,
|
||||||
|
* 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。
|
||||||
|
* 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。 */
|
||||||
function onInput() {
|
function onInput() {
|
||||||
var cur = el.value;
|
var cur = el.value;
|
||||||
if (!cur) { el._rondLaidOut = ''; return; }
|
if (!cur) { el._rondLaidOut = ''; return; }
|
||||||
@@ -256,7 +296,7 @@
|
|||||||
段还没填满就原样留着(他还在敲)。 */
|
段还没填满就原样留着(他还在敲)。 */
|
||||||
if (isStandardSep(cur)) { applyLayout(cur); return; }
|
if (isStandardSep(cur)) { applyLayout(cur); return; }
|
||||||
/* 外来写法(/ . 空格)。只做一件最小修复:把「紧跟在分隔符后面、
|
/* 外来写法(/ . 空格)。只做一件最小修复:把「紧跟在分隔符后面、
|
||||||
与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-/',
|
与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-',
|
||||||
双分隔符会让 fromSeparated 判非法。除此之外一个字符都不动,
|
双分隔符会让 fromSeparated 判非法。除此之外一个字符都不动,
|
||||||
保持用户原样。 */
|
保持用户原样。 */
|
||||||
var fixed = cur.replace(/([-T:])([/.])/g, '$2').replace(/([-T:])\1/g, '$1');
|
var fixed = cur.replace(/([-T:])([/.])/g, '$2').replace(/([-T:])\1/g, '$1');
|
||||||
@@ -267,29 +307,8 @@
|
|||||||
el._rondLaidOut = '';
|
el._rondLaidOut = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 原始输入里,紧跟第 n 位数字之后的那个分隔符是什么(没有就返回空)。
|
|
||||||
* 用来在「段未填满」时把用户已经敲好的分隔符原样带回来,
|
|
||||||
* 避免把 '2026-6' 硬补零成 '2026-06'(用户 1 月就该敲 01,但
|
|
||||||
* 6 月可以只敲 6 —— 这两种都得能往下走)。 */
|
|
||||||
function sepAfterDigits(raw, n) {
|
|
||||||
var seen = 0;
|
|
||||||
for (var k = 0; k < raw.length; k++) {
|
|
||||||
var ch = raw.charAt(k);
|
|
||||||
if (ch >= '0' && ch <= '9') {
|
|
||||||
seen++;
|
|
||||||
if (seen === n) {
|
|
||||||
// 找到第 n 位数字,看它后面紧跟的是不是分隔符
|
|
||||||
var nx = raw.charAt(k + 1);
|
|
||||||
if (nx && (nx === '-' || nx === 'T' || nx === ':')) return nx;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLayout(cur) {
|
function applyLayout(cur) {
|
||||||
var r = layout(cur);
|
var r = layoutTo(spec, cur);
|
||||||
// 越界(月份敲成 66、日敲成 45):原样保留用户输入,交给解析器报错。
|
// 越界(月份敲成 66、日敲成 45):原样保留用户输入,交给解析器报错。
|
||||||
// 这里必须 return 且**不动 el.value**——el.value 一旦被我们改写却没同步
|
// 这里必须 return 且**不动 el.value**——el.value 一旦被我们改写却没同步
|
||||||
// _rondLaidOut,下一轮就会拿旧串去排,越排越乱。
|
// _rondLaidOut,下一轮就会拿旧串去排,越排越乱。
|
||||||
@@ -317,9 +336,9 @@
|
|||||||
|
|
||||||
el.addEventListener('input', onInput);
|
el.addEventListener('input', onInput);
|
||||||
|
|
||||||
/* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01T00:00。
|
/* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01(只日期)
|
||||||
* 不这么做的话用户只填了年份就点走,flatpickr 拿到 '2026' 会按原版规则
|
* 或 2026-01-01T00:00(带时间)。不这么做的话用户只填了年份就点走,
|
||||||
* 补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */
|
* flatpickr 拿到 '2026' 会按原版规则补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */
|
||||||
function onBlur() {
|
function onBlur() {
|
||||||
var cur = el.value.trim();
|
var cur = el.value.trim();
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
@@ -330,18 +349,23 @@
|
|||||||
el.addEventListener('blur', onBlur);
|
el.addEventListener('blur', onBlur);
|
||||||
|
|
||||||
function formatOut(d) {
|
function formatOut(d) {
|
||||||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
var day = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||||
'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
if (opts.dateOnly) return day;
|
||||||
|
return day + 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||||
}
|
}
|
||||||
el._rondFormatOut = formatOut;
|
el._rondFormatOut = formatOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(selector, opts) {
|
/* 中文文案只在这里设一次。以前是各页的 whenReady 里自己调 flatpickr.localize(),
|
||||||
if (!global.flatpickr) return;
|
公共页(筛选区)原先根本不加载 flatpickr,等它也开始加载之后就漏了 ——
|
||||||
var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector;
|
日历是英文的(Sun/Mon/January…),跟站点的中文界面明显割裂。 */
|
||||||
Array.prototype.forEach.call(els, function (el) {
|
var l10nDone = false;
|
||||||
attachPicker(el, opts);
|
function localizeOnce() {
|
||||||
});
|
if (l10nDone || !global.flatpickr) return;
|
||||||
|
l10nDone = true;
|
||||||
|
if (global.flatpickr.l10ns && global.flatpickr.l10ns.zh) {
|
||||||
|
global.flatpickr.localize(global.flatpickr.l10ns.zh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 三处页面(编辑页 / 照片页 / 专题页)共用的挂载入口。
|
/* 三处页面(编辑页 / 照片页 / 专题页)共用的挂载入口。
|
||||||
@@ -350,27 +374,57 @@
|
|||||||
attach 让手输 behave 像原生分段(敲满 4 位年份就跳到月份位)
|
attach 让手输 behave 像原生分段(敲满 4 位年份就跳到月份位)
|
||||||
提交格式仍是 'Y-m-dTH:i',服务端那条 "2006-01-02T15:04" 解析不受影响。 */
|
提交格式仍是 'Y-m-dTH:i',服务端那条 "2006-01-02T15:04" 解析不受影响。 */
|
||||||
function attachPicker(el, opts) {
|
function attachPicker(el, opts) {
|
||||||
if (!global.flatpickr || el._rondPickerReady) return null;
|
opts = opts || {};
|
||||||
el._rondPickerReady = true;
|
var dateOnly = !!opts.dateOnly;
|
||||||
var inst = global.flatpickr(el, {
|
if (!global.flatpickr) { attach(el, opts); return null; }
|
||||||
enableTime: true,
|
localizeOnce();
|
||||||
time_24hr: true,
|
if (!el._rondPickerReady) {
|
||||||
allowInput: true,
|
el._rondPickerReady = true;
|
||||||
// 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度
|
global.flatpickr(el, {
|
||||||
minuteIncrement: 5,
|
enableTime: !dateOnly,
|
||||||
dateFormat: DATE_FMT,
|
time_24hr: true,
|
||||||
parseDate: parseDateStrict
|
allowInput: true,
|
||||||
});
|
// 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度
|
||||||
|
minuteIncrement: 5,
|
||||||
|
dateFormat: dateOnly ? DAY_FMT : DATE_FMT,
|
||||||
|
parseDate: parseDateStrict
|
||||||
|
});
|
||||||
|
}
|
||||||
attach(el, opts);
|
attach(el, opts);
|
||||||
return inst;
|
return el._rondPicker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 统一入口 ----------
|
||||||
|
* 覆盖两种日期输入:带时间的走 flatpickr + 引导,只日期的走(有 flatpickr 就一并给日历,
|
||||||
|
* 没有就只挂引导——公共页不加载 flatpickr,见 layout.html 的说明)。
|
||||||
|
* ⚠️ 以后再加日期字段一律走这里,别在页面里手写 querySelector('input[type=...]'):
|
||||||
|
* 2026-10-09 的事故就是只挂了 datetime-local、新加的 type=date 没人管。 */
|
||||||
|
function attachAll(root, opts) {
|
||||||
|
root = root || document;
|
||||||
|
var els = root.querySelectorAll('input[type=datetime-local], input[type=date]');
|
||||||
|
Array.prototype.forEach.call(els, function (el) {
|
||||||
|
var o = { dateOnly: el.type === 'date' };
|
||||||
|
if (global.flatpickr) attachPicker(el, o);
|
||||||
|
else attach(el, o);
|
||||||
|
});
|
||||||
|
return els.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(selector, opts) {
|
||||||
|
var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector;
|
||||||
|
Array.prototype.forEach.call(els, function (el) { attachPicker(el, opts); });
|
||||||
}
|
}
|
||||||
|
|
||||||
global.rondDatetime = {
|
global.rondDatetime = {
|
||||||
DATE_FMT: DATE_FMT,
|
DATE_FMT: DATE_FMT,
|
||||||
|
DAY_FMT: DAY_FMT,
|
||||||
|
SPEC: SPEC,
|
||||||
|
layoutTo: layoutTo,
|
||||||
parseDate: parseDateStrict,
|
parseDate: parseDateStrict,
|
||||||
attach: attach,
|
attach: attach,
|
||||||
attachPicker: attachPicker,
|
attachPicker: attachPicker,
|
||||||
|
attachAll: attachAll,
|
||||||
init: init,
|
init: init,
|
||||||
pad2: pad2
|
pad2: pad2
|
||||||
};
|
};
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -80,6 +80,35 @@ console.log('\n=== round-trip:解析结果必须能原样喂回 formatDate ===
|
|||||||
eq(bad, [], 'round-trip 一致');
|
eq(bad, [], 'round-trip 一致');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
console.log('\n=== 手输引导 layoutTo:逐键模拟(这才是这次 bug 的现场) ===');
|
||||||
|
(function () {
|
||||||
|
const D = api.SPEC.date, T = api.SPEC.dt;
|
||||||
|
const lay = (spec, raw) => { const r = api.layoutTo(spec, raw); return r ? r.value : null; };
|
||||||
|
// 逐键模拟:每敲一位,就把「当前框里的值 + 新键」再喂回去 —— 与浏览器里 input 事件一致
|
||||||
|
const type = (spec, keys) => {
|
||||||
|
let v = '';
|
||||||
|
for (const k of keys) { const n = lay(spec, v + k); v = (n === null) ? (v + k) : n; }
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ⚠️ 回归:2026-10-09 用户报的就是这个 —— 原生的 type=date 里敲 20260606 变成了 202606/06
|
||||||
|
eq(type(D, '20260606'), '2026-06-06', '只日期模式逐键敲 20260606 → 2026-06-06');
|
||||||
|
eq(lay(D, '20260606'), '2026-06-06', '只日期模式一次性粘 8 位 → 2026-06-06(没有时间尾巴)');
|
||||||
|
eq(type(D, '2026'), '2026-', '只日期:敲完年份就补横杠、光标进月份段');
|
||||||
|
eq(type(D, '202606'), '2026-06-', '只日期:敲完月份就补横杠、光标进日段');
|
||||||
|
eq(type(D, '20260606'), '2026-06-06', '只日期:敲完日段不再补尾巴');
|
||||||
|
|
||||||
|
// 重构后带时间那条必须一字不变(原来 34 条断言的语义)
|
||||||
|
eq(type(T, '202606060930'), '2026-06-06T09:30', '带时间模式逐键敲 12 位 → 2026-06-06T09:30');
|
||||||
|
eq(lay(T, '20260606'), '2026-06-06T', '带时间模式一次性粘 8 位 → 后面还留个 T 等时分');
|
||||||
|
|
||||||
|
// 越界一律「不接管」,原样留给解析器报错
|
||||||
|
eq(lay(D, '20261306'), null, '只日期:月 13 → 不接管');
|
||||||
|
eq(lay(D, '20260632'), null, '只日期:日 32 → 不接管');
|
||||||
|
eq(lay(D, '20260230'), null, '只日期:2026-02-30 不存在 → 不接管');
|
||||||
|
eq(lay(D, '202606061'), null, '只日期:9 位没有第 4 段可放 → 不接管');
|
||||||
|
})();
|
||||||
|
|
||||||
console.log('\n=== 汇总 ===');
|
console.log('\n=== 汇总 ===');
|
||||||
console.log(' pass=' + pass + ' fail=' + fail);
|
console.log(' pass=' + pass + ' fail=' + fail);
|
||||||
process.exit(fail === 0 ? 0 : 1);
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* 迷雾裁剪路径:把若干「容器像素圆」拼成一段 clip-path: path() 数据。
|
||||||
|
*
|
||||||
|
* 为什么自己拼 path():clip-path: circle() 只表达得了一个圆,而专题的显示区是一组圆。
|
||||||
|
* 每个圆一段子路径、圆弧一律 sweep=0(**同绕向**)—— nonzero 填充规则下多段自然取并集,
|
||||||
|
* 重叠处不会互相挖洞;如果两段绕向相反,重叠区会被抵消成一个洞。
|
||||||
|
* 用两段半圆弧拼整圆,而不是往 DOM 里插 <circle>,是为了整串塞进一个 path(),
|
||||||
|
* 不用额外维护 SVG 节点与它的坐标系。
|
||||||
|
*
|
||||||
|
* 单独一个文件(而不是塞进 rond-map.js)是为了能直接跑 node 单测:
|
||||||
|
* rond-map.js 是个大 IIFE,函数取不出来。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// circles: [{x, y, r}],单位都是**地图容器像素**(与 clip-path 的坐标系一致)。
|
||||||
|
// 返回可直接赋给 style.clipPath 的字符串;没有圆时返回空串(调用方据此不改样式)。
|
||||||
|
function pathFor(circles) {
|
||||||
|
if (!circles || !circles.length) return '';
|
||||||
|
var d = '';
|
||||||
|
for (var i = 0; i < circles.length; i++) {
|
||||||
|
var c = circles[i];
|
||||||
|
var x = c.x, y = c.y;
|
||||||
|
// 半径下限 1px:缩到极小时 r=0 会让路径退化(浏览器可能整段忽略)
|
||||||
|
var r = Math.max(1, c.r);
|
||||||
|
var rr = r.toFixed(1), w = (2 * r).toFixed(1);
|
||||||
|
d += 'M' + (x - r).toFixed(1) + ' ' + y.toFixed(1) +
|
||||||
|
'a' + rr + ' ' + rr + ' 0 1 0 ' + w + ' 0' +
|
||||||
|
'a' + rr + ' ' + rr + ' 0 1 0 ' + (-2 * r).toFixed(1) + ' 0Z';
|
||||||
|
}
|
||||||
|
return 'path("' + d + '")';
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = { pathFor: pathFor };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
global.rondFogClip = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/* fog-clip.js 的单元测试(node 直接跑,无依赖)。
|
||||||
|
* 用法:node internal/web/static/js/fog-clip.test.js
|
||||||
|
*
|
||||||
|
* 这里不比对字符串,而是把生成的 path 数据**解析回圆弧**再验几何:
|
||||||
|
* 拼错一个数字(半径、圆心、绕向)都能被抓住,而「看起来差不多」的字符串比对做不到。
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, 'fog-clip.js'), 'utf8');
|
||||||
|
const mod = {};
|
||||||
|
const sandbox = { window: {}, module: mod };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(src, sandbox);
|
||||||
|
const api = sandbox.window.rondFogClip;
|
||||||
|
|
||||||
|
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 : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把 path("M…a…a…Z M…a…a…Z") 解析成 [{cx, cy, r, sweep}]
|
||||||
|
// 只认本模块生成的那种形式:M x y 后跟两段 `a r r 0 1 s dx dy`
|
||||||
|
function parsePath(v) {
|
||||||
|
const m = /^path\("(.*)"\)$/.exec(v);
|
||||||
|
if (!m) return null;
|
||||||
|
const out = [];
|
||||||
|
const subs = m[1].split('M').slice(1);
|
||||||
|
for (const s of subs) {
|
||||||
|
const t = s.match(/^([-\d.]+) ([-\d.]+)a([-\d.]+) ([-\d.]+) 0 1 ([01]) ([-\d.]+) 0a([-\d.]+) ([-\d.]+) 0 1 ([01]) ([-\d.]+) 0Z$/);
|
||||||
|
if (!t) return null;
|
||||||
|
const x0 = +t[1], y = +t[2], r1 = +t[3], r2 = +t[4], sw1 = +t[5], dx1 = +t[6];
|
||||||
|
const r3 = +t[7], r4 = +t[8], sw2 = +t[9], dx2 = +t[10];
|
||||||
|
if (r1 !== r2 || r3 !== r4 || r1 !== r3) return null; // 两个半径必须一致
|
||||||
|
if (Math.abs(dx1 - 2 * r1) > 0.05 || Math.abs(dx2 + 2 * r1) > 0.05) return null;
|
||||||
|
out.push({ cx: x0 + r1, cy: y, r: r1, sweep: [sw1, sw2] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function near(a, b) { return Math.abs(a - b) < 0.15; }
|
||||||
|
|
||||||
|
console.log('=== 一个圆:几何反解必须与输入一致 ===');
|
||||||
|
(function () {
|
||||||
|
const v = api.pathFor([{ x: 100, y: 200, r: 50 }]);
|
||||||
|
const cs = parsePath(v);
|
||||||
|
ok(cs && cs.length === 1, '应解析出 1 个子路径', v);
|
||||||
|
if (!cs || !cs.length) return;
|
||||||
|
ok(near(cs[0].cx, 100) && near(cs[0].cy, 200) && near(cs[0].r, 50),
|
||||||
|
'圆心半径应为 (100,200) r=50', JSON.stringify(cs[0]));
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log('=== 多个圆:每个圆一段子路径,且同绕向(并集的前提)===');
|
||||||
|
(function () {
|
||||||
|
const input = [{ x: 100, y: 200, r: 50 }, { x: 300, y: 400, r: 25 }, { x: 140, y: 210, r: 50 }];
|
||||||
|
const v = api.pathFor(input);
|
||||||
|
const cs = parsePath(v);
|
||||||
|
ok(cs && cs.length === 3, '应解析出 3 个子路径', v);
|
||||||
|
if (!cs || cs.length !== 3) return;
|
||||||
|
input.forEach((c, i) => {
|
||||||
|
ok(near(cs[i].cx, c.x) && near(cs[i].cy, c.y) && near(cs[i].r, c.r),
|
||||||
|
'第 ' + (i + 1) + ' 个圆与原输入一致', JSON.stringify(cs[i]) + ' vs ' + JSON.stringify(c));
|
||||||
|
});
|
||||||
|
// 绕向必须全都一样:两段圆弧 sweep 相同。若某个圆反向,重叠区会被 nonzero 规则抵消成洞。
|
||||||
|
const allSame = cs.every(c => c.sweep[0] === cs[0].sweep[0] && c.sweep[1] === cs[0].sweep[1]);
|
||||||
|
ok(allSame, '所有子路径绕向一致(否则重叠处会被挖成洞)', JSON.stringify(cs.map(c => c.sweep)));
|
||||||
|
// 第 1、3 个圆是重叠的,仍应各是一段完整子路径(不合并、不挖洞)
|
||||||
|
ok(cs.length === 3, '重叠的两个圆不会被合并或抵消');
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log('=== 退化输入 ===');
|
||||||
|
(function () {
|
||||||
|
ok(api.pathFor([]) === '', '空数组 → 空串(调用方据此不改样式)');
|
||||||
|
ok(api.pathFor(null) === '', 'null → 空串');
|
||||||
|
ok(api.pathFor(undefined) === '', 'undefined → 空串');
|
||||||
|
// 半径 0 会让路径退化,必须夹到 >= 1px
|
||||||
|
const v = api.pathFor([{ x: 10, y: 10, r: 0 }]);
|
||||||
|
const cs = parsePath(v);
|
||||||
|
ok(cs && cs.length === 1 && cs[0].r >= 1, '半径 0 被夹到 >= 1px', v);
|
||||||
|
// 负半径同理(拖拽时可能算出负数)
|
||||||
|
const v2 = api.pathFor([{ x: 10, y: 10, r: -5 }]);
|
||||||
|
const cs2 = parsePath(v2);
|
||||||
|
ok(cs2 && cs2.length === 1 && cs2[0].r >= 1, '负半径被夹到 >= 1px', v2);
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log('=== 大量圆:个数与顺序不能错位 ===');
|
||||||
|
(function () {
|
||||||
|
const input = [];
|
||||||
|
for (let i = 0; i < 20; i++) input.push({ x: i * 10, y: 500 - i * 3, r: 4 + i });
|
||||||
|
const cs = parsePath(api.pathFor(input));
|
||||||
|
ok(cs && cs.length === 20, '20 个圆 → 20 个子路径');
|
||||||
|
if (!cs || cs.length !== 20) return;
|
||||||
|
let bad = -1;
|
||||||
|
input.forEach((c, i) => {
|
||||||
|
if (bad < 0 && !(near(cs[i].cx, c.x) && near(cs[i].cy, c.y) && near(cs[i].r, c.r))) bad = i;
|
||||||
|
});
|
||||||
|
ok(bad < 0, '20 个圆的顺序与数值都对应上' + (bad >= 0 ? '(第 ' + bad + ' 个错位)' : ''));
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log('\n=== 汇总 ===');
|
||||||
|
console.log(' pass=' + pass + ' fail=' + fail);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -704,9 +704,49 @@
|
|||||||
// 专题可以在管理页关掉迷雾(opts.showFog=false),此时图层、浓度滑杆、图层开关都不出现。
|
// 专题可以在管理页关掉迷雾(opts.showFog=false),此时图层、浓度滑杆、图层开关都不出现。
|
||||||
var wantFog = opts.showFog !== false;
|
var wantFog = opts.showFog !== false;
|
||||||
var fogLayer = null;
|
var fogLayer = null;
|
||||||
|
/* 专题可以圈一组区域(opts.fogAreas = [{lat, lon, radiusKm}, ...]):此时迷雾只在
|
||||||
|
这些圆的**并集**内显示。
|
||||||
|
做法是给迷雾**单独一个 pane** 再对这个 pane 做 clip-path —— 不能直接裁默认的
|
||||||
|
tilePane,底图也在那里面,会把地图一起裁掉。
|
||||||
|
多圆用 path() 而不是 circle()(后者只表达得了一个圆);拼路径的活交给 fog-clip.js
|
||||||
|
(那样能跑 node 单测),这里只负责把经纬度换算成容器像素。
|
||||||
|
圆心必须像 fitBBox 那样先 project() 到底图坐标系,否则会整体偏出几百米。
|
||||||
|
clip-path 不认识的老浏览器只是「不裁剪」(退回全局迷雾),不会出错。 */
|
||||||
|
var fogPane = null, applyFogClip = null, fogCircles = [];
|
||||||
|
var fogAreaOpts = opts.fogAreas || [];
|
||||||
|
for (var fa = 0; fa < fogAreaOpts.length; fa++) {
|
||||||
|
var area = fogAreaOpts[fa];
|
||||||
|
if (!area || !(area.radiusKm > 0)) continue;
|
||||||
|
var areaC = project(area.lat, area.lon, def.crs);
|
||||||
|
fogCircles.push({
|
||||||
|
center: L.latLng(areaC[0], areaC[1]),
|
||||||
|
// 正北方向同经度偏移 radiusKm,用来量出容器像素半径(随缩放自然变化)
|
||||||
|
north: L.latLng(areaC[0] + (area.radiusKm * 1000) / 111320, areaC[1])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wantFog && cfg.fog && cfg.fog.blocks > 0 && fogCircles.length) {
|
||||||
|
map.createPane('rondFog');
|
||||||
|
fogPane = map.getPane('rondFog');
|
||||||
|
fogPane.style.zIndex = 250; // 底图瓦片 200 之上、覆盖物 400 之下
|
||||||
|
fogPane.style.pointerEvents = 'none';
|
||||||
|
applyFogClip = function () {
|
||||||
|
// 脚本没加载上就退回「不裁剪」,别抛错把地图初始化打断
|
||||||
|
if (!window.rondFogClip) return;
|
||||||
|
var px = [];
|
||||||
|
for (var i = 0; i < fogCircles.length; i++) {
|
||||||
|
var p = map.latLngToContainerPoint(fogCircles[i].center);
|
||||||
|
px.push({ x: p.x, y: p.y,
|
||||||
|
r: Math.max(1, p.distanceTo(map.latLngToContainerPoint(fogCircles[i].north))) });
|
||||||
|
}
|
||||||
|
var v = window.rondFogClip.pathFor(px);
|
||||||
|
if (!v) return;
|
||||||
|
fogPane.style.clipPath = v;
|
||||||
|
fogPane.style.webkitClipPath = v;
|
||||||
|
};
|
||||||
|
}
|
||||||
if (wantFog && cfg.fog && cfg.fog.blocks > 0) {
|
if (wantFog && cfg.fog && cfg.fog.blocks > 0) {
|
||||||
fogLayer = L.tileLayer('/tiles/fog/{z}/{x}/{y}?crs=' + def.crs + '&v=' + cfg.fog.ver, {
|
fogLayer = L.tileLayer('/tiles/fog/{z}/{x}/{y}?crs=' + def.crs + '&v=' + cfg.fog.ver, {
|
||||||
minZoom: 3, maxZoom: 18, opacity: 0.9
|
minZoom: 3, maxZoom: 18, opacity: 0.9, pane: fogPane ? 'rondFog' : 'tilePane'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var overlayDefs = {};
|
var overlayDefs = {};
|
||||||
@@ -742,13 +782,8 @@
|
|||||||
|
|
||||||
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
|
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
|
||||||
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
|
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
|
||||||
// 传 fitCircle(中心 + 半径)或 fitBBox 就能覆盖默认的点集贴合。
|
// 传 fitBBox(专题显示区的并集外接矩形)就能覆盖默认的点集贴合。
|
||||||
var applyFit = function () {
|
var applyFit = function () {
|
||||||
if (opts.fitCircle && opts.fitCircle.radiusKm > 0) {
|
|
||||||
var c = project(opts.fitCircle.lat, opts.fitCircle.lon, def.crs);
|
|
||||||
map.fitBounds(L.latLng(c[0], c[1]).toBounds(opts.fitCircle.radiusKm * 2000));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (opts.fitBBox) {
|
if (opts.fitBBox) {
|
||||||
var b = opts.fitBBox;
|
var b = opts.fitBBox;
|
||||||
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
|
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
|
||||||
@@ -759,6 +794,14 @@
|
|||||||
};
|
};
|
||||||
applyFit();
|
applyFit();
|
||||||
|
|
||||||
|
// 圈了区域的专题:迷雾只在该圆内显示。必须**等视图定下来之后**再算裁剪圆 ——
|
||||||
|
// 视图没定时 latLngToContainerPoint() 会抛「Set map center and zoom first.」,
|
||||||
|
// 那行异常会把后面的图层添加一起打断(实测:pane 建好了、迷雾瓦片一张都没有)。
|
||||||
|
if (applyFogClip) {
|
||||||
|
map.on('move zoom viewreset resize', applyFogClip);
|
||||||
|
applyFogClip();
|
||||||
|
}
|
||||||
|
|
||||||
var st = state4(map, points, group, def.crs, true);
|
var st = state4(map, points, group, def.crs, true);
|
||||||
st.clustered = true;
|
st.clustered = true;
|
||||||
st.pad = 0.15;
|
st.pad = 0.15;
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
/* 专题「显示区」编辑器:在地图上圈一组圆(后台专题表单用)。
|
||||||
|
*
|
||||||
|
* 交互:
|
||||||
|
* · 地图上点一下 = 以「新圆半径」加一个圆
|
||||||
|
* · 拖圆心移动、拖圆边上的把手改半径
|
||||||
|
* · 列表里可改半径、定位、删除;「清除全部」一次清空
|
||||||
|
* · 变化立刻写进隐藏字段 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 半径的「人话版」跨度:半径 20 公里 ⇒「约 40 公里宽」。
|
||||||
|
// 光写「半径 20 公里」很容易被读小 —— 直径才是眼睛看到的那个宽度。
|
||||||
|
function spanLabel(radiusKm) {
|
||||||
|
var d = num(radiusKm) * 2;
|
||||||
|
if (!isFinite(d) || d <= 0) return '';
|
||||||
|
if (d < 1) return '约 ' + Math.round(d * 1000) + ' 米宽';
|
||||||
|
if (d < 10) return '约 ' + (Math.round(d * 10) / 10) + ' 公里宽';
|
||||||
|
return '约 ' + Math.round(d) + ' 公里宽';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两点距离(公里),球面公式;拖把手时用它反推半径
|
||||||
|
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,
|
||||||
|
spanLabel: spanLabel
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 以下需要 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 newSpan = document.getElementById(opts.spanId || 'area-new-span');
|
||||||
|
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);
|
||||||
|
// 比例尺是必须的:没有它,圈圆时手上没有任何长度参照,只能靠地名猜,
|
||||||
|
// 「20 公里到底多大」就变成了拍脑袋(前台的地图一直有,这里以前漏了)。
|
||||||
|
L.control.scale({ imperial: false, position: 'bottomleft' }).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;
|
||||||
|
}
|
||||||
|
// 「新圆半径」旁边实时显示它会画多大:落点之前就能判断合不合适
|
||||||
|
function syncNewSpan() {
|
||||||
|
if (newSpan) newSpan.textContent = spanLabel(newRadius());
|
||||||
|
}
|
||||||
|
if (radiusIn) radiusIn.addEventListener('input', syncNewSpan);
|
||||||
|
|
||||||
|
// 整块重画:圆最多 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();
|
||||||
|
syncNewSpan();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 「半径 20 公里」旁边补一句直径 —— 眼睛量的是宽度,光看半径容易读小
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'muted area-span';
|
||||||
|
span.textContent = spanLabel(a.radiusKm);
|
||||||
|
row.appendChild(span);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/* 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('=== 半径的「人话版」跨度(直径)===');
|
||||||
|
(function () {
|
||||||
|
eq(api.spanLabel(20), '约 40 公里宽', '半径 20 → 约 40 公里宽');
|
||||||
|
eq(api.spanLabel(25), '约 50 公里宽', '半径 25 → 约 50 公里宽');
|
||||||
|
eq(api.spanLabel(3.5), '约 7 公里宽', '半径 3.5 → 约 7 公里宽');
|
||||||
|
eq(api.spanLabel(0.5), '约 1 公里宽', '半径 0.5 → 约 1 公里宽');
|
||||||
|
eq(api.spanLabel(2000), '约 4000 公里宽', '大半径不带小数');
|
||||||
|
// 小于 1 公里改用米,否则会写成「约 0.1 公里宽」这种别扭说法
|
||||||
|
eq(api.spanLabel(0.05), '约 100 米宽', '半径 0.05 → 约 100 米宽');
|
||||||
|
eq(api.spanLabel(0.3), '约 600 米宽', '半径 0.3 → 约 600 米宽');
|
||||||
|
ok(api.spanLabel(0) === '', '半径 0 → 空串(调用方直接不显示)');
|
||||||
|
ok(api.spanLabel(-1) === '', '负半径 → 空串');
|
||||||
|
ok(api.spanLabel(NaN) === '', 'NaN → 空串');
|
||||||
|
ok(api.spanLabel('abc') === '', '非数字 → 空串');
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log('\n=== 汇总 ===');
|
||||||
|
console.log(' pass=' + pass + ' fail=' + fail);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -801,13 +801,11 @@
|
|||||||
// 注意 l10n 对象叫 flatpickr.l10ns(复数);且必须 try 住——这里抛错会把
|
// 注意 l10n 对象叫 flatpickr.l10ns(复数);且必须 try 住——这里抛错会把
|
||||||
// 同一回调里后面的地图和地点搜索初始化一起炸掉。
|
// 同一回调里后面的地图和地点搜索初始化一起炸掉。
|
||||||
try {
|
try {
|
||||||
if (window.flatpickr) {
|
// 统一入口:一次覆盖 datetime-local 与 date(行程筛选的 mfrom / mto 就是 date);
|
||||||
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
|
// 中文文案在 attachPicker 里设,不用各页自己调 localize。
|
||||||
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
|
// 别再手写 querySelector('input[type=...]') —— 2026-10-09 的日期框 bug 就是这么漏掉 type=date 的。
|
||||||
window.rondDatetime.attachPicker(el);
|
window.rondDatetime.attachAll();
|
||||||
});
|
} catch (e) { console.error('日期控件初始化失败', e); }
|
||||||
}
|
|
||||||
} catch (e) { console.error('flatpickr 初始化失败', e); }
|
|
||||||
|
|
||||||
// 融合表单的模式切换。required 不能写在 name/lat 等字段上——切到
|
// 融合表单的模式切换。required 不能写在 name/lat 等字段上——切到
|
||||||
// 「补已有地点」时它们虽被隐藏,HTML5 仍会校验并拦住提交,所以校验交给后端。
|
// 「补已有地点」时它们虽被隐藏,HTML5 仍会校验并拦住提交,所以校验交给后端。
|
||||||
|
|||||||
@@ -505,12 +505,10 @@
|
|||||||
|
|
||||||
function initPickers() {
|
function initPickers() {
|
||||||
try {
|
try {
|
||||||
if (!window.flatpickr) return;
|
// 统一入口(datetime-local + date),中文文案在 attachPicker 里设。
|
||||||
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
|
// 限定在 rowsBox 里:页面别处的输入前面已经挂过了。
|
||||||
rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
|
window.rondDatetime.attachAll(rowsBox);
|
||||||
window.rondDatetime.attachPicker(el);
|
} catch (e) { console.error('日期控件初始化失败', e); }
|
||||||
});
|
|
||||||
} catch (e) { console.error('flatpickr 初始化失败', e); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
|
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}</h2>
|
<h2>{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}</h2>
|
||||||
<div class="card-sub">时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
|
<div class="card-sub">时间范围可精确到分钟;不圈显示区就是「时间范围内的地点画到哪算哪」。</div>
|
||||||
区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选。</div>
|
<form method="post" action="/admin/topics/save" id="topic-form">
|
||||||
<form method="post" action="/admin/topics/save">
|
|
||||||
{{if not .IsNew}}<input type="hidden" name="id" value="{{.Form.ID}}">{{end}}
|
{{if not .IsNew}}<input type="hidden" name="id" value="{{.Form.ID}}">{{end}}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="f" style="flex:2 1 240px"><span>标题<b class="req">*</b></span><input type="text" name="title" value="{{.Form.Title}}" placeholder="如:2026 大理之行"></label>
|
<label class="f" style="flex:2 1 240px"><span>标题<b class="req">*</b></span><input type="text" name="title" value="{{.Form.Title}}" placeholder="如:2026 大理之行"></label>
|
||||||
@@ -30,11 +29,25 @@
|
|||||||
<label class="f" style="flex:1 1 200px"><span>结束时间<b class="req">*</b></span><input type="datetime-local" name="end_at" value="{{.Form.EndDate}}"></label>
|
<label class="f" style="flex:1 1 200px"><span>结束时间<b class="req">*</b></span><input type="datetime-local" name="end_at" value="{{.Form.EndDate}}"></label>
|
||||||
<label class="f" style="flex:1 1 120px"><span>排序</span><input type="number" name="sort_order" value="{{.Form.SortOrder}}" placeholder="小的在前"></label>
|
<label class="f" style="flex:1 1 120px"><span>排序</span><input type="number" name="sort_order" value="{{.Form.SortOrder}}" placeholder="小的在前"></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-sub" style="margin:0 0 8px">区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域</div>
|
<div class="card-sub" style="margin:0 0 8px">显示区(可选):在图上圈一组圆,落在<strong>任一</strong>圆内的足迹才算这个专题的内容;
|
||||||
<div class="row">
|
不圈就是「时间范围内的地点画到哪算哪」。圈了之后,迷雾也只显示在这些圆的并集里。</div>
|
||||||
<label class="f" style="flex:1 1 150px"><span>中心纬度</span><input type="text" name="center_lat" value="{{if .Form.CenterLat}}{{printf "%.6f" .Form.CenterLat}}{{end}}" placeholder="25.606486"></label>
|
{{/* areas 的初值直接渲染在这里:脚本没加载上时提交的仍是原值,不会把已有显示区静默清空。
|
||||||
<label class="f" style="flex:1 1 150px"><span>中心经度</span><input type="text" name="center_lon" value="{{if .Form.CenterLon}}{{printf "%.6f" .Form.CenterLon}}{{end}}" placeholder="100.267638"></label>
|
crs 由编辑器按底图坐标系填(天地图那类是 WGS-84),后端再换算成 GCJ-02。 */}}
|
||||||
<label class="f" style="flex:1 1 120px"><span>半径(公里)</span><input type="text" name="radius_km" value="{{if .Form.RadiusKm}}{{printf "%.1f" .Form.RadiusKm}}{{end}}" placeholder="0 或留空=不限"></label>
|
<input type="hidden" name="areas" value="{{.Form.AreasJSON}}">
|
||||||
|
<input type="hidden" name="crs" value="">
|
||||||
|
<div class="area-tools">
|
||||||
|
<label class="f" style="flex:0 1 150px;margin:0"><span>新圆半径(公里)</span>
|
||||||
|
{{/* step 必须是 any:它是相对 min 递增的,min=0.05 + step=0.5 的合法值只有
|
||||||
|
0.05/0.55/1.05…,用户填 3 或 3.5 会被浏览器判非法并弹原生提示。 */}}
|
||||||
|
<input type="number" id="area-new-km" min="0.05" max="2000" step="any" value="3"></label>
|
||||||
|
<span class="muted area-span" id="area-new-span"></span>
|
||||||
|
<button class="btn" type="button" id="area-clear">清除全部</button>
|
||||||
|
<span class="muted" id="area-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div id="area-map"></div>
|
||||||
|
<div id="area-list"></div>
|
||||||
|
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:10px">
|
||||||
|
迷雾是一份累积快照、没有时间维度,所以只按上面的显示区收口,不按时间范围收口。
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="align-items:center;margin-bottom:10px">
|
<div class="row" style="align-items:center;margin-bottom:10px">
|
||||||
<label class="check" style="margin:0"><input type="checkbox" name="show_fog" {{if .Form.ShowFog}}checked{{end}}> 展示世界迷雾</label>
|
<label class="check" style="margin:0"><input type="checkbox" name="show_fog" {{if .Form.ShowFog}}checked{{end}}> 展示世界迷雾</label>
|
||||||
@@ -85,20 +98,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/topic-areas.js?v={{.AssetV}}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function whenReady(cb) {
|
function whenReady(cb) {
|
||||||
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
|
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
|
||||||
window.addEventListener('load', cb);
|
window.addEventListener('load', cb);
|
||||||
}
|
}
|
||||||
|
// 显示区编辑器:地图与 Leaflet 由 layout 在本块之后引入,所以 init 内部会等 load。
|
||||||
|
// home 是生效数据集的地点包围盒(没有数据时是 null,编辑器自带兜底视野)。
|
||||||
|
if (window.rondTopicAreas) {
|
||||||
|
window.rondTopicAreas.init({
|
||||||
|
mapId: 'area-map', formId: 'topic-form', listId: 'area-list',
|
||||||
|
hintId: 'area-hint', radiusId: 'area-new-km', clearId: 'area-clear',
|
||||||
|
spanId: 'area-new-span',
|
||||||
|
home: {{if .MapHomeJSON}}{{.MapHomeJSON}}{{else}}null{{end}}
|
||||||
|
});
|
||||||
|
}
|
||||||
whenReady(function () {
|
whenReady(function () {
|
||||||
try {
|
try {
|
||||||
if (!window.flatpickr) return;
|
// 统一入口:一次覆盖 datetime-local 与 date(中文文案在 attachPicker 里设)
|
||||||
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
|
window.rondDatetime.attachAll();
|
||||||
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
|
} catch (e) { console.error('日期控件初始化失败', e); }
|
||||||
window.rondDatetime.attachPicker(el);
|
|
||||||
});
|
|
||||||
} catch (e) { console.error('flatpickr 初始化失败', e); }
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -220,9 +220,9 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.Default.css?v={{.AssetV}}">
|
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.Default.css?v={{.AssetV}}">
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Flatpickr}}
|
{{/* 日期选择器的样式。**无条件加载**:筛选区(公共页)也有日期框,按页开关会被漏掉
|
||||||
|
(见下面 datetime-input 那段的长注释)。 */}}
|
||||||
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css?v={{.AssetV}}">
|
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css?v={{.AssetV}}">
|
||||||
{{end}}
|
|
||||||
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetV}}">
|
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetV}}">
|
||||||
{{/* accent-base 是站长选的主色原值:暗色模式会用 color-mix 把它提亮,否则深色底上的链接会看不清(原来的深绿在深底上只有 3.3:1)。 */}}
|
{{/* accent-base 是站长选的主色原值:暗色模式会用 color-mix 把它提亮,否则深色底上的链接会看不清(原来的深绿在深底上只有 3.3:1)。 */}}
|
||||||
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a;--accent-base:{{.Settings.AccentColor}}}</style>
|
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a;--accent-base:{{.Settings.AccentColor}}}</style>
|
||||||
@@ -241,15 +241,33 @@
|
|||||||
{{if .HasMap}}
|
{{if .HasMap}}
|
||||||
<script src="/static/vendor/leaflet/leaflet.js?v={{.AssetV}}"></script>
|
<script src="/static/vendor/leaflet/leaflet.js?v={{.AssetV}}"></script>
|
||||||
{{if or (eq .Nav "map") (eq .Nav "topics")}}<script src="/static/vendor/markercluster/leaflet.markercluster.js?v={{.AssetV}}"></script>{{end}}
|
{{if or (eq .Nav "map") (eq .Nav "topics")}}<script src="/static/vendor/markercluster/leaflet.markercluster.js?v={{.AssetV}}"></script>{{end}}
|
||||||
|
{{/* fog-clip.js 必须在 rond-map.js **之前**:专题的迷雾裁剪要拿它拼多圆的 clip-path: path() */}}
|
||||||
|
<script src="/static/js/fog-clip.js?v={{.AssetV}}"></script>
|
||||||
<script src="/static/js/rond-map.js?v={{.AssetV}}"></script>
|
<script src="/static/js/rond-map.js?v={{.AssetV}}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Flatpickr}}
|
{{/* 日期选择器 + 手输引导。**无条件加载**(js+css+l10n 约 18KB gzip),两个理由:
|
||||||
|
① 筛选区在公共页上也有日期框(/stats /places /timeline 的起止日期、/map 自己的),
|
||||||
|
「按页开开关」这种写法已经被证明会漏 —— 2026-10-09 的日期框 bug 就是只给
|
||||||
|
type=datetime-local 挂了控件,后来加的 type=date 没人管;
|
||||||
|
② 原生控件本身不可靠:Chrome 的年段能吞 6 位数字,敲 20260606 会变成 202606/06
|
||||||
|
且 el.value 为空(筛选条件静默丢掉)。
|
||||||
|
datetime-input.js 必须在 flatpickr 之后、页面内联脚本之前。 */}}
|
||||||
<script src="/static/vendor/flatpickr/flatpickr.min.js?v={{.AssetV}}"></script>
|
<script src="/static/vendor/flatpickr/flatpickr.min.js?v={{.AssetV}}"></script>
|
||||||
<script src="/static/vendor/flatpickr/l10n/zh.js?v={{.AssetV}}"></script>
|
<script src="/static/vendor/flatpickr/l10n/zh.js?v={{.AssetV}}"></script>
|
||||||
{{/* 时间框的解析器与输入引导。必须在 flatpickr 之后、页面内联脚本之前:
|
|
||||||
它不自己调 flatpickr,只提供 parseDate 与 attach,由各页 whenReady 调 attachPicker。 */}}
|
|
||||||
<script src="/static/js/datetime-input.js?v={{.AssetV}}"></script>
|
<script src="/static/js/datetime-input.js?v={{.AssetV}}"></script>
|
||||||
{{end}}
|
<script>
|
||||||
|
/* 每一页都把日期输入改造一次(见 datetime-input.js 顶部说明)。
|
||||||
|
页面自己的 whenReady 里还会再调一次 attachAll —— 那是为了覆盖**动态创建**的输入
|
||||||
|
(照片页是分批建行的);重复调用安全,元素上有 _rondAttached 守卫。 */
|
||||||
|
(function () {
|
||||||
|
var run = function () { if (window.rondDatetime) window.rondDatetime.attachAll(); };
|
||||||
|
/* 这个脚本在 body 末尾,内容都已经解析出来了 —— 立刻改一次,别等 DOMContentLoaded:
|
||||||
|
否则用户会先看到一个原生日期框、过一会儿才变成文本框(本机实测 DCL 在 1.18s,
|
||||||
|
网络慢时更明显)。DCL 再补一次是为了兜住别的脚本往页面里塞的输入。 */
|
||||||
|
run();
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{/* 输入弹层:替代 window.prompt(它会把提示文本与默认值写进地址栏)。
|
{{/* 输入弹层:替代 window.prompt(它会把提示文本与默认值写进地址栏)。
|
||||||
全站都可能用到(报告页复制链接、后台活动改名),所以无条件引入。 */}}
|
全站都可能用到(报告页复制链接、后台活动改名),所以无条件引入。 */}}
|
||||||
<script src="/static/js/prompt-box.js?v={{.AssetV}}"></script>
|
<script src="/static/js/prompt-box.js?v={{.AssetV}}"></script>
|
||||||
|
|||||||
@@ -93,7 +93,10 @@
|
|||||||
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
|
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid g2">
|
{{/* 这两张卡不做左右分栏:类型分布(60 多行)与一天中的分布(24 根柱)体量差太多,
|
||||||
|
并排时右侧下面必然是一大片空白。各占整行 —— 列表像「城市排行」那样横铺开,
|
||||||
|
24 根柱也终于有宽度可用。 */}}
|
||||||
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>类型分布</h2>
|
<h2>类型分布</h2>
|
||||||
<div class="card-sub">按到访次数排序</div>
|
<div class="card-sub">按到访次数排序</div>
|
||||||
|
|||||||
@@ -79,9 +79,11 @@
|
|||||||
window.rondMap.full({
|
window.rondMap.full({
|
||||||
id: 'map',
|
id: 'map',
|
||||||
points: {{.PointsJSON}},
|
points: {{.PointsJSON}},
|
||||||
fitCircle: {{.FitCircleJSON}},
|
|
||||||
fitBBox: {{.FitBBoxJSON}},
|
fitBBox: {{.FitBBoxJSON}},
|
||||||
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}},
|
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}},
|
||||||
|
{{/* 圈了显示区的专题:迷雾只在**这些圆的并集**内显示(rond-map.js 给迷雾单独一个
|
||||||
|
pane,再用 fog-clip.js 拼出多圆的 clip-path: path())。不限区域时是空数组。 */}}
|
||||||
|
fogAreas: {{.Topic.AreasJSON}},
|
||||||
back: '{{.BackQS}}'
|
back: '{{.BackQS}}'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"rond-with-you/internal/geo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func closeTo(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
||||||
|
|
||||||
|
// 显示区(一组圆)的数据层不变量。这些用例都是纯函数级的,不需要数据库。
|
||||||
|
|
||||||
|
// formPost 造一个已经 ParseForm 过的 POST 请求,供 topicAreasFromForm 读字段。
|
||||||
|
func formPost(t *testing.T, values url.Values) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("POST", "/admin/topics/save", strings.NewReader(values.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
t.Fatalf("ParseForm: %v", err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 表单解析:两个入口必须走同一套校验 ----------
|
||||||
|
|
||||||
|
func TestTopicAreasFromForm(t *testing.T) {
|
||||||
|
type want struct {
|
||||||
|
areas int
|
||||||
|
lat float64
|
||||||
|
lon float64
|
||||||
|
km float64
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
values url.Values
|
||||||
|
want want
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "老表单三字段 → 合成一个圆",
|
||||||
|
values: url.Values{"center_lat": {"25.6"}, "center_lon": {"100.2"}, "radius_km": {"30"}},
|
||||||
|
want: want{areas: 1, lat: 25.6, lon: 100.2, km: 30},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "老表单全空 → 不限区域(不是错误)",
|
||||||
|
values: url.Values{"center_lat": {""}, "center_lon": {""}, "radius_km": {""}},
|
||||||
|
want: want{areas: 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "老表单半径 0 → 不限区域",
|
||||||
|
values: url.Values{"center_lat": {"25.6"}, "center_lon": {"100.2"}, "radius_km": {"0"}},
|
||||||
|
want: want{areas: 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "老表单有半径但圆心是 0,0 → 报错(不能静默变成「圈在几内亚湾」)",
|
||||||
|
values: url.Values{"center_lat": {"0"}, "center_lon": {"0"}, "radius_km": {"30"}},
|
||||||
|
wantErr: "点选圆心",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "编辑器 JSON → 多个圆",
|
||||||
|
values: url.Values{"areas": {`[{"lat":25,"lon":100,"radiusKm":3},{"lat":26,"lon":101,"radiusKm":5}]`}},
|
||||||
|
want: want{areas: 2, lat: 25, lon: 100, km: 3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "编辑器 JSON 空数组 → 不限区域",
|
||||||
|
values: url.Values{"areas": {"[]"}},
|
||||||
|
want: want{areas: 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "坏 JSON → 报错而不是当成「不限区域」",
|
||||||
|
values: url.Values{"areas": {"[{lat:25]"}},
|
||||||
|
wantErr: "无法解析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "半径越界 → 报错",
|
||||||
|
values: url.Values{"areas": {`[{"lat":25,"lon":100,"radiusKm":0.001}]`}},
|
||||||
|
wantErr: "半径",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "纬度越界 → 报错",
|
||||||
|
values: url.Values{"areas": {`[{"lat":91,"lon":100,"radiusKm":3}]`}},
|
||||||
|
wantErr: "超出经纬度",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := topicAreasFromForm(formPost(t, tc.values))
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("期望报错(含 %q),却通过了:%+v", tc.wantErr, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("错误信息里应含 %q,实得 %q", tc.wantErr, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不该报错: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != tc.want.areas {
|
||||||
|
t.Fatalf("圆的个数 = %d,期望 %d(%+v)", len(got), tc.want.areas, got)
|
||||||
|
}
|
||||||
|
if tc.want.areas > 0 {
|
||||||
|
if got[0].Lat != tc.want.lat || got[0].Lon != tc.want.lon || got[0].RadiusKm != tc.want.km {
|
||||||
|
t.Fatalf("第一个圆 = %+v,期望 {%v %v %v}", got[0], tc.want.lat, tc.want.lon, tc.want.km)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wgs84 底图上点的圆心必须换算成 GCJ-02 再存:地点过滤是拿它跟 places 的 GCJ-02 坐标
|
||||||
|
// 直接比球面距离的,不换算会整体偏几百米(历史上照片导入那次就是这么偏掉的)。
|
||||||
|
func TestTopicAreasFromFormConvertsWGS84(t *testing.T) {
|
||||||
|
const wgsLat, wgsLon = 25.6, 100.2
|
||||||
|
gcjLat, gcjLon := geo.WGS84ToGCJ02(wgsLat, wgsLon)
|
||||||
|
|
||||||
|
args := []url.Values{
|
||||||
|
// 编辑器路径
|
||||||
|
{"areas": {fmt.Sprintf(`[{"lat":%v,"lon":%v,"radiusKm":3}]`, wgsLat, wgsLon)}, "crs": {"wgs84"}},
|
||||||
|
// 老表单路径
|
||||||
|
{"center_lat": {fmt.Sprint(wgsLat)}, "center_lon": {fmt.Sprint(wgsLon)},
|
||||||
|
"radius_km": {"3"}, "crs": {"wgs84"}},
|
||||||
|
}
|
||||||
|
for i, v := range args {
|
||||||
|
got, err := topicAreasFromForm(formPost(t, v))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%d] %v", i, err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("[%d] 应得 1 个圆,实得 %d", i, len(got))
|
||||||
|
}
|
||||||
|
if !closeTo(got[0].Lat, gcjLat) || !closeTo(got[0].Lon, gcjLon) {
|
||||||
|
t.Errorf("[%d] 没换算成 GCJ-02:得 %.6f,%.6f 期望 %.6f,%.6f", i, got[0].Lat, got[0].Lon, gcjLat, gcjLon)
|
||||||
|
}
|
||||||
|
if d := math.Hypot(got[0].Lat-wgsLat, got[0].Lon-wgsLon); d < 1e-4 {
|
||||||
|
t.Errorf("[%d] 换算前后竟然一样(说明根本没换算)", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crs 不是 wgs84 时保持原值
|
||||||
|
got, err := topicAreasFromForm(formPost(t, url.Values{
|
||||||
|
"areas": {fmt.Sprintf(`[{"lat":%v,"lon":%v,"radiusKm":3}]`, wgsLat, wgsLon)}, "crs": {"gcj02"}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got[0].Lat != wgsLat || got[0].Lon != wgsLon {
|
||||||
|
t.Errorf("gcj02 底图不该换算,实得 %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SQL 占位符纪律 ----------
|
||||||
|
|
||||||
|
var placeholderRe = regexp.MustCompile(`\$(\d+)`)
|
||||||
|
|
||||||
|
// topicAreaCond 拼进 SQL 里的 $n 必须**恰好**是 [prefix+1, len(args)] 这个连续区间:
|
||||||
|
// 不跳号也不越界。这正是「专题内容全变 0」那类事故的判据 —— 少算一个就是 pgx
|
||||||
|
// 「expected N arguments, got M」,而列表页会把错误吞掉,只表现为数字变 0。
|
||||||
|
//
|
||||||
|
// 注意断言的是「本段自己用的那 3n 个编号」:前缀那几个(列表页批量的 dataset/start/end)
|
||||||
|
// 由查询里的别处引用,不该出现在区域条件里。
|
||||||
|
func TestTopicAreaCondPlaceholderDiscipline(t *testing.T) {
|
||||||
|
for _, n := range []int{1, 2, 3, 7} {
|
||||||
|
for _, prefix := range []int{3, 0} { // 3 = 列表页批量(dataset/start/end 先占位)
|
||||||
|
areas := make([]TopicArea, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
areas = append(areas, TopicArea{
|
||||||
|
Lat: 20 + float64(i), Lon: 100 + float64(i), RadiusKm: 3})
|
||||||
|
}
|
||||||
|
topic := &Topic{Areas: areas}
|
||||||
|
args := make([]any, 0, prefix+3*n)
|
||||||
|
for i := 0; i < prefix; i++ {
|
||||||
|
args = append(args, i)
|
||||||
|
}
|
||||||
|
cond := topicAreaCond(topic, &args)
|
||||||
|
|
||||||
|
if len(args) != prefix+3*n {
|
||||||
|
t.Fatalf("n=%d prefix=%d:参数个数 = %d,期望 %d", n, prefix, len(args), prefix+3*n)
|
||||||
|
}
|
||||||
|
seen := map[int]int{}
|
||||||
|
maxIdx, count := 0, 0
|
||||||
|
for _, m := range placeholderRe.FindAllStringSubmatch(cond, -1) {
|
||||||
|
idx, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析占位符 %q 失败", m[1])
|
||||||
|
}
|
||||||
|
if seen[idx] == 0 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
seen[idx]++
|
||||||
|
if idx > maxIdx {
|
||||||
|
maxIdx = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 最大值必须正好是最后一个参数 —— 否则下一个条件接着编号就会错位
|
||||||
|
if maxIdx != len(args) {
|
||||||
|
t.Errorf("n=%d prefix=%d:最大编号 = $%d,应正好是 $%d(否则后续条件会错位)\n%s",
|
||||||
|
n, prefix, maxIdx, len(args), cond)
|
||||||
|
}
|
||||||
|
// 且必须是 prefix+1 起、3n 个互不相同的连续编号
|
||||||
|
if count != 3*n {
|
||||||
|
t.Errorf("n=%d prefix=%d:引用了 %d 个不同编号,期望 %d\n%s", n, prefix, count, 3*n, cond)
|
||||||
|
}
|
||||||
|
for idx := prefix + 1; idx <= len(args); idx++ {
|
||||||
|
if seen[idx] == 0 {
|
||||||
|
t.Errorf("n=%d prefix=%d:$%d 没被引用(跳号)\n%s", n, prefix, idx, cond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for idx := 1; idx <= prefix; idx++ {
|
||||||
|
if seen[idx] != 0 {
|
||||||
|
t.Errorf("n=%d prefix=%d:不该引用前缀的 $%d(那是查询别处的参数)", n, prefix, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OR 的个数 = 圆的个数 - 1(并集语义:落在任一圆内即可)
|
||||||
|
if got := strings.Count(cond, " OR "); got != n-1 {
|
||||||
|
t.Errorf("n=%d:OR 个数 = %d,期望 %d\n%s", n, got, n-1, cond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不限区域时既不加参数也不加条件
|
||||||
|
args := []any{1, 2, 3}
|
||||||
|
if cond := topicAreaCond(&Topic{}, &args); cond != "" || len(args) != 3 {
|
||||||
|
t.Errorf("不限区域时应空手而归,实得 cond=%q args=%d", cond, len(args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 老行(areas 还没回填)与测试里直接构造 Topic 的写法都得继续工作。
|
||||||
|
func TestTopicAreaListFallsBackToLegacyCircle(t *testing.T) {
|
||||||
|
legacy := Topic{CenterLat: 25, CenterLon: 100, RadiusKm: 30}
|
||||||
|
if !legacy.HasArea() {
|
||||||
|
t.Error("只有老的三列时 HasArea 应为真")
|
||||||
|
}
|
||||||
|
if got := legacy.AreaList(); len(got) != 1 || got[0].RadiusKm != 30 {
|
||||||
|
t.Errorf("应退回单圆,实得 %+v", got)
|
||||||
|
}
|
||||||
|
// Areas 非空时以 Areas 为准,老的列不再参与
|
||||||
|
both := Topic{CenterLat: 1, CenterLon: 1, RadiusKm: 1,
|
||||||
|
Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}, {Lat: 26, Lon: 101, RadiusKm: 4}}}
|
||||||
|
if got := both.AreaList(); len(got) != 2 || got[0].Lat != 25 {
|
||||||
|
t.Errorf("应以 Areas 为准,实得 %+v", got)
|
||||||
|
}
|
||||||
|
// 半径 0 / 无坐标 = 不限区域
|
||||||
|
if (Topic{}).HasArea() || (Topic{CenterLat: 25, CenterLon: 100}).HasArea() {
|
||||||
|
t.Error("没有圆时 HasArea 应为假")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 出库值不能是 JSON null:areas 列有 NOT NULL,而 json.Marshal(nil slice) 恰好给 "null"。
|
||||||
|
func TestTopicAreasColumnNeverNull(t *testing.T) {
|
||||||
|
for _, in := range [][]TopicArea{nil, {}, {{Lat: 1, Lon: 2, RadiusKm: 3}}} {
|
||||||
|
got := string(topicAreasColumn(in))
|
||||||
|
if got == "null" {
|
||||||
|
t.Fatalf("输入 %+v 时写成了 null(列上有 NOT NULL)", in)
|
||||||
|
}
|
||||||
|
var back []TopicArea
|
||||||
|
if err := json.Unmarshal([]byte(got), &back); err != nil {
|
||||||
|
t.Fatalf("%s 不是合法 JSON: %v", got, err)
|
||||||
|
}
|
||||||
|
if len(back) != len(in) {
|
||||||
|
t.Errorf("往返后圆的个数 %d ≠ %d", len(back), len(in))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 视野贴合 ----------
|
||||||
|
|
||||||
|
// 外接矩形必须把**每一个**圆都包住(漏一个圆,那个圆里的迷雾就会被挤到视野外)。
|
||||||
|
func TestTopicAreasBoundsCoversEveryCircle(t *testing.T) {
|
||||||
|
areas := []TopicArea{
|
||||||
|
{Lat: 25.0, Lon: 100.0, RadiusKm: 3},
|
||||||
|
{Lat: 30.5, Lon: 110.25, RadiusKm: 10},
|
||||||
|
{Lat: 20.0, Lon: 95.0, RadiusKm: 50},
|
||||||
|
}
|
||||||
|
minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(areas)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("应返回 ok")
|
||||||
|
}
|
||||||
|
for _, a := range areas {
|
||||||
|
dLat := a.RadiusKm / 111.32
|
||||||
|
cos := math.Cos(a.Lat * math.Pi / 180)
|
||||||
|
dLon := a.RadiusKm / (111.32 * cos)
|
||||||
|
if a.Lat-dLat < minLat-1e-9 || a.Lat+dLat > maxLat+1e-9 ||
|
||||||
|
a.Lon-dLon < minLon-1e-9 || a.Lon+dLon > maxLon+1e-9 {
|
||||||
|
t.Errorf("圆 %+v 没被外接矩形包住:纬度[%v,%v] 经度[%v,%v]",
|
||||||
|
a, minLat, maxLat, minLon, maxLon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 单圆时与「圆心 ± r/111.32」等价(原来 fitCircle 用 toBounds(2r) 给的就是这个正方形)
|
||||||
|
one := []TopicArea{{Lat: 25.0, Lon: 100.0, RadiusKm: 30}}
|
||||||
|
minLat, maxLat, minLon, maxLon, _ = topicAreasBounds(one)
|
||||||
|
if !closeTo(minLat, 25-30/111.32) || !closeTo(maxLat, 25+30/111.32) {
|
||||||
|
t.Errorf("单圆纬度区间 = [%v,%v]", minLat, maxLat)
|
||||||
|
}
|
||||||
|
wantDLon := 30 / (111.32 * math.Cos(25*math.Pi/180))
|
||||||
|
if !closeTo(minLon, 100-wantDLon) || !closeTo(maxLon, 100+wantDLon) {
|
||||||
|
t.Errorf("单圆经度区间 = [%v,%v],期望 ±%v", minLon, maxLon, wantDLon)
|
||||||
|
}
|
||||||
|
// 空 → 没有矩形
|
||||||
|
if _, _, _, _, ok := topicAreasBounds(nil); ok {
|
||||||
|
t.Error("没有圆时不该返回矩形")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 贴合参数:有显示区就用所有圆的并集外接矩形,否则退回点集包围盒;
|
||||||
|
// 两者都没有时给 null(脚本据此再退回它自己的 fit)。
|
||||||
|
func TestFitParamsUsesBBox(t *testing.T) {
|
||||||
|
var b struct{ MinLat, MaxLat, MinLon, MaxLon float64 }
|
||||||
|
|
||||||
|
bbox := fitParams(Topic{Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}}}, nil)
|
||||||
|
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
|
||||||
|
t.Fatalf("有显示区时 fitBBox 不是合法 JSON: %s (%v)", bbox, err)
|
||||||
|
}
|
||||||
|
if !(b.MinLat < 25 && b.MaxLat > 25 && b.MinLon < 100 && b.MaxLon > 100) {
|
||||||
|
t.Errorf("fitBBox 应包住圆心:%+v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多圆:矩形要同时包住两个圆,而不是只包住第一个
|
||||||
|
bbox = fitParams(Topic{Areas: []TopicArea{
|
||||||
|
{Lat: 25, Lon: 100, RadiusKm: 3}, {Lat: 30, Lon: 110, RadiusKm: 5}}}, nil)
|
||||||
|
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
|
||||||
|
t.Fatalf("多圆 fitBBox 不是合法 JSON: %s (%v)", bbox, err)
|
||||||
|
}
|
||||||
|
if !(b.MinLat < 25 && b.MaxLat > 30 && b.MinLon < 100 && b.MaxLon > 110) {
|
||||||
|
t.Errorf("多圆 fitBBox 应同时包住两个圆:%+v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不限区域时退回点集包围盒
|
||||||
|
bbox = fitParams(Topic{}, []MapPoint{{Lat: 21, Lon: 99}, {Lat: 23, Lon: 103}})
|
||||||
|
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
|
||||||
|
t.Fatalf("点集 fitBBox 不是合法 JSON: %s", bbox)
|
||||||
|
}
|
||||||
|
if b.MinLat != 21 || b.MaxLat != 23 || b.MinLon != 99 || b.MaxLon != 103 {
|
||||||
|
t.Errorf("点集包围盒 = %+v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两者都没有 → null,交给脚本自己的点集贴合
|
||||||
|
if got := fitParams(Topic{}, nil); string(got) != "null" {
|
||||||
|
t.Errorf("既无显示区又无点时应给 null,实得 %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 经纬度夹取:极地 / 超大半径不至于算出 ±inf 或越界值喂给 fitBounds。
|
||||||
|
func TestTopicAreasBoundsClampsToGlobe(t *testing.T) {
|
||||||
|
minLat, maxLat, minLon, maxLon, ok := topicAreasBounds([]TopicArea{{Lat: 89.9, Lon: 179.9, RadiusKm: 2000}})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("应返回 ok")
|
||||||
|
}
|
||||||
|
for _, v := range []float64{minLat, maxLat} {
|
||||||
|
if v < -90 || v > 90 || math.IsInf(v, 0) || math.IsNaN(v) {
|
||||||
|
t.Errorf("纬度夹取后仍是 %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []float64{minLon, maxLon} {
|
||||||
|
if v < -180 || v > 180 || math.IsInf(v, 0) || math.IsNaN(v) {
|
||||||
|
t.Errorf("经度夹取后仍是 %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 个数上限 ----------
|
||||||
|
|
||||||
|
func TestNormalizeTopicAreasLimits(t *testing.T) {
|
||||||
|
ok := make([]TopicArea, maxTopicAreas)
|
||||||
|
for i := range ok {
|
||||||
|
ok[i] = TopicArea{Lat: 20 + float64(i)/100, Lon: 100, RadiusKm: 3}
|
||||||
|
}
|
||||||
|
if _, err := normalizeTopicAreas(ok, ""); err != nil {
|
||||||
|
t.Errorf("%d 个圆应当允许:%v", maxTopicAreas, err)
|
||||||
|
}
|
||||||
|
tooMany := append(append([]TopicArea(nil), ok...), TopicArea{Lat: 25, Lon: 100, RadiusKm: 3})
|
||||||
|
if _, err := normalizeTopicAreas(tooMany, ""); err == nil {
|
||||||
|
t.Errorf("%d 个圆应当拒绝", len(tooMany))
|
||||||
|
}
|
||||||
|
for _, bad := range []TopicArea{
|
||||||
|
{Lat: math.NaN(), Lon: 100, RadiusKm: 3},
|
||||||
|
{Lat: 25, Lon: math.Inf(1), RadiusKm: 3},
|
||||||
|
{Lat: 25, Lon: 100, RadiusKm: math.NaN()},
|
||||||
|
{Lat: 25, Lon: 100, RadiusKm: topicAreaMaxRadiusKm + 1},
|
||||||
|
{Lat: 25, Lon: 100, RadiusKm: 0},
|
||||||
|
} {
|
||||||
|
if _, err := normalizeTopicAreas([]TopicArea{bad}, ""); err == nil {
|
||||||
|
t.Errorf("%+v 应当被拒绝", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 编辑器:两边写死的上限必须一致 ----------
|
||||||
|
//
|
||||||
|
// 前端 validate() 与后端 normalizeTopicAreas() 各写了一份上限。不一致的表现很别扭:
|
||||||
|
// 前端放行、后端拒绝 ⇒ 用户看到一句红字却不知道改哪里(甚至以为是自己填错了)。
|
||||||
|
// 这两份数字没有办法共享(一边 Go 一边 JS),所以直接静态比对。
|
||||||
|
func TestTopicAreaLimitsMatchBetweenGoAndJS(t *testing.T) {
|
||||||
|
js, err := staticFS.ReadFile("static/js/topic-areas.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 topic-areas.js 失败: %v", err)
|
||||||
|
}
|
||||||
|
src := string(js)
|
||||||
|
get := func(name string) string {
|
||||||
|
m := regexp.MustCompile(name + `\s*=\s*([0-9.]+)`).FindStringSubmatch(src)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("topic-areas.js 里找不到 %s 的定义", name)
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
jsName string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"MAX_AREAS", float64(maxTopicAreas)},
|
||||||
|
{"MIN_KM", topicAreaMinRadiusKm},
|
||||||
|
{"MAX_KM", topicAreaMaxRadiusKm},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := strconv.ParseFloat(get(c.jsName), 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析 %s 失败: %v", c.jsName, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s:JS 写的是 %v,后端是 %v —— 两边必须一致(前端放行后端拒绝,用户不知道改哪里)",
|
||||||
|
c.jsName, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 编辑器:模板契约 ----------
|
||||||
|
//
|
||||||
|
// 钉住「编辑器的挂点」与「旧入口已经拆掉」:
|
||||||
|
//
|
||||||
|
// ① 三个手填坐标输入框必须没了 —— 留着就是第二套入口,而它只写第 1 个圆,
|
||||||
|
// 用户会觉得「我明明填了坐标,怎么只认一个」;
|
||||||
|
// ② areas 的初值必须**渲染进隐藏字段**(不是只放在 JS 里):脚本没加载上时提交的
|
||||||
|
// 仍是原值,否则一次保存就把已有显示区静默清空;
|
||||||
|
// ③ 编辑器脚本必须在页面里引入,且 init 的挂点 id 与模板里的 id 对得上。
|
||||||
|
func TestTopicAdminUsesMapEditorNotCoordInputs(t *testing.T) {
|
||||||
|
b, err := tmplFS.ReadFile("templates/admin/topics.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 admin/topics.html 失败: %v", err)
|
||||||
|
}
|
||||||
|
body := string(b)
|
||||||
|
for _, gone := range []string{`name="center_lat"`, `name="center_lon"`, `name="radius_km"`} {
|
||||||
|
if strings.Contains(body, gone) {
|
||||||
|
t.Errorf("admin/topics.html 不该再有 %s —— 显示区改由地图点选,留着就是第二套只写第 1 个圆的入口", gone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, need := range []string{
|
||||||
|
`id="area-map"`, `id="area-list"`, `id="area-new-km"`, `id="area-clear"`, `id="area-hint"`,
|
||||||
|
`id="topic-form"`, `name="areas"`, `name="crs"`,
|
||||||
|
`/static/js/topic-areas.js`, `rondTopicAreas.init(`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, need) {
|
||||||
|
t.Errorf("admin/topics.html 里缺 %q —— 显示区编辑器没挂全", need)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// areas 初值要在隐藏字段上(服务端渲染),不是只传进 JS
|
||||||
|
if !strings.Contains(body, `name="areas" value="{{.Form.AreasJSON}}"`) {
|
||||||
|
t.Error(`areas 的初值必须渲染进隐藏字段(value="{{.Form.AreasJSON}}"):` +
|
||||||
|
"脚本没加载上时要能原样提交,否则一次保存就清空已有显示区")
|
||||||
|
}
|
||||||
|
// 日期控件那条既有约定不能被挤掉
|
||||||
|
if !strings.Contains(body, "rondDatetime.attachAll") {
|
||||||
|
t.Error("admin/topics.html 里的日期控件初始化没了")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台专题页要有地图 ⇒ 必须设 p.HasMap(布局按它决定引不引入 Leaflet)。
|
||||||
|
// 漏了的表现很隐蔽:脚本加载了、编辑器对象也在,就是 window.L 永远 undefined,
|
||||||
|
// init 直接静默返回 —— 页面看着「什么都没发生」,控制台也不报错。实测就是这么踩的。
|
||||||
|
func TestTopicsAdminPageLoadsLeaflet(t *testing.T) {
|
||||||
|
src, err := os.ReadFile(pkgFile(t, "handlers_topics.go"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 handlers_topics.go 失败: %v", err)
|
||||||
|
}
|
||||||
|
body := string(src)
|
||||||
|
i := strings.Index(body, "func (s *Server) topicsAdminPage(")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("handlers_topics.go 里找不到 topicsAdminPage")
|
||||||
|
}
|
||||||
|
// 取到下一个顶层 func 为止(函数体不会嵌套顶层 func)
|
||||||
|
rest := body[i:]
|
||||||
|
if j := strings.Index(rest[1:], "\nfunc "); j >= 0 {
|
||||||
|
rest = rest[:j+1]
|
||||||
|
}
|
||||||
|
if !strings.Contains(rest, "HasMap = true") {
|
||||||
|
t.Error("topicsAdminPage 必须设 p.HasMap = true —— 不设的话布局不引入 Leaflet," +
|
||||||
|
"显示区编辑器会静默不出现(脚本在、编辑器在,就是 window.L 是 undefined)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数字输入框:step 是相对 min 递增的 ----------
|
||||||
|
//
|
||||||
|
// <input type="number" min="0.05" step="0.5"> 的合法值**不是** 0.5 的倍数,而是
|
||||||
|
// min + n*step = 0.05 / 0.55 / 1.05 …:用户填 3 或 3.5 会被 Chrome 判非法,提交时弹
|
||||||
|
// 「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」——这是**浏览器原生**提示,
|
||||||
|
// JS 与后端都拦不到,只能在源头避免(2026-10-12 专题显示区的半径输入框就是这么踩的)。
|
||||||
|
//
|
||||||
|
// 规则:要么 step="any"(随便填,交给 JS/后端校验),要么 min 必须是 step 的整数倍。
|
||||||
|
// 这条守卫扫**所有模板**里的数字框,不是只盯着出问题那个 —— 否则下次换个页面又会踩。
|
||||||
|
func TestNumberInputStepNeverMismatchesMin(t *testing.T) {
|
||||||
|
tagRe := regexp.MustCompile(`<input\b[^>]*>`)
|
||||||
|
attr := func(tag, name string) string {
|
||||||
|
m := regexp.MustCompile(name + `="([^"]*)"`).FindStringSubmatch(tag)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
checked := 0
|
||||||
|
for _, dir := range []string{"templates/*.html", "templates/admin/*.html"} {
|
||||||
|
names, err := fs.Glob(tmplFS, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列举 %s 失败: %v", dir, err)
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
body, err := tmplFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
for _, tag := range tagRe.FindAllString(string(body), -1) {
|
||||||
|
if !strings.Contains(tag, `type="number"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
step := attr(tag, "step")
|
||||||
|
if step == "" || step == "any" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minRaw := attr(tag, "min")
|
||||||
|
if minRaw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mn, e1 := strconv.ParseFloat(minRaw, 64)
|
||||||
|
st, e2 := strconv.ParseFloat(step, 64)
|
||||||
|
if e1 != nil || e2 != nil || st <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
if r := math.Mod(mn, st); r > 1e-9 && st-r > 1e-9 {
|
||||||
|
t.Errorf("%s 里 min=%q step=%q 是非法组合:step 相对 min 递增,合法值只有 min+n*step "+
|
||||||
|
"⇒ 正常输入会被浏览器判非法并弹原生提示。改成 step=\"any\",或让 min 是 step 的整数倍。\n %s",
|
||||||
|
name, minRaw, step, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("扫过 %d 个「有 min 且有小数 step」的数字框", checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器里列表的半径输入框是 JS 动态建的,模板守卫扫不到,单独钉一下。
|
||||||
|
func TestTopicAreaRadiusInputAllowsAnyStep(t *testing.T) {
|
||||||
|
js, err := staticFS.ReadFile("static/js/topic-areas.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 topic-areas.js 失败: %v", err)
|
||||||
|
}
|
||||||
|
src := string(js)
|
||||||
|
if !strings.Contains(src, "inp.step = 'any'") {
|
||||||
|
t.Error("topic-areas.js 里半径输入框必须 inp.step = 'any' —— " +
|
||||||
|
"写死小数 step 会和 min 组合出「正常值被判非法」的原生提示")
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`inp\.step\s*=\s*'[0-9]`).MatchString(src) {
|
||||||
|
t.Error("topic-areas.js 里半径输入框的 step 别写死数字(见 step 相对 min 递增那条)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器必须给出长度参照:地图上要有比例尺,半径旁边要标出「约多宽」。
|
||||||
|
//
|
||||||
|
// 起因:2026-10-13 用户反馈「这个圆无论怎么缩放大小都一样,20km 却覆盖了整个南宁」。
|
||||||
|
// 实测三处画圆(编辑器 L.circle、改半径把手、前台迷雾裁剪)全都是地理锚定、随缩放正确
|
||||||
|
// 变化的,半径值也确实生效 —— 真正缺的是**参照物**:编辑器地图以前没有比例尺
|
||||||
|
// (前台专题页一直有),圈圆时只能靠地名猜,自然觉得「不对」。
|
||||||
|
// 所以这条守卫钉的是「别再把这个参照物弄丢」。
|
||||||
|
func TestTopicAreaEditorGivesSizeReference(t *testing.T) {
|
||||||
|
js, err := staticFS.ReadFile("static/js/topic-areas.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 topic-areas.js 失败: %v", err)
|
||||||
|
}
|
||||||
|
src := string(js)
|
||||||
|
if !strings.Contains(src, "L.control.scale(") {
|
||||||
|
t.Error("topic-areas.js 必须给编辑器地图加比例尺(L.control.scale)—— " +
|
||||||
|
"没有它,圈圆时没有任何长度参照,「20 公里到底多大」只能拍脑袋")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "spanLabel") {
|
||||||
|
t.Error("topic-areas.js 必须在半径旁标出跨度(spanLabel)")
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := tmplFS.ReadFile("templates/admin/topics.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 admin/topics.html 失败: %v", err)
|
||||||
|
}
|
||||||
|
body := string(b)
|
||||||
|
for _, need := range []string{`id="area-new-span"`, "spanId"} {
|
||||||
|
if !strings.Contains(body, need) {
|
||||||
|
t.Errorf("admin/topics.html 里缺 %q —— 「新圆半径」旁边要显示它会有多宽", need)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,41 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
|||||||
_ = f.srv.db.QueryRowContext(ctx, `SELECT city FROM places WHERE dataset_id=$1
|
_ = f.srv.db.QueryRowContext(ctx, `SELECT city FROM places WHERE dataset_id=$1
|
||||||
AND COALESCE(city,'')<>'' GROUP BY city ORDER BY count(*) DESC LIMIT 1`, ds).Scan(&city)
|
AND COALESCE(city,'')<>'' GROUP BY city ORDER BY count(*) DESC LIMIT 1`, ds).Scan(&city)
|
||||||
|
|
||||||
|
// 挑两个**离得最远**的地点做圆心,好让两个圆尽量不相交 —— 相交的话
|
||||||
|
// 「并集 = 两个单圆之和」这个判据就退化成「两个圆其实差不多」,抓不住 OR 只算一半的错。
|
||||||
|
type pt struct {
|
||||||
|
lat, lon float64
|
||||||
|
}
|
||||||
|
var cand []pt
|
||||||
|
rows, err := f.srv.db.QueryContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places
|
||||||
|
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL
|
||||||
|
AND COALESCE(lat,0)<>0 AND COALESCE(lon,0)<>0
|
||||||
|
ORDER BY visit_count DESC LIMIT 200`, ds)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var p pt
|
||||||
|
if err := rows.Scan(&p.lat, &p.lon); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cand = append(cand, p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(cand) < 2 {
|
||||||
|
t.Skip("生效数据集里不足两个带坐标的地点,无法构造多圆用例")
|
||||||
|
}
|
||||||
|
a0, a1, far := cand[0], cand[1], 0.0
|
||||||
|
for _, p := range cand[1:] {
|
||||||
|
d := (p.lat-a0.lat)*(p.lat-a0.lat) + (p.lon-a0.lon)*(p.lon-a0.lon)
|
||||||
|
if d > far {
|
||||||
|
far, a1 = d, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mid := minAt.Time.Add(maxAt.Time.Sub(minAt.Time) / 2)
|
mid := minAt.Time.Add(maxAt.Time.Sub(minAt.Time) / 2)
|
||||||
base := []Topic{
|
base := []Topic{
|
||||||
{ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time},
|
{ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time},
|
||||||
@@ -43,6 +78,14 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
|||||||
CenterLat: lat, CenterLon: lon, RadiusKm: 30},
|
CenterLat: lat, CenterLon: lon, RadiusKm: 30},
|
||||||
{ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time,
|
{ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time,
|
||||||
CenterLat: 1, CenterLon: 1, RadiusKm: 1},
|
CenterLat: 1, CenterLon: 1, RadiusKm: 1},
|
||||||
|
// 6/7/8 是多圆用例:8 必须恰好等于 6 与 7 的并集(下面有专门断言)
|
||||||
|
{ID: 6, Title: "圆A", StartAt: minAt.Time, EndAt: maxAt.Time,
|
||||||
|
Areas: []TopicArea{{Lat: a0.lat, Lon: a0.lon, RadiusKm: 5}}},
|
||||||
|
{ID: 7, Title: "圆B", StartAt: minAt.Time, EndAt: maxAt.Time,
|
||||||
|
Areas: []TopicArea{{Lat: a1.lat, Lon: a1.lon, RadiusKm: 5}}},
|
||||||
|
{ID: 8, Title: "A+B(并集)", StartAt: minAt.Time, EndAt: maxAt.Time,
|
||||||
|
Areas: []TopicArea{{Lat: a0.lat, Lon: a0.lon, RadiusKm: 5},
|
||||||
|
{Lat: a1.lat, Lon: a1.lon, RadiusKm: 5}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
type round struct {
|
type round struct {
|
||||||
@@ -59,19 +102,28 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多圆并集语义至少要被真正测到一轮(两个圆都非空),否则形同虚设
|
||||||
|
unionRounds := 0
|
||||||
for _, rd := range rounds {
|
for _, rd := range rounds {
|
||||||
// 详情页路径:逐专题查询
|
// 详情页路径:逐专题查询
|
||||||
want := append([]Topic(nil), base...)
|
want := append([]Topic(nil), base...)
|
||||||
|
placeSets := map[int64]map[int64]bool{}
|
||||||
for i := range want {
|
for i := range want {
|
||||||
points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude)
|
points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err)
|
t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err)
|
||||||
}
|
}
|
||||||
want[i].PlaceCount, want[i].VisitCount = len(points), visits
|
ids := map[int64]bool{}
|
||||||
want[i].DayCount, want[i].CityCount = days, cities
|
|
||||||
for _, p := range points {
|
for _, p := range points {
|
||||||
|
ids[p.ID] = true
|
||||||
want[i].DwellMin += p.Dwell
|
want[i].DwellMin += p.Dwell
|
||||||
}
|
}
|
||||||
|
placeSets[want[i].ID] = ids
|
||||||
|
want[i].PlaceCount, want[i].VisitCount = len(points), visits
|
||||||
|
want[i].DayCount, want[i].CityCount = days, cities
|
||||||
|
}
|
||||||
|
if checkAreaUnionSemantics(t, rd.name, placeSets) {
|
||||||
|
unionRounds++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表页路径:一条 UNION ALL
|
// 列表页路径:一条 UNION ALL
|
||||||
@@ -104,4 +156,42 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("[%s] %d 个专题逐个比对通过(有内容 %d 个)", rd.name, len(got), nonZero)
|
t.Logf("[%s] %d 个专题逐个比对通过(有内容 %d 个)", rd.name, len(got), nonZero)
|
||||||
}
|
}
|
||||||
|
if unionRounds == 0 {
|
||||||
|
t.Error("多圆并集语义一轮都没真正测到(两个圆里总有一个是空的)—— 用例形同虚设")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAreaUnionSemantics 校验多圆语义:主题 8(两个圆)的地点集合必须**恰好**等于
|
||||||
|
// 主题 6 与 7(各一个圆)的并集。这是定义性不变量,不是相对计数:
|
||||||
|
// - 只认第一个圆 ⇒ 退化成 6 的子集,7 那些地点全部缺失;
|
||||||
|
// - OR 写成 AND ⇒ 退化成 6 与 7 的交集(两圆不相交时直接空)。
|
||||||
|
//
|
||||||
|
// 返回值报告这一轮是否「真的测到了」:两个圆都非空才有区分力。
|
||||||
|
// 带区域白/黑名单时其中一个圆可能整个被过滤掉(圆心在外地),那是正常数据行为,
|
||||||
|
// 所以不在这里硬失败,改由调用方要求「至少有一轮测到」。
|
||||||
|
func checkAreaUnionSemantics(t *testing.T, round string, sets map[int64]map[int64]bool) bool {
|
||||||
|
t.Helper()
|
||||||
|
a, b, both := sets[6], sets[7], sets[8]
|
||||||
|
if a == nil || b == nil || both == nil {
|
||||||
|
t.Fatalf("[%s] 多圆用例没跑起来(sets 里缺 6/7/8)", round)
|
||||||
|
}
|
||||||
|
union := map[int64]bool{}
|
||||||
|
for id := range a {
|
||||||
|
union[id] = true
|
||||||
|
}
|
||||||
|
for id := range b {
|
||||||
|
union[id] = true
|
||||||
|
}
|
||||||
|
for id := range union {
|
||||||
|
if !both[id] {
|
||||||
|
t.Errorf("[%s] 并集里的地点 %d 没出现在两圆专题里(少算了某个圆?)", round, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range both {
|
||||||
|
if !union[id] {
|
||||||
|
t.Errorf("[%s] 两圆专题多出地点 %d(不属于任何一个圆)", round, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("[%s] 多圆并集:A=%d B=%d 并集=%d 两圆专题=%d", round, len(a), len(b), len(union), len(both))
|
||||||
|
return len(a) > 0 && len(b) > 0
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-16
@@ -73,9 +73,6 @@ type Page struct {
|
|||||||
// HasMap 决定是否加载 Leaflet,避免没有地图的页面白下载一份地图库。
|
// HasMap 决定是否加载 Leaflet,避免没有地图的页面白下载一份地图库。
|
||||||
HasMap bool
|
HasMap bool
|
||||||
|
|
||||||
// Flatpickr 决定是否加载日期时间选择器(目前只有数据编辑页在用)。
|
|
||||||
Flatpickr bool
|
|
||||||
|
|
||||||
// HasTopics 表示该数据集有专题,导航里才露出「专题」入口。
|
// HasTopics 表示该数据集有专题,导航里才露出「专题」入口。
|
||||||
HasTopics bool
|
HasTopics bool
|
||||||
|
|
||||||
@@ -1575,6 +1572,15 @@ func dict(kv ...any) map[string]any {
|
|||||||
|
|
||||||
// ---------- 专题 ----------
|
// ---------- 专题 ----------
|
||||||
|
|
||||||
|
// TopicArea 是专题的一个圆形显示区(圆心 + 半径,半径单位公里)。
|
||||||
|
// 坐标是 **GCJ-02**(与 places 同系):地点过滤是拿它和 p.lat/p.lon 直接比球面距离的,
|
||||||
|
// 前台地图侧再由 rond-map.js 的 project() 换算到底图坐标系。存成底图坐标就会整体偏几百米。
|
||||||
|
type TopicArea struct {
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
RadiusKm float64 `json:"radiusKm"`
|
||||||
|
}
|
||||||
|
|
||||||
// Topic 是一个行程专题:某段时间、某个区域内的足迹打包展示。
|
// Topic 是一个行程专题:某段时间、某个区域内的足迹打包展示。
|
||||||
type Topic struct {
|
type Topic struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -1588,12 +1594,16 @@ type Topic struct {
|
|||||||
EndDate string
|
EndDate string
|
||||||
// RangeLabel 是展示用的时间范围文案
|
// RangeLabel 是展示用的时间范围文案
|
||||||
RangeLabel string
|
RangeLabel string
|
||||||
CenterLat float64
|
// Areas 是显示区(一组圆),空表示不限区域。
|
||||||
CenterLon float64
|
Areas []TopicArea
|
||||||
RadiusKm float64
|
// CenterLat / CenterLon / RadiusKm **已废弃**:早期只支持一个圆时的那三列。
|
||||||
ShowFog bool
|
// 保留只为兼容「还没回填的老行」与「测试里直接构造 Topic 的调用方」——新代码一律走 Areas。
|
||||||
Enabled bool
|
CenterLat float64
|
||||||
SortOrder int
|
CenterLon float64
|
||||||
|
RadiusKm float64
|
||||||
|
ShowFog bool
|
||||||
|
Enabled bool
|
||||||
|
SortOrder int
|
||||||
// PlaceCount / VisitCount / DayCount / CityCount / DwellMin 由内容统计得出
|
// PlaceCount / VisitCount / DayCount / CityCount / DwellMin 由内容统计得出
|
||||||
PlaceCount int
|
PlaceCount int
|
||||||
VisitCount int
|
VisitCount int
|
||||||
@@ -1602,8 +1612,35 @@ type Topic struct {
|
|||||||
DwellMin int64
|
DwellMin int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasArea 报告专题是否限定了区域(中心点 + 半径)。
|
// AreaList 归一显示区:Areas 为空时退回老的单圆。
|
||||||
func (t Topic) HasArea() bool { return t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) }
|
// 这样「还没回填的老行」和「测试里直接构造的 Topic」都继续工作,
|
||||||
|
// 不用要求每个调用方自己记着处理两套字段。
|
||||||
|
func (t Topic) AreaList() []TopicArea {
|
||||||
|
if len(t.Areas) > 0 {
|
||||||
|
return t.Areas
|
||||||
|
}
|
||||||
|
if t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) {
|
||||||
|
return []TopicArea{{Lat: t.CenterLat, Lon: t.CenterLon, RadiusKm: t.RadiusKm}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasArea 报告专题是否限定了显示区。
|
||||||
|
func (t Topic) HasArea() bool { return len(t.AreaList()) > 0 }
|
||||||
|
|
||||||
|
// AreasJSON 把显示区序列化成内联给地图脚本的 JSON(不限区域时是 `[]`,前端按 length 判)。
|
||||||
|
// 走 AreaList() 而不是裸 Areas:保证「HasArea 为真」与「脚本拿得到圆」永远同一份判据。
|
||||||
|
func (t Topic) AreasJSON() template.JS {
|
||||||
|
areas := t.AreaList()
|
||||||
|
if len(areas) == 0 {
|
||||||
|
return template.JS("[]")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(areas)
|
||||||
|
if err != nil {
|
||||||
|
return template.JS("[]")
|
||||||
|
}
|
||||||
|
return template.JS(b)
|
||||||
|
}
|
||||||
|
|
||||||
// TopicAdminData 是后台专题管理页的数据。
|
// TopicAdminData 是后台专题管理页的数据。
|
||||||
type TopicAdminData struct {
|
type TopicAdminData struct {
|
||||||
@@ -1613,6 +1650,9 @@ type TopicAdminData struct {
|
|||||||
Flash string
|
Flash string
|
||||||
Err string
|
Err string
|
||||||
IsNew bool
|
IsNew bool
|
||||||
|
// MapHomeJSON 是显示区编辑器的初始视野(生效数据集的地点包围盒),没有数据时是 null。
|
||||||
|
// 让地图一上来就落在站长自己的地界上,而不是全国视野还要自己找。
|
||||||
|
MapHomeJSON template.JS
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopicListData 是前台专题列表页的数据。
|
// TopicListData 是前台专题列表页的数据。
|
||||||
@@ -1627,11 +1667,10 @@ type TopicData struct {
|
|||||||
Topic Topic
|
Topic Topic
|
||||||
Points []MapPoint
|
Points []MapPoint
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
// PointsJSON / FitCircleJSON / FitBBoxJSON 内联给地图脚本
|
// PointsJSON / FitBBoxJSON 内联给地图脚本
|
||||||
// (template.JS 跳过转义,避免被二次转义搞坏)。两个 fit 只有一个非 null。
|
// (template.JS 跳过转义,避免被二次转义搞坏);FitBBoxJSON 为 null 时脚本退回点集贴合。
|
||||||
PointsJSON template.JS
|
PointsJSON template.JS
|
||||||
FitCircleJSON template.JS
|
FitBBoxJSON template.JS
|
||||||
FitBBoxJSON template.JS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackTag 把配置里的追踪脚本转成可输出的 HTML。
|
// trackTag 把配置里的追踪脚本转成可输出的 HTML。
|
||||||
|
|||||||
@@ -149,6 +149,36 @@ if [ -z "$RUN" ] && [ "$PKGS" = "./..." ]; then
|
|||||||
ok "纯净往返包已生成:$(basename "$CLEAN_OUT")"
|
ok "纯净往返包已生成:$(basename "$CLEAN_OUT")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- 第 1.5 段:前端脚本单测(node 直跑,不依赖数据库)--------------------------
|
||||||
|
# internal/web/static/js/*.test.js 是纯函数用例(日期解析、迷雾裁剪路径拼装)。
|
||||||
|
# 以前只靠人记得手动跑 = 等于没有:改了脚本没人拦。这里挂进验收。
|
||||||
|
# 找不到 node 时**直接失败**而不是「跳过」—— 跳过会让这次验收看着全绿,
|
||||||
|
# 而实际上前端脚本一行都没验(这正是「0 skip」那条纪律要防的事)。
|
||||||
|
NODE_BIN="${NODE_BIN:-}"
|
||||||
|
if [ -z "$NODE_BIN" ]; then
|
||||||
|
for cand in "$(command -v node 2>/dev/null)" \
|
||||||
|
"$HOME/.workbuddy/binaries/node/versions/22.22.2-6/node.exe" \
|
||||||
|
"/c/Program Files/nodejs/node.exe"; do
|
||||||
|
if [ -n "$cand" ] && [ -x "$cand" ]; then NODE_BIN="$cand"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
[ -n "$NODE_BIN" ] || fail "找不到 node(前端脚本单测跑不了)。用 NODE_BIN=/path/to/node 指定后再跑"
|
||||||
|
JSPASS=0; JSFAIL=0
|
||||||
|
for t in "$ROOT"/internal/web/static/js/*.test.js; do
|
||||||
|
[ -f "$t" ] || continue
|
||||||
|
if jsout=$("$NODE_BIN" "$t" 2>&1); then
|
||||||
|
n=$(printf '%s\n' "$jsout" | sed -n 's/.*pass=\([0-9]\{1,\}\).*/\1/p' | tail -1)
|
||||||
|
JSPASS=$((JSPASS + ${n:-0}))
|
||||||
|
ok "前端单测 $(basename "$t"):pass=${n:-0}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$jsout" | tail -25
|
||||||
|
JSFAIL=$((JSFAIL + 1))
|
||||||
|
echo "✗ 前端单测失败:$(basename "$t")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$JSFAIL" = 0 ] || fail "前端单测有 $JSFAIL 个文件失败"
|
||||||
|
echo "== 前端单测合计:pass=$JSPASS,失败文件 $JSFAIL"
|
||||||
|
|
||||||
# --- 第二段:全量(或定点)------------------------------------------------------
|
# --- 第二段:全量(或定点)------------------------------------------------------
|
||||||
RUNARG=""
|
RUNARG=""
|
||||||
[ -n "$RUN" ] && RUNARG="-run $RUN"
|
[ -n "$RUN" ] && RUNARG="-run $RUN"
|
||||||
|
|||||||
Reference in New Issue
Block a user