Compare commits
22
Commits
4015ddf373
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3321c6e6 | ||
|
|
862160c537 | ||
|
|
6aee7e441b | ||
|
|
9cf3d89c3c | ||
|
|
bf7121f11b | ||
|
|
d795c6b579 | ||
|
|
9c76437a76 | ||
|
|
829a318400 | ||
|
|
7ab33227a5 | ||
|
|
48938e6ae1 | ||
|
|
2d46b8a0ca | ||
|
|
b40160867e | ||
|
|
44763dfc3f | ||
|
|
1bb35dbbc9 | ||
|
|
cc37c9d1c9 | ||
|
|
eb35abb7f2 | ||
|
|
cd69efe8f1 | ||
|
|
b19f27ab69 | ||
|
|
b69b827d6e | ||
|
|
3077250a82 | ||
|
|
ab7ccd22ce | ||
|
|
d4e4257a23 |
@@ -0,0 +1,272 @@
|
||||
# 2026-10-09
|
||||
|
||||
## 排查:/admin/settings 报错(旧二进制 + 新模板)
|
||||
|
||||
**现象**:「统计脚本做成后台开关」那次提交(4015ddf)之后,访问 /admin/settings 报错。
|
||||
|
||||
**根因(已实测复现)**:跑的是**旧二进制**(根目录 `rond.exe`,10-08 18:06 编译,早于 18:27 的提交),
|
||||
磁盘上的模板却是新的。`debug=true` 下 `pageSet()` 每次渲染都从磁盘重解析模板,
|
||||
于是新 `settings.html:148` 引用了旧 `setting.Settings` 结构体里没有的 `TrackEnabled` → 执行期报错:
|
||||
|
||||
```
|
||||
渲染 admin/settings 失败: template: settings.html:148:85: executing "content"
|
||||
at <.Settings.TrackEnabled>: can't evaluate field TrackEnabled in type setting.Settings
|
||||
```
|
||||
|
||||
`render` 是流式直写 ⇒ header 已发(HTTP 200)但表单在「统计脚本」卡片处截断,
|
||||
没保存按钮、没 `</form></html>`。这就是用户看到的「报错」。
|
||||
|
||||
**判据**:`strings <二进制> | grep -c cleanTrackScript` —— 旧根二进制 0,新编译的 2。
|
||||
(注意别用 `strings | grep TrackScript` 判定,config 里本来就有这个名字,旧二进制也命中。)
|
||||
|
||||
**修复**:`go build -o rond.exe .`(+ `bin/rond.exe`),重启。旧二进制备份到
|
||||
`bin/rond.exe.old-20261008-stale`。修后 /admin/settings 完整(19642B,含 `</html>`/保存按钮)。
|
||||
|
||||
**教训**:Go 结构与模板字段是成对改的,`debug=true` 的热加载只覆盖模板 —— 改 Go 必须重编译重启,
|
||||
否则就是「新模板 + 旧结构 = 执行期报错」。想根治可在启动时把所有页面各渲染一遍做冒烟(现在没有)。
|
||||
|
||||
## 其他发现(未改代码)
|
||||
|
||||
1. **P1 待确认**:`settings` 行 `pages=null`(写入时间 2026-10-08 18:14:05)⇒ 访客侧
|
||||
`/map /places /timeline /stats /report` 全部 404、导航只剩 1 个链接。代码语义是
|
||||
「全不选 = 访客只能看主页」,属合法状态,像是测 404 分档时留下的。**没动数据,等确认**。
|
||||
2. **P2**:`cleanTrackScript` 的 `src=` 检查是**全文 `strings.Index`**,会误伤脚本体内的
|
||||
`src=`。实测:`<script>var i=new Image();i.src=u;</script>` → 返回 `""`(整段被静默清空),
|
||||
而页面仍提示「设置已保存」⇒ 静默失效。建议只在标签属性里查 src,或把「被清空」回传前端显式提示。
|
||||
3. **P2**:开闭标签只拒 `shut > open`;末尾未闭合 `<script>`(`open > shut`)会吞掉后面内容。
|
||||
实测 `<script>x</script><script>y` 与 `<script>y` 都被放行。建议改成要求 `open == shut`。
|
||||
4. **运维**:根 `rond.exe` 与 `bin/rond.exe` 两份二进制容易走偏(这次根目录那份就是旧的)。
|
||||
考虑只保留 `bin/`,或在启动日志打印构建时间便于比对。
|
||||
|
||||
**回归**:`go vet` 干净、`gofmt -l` 空、JS 单测 34/34、`go test ./...` 13 包全 ok 0 FAIL。
|
||||
(注:`internal/web` 里 110 例是 DB 门控的,不开 `ROND_TEST_CONF`/`ROND_SRC` 会 skip,不算真跑过。)
|
||||
|
||||
---
|
||||
|
||||
## 用户确认后的收尾(四件)
|
||||
|
||||
用户反馈:「管理员访问也只剩主页,这好像不对」+ P2 按建议修 + 二进制只留根目录 + 防复发一并做。
|
||||
|
||||
### 1. P1 更正:不是配置问题,是导航缺 IsAdmin 分支
|
||||
|
||||
上面第 1 条判断得不完整。实测:站长访问 /map /places /timeline /stats /report **全是 200**
|
||||
(`guardPage` 只拦访客),但导航里只有「主页」⇒ `layout.html` 的页面显隐写的是
|
||||
`{{if .Settings.HasPage "x"}}`,**没有 IsAdmin 分支**,与 `guardPage` 口径不一致。
|
||||
改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}` 五处。门禁 `nav_visibility_test.go`
|
||||
(三向不变量 + 变异验证)。**访客侧的 pages=null 没动**(仍是「只对访客关」的配置状态)。
|
||||
|
||||
### 2. P2 修复:`cleanTrackScript` 重写
|
||||
|
||||
- `src=` 只在 `<script …>` **开标签内部**找(原来对整段 `strings.Index`,`i.src=u` 被误伤清空)。
|
||||
- 开闭标签从「只拒 `shut > open`」改成**严格配平**(末尾未闭合的开标签原来会被放行)。
|
||||
- 签名改 `(script, warn string)`,说明码经 `?warn=` 回显成人话;判危险时**整段不采纳、
|
||||
库里原脚本与开关不动**。
|
||||
- 实测(测试实例 8081 / `rond_fog_test`,用完已还原):`i.src=u` 现在原样落库;
|
||||
不配平 → `?warn=unbalanced` 且库值不变;`javascript:` → `?warn=badsrc` 且库值不变;
|
||||
5000 字符 → `?warn=truncated` 且落库 4096。注入范围精确复核:只在公开页(/、/map),
|
||||
后台与登录页 0。
|
||||
|
||||
### 3. `bin/` 删除
|
||||
|
||||
15 个 exe、412MB 全删;只保留根 `rond.exe`。技能 `rond-web-verify` 与 details 十八的
|
||||
`bin/rond.exe` 写法已同步改掉。
|
||||
|
||||
### 4. 防复发:启动自检 `smokeRender`
|
||||
|
||||
`template_smoke.go`:`pageProbe`(24 个页面各一份最小可用数据,键与 `pageFiles` 双向校验)
|
||||
+ `New()` 里全量渲染一遍,失败即 `log.Fatalf` 拒启。实测:往 settings.html 插一个不存在的
|
||||
字段 → 启动报 `模板自检未通过…admin/settings: can't evaluate field …`,退出码 1。
|
||||
门禁 `template_smoke_test.go`(含变异验证:把探针换成字段更少的类型必须报错)。
|
||||
|
||||
### 5. 用户裁决 + 文案订正
|
||||
|
||||
- **访客侧 `pages=null` 不动**:用户明确说「访客那一侧是根据我在后台配置的显示范围公开的,
|
||||
倒不如说原来默认就是这样才对」⇒ 那是**预期配置**,不是故障。已把这个结论写进
|
||||
details 四十一,免得后续会话又去「修」数据。
|
||||
- **settings.html 的统计脚本文案订正**(P3):原文两处不准 ——
|
||||
①「多一个 `</script>` 会被判为粘贴错误」漏了「少一个也会拒」(判据已改成严格配平);
|
||||
②「`conf/app.ini` 的 `track_script` 会作为这里的默认值」有歧义:它**不会回填文本框**,
|
||||
而是「这个框留空时,注入时回落到 ini 的值」。已改成准确说法。
|
||||
实测热加载生效(debug=true,无需重编),页面 19996B、含 `</html>`,旧文案 0 命中。
|
||||
- 验收:官方 `./scripts/verify.sh` → **PASS=346 FAIL=0 SKIP=0**(改动前那次全量;
|
||||
之后只动了这一个模板文案,重跑了全部模板门禁与 `TestSmokeRender*`,全绿)。
|
||||
|
||||
## 提交 + 前端现代化方案
|
||||
|
||||
**提交**:`d4e4257`「启动模板自检 + 站长导航放行 + 统计脚本关口收紧」(12 文件,+564/−75)。
|
||||
|
||||
⚠️ **`shots/` 里的截图是旧状态,做视觉判断前必须现场重截**:`mobile-home.png` 还显示顶栏
|
||||
折成 3 行、右按钮竖排,实测现在 390 下品牌名只留 🐾、按钮「后台」、`navH=51` 且不溢出。
|
||||
差点拿旧图当现状写进方案。
|
||||
|
||||
**现状实测(CDP,真实视口)**:
|
||||
- 卡片:17 个盒子(`.card`/`.kpi`/`.filters-card`)**只有 2 种样式指纹** ⇒ 层级扁平。
|
||||
- 字阶 8 档(10/12/13/14/15/18/21/23),10–15px 挤了 5 档;字重 400/500/600/**650**。
|
||||
- 正文色只有 4 种;类型色 10 种只用在圆点与柱子,没进界面语义层。
|
||||
- 站长导航 `scrollWidth=277`:430→254(溢 23)、390→214(溢 63)、320→144(溢 133);
|
||||
390 下「统计」被切成半个字紧贴「后台」,`.overflow-right` 的渐隐看不见。
|
||||
- 已有优点别改坏:KPI 已是 `tabular-nums`、`--text-3` 按 AA 调过、暗色模式已有 3 段 media。
|
||||
|
||||
**产出**:`frontend-plan-2026-10-09.md`(仓库根,与 `audit-2026-09-21.md` 并列)。
|
||||
四批:A 设计令牌收敛(字阶砍到 6 档、字重 3 档、间距/圆角/阴影令牌)→ B 卡片分 hero/常规/次级
|
||||
+ 首屏 1 个大数 → C 手写图表加基线/峰值/轴标(不上图表库)→ D 类型色语义化 + 窄屏导航。
|
||||
**明确不做**:前端框架/构建链、图表库、信息架构重构。建议先做 A+B(约 1.5 天,纯 CSS + 少量 class)。
|
||||
方案**未改任何代码**,等选批次。
|
||||
|
||||
---
|
||||
|
||||
## 落地:A + B 批次(用户选了「先按 A-B 去做」)
|
||||
|
||||
**做法**:先跑基线存档(`ab.js` 探针 + `audit/run_all.py --mobile 390` / `--overflow 1440`),
|
||||
再改,再用同一份探针跑一遍逐值 diff。CSS 改动量 187+/150−,另有 19 个模板共 54 处内联字号并档。
|
||||
|
||||
**关键结果**(同一探针,改动前 → 后):
|
||||
- `/` 的 (字号,字重) 组合 390:**13 → 10**、1440:**15 → 10**;`/places` 10 → **5**;
|
||||
`/timeline` 10 → **6**;`/stats` 15 → **9**;`/admin/settings` 10 → **7**。
|
||||
- 最小字号到处都 ≥11px(原来有 10 / 10.5px)。
|
||||
- 卡片样式档:`/` 2 → **3**,`/admin/settings` 1 → **2**。
|
||||
- 溢出:移动端审计**逐值一致**;桌面 1440 唯一差异是 `/map` 露外元素 9 → 8。
|
||||
- 验收 `./scripts/verify.sh`:**PASS=347 FAIL=0 SKIP=0**。
|
||||
|
||||
**踩到的四处**(都只有「量 DOM 的 (字号,字重) 组合」才看得见,截图上看都正常):
|
||||
1. **Leaflet 字号跟着根字号漂**:vendor `.leaflet-container { font-size: .75rem }`,
|
||||
根 14 → 13px 后地图署名/比例尺掉到 **9.8px**、弹窗正文也跟着缩。
|
||||
已在 app.css 钉死(容器 `--fs-sm`、署名/比例尺 `--fs-xs`)。
|
||||
2. **项目自带的静态守卫抓到我**:`TestNavLinksNarrowRuleHasNoDeadPadding` 写死了 `font-size: 13px`,
|
||||
并档后它红了。守卫是对的,改判据为「钉规则还在、且设了字号」;另补 `TestNoBareFontSizePx`
|
||||
(静态扫 app.css + 模板禁裸 px 字号,做过变异验证)。
|
||||
3. **`h1..h4` 从没设过字号**(原来只有字重与 margin),靠浏览器默认 `2em/1.5em/1.17em` ⇒ 也跟着根字号漂:
|
||||
根 14 → 13px 后,`/stats` 里没被 `.card > h2` 命中的两个 h2 从 21px 变成 19.5px,比同页卡片标题(15px)大一圈。
|
||||
已显式给出 h1/h2/h3 字号,实测 12 个 h2 统一 `15px/600`。
|
||||
4. **`<b>/<strong>` 浏览器默认 700**,比全站最高层级(标题 600)还重 ⇒ 已钉成 600。
|
||||
|
||||
**教训(已写进 details 四十二)**:quiet 卡片第一版用 `--bg-2`(#fafbfb),
|
||||
与页面底 #f6f7f8 只差 4/255 ⇒ 实测看不出边界、像「破了个洞」。**配色差异必须量,不能凭「应该能看出来」。**
|
||||
|
||||
**运维提醒**:测试库跑到 180MB / 21 万死元组时 `verify.sh` 会从 8 分钟拖到 12 分钟以上
|
||||
(脚本 200MB 才自动 VACUUM);手动 `VACUUM (ANALYZE)` 后回到 ~10 分钟、137MB / 0 死元组。
|
||||
|
||||
**发现未修**:7 个模板工作区里是 CRLF,而 `.gitattributes` 要求 `eol=lf`
|
||||
(`admin/index.html`、`admin/login.html`、`admin/twofa-login.html`、`admin/twofa.html`、
|
||||
`map.html`、`places.html`、`timeline.html`)。本批按字节替换保住了各自行尾、diff 无行尾噪声,
|
||||
要不要统一另开一次清理。
|
||||
|
||||
---
|
||||
|
||||
## 落地:C + D 批次(用户选了「C和D继续做」)
|
||||
|
||||
**C 图表**:月度分布加基线 + 峰值柱标次数(`.pv`,副标题不再重复写「最高 N 次」);
|
||||
一天中的分布把「0/6/12/18 四个数字」换成 **6 小时一段的标尺**(深夜/上午/下午/晚上),
|
||||
用**同一套 24 列 grid** 保证段边界与柱子严格对齐;首页月度图同样加峰值(给 `HomeData` 补了 `MaxMonthly`)。
|
||||
**空态那条划掉了** —— 查下来已经统一(8 页共用 `emptystate`、局部走 `.empty`)。
|
||||
|
||||
**D1 类型色**:先算对比度,结论推翻了原计划 ——
|
||||
|
||||
- 白底上 6 个色低于图形 3:1(yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57)
|
||||
- 暗底只有 indigo 2.90 不够
|
||||
|
||||
所以**没有**把类型色做成 chips / 表格类型列 / KPI 强调(那几处要的是文字色,会更难读)——
|
||||
这恰好解释了全站为什么一直只用 8px 小圆点做标记。浅色 6 个色一个没动(是手机端/地图的同一套身份色)。
|
||||
实际做了:色值收口成 `--cat-*` + `--cat-*-soft`(26 个变量)、模板 `colorof` → `csscolor`/`csssoft`(19 处)、
|
||||
暗色只提亮 indigo。`colorOf` 已成死代码,删掉。
|
||||
|
||||
⚠️ **踩到 `ZgotmplZ`**:`html/template` 的 CSS 过滤器不认识 `var()`,函数返回普通 string 会被替换成
|
||||
`ZgotmplZ`,页面上所有圆点当场变透明、**且不报错、模板测试照过**。
|
||||
必须返回 `template.CSS`;而挂了它等于关掉过滤,所以数据侧色值先过严格校验(只有 `#`+6 位 hex 放行)。
|
||||
已加守卫 `TestCSSColorSurvivesTemplateSanitizer`(两个方向都钉,做过变异验证)。
|
||||
|
||||
**D2 窄屏导航**:≤620px 整行收进 `.nav-menu`(原生 `<details>`,JS 只补点外面/Esc/点链接后收起)。
|
||||
⚠️ 顶栏 z-index 抬到 `--z-nav: 1200`(必须高于地图浮层 1100,否则展开的菜单被筛选面板盖住),
|
||||
已在 `/map` 用 `elementFromPoint` 实测。改前 390 溢 63px、320 溢 133px(「统计」被切半个字)。
|
||||
|
||||
**回归**:`verify.sh` **PASS=348 FAIL=0 SKIP=0**;320/390 溢出**全 0**(改前 `/admin` 在 320 还溢 34px);
|
||||
桌面 1440 与改前一致;**7 个页面都没有低于 11px 的文字**;暗色下菜单/类型色/阴影均按暗色变量生效。
|
||||
|
||||
---
|
||||
|
||||
## 提交 + 完整回归(含「原有代码是否受影响」专项)
|
||||
|
||||
**提交**:`ab7ccd2`「前端改造:设计令牌 + 卡片层级 + 图表可读性 + 类型色收口 + 窄屏导航」
|
||||
(32 文件,+997/−283)。A/B 与 C/D 本想拆两笔,但 `app.css`/`layout.html`/`view.go` 里两批改动交织,
|
||||
按文件拆不开、按 hunk 拆容易做出「中间那笔编译不过」的历史,**合并成一笔、消息里分段**,并在提交消息里写明原因。
|
||||
|
||||
**完整验证链**(一次跑齐,全部串行):
|
||||
- `./scripts/verify.sh`:**PASS=348 FAIL=0 SKIP=0**
|
||||
- `./scripts/verify.sh --pkg ./internal/fog/ --race`:**PASS=22 FAIL=0 SKIP=0**(mingw 在 PATH 里,-race 可用)
|
||||
- JS 单测 34/34
|
||||
- 移动端 320 / 390 + 桌面 1440 审计:**320/390 溢出全 0**;1440 只有 −15(滚动条宽,既有口径)
|
||||
|
||||
**「原有代码受影响」专项检查**(静态扫描 + 逐文件回读 diff):
|
||||
- 自定义属性:定义 90 / 使用 61,**「用了但没定义」为 0**(先前一轮报的 12 个全是我正则的假阳性:
|
||||
CSS 一行写多个属性、`--cat-*` 是 Go 侧动态拼的、模板 `:root` 里也有定义)。
|
||||
- 定义了没人用的:只剩 28 个 `--cat-*`(Go 动态拼接,运行时实测 14 种颜色都渲染出来了)——
|
||||
另外揪出 `--sp-5` 真没人用,已用到「停一下」页的留白上(正好是大间距场景)。
|
||||
- 模板函数:**没有真孤儿**(先前列表是正则把 `appleColors` 的键当成了函数名)。
|
||||
- 已删项残留:`colorof`/`--radius`/`--shadow`/旧测试名 全部 0 残留(`app.css` 里那句注释也顺手改对了)。
|
||||
- 逐文件回读:19 个模板的 diff 只有字号替换与 `colorof`→`csscolor`,没有误伤;
|
||||
Go 侧 diff 只有 `MaxMonthly`、`cssColor/cssColorSoft`、注册表与 `regexp` 导入。
|
||||
- 顺带发现(**既有、与本批无关**):`.g3`/`.g4` 是 HEAD 里就有的死类(模板/JS 都没引用);
|
||||
`.notice-card` 等不是死类 —— 它在 `notice.go` 里由 Go 拼 HTML,我的模板侧扫描覆盖不到。
|
||||
|
||||
**验证中抓到的一例失败(已实证是环境抖动)**:
|
||||
`TestOpenCreatesMissingDatabase` 报「建库后首次连接超时」10.02s。根因是 `db.Open` 的 ping **写死 10s**,
|
||||
当时库正被验收 + autovacuum + CDP 审计一起压着;停掉审计单独跑 **4.1s 通过**。
|
||||
⚠️ 这条测试失败还会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` 成功之后才注册)——已清掉残留。
|
||||
两条结论都写进了 details 三十二的验收小节。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 第三批:日期框(用户报的硬 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」的本质是**界面缺参照物**,不是算错。以后遇到「大小看起来不对」类反馈,
|
||||
先问「用户手上有什么可以量」,而不只是去验算几何。
|
||||
@@ -110,7 +110,7 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块
|
||||
|
||||
## 十八、重启与操作纪律
|
||||
|
||||
- **重编重启**:`go build -o bin/rond.exe .` → 停掉占 8080 的进程 → `./bin/rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
|
||||
- **重编重启**:`go build -o rond.exe .`(**只构建到仓库根,`bin/` 已废弃、2026-10-09 删除**)→ 停掉占 8080 的进程 → `./rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
|
||||
- 生产 `life.shiroko.one` **只走 IPv4**(curl 必须 `-4`);会话 cookie `rond_session=uid|token`(HMAC);HTTP 303 **不能跟随**(Location 里带 `ok=`/`err=`)。`/tmpfog/` 不是运行必需,`tmpfog/_ck.txt` 是**死票**(实打接口被 302)⇒ 不必轮换 `secret_key`。
|
||||
- **`gofmt -l` 会报假警**:仓库存 LF、`core.autocrlf=true` 把工作区换成 CRLF(已加 `.gitattributes` 钉住);**用 python `open(p,'w')` 回写 Go/CSS 会把行尾变 CRLF**,要二进制模式读写。
|
||||
- **同一文件绝不并行改**:多条 Edit 一起发给同一文件时,各自「读整份-替换-写整份」会互相覆盖——工具**报 success 但磁盘上没落**(实测一次发 3 条给 `map.html`,`map-count` 那条丢了)。**一个文件串行改,改完 `grep` 复核关键标记,再编译。**
|
||||
@@ -534,6 +534,17 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块
|
||||
- 验收四件套 `ROND_TEST_CONF`+`ROND_SRC`+`FOG_SRC`+`ROND_DSN`(须指 `rond_fog_test`);`-count=1 -v ./...` 要 **0 skip**。两个假绿坑:缺 `ROND_SRC` 静默 Skip;共用库 `Import` 让 `internal/stats` 假 skip ⇒ 用专用库,`t.Cleanup` 在 `defer` **之后**才跑。
|
||||
- 静态守卫走 `moduleRoot(t)`/`pkgFile(t,name)`;`-race` 需 mingw 在 PATH。纪律:只走 IPv4、303 不跟随;**同一文件绝不并行发多条 Edit**;新回归测试做**变异验证**。
|
||||
- 📐 覆盖率**必须 `-coverpkg=./internal/...`**(`-cover` 只算本包 ⇒ rond/geo 假报 0%,真值 82.2%/92.9%);解析 profile 按 `(文件,行段)` 取 **max 别求和**;**死代码删掉、别给它补测试**。关键页冒烟 `scripts/smoke.py`(`verify.sh --smoke`/`--all`):关键区块不能用 `nav.nav`、用 `.card` 不用 `div.card`。`details 廿九`
|
||||
- ⚠️ **验收要在「安静环境」跑,别和别的东西并发**:`db.Open` 的 ping 是**写死 10s**(`db.go:52`),
|
||||
库被压着时 `TestOpenCreatesMissingDatabase` 会以「建库后的首次连接超时」失败 ——
|
||||
2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉**单独跑 4.1s 就过**(环境抖动,不是代码问题)。
|
||||
另外这条测试失败时会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` **成功之后**才注册),
|
||||
跑完记得手动 `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 源码参与缓存键)。
|
||||
|
||||
|
||||
---
|
||||
@@ -879,14 +890,368 @@ Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器
|
||||
`settingsSubmit()` 的表单读取。`Load()` 里布尔项一律用 `present[key]` 判:
|
||||
历史配置没有这个键时若直接套零值,语义会悄悄反转。
|
||||
|
||||
### `cleanTrackScript` 的判据(这里踩过一次)
|
||||
### `cleanTrackScript` 的判据(这里踩过两次,2026-10-09 重写)
|
||||
|
||||
⚠️ **不能用「出现 `</script` 就拒」当规则** —— 百度统计、Umami 贴进来的就是完整
|
||||
HTML,一行里必然带 `</script>`,一拒就把正常用法挡死了(我第一版就是这么写的,
|
||||
被自己的测试当场抓住)。真正的注入面只有一种:**未闭合的 `<script>` 里出现
|
||||
`</script>` 会提前闭合**,后面内容当正文渲染。判据改成**开闭标签数配平**
|
||||
(`shut > open` 才拒),配平则整段原样放行。
|
||||
另挡 `src=` 的 `javascript:` / `data:` 伪协议(只放行 http/https 与 `/` 开头),
|
||||
超 4096 截断。
|
||||
门禁 `TestCleanTrackScript` 除了逐条边界,还钉了**定义性不变量**:
|
||||
任何输入都不得产出「含 `</script` 的非空结果」。
|
||||
被自己的测试当场抓住)。
|
||||
|
||||
现行判据两条:
|
||||
1. **开闭 `<script>` 标签必须严格配平(`open != shut` 就拒)**。少一个 `</script>`
|
||||
会提前闭合、后面内容当正文渲染;多一个 `<script>` 会把页面剩下的收尾整段吞进脚本。
|
||||
⚠️ 只拒 `shut > open` 是不够的(初版就是),末尾未闭合的开标签会被放行 ——
|
||||
`<script>x</script><script>y` 实测通过,已改成严格配平。
|
||||
2. **`src=` 只在 `<script …>` 开标签内部找**,只放行 `http(s)://` 与 `/` 开头的相对路径。
|
||||
⚠️ 初版对**整段**做 `strings.Index(low, "src=")`,脚本体里一句 `img.src=u` 就被命中、
|
||||
整段被判危险而清空 —— 而页面照样提示「设置已保存」,站长完全看不出脚本没挂上。
|
||||
实测:`<script>var i=new Image();i.src=u;</script>` 旧版返回 `""`,现在原样放行。
|
||||
超 4096 截断。
|
||||
|
||||
**收尾要显式**:函数签名改成 `(script, warn string)`,说明码
|
||||
(`truncated` / `unbalanced` / `badsrc`)经 `?warn=` 跳到设置页、由 `trackWarnMessage`
|
||||
翻成人话显示。用**固定码**而不是回显查询串原文(避免反射内容)。
|
||||
判为危险时**整段不采纳、库里原脚本与开关都不动**(把站长手上好用的脚本一并清掉更糟)。
|
||||
|
||||
门禁 `TestCleanTrackScript` 逐条钉边界 + **定义性不变量**:
|
||||
采纳了非空结果时,里面的开闭标签数必定相等(不是「不得含 `</script`」——整段贴进来的
|
||||
本来就带闭合标签)。
|
||||
|
||||
## 四十一、2026-10-09 三处修复:导航口径 / 启动自检 / 二进制只留根目录
|
||||
|
||||
### 1. 导航的页面显隐**只管访客**(`layout.html`)
|
||||
|
||||
`guardPage`(`server.go`)拦的只是访客:`userFrom(ctx)==nil && !sets.HasPage(nav)` → 404;
|
||||
站长始终能访问全部公开页。但导航写的是 `{{if .Settings.HasPage "map"}}`,**没有 IsAdmin 分支**
|
||||
⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
|
||||
已改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}`(map/places/timeline/stats/report 五处)。
|
||||
门禁 `nav_visibility_test.go`:直接执行 `nav` 模板,钉「关掉开关后站长仍有 5 个入口、
|
||||
访客一个都没有、全开放时访客也看得到」三向不变量(做过变异验证)。
|
||||
|
||||
⚠️ **访客侧「只有主页」是配置出来的预期状态,别去"修"数据**:库里 `settings.pages = null`
|
||||
(= 站长把公开页面全关了),访客 `/map /places /timeline /stats /report` 一律 404。
|
||||
2026-10-09 用户明确确认:**访客能看哪些页就是按后台配的范围公开,这样才对**。
|
||||
要改就改后台的「公开页面」开关,不要直接动 `settings` 行。
|
||||
|
||||
### 2. 启动自检 `smokeRender`(`template_smoke.go`)
|
||||
|
||||
⚠️ **「模板能解析」不等于「渲染得完」**:字段不存在、nil 解引用都只在**执行期**暴露,
|
||||
而 `render` 只把错误写进日志 ⇒ 线上是「HTTP 200 + 渲染到一半的半张页面」。
|
||||
2026-10-09 的 /admin/settings 截断就是这么坏的(提交里同时改了 `setting.Settings` 和
|
||||
`settings.html`,跑的是改动前编译的二进制,debug 下模板走磁盘热加载 → 新模板 + 旧结构体)。
|
||||
|
||||
做法:`pageProbe`(每个页面一份「最小可用数据」)+ `smokeRender` 在 `New()` 里把
|
||||
**所有页面各渲染一遍**,任一失败就 `New` 返回 error → main `log.Fatalf` **拒绝启动**。
|
||||
- `pageProbe` 的键必须与 `pageFiles` **双向一致**:缺项按失败处理(新加页面必须补探针)。
|
||||
- 探针值只求「类型对、不炸」——页面本来就得能渲染空数据。
|
||||
- `smokeOne` 里 `recover()` 兜住方法里的 panic,报出「哪一页坏了」而不是崩栈。
|
||||
- 门禁 `template_smoke_test.go`:一个跑全量自检;一个**变异验证**(把 `admin/settings`
|
||||
的探针换成字段更少的 `Page`,必须报错且点名该页)。
|
||||
- 实测:往 `settings.html` 插一个不存在的字段 → 启动即报
|
||||
`初始化服务失败: 模板自检未通过…admin/settings: …can't evaluate field …`,退出码 1。
|
||||
|
||||
### 3. 二进制只留在仓库根(`bin/` 已删,2026-10-09)
|
||||
|
||||
`go build -o rond.exe .` 一个位置。`bin/` 整套(15 个 exe、412MB)已删;
|
||||
`.gitignore` 的 `/bin` 保留作为兜底。`scripts/build-linux.sh` 照旧只产
|
||||
`dist/rond-linux-amd64`。相关技能(`rond-web-verify`)与 details 十八已同步改掉
|
||||
`bin/rond.exe` 的写法。
|
||||
|
||||
## 四十二、前端设计令牌(A/B 批次,2026-10-09)
|
||||
|
||||
完整方案与实测数据见仓库根 `frontend-plan-2026-10-09.md`。这里只记**以后改前端必须守的规矩**。
|
||||
|
||||
### 字阶 / 圆角 / 阴影一律走令牌,别写 px
|
||||
|
||||
- 文本 6 档:`--fs-xs/sm/md/lg/xl/2xl` = 11 / 13 / 15 / 21 / 26 / 34px。
|
||||
- `--fs-control: 16px`:**窄屏表单控件必须 ≥16px**(iOS 聚焦 <16px 会把整页放大)。这是功能约束,
|
||||
所以它独立于字阶;窄屏媒体查询里用 `--field-fs` 覆盖。**别把它并进字阶。**
|
||||
- `--glyph-*` 6 档:✕ / 箭头 / 空态图标 / 表情的字号。**字形不是文字层级**,按控件尺寸定;
|
||||
硬塞进字阶(比如把 22px 的 ✕ 提到 26px)只会把按钮撑坏。
|
||||
- 圆角 `--r-xs/sm/md/lg` = 3 / 6 / 8 / 12px。`50%` 与 `999px` 是形状(圆 / 胶囊),不算圆角档。
|
||||
- 阴影 `--sh-1/2/3`:⚠️ **暗色模式必须整套覆盖**(原来只覆盖一个 `--shadow`,拆档后漏一个就会
|
||||
在暗色下用错档)。
|
||||
- 间距 `--sp-1..5` 本批**只迁移了卡片内边距这类主节奏**,其余没做全量替换。
|
||||
- `--card-pad` 与折叠卡片标题行的负 margin 绑在一起(`calc(var(--card-pad) * -1)`),改一个即改两处。
|
||||
|
||||
**门禁** `TestNoBareFontSizePx`:静态扫 app.css 与全部模板,出现 `font-size: <数字>px` 就红。
|
||||
|
||||
### 卡片三档
|
||||
|
||||
`.card--hero`(首屏地图 / 主数区)/ `.card`(默认)/ `.card--quiet`(只读说明)。
|
||||
⚠️ quiet **别拿 `--bg-2` 当底色**:它与页面底 `--bg`(#f6f7f8) 只差 4/255,实测看不出边界,
|
||||
卡片会像「破了个洞」而不是「轻了一档」。要「页面底色 + 保留细边 + 无阴影」。
|
||||
|
||||
首屏主数用 `.kpi-lead`(不是 `.kpi`):主数 + 一行上下文 + 左侧 3px 主色条。
|
||||
|
||||
### ⚠️ 改根字号会带动 vendor 库与「没设字号的默认标签」
|
||||
|
||||
`html, body` 的 font-size 一改,至少三处会跟着漂:
|
||||
|
||||
1. **Leaflet**:vendor 里 `.leaflet-container { font-size: 0.75rem }` 是**相对根字号**的,
|
||||
根从 14 → 13px 后地图署名/比例尺掉到 **9.8px**、弹窗正文也跟着缩。
|
||||
app.css 里已钉死(容器 `--fs-sm`、署名/比例尺 `--fs-xs`)。
|
||||
2. **`h1..h4`**:app.css 原来只给它们设了字重与 margin、**没设字号**,于是靠浏览器默认的
|
||||
2em / 1.5em / 1.17em —— 同样跟着根字号漂。已显式给出 h1/h2/h3。
|
||||
3. **`<b> / <strong>`**:浏览器默认 bold(700),比全站最高层级(标题 600)还重。已钉成 600。
|
||||
|
||||
判据:**量 DOM 的 (字号,字重) 组合**。只看截图不会发现这三处(页面「看着正常」,
|
||||
只是有点不齐)。其它 rem/em 的 vendor 样式同理要查。
|
||||
|
||||
## 四十三、图表 / 类型色 / 窄屏导航(C、D 批次,2026-10-09)
|
||||
|
||||
### 图表(手写 .cols / .bars,不引图表库)
|
||||
|
||||
- `.cols` 有基线(`border-bottom`)与 `padding-top: 16px` —— 后者是给峰值柱顶上那个数字(`.pv`)留的位置,
|
||||
改高度时别把 padding 弄丢(`height` 走 border-box,内容区才是柱子的 100% 基准)。
|
||||
- 峰值柱:模板里 `{{if eq .Count $.MaxMonthly}} peak{{end}}`,柱顶标次数;副标题里就不再重复写「最高 N 次」。
|
||||
- `.cols-band` 是「6 小时一段」的标尺,**必须用 24 列 grid**(不是 `flex: 6`),否则段边界与柱子对不齐。
|
||||
它取代了原来那行只有一个数字的坐标轴。
|
||||
- 首页的月度图峰值靠 `HomeData.MaxMonthly`(与统计页同口径 `maxOf`),别只改一边。
|
||||
|
||||
### 类型色:只在 CSS 变量里用,别当文字色
|
||||
|
||||
- 模板用 `csscolor` / `csssoft`(`colorof` 已删)。
|
||||
⚠️ 这两个函数**必须返回 `template.CSS`**:`html/template` 的 CSS 过滤器不认识 `var()`,
|
||||
返回普通 string 会被换成 **`ZgotmplZ`** —— 页面上所有圆点当场变透明,而且不报错、模板测试照过(踩过)。
|
||||
守卫 `TestCSSColorSurvivesTemplateSanitizer`。
|
||||
- 但挂了 `template.CSS` 就等于关掉那道过滤,所以**数据侧色值必须先过严格校验**(只有 `#` + 6 位十六进制放行),
|
||||
否则 `#"><img…` 能直接逃出 style 属性。
|
||||
- ⚠️ **这套色连图形对象 3:1 都到不了**:白底上 yellow 1.87、mint 2.12、orange 2.20、green 2.22、
|
||||
cyan 2.54、teal 2.57。所以它们**只能当身份标记(圆点 / 柱条),绝不能当文字色或承载唯一信息的图形**。
|
||||
实测过、别再被「做成彩色标签更好看」带走。
|
||||
- 浅色那 6 个色**不要改暗**:它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
|
||||
- 地图那份调色板(`rond-map.js` 的 `APPLE`)**故意独立**:瓦片在暗色下仍是浅色,标记要用浅色底那套值。
|
||||
- 暗色只提亮了 `--cat-indigo`(2.90 → 3.55:1),其余一律不动。
|
||||
|
||||
### 窄屏导航(≤620px)
|
||||
|
||||
- 链接本体是 `{{define "navitems"}}`,渲染两遍:桌面 `.nav-links` 平铺、窄屏 `.nav-menu`(原生 `<details>`,
|
||||
不靠 JS 也能开合;JS 只补点外面 / Esc / 点链接后收起)。
|
||||
守卫 `TestNarrowNavCollapsesToMenu` 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。
|
||||
- ⚠️ 顶栏 `--z-nav: 1200`,**必须高于 `--z-map-overlay`(1100)**:菜单是 `.nav` 的子元素、
|
||||
自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(`/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 冲突」可以放下了)。
|
||||
|
||||
+19
-17
@@ -1,8 +1,8 @@
|
||||
# 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)
|
||||
- 建地点+到访**同一事务**;新行号只走 `nextSrcPK`+`clearStaleRaw`,**禁 `MAX(src_pk)+1`**。`seq_test.go`
|
||||
@@ -21,7 +21,7 @@
|
||||
- rows 收尾必须 `drainRows`(先 `Err()` 再 `Close()`)。`parse_rows_test.go`
|
||||
- **扫进裸 `bool` 的列必须先 `COALESCE`**:`weather.is_daylight` 漏写 ⇒ NULL ⇒ 整包拒收(details 卅C)。
|
||||
- 核对导出包**别按留档 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 三、九、十、廿二)
|
||||
- `.fwss`=zip、条目 zlib;`Model/*/`=头 32768B+每块 515B;`Model/~/`、`Model/#/` 原样写回;清空/替换都要连 `fog_raw_files`。
|
||||
@@ -30,13 +30,17 @@
|
||||
- 瓦片须与底图同 `crs`;带地图页须 `p.HasMap=true`。
|
||||
- `tileDigits` 补前导 0;`tileName` 尾缀 `len(s)-2` 单数字编号时为 -1 ⇒ panic;解码守卫 `len(name)<8`。
|
||||
- `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、卅七)
|
||||
- ⚠️ **对访客公开的块,措辞必须按身份分支**(`{{if .IsAdmin}}我的…{{else}}站长…{{end}}`):
|
||||
写模板时人站在站长视角下笔,「我的结论 / 你自己 / 我推荐的地点」露给访客就没人读得懂。
|
||||
照 `place.html` 投票区与 `rond-map.js` 气泡的写法。门禁 `TestVisitorPagesUseThirdPersonCopy`
|
||||
(以访客 ctx 渲染 `/`、`/stats`、`/report` 逐个断言禁用词,并确认站长那份仍说「我的」)。
|
||||
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。
|
||||
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。⚠️ **新模板+旧二进制**=「渲染到一半仍 200」;启动自检 `smokeRender` 会拒启(四十一)。
|
||||
- 字阶/圆角/阴影**只走令牌**,裸 `font-size: 13px` 有静态守卫 `TestNoBareFontSizePx`;卡片三档 `.card--hero/--quiet`。⚠️ 改**根字号**要查 Leaflet(`.75rem` 会跟着漂),(四十二)。
|
||||
- 类型色走 `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`),勿手工转义。
|
||||
- 窄屏覆盖写 `app.css` **末尾那块**;宽表堆叠类名**成对** `class="tb tb-stack"`;触控 44px。`tb_stack_test.go`
|
||||
- 加表格列:`PlaceCols`/`scanPlace` **成对**、新列追加最后;CJK 挤换行只抬该表 `min-width`。
|
||||
@@ -56,35 +60,33 @@
|
||||
- 站内新建行 `entity_raw.raw IS NULL`;`ZCATEGORY_` 真机只有 `MKPOICategoryXxx` 枚举。
|
||||
- 类别候选:编辑/照片页=库里在用 `GROUP BY`;补全页=在用 ∪ 静态表;下拉留「其它(自己写)」,默认「不动」。
|
||||
|
||||
## 归属口径 / 防复发(details 卅五)
|
||||
## 归属口径 / 跨备份 / 附属数据(details 十九~廿一、卅四、卅五)
|
||||
- ⚠️ **铁律:这份记录能不能从备份重新导入?** 能 ⇒ 可挂 `dataset_id … ON DELETE CASCADE`
|
||||
(places/visits/movements/weather/activities/tags/entity_raw);**不能 ⇒ 只留 dataset_id 当出处、不许挂外键**
|
||||
(place_images/place_notes/place_votes/topics)。挂在备份里没有的表上 = 删数据集就永久丢失。
|
||||
- 两道门禁都钉着它:运行时查 `pg_constraint`(表必须在白名单里,反向也查)+ **静态扫 `schema.sql`**
|
||||
(新建的库走 CREATE TABLE,只看运行时会有盲点)。`dataset_ownership_test.go`
|
||||
(新建的库走 CREATE TABLE,只看运行时会有盲点)。`dataset_ownership_test.go`(卅五)
|
||||
- **体检常驻后台首页**:库里 N 张 / 本地盘上 M 个 / 落单 K 个(`imageHealth`)——
|
||||
事故的本质是「行与对象悄悄对不上、没人发现」,让它当场可见而不是等照片消失才追。
|
||||
|
||||
## 跨备份 / 附属数据(details 十九~廿一、卅四)
|
||||
- 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。
|
||||
- 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。
|
||||
- **站长结论(`place_votes`)与访客票(`place_visitor_votes`)是两码事**:各存各表、各看各榜;
|
||||
统计页拆成「我的结论」与「访客点赞」两块(`votes.TopVisitorVoted`,`HAVING` 只算有票的那个方向)。
|
||||
⚠️ 票表也按坐标键跨数据集读 ⇒ 用例必须按 `place_key` 清历史票,否则票数越跑越多。(details 卅六)
|
||||
- ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/<id>.sqlite`(**别清 baselines 目录**);⚠️ 来源必须**全部读进来合并**(places → entity_raw 留档 → 该 sqlite 及**每份 `.bak`**)——换基线会覆盖底座文件,只看当前那份会整片认不出(实测 40 张只挂回 7 张)。`image_recovery_test.go`(details 卅四)
|
||||
- **站长结论(`place_votes`)与访客票(`place_visitor_votes`)是两码事**:各存各表、各看各榜(`votes.TopVisitorVoted`,`HAVING` 只算有票的方向)。
|
||||
⚠️ 票表也按坐标键跨数据集读 ⇒ 用例必须按 `place_key` 清历史票,否则票数越跑越多。(卅六)
|
||||
- ⚠️ **附属数据不能挂数据集外键**(`place_images` 曾按 `dataset_id` CASCADE ⇒ 删数据集=删行、**文件还在盘上**);
|
||||
恢复走后台「图片挂载恢复」,认地点靠 `baselines/<id>.sqlite`(**别清该目录**);
|
||||
⚠️ 来源必须**全部读进来合并**(places → entity_raw 留档 → 该 sqlite 及**每份 `.bak`**),只看当前那份会整片认不出。`image_recovery_test.go`
|
||||
- ⚠️ 恢复**同时扫对象存储**(R2/S3):`media.ListObjects`(ListObjectsV2 + SigV4,查询串先编码再签),
|
||||
需 `s3:ListBucket` 权限;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集
|
||||
(`user_id` 过滤,内容仍照生效数据集算)。用户级数据靠 `Dataset.UserID` 定位。(details 卅四)
|
||||
需 `s3:ListBucket`;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集(`user_id` 过滤,内容仍照生效数据集算)。(卅四)
|
||||
- ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。
|
||||
- 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。
|
||||
|
||||
## 后端 / 性能(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`(按子查询分段判)
|
||||
- multipart handler 直接 `ParseMultipartForm`(`r.ParseForm()` 毒化字段);**之前必须 `MaxBytesReader`**。五个上传口:备份/GPX/迷雾/照片/地点图片。
|
||||
- 包了 `ResponseWriter` 必须实现 `Unwrap()`(`gzipWriter`/`headerSent`);`recover` 写错误页前须看 `headerSent`。`server_error_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()`。
|
||||
|
||||
## 安全 / 跳转(details 廿八B)
|
||||
@@ -106,4 +108,4 @@
|
||||
- 地点配对只有一份实现 `rond.MatchPlaces`(差异与合并共用);同名兜底必须有距离上限(`nameMatchMaxKm=1.0`)。
|
||||
- 差异页默认 radio 是「**用实机**」;写合并测试要照这个提交(抓 `value="backup"`),否则测的不是真实点击行为。
|
||||
- 自检不变量:合并后没有任何地点被搬动 >1km、也没有任何到访离它的实机地点 >1km。
|
||||
- **三条铁律**:① app 只靠 `Z_PK` 解引用地点/活动 ⇒ 导出的编号必须与**数据集基线**同一套 ⇒ **基线必须是手机包**;② rond 导入是**整库替换** ⇒ 导入前先清空 app 数据;③ 站点数据是超集 ⇒ 用「手机导出作新数据集 + 旧站点导出当备份合并进去」对齐;`ZRAW` 这类从源包抄来的计数会越界,靠导出侧夹取兜住。
|
||||
- **三条铁律**:① app 只靠 `Z_PK` 解引用 ⇒ 导出编号必须与**数据集基线**同一套 ⇒ **基线必须是手机包**;② rond 导入是**整库替换** ⇒ 导入前先清空 app 数据;③ 站点数据是超集 ⇒ 用「手机导出作新数据集 + 旧站点导出当备份合并进去」;`ZRAW` 这类从源包抄来的计数会越界,靠导出侧夹取兜住。
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# 前端现代化方案
|
||||
|
||||
- **日期**:2026-10-09
|
||||
- **范围**:视觉层升级 —— `app.css`(1114 行)+ 24 个模板的样式类使用;**不动**信息架构、路由、模板数据结构
|
||||
- **方法**:先用无头 Chrome 在真实视口量现状(390 / 430 / 1440),再定方案。下面每个数字都是量的,不是估的
|
||||
- **前提约束**(这些都是现状,方案不得破坏):无前端构建链(服务端渲染 + 单 CSS + 手写 JS)/ 1C1G 服务器 / 已有暗色模式(`prefers-color-scheme` 3 段)/ 已按 WCAG AA 调过 `--text-3`
|
||||
|
||||
> ⚠️ `shots/` 里的截图(`mobile-home.png` 等)是**旧状态**:那张顶栏折成 3 行、右按钮竖排的图早就不成立了,
|
||||
> 现在 390 下品牌名只留 🐾、按钮是「后台」。做视觉判断前一律**现场重新截图**,别拿 `shots/` 当现状。
|
||||
|
||||
## 一、现状实测
|
||||
|
||||
| 观测 | 实测值 | 说明 |
|
||||
|---|---|---|
|
||||
| 卡片样式数 | 17 个卡片盒子(`.card`/`.kpi`/`.filters-card`)→ **只有 2 种样式指纹** | 全站一个「白盒 + 1px 边 + 浅阴影 + 10px 圆角」用到底,KPI / 地图 / 图表 / 表格 / 筛选器全等重 ⇒ 眼睛没有落点 |
|
||||
| 字阶 | 字号 **8 档**:10/12/13/14/15/18/21/23px,其中 **10–15px 挤了 5 档** | 中段过密、大段太少;层级挤不出来,只能靠加粗 |
|
||||
| 字重 | 400 / 500 / 600 / **650** | 650 是半档,等于没规范 |
|
||||
| 正文色 | **4 种**(`--text`/`--text-2`/`--text-3` + 链接)+ 1 个主色 | 整页几乎单色 |
|
||||
| 图表 | 手写 `.bars`(无图表库),月度/一天分布只有色块 | 无基线、无峰值标注、无静止态数值 |
|
||||
| 类型色 | 10 种(家/工作/餐厅/…)**只用在 8px 圆点和柱子上** | 没进入界面语义层(chips、表格类型列、KPI 都用不到) |
|
||||
| 窄屏导航 | 站长导航(6 项)`scrollWidth=277`,可用宽:**430→254(溢 23)**、**390→214(溢 63)**、**320→144(溢 133)** | 靠 `.nav-links` 横向滚动兜底;390 下「统计」被切成半个字紧贴「后台」按钮,渐隐提示实际看不见 ⇒ 像坏了 |
|
||||
| 暗色模式 | 已存在(586/590/742 三段 media) | 任何配色改动要同步两套变量 |
|
||||
| 已有优点(别改坏) | KPI 数字已是 `tabular-nums`;`--text-3` 按 AA 调过;地图/时间线的 z-index 与安全区已有处理 | 这些是已经踩过坑换来的 |
|
||||
|
||||
## 二、为什么不重写(明确排除项)
|
||||
|
||||
- **不引入前端框架 / 构建链**(Vue/React/Tailwind/Vite):现状是 Go 模板直出 + 单 CSS + 无 npm。引构建链会让「改一行字要跑 node」、部署变复杂,而站点是个人足迹站、并发极低 —— 收益不抵成本。
|
||||
- **不上图表库**:为几个柱图多背 100KB+ 与一个依赖,不值得。`.bars` 手写继续用,只升级表达力。
|
||||
- **不重做信息架构**:页面、路由、模板数据结构都不动;只改「长什么样、怎么看」。
|
||||
|
||||
## 三、四批方案(由低风险到高收益)
|
||||
|
||||
### 批次 A:设计令牌收敛(纯 CSS,约半天)
|
||||
|
||||
1. **字阶砍到 6 档**:`11 / 13 / 15 / 21 / 26 / 34`
|
||||
- 11 = 脚注/单位,13 = 正文(默认),15 = 卡片标题,21 = 页面标题,26 = KPI,34 = 首屏主数
|
||||
- 现在 10/12/14/18/23 全部并档;`--text-3` 的灰阶不动(它承载 a11y 口径)
|
||||
2. **字重砍到 3 档**:400 / 500 / 600(删掉 650)。层级主要靠**字号 + 颜色**,不靠加粗 —— 这更接近现代做法
|
||||
3. **间距令牌**:`--sp-1..5` = 4/8/12/16/24,替换散落的 padding/margin 字面量
|
||||
4. **圆角令牌**:`--r-sm/md/lg` = 6/10/14(现在只有 `--radius:10px` 和一个 8px)
|
||||
5. **阴影分 3 档**:`--sh-1`(卡片)/`--sh-2`(浮层)/`--sh-3`(hero);现在只有 1 档
|
||||
|
||||
> 判据:`cardVariants` 2 → ≥3;(字号/字重) 组合 10 → ≤6;CSS 里裸 `border-radius:` 数字 0 处。
|
||||
|
||||
### 批次 B:给卡片分主次(约 1 天)
|
||||
|
||||
三档面,替代现在唯一的一种:
|
||||
|
||||
| 类 | 用途 | 样子 |
|
||||
|---|---|---|
|
||||
| `.card--hero` | 首屏 KPI 区、地图 | 更大内边距 + `--sh-3`、无边框、左侧 3px 主色条 |
|
||||
| `.card`(默认) | 图表、列表、表单 | 保持现状(已够干净) |
|
||||
| `.card--quiet` | 说明块、次级列表 | `--bg-2` 浅底、无边框无阴影 |
|
||||
|
||||
配套把首屏 KPI 从「4×2 等重白盒」改成 **1 个 hero 大数 + 3 个指标**:首屏必须有一个一眼能落下去的点。手机端同理(hero 占整行,后 3 个两列)。
|
||||
|
||||
> 判据:把首屏截图给人看 3 秒,对方能指出「最重要的那个数」。
|
||||
|
||||
### 批次 C:让图表说人话(约 1–2 天,仍不用库)
|
||||
|
||||
- **月度分布**:加基线、峰值柱顶部标数值 + 一处「最高 197 次」文案、每根柱 `<title>` 与 hover 值
|
||||
- **一天分布**:加 0/6/12/18 轴标 + 早/午/晚三段极浅底色,让「作息」这个概念不用读标题也懂
|
||||
- **类型分布**:柱色改用类型色(见批次 D),数值列 `tabular-nums`(数字列宽不再跳)
|
||||
- **空态统一**:现在有的写「暂无数据」、有的空着;统一一个 `emptystate`(模板里已有这个定义,直接用)
|
||||
|
||||
> 判据:不看图例、不看标题,也能说出这张图在讲什么。
|
||||
|
||||
### 批次 D:色彩语义化 + 窄屏导航(约 1 天)
|
||||
|
||||
1. **类型色升级为语义令牌**:`--cat-home` / `--cat-work` / `--cat-food` … 10 个,并用 `color-mix()` 派生各自浅底(沿用现有 `--accent-soft` 的做法,不手写 10 套浅色)
|
||||
- 用在:筛选 chips、地点表「主要类型」列、KPI 强调、图表
|
||||
- 硬约束:正文用色对比 ≥4.5:1、大字/图形 ≥3:1(延续现有口径),**暗色模式各派生一套**
|
||||
2. **窄屏导航**:现状溢出 23–133px。
|
||||
- 第一步(低风险):主链接收到 4 个(主页/地图/地点/统计),「时间线/报告」进「更多」;把 `.overflow-right` 的渐隐加重到看得见
|
||||
- 第二步(可选,需评估):底部 tab bar。⚠️ 地图页的浮层 z-index 与 `--nav-h` 已经踩过坑(审计 F11/F12),上底部栏要先确认与 Leaflet 控件、安全区的冲突
|
||||
|
||||
> 判据:320/390 下**没有任何链接被切半个字**;导航高度不增(现 51px);暗色模式下新色阶对比度同样达标。
|
||||
|
||||
## 四、建议顺序
|
||||
|
||||
先 **A + B**(约 1.5 天):纯 CSS + 少量模板 class,风险最低、观感提升最直接,改完能立刻判断方向对不对。
|
||||
C 独立(图表可以慢慢磨),D 涉及配色与导航,放最后一次性做,避免中途改两次色。
|
||||
|
||||
## 五、验收方式(沿用项目既有纪律)
|
||||
|
||||
- 视觉:`rond-web-verify` 技能在 **320/390/430/1440** 四个宽度现场截图 + 量数(别用 `shots/` 里的旧图)
|
||||
- 不改坏:`./scripts/verify.sh` 必须仍然 **0 skip 全绿**(模板改动会影响 `tb_stack_test`、`TestVisitorPagesUseThirdPersonCopy` 等)
|
||||
- 暗色:`--color-scheme dark` 跑同一组断言
|
||||
- 无障碍:`audit/a11y.js` 的对比度与字号判据不得比改动前更差(改动前基线要先跑一遍存档)
|
||||
- 本次只出方案,**未改任何代码**。
|
||||
|
||||
---
|
||||
|
||||
# 落地记录:A + B 批次(2026-10-09)
|
||||
|
||||
方案通过后先做了 A(设计令牌收敛)与 B(卡片分主次)。下表每个数字都是**同一份探针在改动前后各跑一遍**得到的。
|
||||
|
||||
## A:设计令牌收敛
|
||||
|
||||
**字阶**:文本 6 档 `--fs-xs/sm/md/lg/xl/2xl` = 11 / 13 / 15 / 21 / 26 / 34px。
|
||||
- app.css 原来 19 种字号、96 处声明;模板里另有 54 处内联字号 —— 全部并档。
|
||||
- **两个字外档,都不是漏掉的**:`--fs-control: 16px`(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页,属功能约束,不能进字阶);`--glyph-*` 6 档(✕ / 箭头 / 空态图标,按控件尺寸定 —— 把 22px 的 ✕ 塞进字阶只会把按钮撑坏)。
|
||||
- 字重收敛到 400 / 500 / 600(删掉 650 与 700 两个半档)。
|
||||
|
||||
**圆角** 4 档 `--r-xs/sm/md/lg` = 3 / 6 / 8 / 12px(原来 2.5/4/5/6/7/8/9/10/12/20px 混用;卡片 10 → 12)。
|
||||
**阴影** 3 档 `--sh-1/2/3`(原来只有 1 档变量 + 6 处写死的阴影);暗色模式整套覆盖。
|
||||
**间距** 5 档 `--sp-1..5`。⚠️ **本批只用在卡片内边距这类主节奏上,没有做全量迁移** —— 一次性铺开的改动面太大、收益最低,留到需要时再逐批替换。
|
||||
**`--card-pad` 单列**:折叠卡片的标题行要用负 margin 顶到边,两处必须永远相等。
|
||||
|
||||
## B:卡片分主次
|
||||
|
||||
- `.card--hero`(首屏地图:无边框 + `--sh-3` + 更大内边距)、`.card`(默认)、`.card--quiet`(设置页「地图底图 / 图片存储」两块只读说明:页面底色 + 细边 + 无阴影)。
|
||||
⚠️ quiet 第一版用 `--bg-2`(#fafbfb),实测与页面底 #f6f7f8 只差 **4/255** —— 根本看不出边界,卡片像「破了个洞」。改成「页面底色 + 保留细边」才对。
|
||||
- 首屏 KPI 从「4×2 等重白盒」改成 `.kpi-lead`(主数 + 城市/省份上下文)+ 5 个常规指标。**8 个数字一个没少**,只是分了主次。
|
||||
|
||||
## 改动前后(同一探针)
|
||||
|
||||
| 页面 / 视口 | (字号,字重) 组合 | 卡片样式档 | 最小字号 |
|
||||
|---|---|---|---|
|
||||
| `/` 390 | 13 → **10** | 2 → **3** | 10 → 11 |
|
||||
| `/` 1440 | 15 → **10** | 2 → **3** | 10 → 11 |
|
||||
| `/places` 390 | 10 → **5** | 2 → 2 | 12 → 12 |
|
||||
| `/timeline` 390 | 10 → **6** | 2 → 2 | 11.5 → 11 |
|
||||
| `/stats` 1440 | 15 → **9** | 2 → 2 | 10 → 11 |
|
||||
| `/admin/settings` 1440 | 10 → **7** | 1 → **2** | 12 → 13 |
|
||||
|
||||
首页 390 的字阶从
|
||||
`10/400 11.5/400 12.5/400 13/400 13/500 13.5/400 14/400 14/500 15/600 17/400 18/600 21/600 21.8/650`
|
||||
收敛到
|
||||
`11/400 13/400 13/500 15/500 15/600 18/400 18/600 21/600 21.8/600 28.1/600`
|
||||
(字重只剩 400/500/600;多出来的 28.1px 是新加的首屏主数)
|
||||
|
||||
## 回归
|
||||
|
||||
- `./scripts/verify.sh`:**PASS=347 FAIL=0 SKIP=0**(含本轮新增的两条守卫)
|
||||
- 移动端 390 审计(溢出 / 小触控 / 小字 / iOS 自放大):**逐值一致**
|
||||
- 桌面 1440 溢出审计:唯一差异是 `/map` 露外元素 9 → 8
|
||||
- 暗色模式:三档阴影与主色强调条都按暗色变量生效(实测 computed 值)
|
||||
|
||||
## 途中发现并修掉的四处
|
||||
|
||||
1. **Leaflet 的字号跟着根字号漂**。vendor 里 `.leaflet-container { font-size: 0.75rem }`,根字号 14 → 13px 之后,地图右下角的**署名与比例尺缩到 9.8px**(掉到本站 11px 下限之下),弹窗正文也跟着缩。已按自己的字阶钉死:容器 = `--fs-sm`,署名/比例尺 = `--fs-xs`;`/map` 上小于 11px 的元素从 2 个降到 0 个。
|
||||
2. **静态守卫抓到并档**。`TestNavLinksNarrowRuleHasNoDeadPadding` 里写死了 `font-size: 13px`,改成令牌后它红了 —— 守卫本身是对的(它就该在写法漂移时叫),已把判据从「钉具体值」改成「钉窄屏那条规则还在、且设了字号」。另补 `TestNoBareFontSizePx`:静态扫 app.css + 全部模板,禁止再出现裸 px 字号(做过变异验证:注入一条 `font-size: 14px` 会红)。
|
||||
3. **`h1..h4` 从来就没设过字号**,一直靠浏览器默认的 `2em / 1.5em / 1.17em` —— 也就是跟着根字号漂。根从 14 → 13px 后,`/stats` 里没被 `.card > h2` 命中的两个 h2(「我的结论」「访客点赞」)从 21px 悄悄变成 19.5px,比同页其它卡片标题(15px)大一圈。已显式给出字号:h1 = `--fs-lg`、h2 = `--fs-md`、h3/h4 = `--fs-sm`;实测 `/stats` 的 12 个 h2 现在统一 `15px/600`。
|
||||
4. **`<b> / <strong>` 走浏览器默认 700**,比全站最高层级(标题 600)还重。已钉成 600。
|
||||
|
||||
> 3 与 4 都是「量 DOM 的 (字号,字重) 组合」才浮出来的:肉眼看页面完全正常,只是觉得「有点不齐」。
|
||||
> 这也是为什么这一批要**先存基线、再用同一份探针逐值 diff**,而不是改完看一眼截图。
|
||||
|
||||
## 附:一处历史遗留(本批未动)
|
||||
|
||||
7 个模板(`admin/index.html`、`admin/login.html`、`admin/twofa-login.html`、`admin/twofa.html`、`map.html`、`places.html`、`timeline.html`)在**工作区里是 CRLF**,而 `.gitattributes` 要求 `eol=lf`。
|
||||
本批按字节替换、原样保住了各自行尾,所以 diff 里没有行尾噪声。要不要统一成 LF,另开一次清理。
|
||||
|
||||
---
|
||||
|
||||
# 落地记录:C + D 批次(2026-10-09)
|
||||
|
||||
## C:让图表说人话
|
||||
|
||||
- **月度分布**:柱底压一道基线(原来柱子直接悬在空白里,高低只能靠互相比);峰值柱不透明拉满、并在柱顶标出次数(`.pv`),副标题里就不再重复写一遍「最高 N 次」。
|
||||
- **一天中的分布**:原来只能靠 0/6/12/18 四个数字猜。现在换成一行 **6 小时一段的标尺**(深夜 0-6 / 上午 6-12 / 下午 12-18 / 晚上 18-24),用的是**同一套 24 列网格**(不是 `flex: 6`),所以段边界与柱子严格对齐;峰值小时也标了出来。
|
||||
- 首页那份「月度活跃度」同样加了峰值锚点 —— 为此给 `HomeData` 补了 `MaxMonthly`,与统计页同一口径(`maxOf`)。
|
||||
- **空态这条划掉了**:查下来已经统一 —— 8 个页面共用 `emptystate`,局部空态一律 `<div class="empty">`,没有需要收拾的。
|
||||
|
||||
## D1:类型色语义化(结论和原计划差得比较多)
|
||||
|
||||
先把 13 个色在浅/暗两个底上**算了对比度**:
|
||||
|
||||
| 底 | 结果 |
|
||||
|---|---|
|
||||
| 白底(浅色面板) | **6 个色低于图形对象 3:1**:yellow 1.87、mint 2.12、orange 2.20、green 2.22、cyan 2.54、teal 2.57 |
|
||||
| 暗底 `#1c2024` | 只有 indigo 2.90 不够,其余都在 3.9 以上 |
|
||||
|
||||
所以**没有**按原计划把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是**文字**,这套色连图形 3:1 都到不了,
|
||||
做成彩色标签只会更难读。这也正好解释了全站为什么从头到尾只用 8px 小圆点做身份标记。
|
||||
浅色那 6 个色**一个没动**:它们是手机端与地图上的同一套身份色,改暗会破坏对应关系;而且它们只当标记、不承载唯一信息。
|
||||
|
||||
实际做了三件事:
|
||||
|
||||
1. **色值收口到 CSS**:`--cat-*` 13 个(原样 Apple 色名值)+ `--cat-*-soft` 13 个浅底(半透明,两个主题自适应)。
|
||||
Go 侧 `appleColors` 退成「哪些色名合法」的名单;`colorOf` 已成死代码,删掉。
|
||||
2. **模板函数换成 `csscolor` / `csssoft`**(17 + 2 处)。⚠️ 返回值必须是 `template.CSS`:
|
||||
`html/template` 的 CSS 上下文过滤器**不认识 `var()`**,普通 string 会被替换成 `ZgotmplZ` ——
|
||||
页面上所有圆点当场变透明,而且不报错、模板测试照过(本批实测踩到)。
|
||||
挂了 `template.CSS` 等于关掉这道过滤,所以数据侧色值必须先过严格校验(只有 `#` + 6 位十六进制放行)。
|
||||
守卫 `TestCSSColorSurvivesTemplateSanitizer` 把两个方向都钉住:不出现 `ZgotmplZ`、恶意色值不得逃出 `style` 属性(做过变异验证)。
|
||||
3. **暗色只提亮一个**:`--cat-indigo` 掺 10% 白(2.90 → 3.55:1),其余不动。
|
||||
地图那份调色板(`rond-map.js` 的 `APPLE`)**保持独立**:地图瓦片在暗色下仍是浅色,标记就该用浅色底那套值 —— 硬统一反而会错。
|
||||
|
||||
## D2:窄屏导航
|
||||
|
||||
改前 ≤620px 靠横向滚动兜底:**390 溢 63px、320 溢 133px**,最深处的「统计」被切成半个字紧贴后面的按钮,
|
||||
而 `.overflow-right` 的渐隐提示实际看不出来。现在:
|
||||
|
||||
- 链接本体抽成 `{{define "navitems"}}` 渲染两遍:桌面在 `.nav-links` 平铺,窄屏收进 `.nav-menu`(原生 `<details>`,不靠 JS 也能开合)。
|
||||
- JS 只补原生不给的三件事:点外面 / Esc / 点链接后收起。
|
||||
- ⚠️ **顶栏 z-index 抬到 `--z-nav: 1200`**:菜单属于 `.nav` 这个层叠上下文、自己抬不动,不抬就会被
|
||||
地图页的 `--z-map-overlay`(1100) 盖住。已在 `/map` 用 `elementFromPoint` 实测(`menuOnTop=true`)。
|
||||
- 顺手删掉窄屏那几条已经够不着的滚动/遮罩规则;宽屏保留横向兜底(以后又加入口时宁可能滑,也别切字)。
|
||||
|
||||
## C + D 的回归
|
||||
|
||||
- `./scripts/verify.sh`:**PASS=348 FAIL=0 SKIP=0**
|
||||
- 移动端 390 / 320:**溢出全 0**(改前 `/admin` 在 320 还溢 34px,现在也归零)
|
||||
- 桌面 1440:溢出与改前一致(−15 是滚动条宽,按既有口径属正常)
|
||||
- **7 个页面都没有低于 11px 的文字**(审计的「小字」判据是 <12px,会把我们 11px 的脚注档一并算进去,属设计内)
|
||||
- 暗色:菜单、类型色、三档阴影都按暗色变量生效(实测 computed 值)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module rond-with-you
|
||||
|
||||
go 1.25
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
|
||||
+17
-1
@@ -383,7 +383,7 @@ BEGIN
|
||||
END $$;
|
||||
|
||||
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
|
||||
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
|
||||
-- 「区域」是**一组圆**(areas,见下),空数组表示不限区域(时间范围内的地点画到哪算哪)。
|
||||
--
|
||||
-- ⚠️ 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);
|
||||
|
||||
-- 专题的显示区:**一组圆**,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/#)。
|
||||
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCSSColorSurvivesTemplateSanitizer 钉住两个方向:
|
||||
//
|
||||
// 1. `var(--cat-*)` 必须原样渲染进 style。html/template 的 CSS 上下文过滤器**不认识 var()**,
|
||||
// 函数返回普通 string 会被替换成 `ZgotmplZ` —— 页面上所有类型色圆点会当场变成透明,
|
||||
// 而且不报错、模板测试也照过(实测踩过)。
|
||||
// 2. 一旦返回 template.CSS 就等于关掉这道过滤,所以**数据侧来的色值必须先严格校验**:
|
||||
// 只有「# + 6 位十六进制」才放行,否则 `#"><img …` 能直接逃出 style 属性。
|
||||
func TestCSSColorSurvivesTemplateSanitizer(t *testing.T) {
|
||||
tpl := template.Must(template.New("t").Funcs(templateFuncs()).
|
||||
Parse(`<i style="background:{{csscolor .C}}"></i><b style="background:{{csssoft .C}}"></b>`))
|
||||
|
||||
render := func(v string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, map[string]string{"C": v}); err != nil {
|
||||
t.Fatalf("渲染失败(色值 %q): %v", v, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"调色板色名走 CSS 变量", "teal",
|
||||
`<i style="background:var(--cat-teal)"></i><b style="background:var(--cat-teal-soft)"></b>`},
|
||||
{"自定义色值原样用(浅底拼 22 当 alpha)", "#a2845e",
|
||||
`<i style="background:#a2845e"></i><b style="background:#a2845e22"></b>`},
|
||||
{"空值退兜底", "",
|
||||
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
|
||||
{"想逃出属性的串退兜底", `#"><img src=x>`,
|
||||
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
|
||||
{"位数不对的 hex 退兜底", "#12345",
|
||||
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
|
||||
{"套了别的 CSS 声明也退兜底", "red;background:url(javascript:1)",
|
||||
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := render(c.in); got != c.want {
|
||||
t.Errorf("%s:csscolor/csssoft(%q)\n 得到 %s\n 期望 %s", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -152,19 +154,220 @@ func TestPlaceImageControlsAreLabelled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- F14:窄屏导航链接的内边距别再写死一处(会被后面的触控块盖掉) ----------
|
||||
func TestNavLinksNarrowRuleHasNoDeadPadding(t *testing.T) {
|
||||
// ---------- 窄屏导航:链接行收进菜单(原来是横向滚动,最右边那个字会被切掉) ----------
|
||||
//
|
||||
// 620px 以下链接行一定放不下(实测:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴
|
||||
// 后面的按钮,而渐隐提示实际上看不出来)。现在整行收进 .nav-menu 这个原生 <details>。
|
||||
//
|
||||
// 这条守卫钉的是**成对关系**:CSS 的两半(桌面藏菜单 / 窄屏藏链接行)与模板里的两个类
|
||||
// 缺任何一个,窄屏就会退回老样子、或者宽屏凭空多出一个菜单按钮。
|
||||
func TestNarrowNavCollapsesToMenu(t *testing.T) {
|
||||
css, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取 app.css 失败: %v", err)
|
||||
}
|
||||
src := string(css)
|
||||
for _, want := range []string{
|
||||
".nav-menu { display: none; }", // 桌面:菜单按钮不出现
|
||||
".nav-links { display: none; }", // 窄屏:链接行不出现
|
||||
".nav-menu { display: block; flex: 1; }", // 窄屏:菜单按钮取而代之
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("app.css 缺少 %q —— 「桌面藏菜单 / 窄屏藏链接行」这两半必须成对,"+
|
||||
"否则窄屏会退回「横向滚动 + 最右边的字被切掉」", want)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := tmplFS.ReadFile("templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatalf("读取 layout.html 失败: %v", err)
|
||||
}
|
||||
nav := string(b)
|
||||
if !strings.Contains(nav, `class="nav-links"`) || !strings.Contains(nav, `class="nav-menu"`) {
|
||||
t.Error("layout.html 里 .nav-links 与 .nav-menu 必须都在:CSS 的两半分别作用在这两个元素上")
|
||||
}
|
||||
if n := strings.Count(nav, `{{template "navitems" .}}`); n != 2 {
|
||||
t.Errorf("导航链接应当渲染两遍(宽屏平铺 + 窄屏菜单),实际 %d 处", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 日期输入必须走统一入口 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 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 /
|
||||
// 17 / 18 / 21px 十几档字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶;
|
||||
// 模板里另有 54 处内联字号。现在:文本走 --fs-*,表单控件走 --fs-control(窄屏必须 ≥16px,
|
||||
// 否则 iOS 聚焦会放大整页),字形走 --glyph-*(22px 的 ✕ 塞进字阶只会把按钮撑坏)。
|
||||
func TestNoBareFontSizePx(t *testing.T) {
|
||||
re := regexp.MustCompile(`font-size:\s*[^;\n]*[0-9]+(\.[0-9]+)?px`)
|
||||
check := func(name string, b []byte) {
|
||||
for _, m := range re.FindAllString(string(b), -1) {
|
||||
t.Errorf("%s 里还有裸字号 %q —— 一律走 --fs-* / --fs-control / --glyph-* 令牌", name, m)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取 app.css 失败: %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, ".nav-links a { font-size: 13px; }") {
|
||||
t.Fatal("找不到窄屏的 `.nav-links a { font-size: 13px; }`(写法变了,这条守卫失效)")
|
||||
check("app.css", b)
|
||||
|
||||
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)
|
||||
}
|
||||
check(name, body)
|
||||
}
|
||||
if strings.Contains(src, ".nav-links a { padding:") {
|
||||
t.Error("窄屏 .nav-links a 又写了 padding:同特异性的内边距由后面「窄屏触控」那块给," +
|
||||
"写在这里会被盖掉(死代码)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -467,6 +468,7 @@ func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
d := SettingsData{Page: p, Modules: setting.AllModules, Pages: setting.AllPages,
|
||||
MapInfo: s.mapInfo(), MapLayers: layers, S3Info: s.s3Info()}
|
||||
d.Flash = r.URL.Query().Get("saved")
|
||||
d.Warn = trackWarnMessage(r.URL.Query().Get("warn"))
|
||||
s.render(w, "admin/settings", d)
|
||||
}
|
||||
|
||||
@@ -581,57 +583,105 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PostFormValue("regions_mode") == setting.RegionsBlacklist {
|
||||
next.RegionsMode = setting.RegionsBlacklist
|
||||
}
|
||||
next.TrackScript = cleanTrackScript(r.PostFormValue("track_script"))
|
||||
script, trackWarn := cleanTrackScript(r.PostFormValue("track_script"))
|
||||
if trackWarnRejected(trackWarn) {
|
||||
// 判为危险:这次改动整段不采纳,库里原有的脚本与开关都保持不动
|
||||
// (next := cur 已经带上了它们)。把站长手上本来好用的脚本一并清掉,
|
||||
// 比拒绝这次改动更糟。
|
||||
} else {
|
||||
next.TrackScript = script
|
||||
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
|
||||
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
|
||||
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
|
||||
}
|
||||
if err := s.sets.Save(ctx, next); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/settings?saved=1", http.StatusSeeOther)
|
||||
dest := "/admin/settings?saved=1"
|
||||
if trackWarn != "" {
|
||||
dest += "&warn=" + url.QueryEscape(trackWarn)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// cleanTrackScript 收拾管理员粘进来的统计脚本。
|
||||
// 统计脚本没被原样采纳时的三类说明码。它们是固定常量、不是用户输入,
|
||||
// 所以可以安全地放进跳转查询串,再由 trackWarnMessage 翻成人话。
|
||||
const (
|
||||
warnTrackTruncated = "truncated"
|
||||
warnTrackUnbalanced = "unbalanced"
|
||||
warnTrackBadSrc = "badsrc"
|
||||
)
|
||||
|
||||
// trackWarnMessage 把说明码翻成人话;未识别的码不提示(不回显查询串原文)。
|
||||
func trackWarnMessage(code string) string {
|
||||
switch code {
|
||||
case warnTrackTruncated:
|
||||
return "统计脚本超过 4096 字符,已截断保存"
|
||||
case warnTrackUnbalanced:
|
||||
return "统计脚本里 <script> 与 </script> 的数量不配对,本次改动已拒绝,库里原有脚本保持不变"
|
||||
case warnTrackBadSrc:
|
||||
return "统计脚本的 src 只接受 http://、https:// 或站内相对路径,本次改动已拒绝,库里原有脚本保持不变"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trackWarnRejected 报告这个说明码是否代表「整段没被采纳」。
|
||||
func trackWarnRejected(code string) bool {
|
||||
return code == warnTrackUnbalanced || code == warnTrackBadSrc
|
||||
}
|
||||
|
||||
// cleanTrackScript 收拾管理员粘进来的统计脚本,返回采纳后的内容与一个说明码(空 = 原样通过)。
|
||||
//
|
||||
// 这段内容会被原样注入**每一个公开页面**,所以要挡住几类会伤到访客的输入。
|
||||
// 但**不能**简单地把「出现 </script 就拒」当规则:百度统计、Umami 这类服务
|
||||
// 贴进来的就是完整 HTML,一行里必然带 </script>,一拒就把正常用法挡死了。
|
||||
//
|
||||
// 真正的注入面只有一种:**未闭合的 <script> 里出现 </script> 会提前闭合**,
|
||||
// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」:
|
||||
// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。
|
||||
// 两条判据:
|
||||
// - 开闭 <script> 标签必须**严格配平**:少一个 </script> 会提前闭合、后面的内容
|
||||
// 当正文渲染(页面布局散架),多一个 <script> 则会把页面剩下的收尾整段吞进脚本。
|
||||
// - 开标签上的 src 只放行 http(s):// 与站内相对路径,挡掉 javascript: / data: 伪协议。
|
||||
//
|
||||
// 另外挡住:javascript: / data: 这类伪协议的 src(点击即执行),
|
||||
// 以及超长内容(多半是误粘了整份 HTML 文档,截断)。
|
||||
func cleanTrackScript(v string) string {
|
||||
// src 只在**标签内部**找。以前是对整段做 strings.Index(low, "src="),于是脚本体里
|
||||
// 一句 `img.src=u` 就会被命中、整段被判危险而清空——页面却照样提示「设置已保存」,
|
||||
// 站长根本看不出脚本没挂上(实测复现过)。
|
||||
func cleanTrackScript(v string) (script, warn string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
const maxLen = 4096
|
||||
if len(v) > maxLen {
|
||||
v = v[:maxLen]
|
||||
warn = warnTrackTruncated
|
||||
}
|
||||
low := strings.ToLower(v)
|
||||
// 开闭标签数必须配平,否则有人能靠 </script> 提前闭合、把后面的内容
|
||||
// 从「脚本」变成「正文」注入到页面里。
|
||||
open := strings.Count(low, "<script")
|
||||
// 去掉属性里的 <script 字样不算开标签(如 data-src="<script"),这里只做保守计数:
|
||||
// 宁可多拒一次,也别放过真的注入。
|
||||
shut := strings.Count(low, "</script")
|
||||
if shut > open {
|
||||
return ""
|
||||
open := 0
|
||||
for i := 0; ; {
|
||||
j := strings.Index(low[i:], "<script")
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
// src 只放行 http/https 与站内相对路径,挡掉 javascript: / data: 伪协议
|
||||
if idx := strings.Index(low, "src="); idx >= 0 {
|
||||
rest := strings.TrimLeft(v[idx+4:], "\"'")
|
||||
start := i + j
|
||||
open++
|
||||
end := strings.IndexByte(low[start:], '>')
|
||||
if end < 0 {
|
||||
break // 这个开标签没闭合,交给下面的配平判定处理
|
||||
}
|
||||
tag := low[start : start+end]
|
||||
if k := strings.Index(tag, "src="); k >= 0 {
|
||||
rest := strings.TrimLeft(tag[k+4:], "'\" \t")
|
||||
if rest != "" && !strings.HasPrefix(rest, "http://") &&
|
||||
!strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") {
|
||||
return ""
|
||||
return "", warnTrackBadSrc
|
||||
}
|
||||
}
|
||||
return v
|
||||
i = start + end + 1
|
||||
}
|
||||
if shut := strings.Count(low, "</script"); open != shut {
|
||||
return "", warnTrackUnbalanced
|
||||
}
|
||||
return v, warn
|
||||
}
|
||||
|
||||
// ---------- 修改口令 ----------
|
||||
|
||||
@@ -31,7 +31,6 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p.HasMap = true
|
||||
p.Flatpickr = true
|
||||
q := r.URL.Query()
|
||||
d := EditData{Page: p, Query: strings.TrimSpace(q.Get("q")), SourceCounts: map[string]int{}}
|
||||
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)
|
||||
return
|
||||
}
|
||||
p.Flatpickr = true
|
||||
p.HasMap = true
|
||||
d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""}
|
||||
catNames := s.categoryLabels(r.Context())
|
||||
|
||||
@@ -369,6 +369,7 @@ func (s *Server) home(w http.ResponseWriter, r *http.Request) {
|
||||
d.MaxCity = maxOf(d.CityStats, func(c stats.CityStat) int { return c.Count })
|
||||
d.MaxTransport = maxOf(d.Transports, func(t stats.TransportStat) int { return t.Count })
|
||||
d.MaxTag = maxOf(d.Tags, func(t stats.TagStat) int { return t.Count })
|
||||
d.MaxMonthly = maxOf(d.Monthly, func(m MonthBar) int { return m.Count })
|
||||
s.render(w, "home", d)
|
||||
}
|
||||
|
||||
|
||||
+213
-33
@@ -9,10 +9,10 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/stats"
|
||||
)
|
||||
|
||||
@@ -41,6 +41,131 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
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 时只取对外展示的(前台用)。
|
||||
@@ -50,7 +175,7 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
|
||||
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,
|
||||
radius_km, show_fog, enabled, sort_order
|
||||
radius_km, show_fog, enabled, sort_order, areas
|
||||
FROM topics WHERE user_id=$1`
|
||||
if onlyEnabled {
|
||||
q += ` AND enabled`
|
||||
@@ -64,10 +189,15 @@ func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool
|
||||
var out []Topic
|
||||
for rows.Next() {
|
||||
var t Topic
|
||||
var rawAreas []byte
|
||||
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
|
||||
}
|
||||
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.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
||||
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) +
|
||||
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
||||
|
||||
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
|
||||
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
|
||||
// topicAreaCond 追加显示区的参数并返回区域条件:落在**任一**圆内就算在区域内(并集)。
|
||||
// 批量统计要重复拼这段,抽出来保证两条路径的占位符编号逐字一致 ——
|
||||
// 这里少算一个 $n,表现就是「专题内容全变 0」。每个圆占 3 个参数(纬度、经度、半径)。
|
||||
func topicAreaCond(t *Topic, args *[]any) string {
|
||||
if !t.HasArea() {
|
||||
areas := t.AreaList()
|
||||
if len(areas) == 0 {
|
||||
return ""
|
||||
}
|
||||
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||||
conds := make([]string, 0, len(areas))
|
||||
for _, a := range areas {
|
||||
*args = append(*args, a.Lat, a.Lon, a.RadiusKm)
|
||||
n := len(*args)
|
||||
return fmt.Sprintf(" AND %s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||||
// 球面距离表达式的三个 %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 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
||||
@@ -258,14 +395,15 @@ func parseTopicForm(r *http.Request) (Topic, error) {
|
||||
return t, fmt.Errorf("结束时间不能早于开始时间")
|
||||
}
|
||||
t.StartAt, t.EndAt = start, end
|
||||
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
|
||||
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
|
||||
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
|
||||
if t.RadiusKm < 0 {
|
||||
t.RadiusKm = 0
|
||||
areas, err := topicAreasFromForm(r)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
|
||||
return t, fmt.Errorf("限定区域时要填中心点坐标")
|
||||
t.Areas = areas
|
||||
// 镜像第一个圆进那三列废弃列:万一回滚旧二进制,退化成「第一个圆」总比「没有区域」强。
|
||||
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.Enabled = r.PostFormValue("enabled") == "on"
|
||||
@@ -283,7 +421,9 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
p.Flatpickr = true
|
||||
// 显示区编辑器要用 Leaflet:布局按这个标记引入它,漏了就是「地图整块不出现」
|
||||
// (实测踩过:脚本加载了、rondTopicAreas 也在,但 window.L 一直是 undefined)。
|
||||
p.HasMap = true
|
||||
d := TopicAdminData{Page: p}
|
||||
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
||||
u := userFrom(ctx)
|
||||
@@ -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 {
|
||||
log.Printf("专题统计失败: %v", err)
|
||||
}
|
||||
// 显示区编辑器的初始视野落在站长自己的地界上(拿不到就退回编辑器自带的兜底视野)
|
||||
if b, err := s.placesBounds(ctx, ds.ID); err != nil {
|
||||
log.Printf("取地点包围盒失败: %v", err)
|
||||
} else {
|
||||
d.MapHomeJSON = b
|
||||
}
|
||||
}
|
||||
d.Topics = list
|
||||
// 编辑模式:?edit=<id>
|
||||
@@ -325,6 +471,29 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "admin/topics", d)
|
||||
}
|
||||
|
||||
// placesBounds 是生效数据集里可用地点的包围盒,给显示区编辑器当初始视野。
|
||||
// 「可用」=有坐标且不是 (0,0)(脏数据会被填成 0,0,落进去会把视野拉到几内亚湾)。
|
||||
// 没有地点时返回 null,编辑器自己有兜底视野。
|
||||
func (s *Server) placesBounds(ctx context.Context, datasetID int64) (template.JS, error) {
|
||||
var minLat, maxLat, minLon, maxLon sql.NullFloat64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT min(lat), max(lat), min(lon), max(lon) FROM places
|
||||
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat <> 0 OR lon <> 0)`,
|
||||
datasetID).Scan(&minLat, &maxLat, &minLon, &maxLon); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !minLat.Valid {
|
||||
return "null", nil
|
||||
}
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"minLat": minLat.Float64, "maxLat": maxLat.Float64,
|
||||
"minLon": minLon.Float64, "maxLon": maxLon.Float64,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return template.JS(b), nil
|
||||
}
|
||||
|
||||
func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
u := userFrom(ctx)
|
||||
@@ -346,10 +515,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
if id > 0 {
|
||||
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,
|
||||
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`,
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -361,10 +531,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
|
||||
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
||||
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
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,$14::jsonb) RETURNING id`,
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -444,11 +615,13 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var t Topic
|
||||
var rawAreas []byte
|
||||
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).
|
||||
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 {
|
||||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||
return
|
||||
@@ -457,6 +630,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||
s.serverError(w, r, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err))
|
||||
return
|
||||
}
|
||||
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
||||
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
||||
if !t.Enabled && !p.IsAdmin() {
|
||||
@@ -489,10 +666,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
fitCircle, fitBBox := fitParams(t, points)
|
||||
fitBBox := fitParams(t, points)
|
||||
d := TopicData{
|
||||
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)
|
||||
}
|
||||
@@ -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{
|
||||
"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 {
|
||||
return template.JS("null"), template.JS("null")
|
||||
return template.JS("null")
|
||||
}
|
||||
minLat, maxLat := points[0].Lat, points[0].Lat
|
||||
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{
|
||||
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
||||
})
|
||||
return template.JS("null"), template.JS(b)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/auth"
|
||||
"rond-with-you/internal/setting"
|
||||
)
|
||||
|
||||
// TestNavShowsAllPagesToAdmin 钉住导航的显隐口径:页面开关**只管访客**。
|
||||
//
|
||||
// 站长的公开页一直是能访问的(guardPage 只拦访客),导航却没跟着放行——
|
||||
// 关掉页面开关后站长自己也只剩「主页」一个入口,明明能访问却点不到。
|
||||
// 这条口径必须和 guardPage 保持一致,所以两边都写死在这里。
|
||||
func TestNavShowsAllPagesToAdmin(t *testing.T) {
|
||||
base, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(tmplFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatalf("解析 layout.html: %v", err)
|
||||
}
|
||||
renderNav := func(p Page) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := base.ExecuteTemplate(&buf, "nav", p); err != nil {
|
||||
t.Fatalf("渲染 nav: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
links := []string{`href="/map"`, `href="/places"`, `href="/timeline"`, `href="/stats"`, `href="/report"`}
|
||||
|
||||
// 页面开关全关:访客点不到,站长必须照样有入口
|
||||
closed := setting.Default()
|
||||
closed.Pages = nil
|
||||
|
||||
adminNav := renderNav(Page{Nav: "home", Settings: closed, User: &auth.User{}})
|
||||
for _, l := range links {
|
||||
if !strings.Contains(adminNav, l) {
|
||||
t.Errorf("站点关闭了页面开关,但站长导航少了 %s——开关只管访客", l)
|
||||
}
|
||||
}
|
||||
|
||||
visitorNav := renderNav(Page{Nav: "home", Settings: closed})
|
||||
for _, l := range links {
|
||||
if strings.Contains(visitorNav, l) {
|
||||
t.Errorf("页面已对访客关闭,访客导航里不该出现 %s", l)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:全开放时访客也要看得到(防止把判据改成「只有站长」)
|
||||
openNav := renderNav(Page{Nav: "home", Settings: setting.Default()})
|
||||
for _, l := range links {
|
||||
if !strings.Contains(openNav, l) {
|
||||
t.Errorf("页面已对访客开放,访客导航里应出现 %s", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,11 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) {
|
||||
media: media.New(cfg),
|
||||
}
|
||||
s.routes()
|
||||
// 启动自检:把每个页面各渲染一遍,任一页失败就拒绝启动。
|
||||
// 「模板能解析」不代表「渲染得完」——字段不存在、nil 解引用都只在执行期暴露。
|
||||
if err := s.smokeRender(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
+381
-169
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
* (altInput 默认 false、不新建节点),原生那套分段编辑器被整个替换掉,
|
||||
* 所以「敲满一段自动跳下一段」这个行为根本不存在。
|
||||
* 2. allowInput 的语义是「拿到整串再 parseDate」,不是「逐段消费」。实测:
|
||||
* parseDate('2026') → 2026 年 1 月
|
||||
* parseDate('202666') → 2026 年 6 月 ← 用户要打到这个长度才「看到月份」
|
||||
* parseDate('20260606') → 2030 年 12 月 ← 静默错解,且不报错
|
||||
* 年份得吃满 4 位才被认,之后月份位还要再吃 2 位 ⇒ 正是「年份很长才跳下一位」。
|
||||
* 为什么不用原生控件(两次踩过,都是实测):
|
||||
* 1. flatpickr 会把 type=datetime-local 就地改成 type=text(altInput 默认 false、不新建节点),
|
||||
* 原生那套分段编辑器被整个替换掉;而 allowInput 的语义是「拿到整串再 parseDate」,
|
||||
* 实测 parseDate('2026') → 1 月、'202666' → 2026-06、'20260606' → 2030-12(静默错解)。
|
||||
* 2. **原生 type=date 自己也不可靠**:Chrome 的年段允许超过 4 位数字(年份上限 275760),
|
||||
* 于是敲 20260606 会被切成 年=202606 + 月=06,框里显示 202606/06(zh-CN 用 / 分隔),
|
||||
* 而 el.value 是**空**=非法 ⇒ 筛选条件静默丢掉。2026-10-09 用户报的就是这条。
|
||||
* 原生分段状态读不到、光标也设不了(setSelectionRange 对 date 类输入直接抛),
|
||||
* 所以「修原生」这条路走不通,只能换掉控件。
|
||||
*
|
||||
* 这里做两件事:
|
||||
* parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、
|
||||
* 2026 等写法;解析不出来就是 null,绝不猜。
|
||||
* attach 按键引导:年份满 4 位自动补分隔符并把光标推到月份段,
|
||||
* 月/日/时/分满 2 位同理,让手输 behave 像原生分段。
|
||||
* 于是这里做三件事:
|
||||
* parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、2026 等,
|
||||
* 解析不出来就是 null,绝不猜(交给 flatpickr 的 parseDate 钩子)。
|
||||
* attach 输入引导:年满 4 位自动补分隔符并把光标推到月份段,其余段满 2 位同理。
|
||||
* attachAll **统一入口**:一次覆盖 type=datetime-local 与 type=date 两种输入。
|
||||
* 2026-10-09 的事故根因就是「只挂了 datetime-local」,后来加的 type=date
|
||||
* 筛选框没人管 —— 以后加日期字段一律走 attachAll,别在页面里散着手写选择器。
|
||||
*
|
||||
* 产出格式恒为 'Y-m-dTH:i'(即 2026-06-06T09:30),与服务端
|
||||
* handlers_edit.go / handlers_topics.go 里的 "2006-01-02T15:04" 同一形状。
|
||||
* 产出格式:datetime 恒为 'Y-m-dTH:i'(2026-06-06T09:30),只日期恒为 'Y-m-d'(2026-06-06),
|
||||
* 分别与服务端 handlers_edit.go 的 "2006-01-02T15:04" 和筛选区的 "2006-01-02" 同一形状。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
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); }
|
||||
|
||||
@@ -137,6 +149,7 @@
|
||||
* 严格分段格式:年 4 位、月/日/时/分各 2 位,一律补零,敲满一段立刻补分隔符并跳下一段。
|
||||
* 于是「2026」+「06」+「06」+「09」+「30」会边敲边变成
|
||||
* 2026-06-06T09:30,敲完 4 位年份光标就已经在月份位(这正是原版做不到的那件事)。
|
||||
* 只日期版少掉时分两段,输出 2026-06-06。
|
||||
*
|
||||
* 不做的事(有意为之):
|
||||
* - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。
|
||||
@@ -144,8 +157,6 @@
|
||||
* - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。
|
||||
* 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。
|
||||
*/
|
||||
var SEG_LEN = [4, 2, 2, 2, 2]; // 年月日时分
|
||||
var SEP_AFTER = ['-', '-', 'T', ':', ''];
|
||||
|
||||
// 各段填满后的合法范围(只判能明确非法的:月 13、日 32、时 24、分 60)
|
||||
var SEG_FULL_MAX = [
|
||||
@@ -172,18 +183,35 @@
|
||||
return new Date(y, mo, 0).getDate();
|
||||
}
|
||||
|
||||
function attach(el, opts) {
|
||||
opts = opts || {};
|
||||
/* 原始输入里,紧跟第 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 位 —— 不猜、不前滚、不自动补零。
|
||||
* 年份固定 4 位;月/日(时/分)固定 2 位 —— 不猜、不前滚、不自动补零。
|
||||
*
|
||||
* 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时
|
||||
* 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020),
|
||||
* 否则位次会整体错位一格——这是实测踩出来的。 */
|
||||
function layout(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++) {
|
||||
@@ -191,10 +219,10 @@
|
||||
if (ch >= '0' && ch <= '9') digits += ch;
|
||||
}
|
||||
if (!digits) return { value: '', caret: 0 };
|
||||
if (digits.length > 14) return null;
|
||||
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 < SEG_LEN.length; i++) {
|
||||
var want = SEG_LEN[i];
|
||||
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];
|
||||
@@ -212,7 +240,7 @@
|
||||
if (i === 1) mon = +have;
|
||||
out += have;
|
||||
pos += want;
|
||||
var sep = SEP_AFTER[i] || '';
|
||||
var sep = spec.SEP[i] || '';
|
||||
// 数字用完了:本段后面还有段要填就立刻补分隔符、把光标推到下一段起点。
|
||||
// 这一步就是「敲完 4 位年份自动跳到月份位」的实现。
|
||||
if (pos >= digits.length) {
|
||||
@@ -224,16 +252,22 @@
|
||||
return { value: out, caret: out.length };
|
||||
}
|
||||
|
||||
/* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。
|
||||
*
|
||||
* 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate,
|
||||
* 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走,
|
||||
* 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。
|
||||
* 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。
|
||||
*
|
||||
* 交接判据是「值里除我们自己补的以外还有没有别的分隔符」:
|
||||
* 一旦用户亲手敲了 / 或 :,立刻彻底放手(否则会出现 '2026-/6/6' 这种
|
||||
* 我们补的 '-' 和用户的 '/' 混排的乱串),改由 fromSeparated 解他的写法。 */
|
||||
function attach(el, opts) {
|
||||
opts = opts || {};
|
||||
if (el._rondAttached) return;
|
||||
el._rondAttached = true;
|
||||
var spec = specFor(opts);
|
||||
|
||||
/* 先把控件换成 text。引导过程会往框里写 '2026-06-' 这种**半成品**,
|
||||
而 type=date / datetime-local 对非法值一律置空(值当场消失),
|
||||
只有 text 我们才拿得住。提交格式不变(date → Y-m-d、datetime → Y-m-dTH:i)。 */
|
||||
if (el.type !== 'text') {
|
||||
el.type = 'text';
|
||||
el.setAttribute('inputmode', 'numeric'); // 手机上直接给数字键盘
|
||||
el.setAttribute('autocomplete', 'off');
|
||||
if (!el.placeholder) el.placeholder = opts.dateOnly ? '2026-06-06' : '2026-06-06T09:30';
|
||||
}
|
||||
|
||||
/* 分隔符是否全是标准套(- T :)。'2026-6-6' 算(横杠是我们和用户通用的),
|
||||
* '2026/6/6' 不算(外来写法,交给 fromSeparated 原样解)。 */
|
||||
function isStandardSep(v) {
|
||||
@@ -246,6 +280,12 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
/* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。
|
||||
*
|
||||
* 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate,
|
||||
* 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走,
|
||||
* 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。
|
||||
* 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。 */
|
||||
function onInput() {
|
||||
var cur = el.value;
|
||||
if (!cur) { el._rondLaidOut = ''; return; }
|
||||
@@ -256,7 +296,7 @@
|
||||
段还没填满就原样留着(他还在敲)。 */
|
||||
if (isStandardSep(cur)) { applyLayout(cur); return; }
|
||||
/* 外来写法(/ . 空格)。只做一件最小修复:把「紧跟在分隔符后面、
|
||||
与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-/',
|
||||
与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-',
|
||||
双分隔符会让 fromSeparated 判非法。除此之外一个字符都不动,
|
||||
保持用户原样。 */
|
||||
var fixed = cur.replace(/([-T:])([/.])/g, '$2').replace(/([-T:])\1/g, '$1');
|
||||
@@ -267,29 +307,8 @@
|
||||
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) {
|
||||
var r = layout(cur);
|
||||
var r = layoutTo(spec, cur);
|
||||
// 越界(月份敲成 66、日敲成 45):原样保留用户输入,交给解析器报错。
|
||||
// 这里必须 return 且**不动 el.value**——el.value 一旦被我们改写却没同步
|
||||
// _rondLaidOut,下一轮就会拿旧串去排,越排越乱。
|
||||
@@ -317,9 +336,9 @@
|
||||
|
||||
el.addEventListener('input', onInput);
|
||||
|
||||
/* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01T00:00。
|
||||
* 不这么做的话用户只填了年份就点走,flatpickr 拿到 '2026' 会按原版规则
|
||||
* 补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */
|
||||
/* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01(只日期)
|
||||
* 或 2026-01-01T00:00(带时间)。不这么做的话用户只填了年份就点走,
|
||||
* flatpickr 拿到 '2026' 会按原版规则补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */
|
||||
function onBlur() {
|
||||
var cur = el.value.trim();
|
||||
if (!cur) return;
|
||||
@@ -330,18 +349,23 @@
|
||||
el.addEventListener('blur', onBlur);
|
||||
|
||||
function formatOut(d) {
|
||||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
||||
'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
var day = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||
if (opts.dateOnly) return day;
|
||||
return day + 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
}
|
||||
el._rondFormatOut = formatOut;
|
||||
}
|
||||
|
||||
function init(selector, opts) {
|
||||
if (!global.flatpickr) return;
|
||||
var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector;
|
||||
Array.prototype.forEach.call(els, function (el) {
|
||||
attachPicker(el, opts);
|
||||
});
|
||||
/* 中文文案只在这里设一次。以前是各页的 whenReady 里自己调 flatpickr.localize(),
|
||||
公共页(筛选区)原先根本不加载 flatpickr,等它也开始加载之后就漏了 ——
|
||||
日历是英文的(Sun/Mon/January…),跟站点的中文界面明显割裂。 */
|
||||
var l10nDone = false;
|
||||
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,26 +374,56 @@
|
||||
attach 让手输 behave 像原生分段(敲满 4 位年份就跳到月份位)
|
||||
提交格式仍是 'Y-m-dTH:i',服务端那条 "2006-01-02T15:04" 解析不受影响。 */
|
||||
function attachPicker(el, opts) {
|
||||
if (!global.flatpickr || el._rondPickerReady) return null;
|
||||
opts = opts || {};
|
||||
var dateOnly = !!opts.dateOnly;
|
||||
if (!global.flatpickr) { attach(el, opts); return null; }
|
||||
localizeOnce();
|
||||
if (!el._rondPickerReady) {
|
||||
el._rondPickerReady = true;
|
||||
var inst = global.flatpickr(el, {
|
||||
enableTime: true,
|
||||
global.flatpickr(el, {
|
||||
enableTime: !dateOnly,
|
||||
time_24hr: true,
|
||||
allowInput: true,
|
||||
// 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度
|
||||
minuteIncrement: 5,
|
||||
dateFormat: DATE_FMT,
|
||||
dateFormat: dateOnly ? DAY_FMT : DATE_FMT,
|
||||
parseDate: parseDateStrict
|
||||
});
|
||||
}
|
||||
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 = {
|
||||
DATE_FMT: DATE_FMT,
|
||||
DAY_FMT: DAY_FMT,
|
||||
SPEC: SPEC,
|
||||
layoutTo: layoutTo,
|
||||
parseDate: parseDateStrict,
|
||||
attach: attach,
|
||||
attachPicker: attachPicker,
|
||||
attachAll: attachAll,
|
||||
init: init,
|
||||
pad2: pad2
|
||||
};
|
||||
|
||||
@@ -80,6 +80,35 @@ console.log('\n=== round-trip:解析结果必须能原样喂回 formatDate ===
|
||||
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(' pass=' + pass + ' fail=' + fail);
|
||||
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),此时图层、浓度滑杆、图层开关都不出现。
|
||||
var wantFog = opts.showFog !== false;
|
||||
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) {
|
||||
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 = {};
|
||||
@@ -742,13 +782,8 @@
|
||||
|
||||
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
|
||||
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
|
||||
// 传 fitCircle(中心 + 半径)或 fitBBox 就能覆盖默认的点集贴合。
|
||||
// 传 fitBBox(专题显示区的并集外接矩形)就能覆盖默认的点集贴合。
|
||||
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) {
|
||||
var b = opts.fitBBox;
|
||||
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
|
||||
@@ -759,6 +794,14 @@
|
||||
};
|
||||
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);
|
||||
st.clustered = true;
|
||||
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);
|
||||
@@ -0,0 +1,99 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"rond-with-you/internal/setting"
|
||||
)
|
||||
|
||||
// 启动时的模板自检:把每个页面各用一份「最小可用数据」渲染一遍,任一页失败就拒绝启动。
|
||||
//
|
||||
// 防的是一类**只在执行期暴露**的错误:模板引用了 Go 结构体里不存在的字段
|
||||
// (典型是改了结构体却没重新编译,磁盘上的新模板被老二进制拿去渲染),
|
||||
// 或者模板里对着 nil 解引用。这类错误既不会让模板解析失败,render 又只把错误写进日志,
|
||||
// 于是线上表现成「页面渲染到一半就断了、HTTP 却仍是 200」——用户对着半张没有收尾的页面
|
||||
// 猜半天,服务端日志里只有孤零零一行。
|
||||
//
|
||||
// 2026-10-09 就是这么踩的:提交里同时改了 setting.Settings 和 settings.html,
|
||||
// 但跑的是改动前编译的二进制,debug 模式下模板走磁盘热加载 ⇒ 新模板 + 旧结构体,
|
||||
// /admin/settings 在「统计脚本」那张卡片处被截断。启动自检能把这种组合直接挡在门外。
|
||||
//
|
||||
// 探针值只求「类型对、不炸」:这些页面本来就得能渲染空数据(还没导入数据时的首页就是空的),
|
||||
// 所以空值探针是合理的下限,而不是凑数。
|
||||
var pageProbe = map[string]func() any{
|
||||
"home": func() any { return HomeData{Page: probePage("home")} },
|
||||
"map": func() any { return MapData{Page: probePage("map")} },
|
||||
"places": func() any { return PlacesData{Page: probePage("places")} },
|
||||
"place": func() any { return PlaceData{Page: probePage("places")} },
|
||||
"timeline": func() any { return TimelineData{Page: probePage("timeline")} },
|
||||
"stats": func() any { return StatsData{Page: probePage("stats")} },
|
||||
"report": func() any { return ReportData{Page: probePage("report")} },
|
||||
"topics": func() any { return TopicListData{Page: probePage("topics")} },
|
||||
"topic": func() any { return TopicData{Page: probePage("topics")} },
|
||||
"admin/index": func() any { return AdminData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
|
||||
"admin/password": func() any { return PasswordData{Page: probePage("admin"), Username: "admin"} },
|
||||
"admin/twofa": func() any { return TwoFAData{Page: probePage("admin")} },
|
||||
"admin/fog-mask": func() any { return FogMaskData{Page: probePage("admin")} },
|
||||
"admin/fog-preview": func() any { return FogPreviewData{Page: probePage("admin")} },
|
||||
"admin/backup-preview": func() any { return BackupDiffData{Page: probePage("admin")} },
|
||||
"admin/baseline": func() any { return BaselineData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
|
||||
"admin/fillup": func() any { return FillupData{Page: probePage("admin")} },
|
||||
"admin/notes": func() any { return NotesData{Page: probePage("admin")} },
|
||||
"admin/edit": func() any { return EditData{Page: probePage("admin")} },
|
||||
"admin/topics": func() any { return TopicAdminData{Page: probePage("admin")} },
|
||||
"admin/photos": func() any { return PhotoData{Page: probePage("admin")} },
|
||||
"admin/login": func() any { return LoginData{Settings: setting.Default()} },
|
||||
"admin/twofa-login": func() any { return TwoFALoginData{Settings: setting.Default()} },
|
||||
"admin/settings": func() any {
|
||||
return SettingsData{Page: probePage("admin"), Modules: setting.AllModules,
|
||||
Pages: setting.AllPages, MapLayers: []MapLayerOption{{Key: "k", Label: "l"}}}
|
||||
},
|
||||
}
|
||||
|
||||
// probePage 是所有探针共用的页面上下文:给足站点设置,免得模板在取值阶段就断。
|
||||
func probePage(nav string) Page {
|
||||
return Page{Nav: nav, Title: "模板自检", Settings: setting.Default()}
|
||||
}
|
||||
|
||||
// smokeRender 把所有页面各渲染一遍,专为触发执行期错误。
|
||||
// 任一页失败都返回错误,让 New 拒绝启动——宁可起不来,也不要起来后一半页面是残的。
|
||||
func (s *Server) smokeRender() error {
|
||||
var failed []string
|
||||
for key := range pageFiles {
|
||||
probe, ok := pageProbe[key]
|
||||
if !ok {
|
||||
// 没有探针的页面等于没被自检覆盖。这是漏,不是省事,所以按失败处理。
|
||||
failed = append(failed, key+": pageProbe 里没有配自检数据")
|
||||
continue
|
||||
}
|
||||
if err := s.smokeOne(key, probe()); err != nil {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("模板自检未通过,多半是 Go 结构体与模板字段不同步(改完 Go 要重新编译):\n %s",
|
||||
strings.Join(failed, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) smokeOne(key string, data any) (err error) {
|
||||
// 模板里调到的方法可能对着空数据 panic,别让它把整个启动带走——
|
||||
// 这里要的是一个可读的「哪一页坏了」,不是一段崩栈。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("%s: 渲染时 panic: %v", key, r)
|
||||
}
|
||||
}()
|
||||
set, err := s.pageSet(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := set.t.ExecuteTemplate(&buf, set.entry, data); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/config"
|
||||
)
|
||||
|
||||
// TestSmokeRenderAllPages 把启动自检搬进 go test:每个页面都要能用「最小可用数据」渲染到底。
|
||||
//
|
||||
// 比 TestPageTemplatesParse 更强的地方在于**执行**:模板能解析不代表渲染得完,
|
||||
// 「字段不存在 / 对着 nil 解引用」只在执行期暴露,而线上表现是「HTTP 200 + 半张页面」。
|
||||
// 2026-10-09 的 /admin/settings 截断就是这么来的(新模板引用了旧结构体里没有的字段)。
|
||||
func TestSmokeRenderAllPages(t *testing.T) {
|
||||
srv := &Server{cfg: &config.Config{}}
|
||||
pages := make(map[string]pageSet, len(pageFiles))
|
||||
for key, def := range pageFiles {
|
||||
tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
|
||||
ParseFS(tmplFS, def.files...)
|
||||
if err != nil {
|
||||
t.Fatalf("解析模板 %s 失败: %v", key, err)
|
||||
}
|
||||
pages[key] = pageSet{t: tpl, entry: def.entry}
|
||||
}
|
||||
srv.pages = pages
|
||||
|
||||
if err := srv.smokeRender(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 定义性不变量:pageFiles 里每加一个页面,就必须在 pageProbe 里配一份自检数据,
|
||||
// 否则那一页会悄悄逃过自检(smokeRender 会把缺项报成失败,这里再独立钉一次)。
|
||||
for key := range pageFiles {
|
||||
if _, ok := pageProbe[key]; !ok {
|
||||
t.Errorf("页面 %s 没有自检数据(pageProbe 缺项)——新加页面时要一起补", key)
|
||||
}
|
||||
}
|
||||
for key := range pageProbe {
|
||||
if _, ok := pageFiles[key]; !ok {
|
||||
t.Errorf("pageProbe 里的 %s 不是已注册页面(改名后忘了同步?)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmokeRenderCatchesFieldDrift 变异验证:把页面数据换成**字段更少**的类型时必须报错。
|
||||
//
|
||||
// 光断言「当前代码自检通过」证明不了自检有效——它可能只是什么都没检查。
|
||||
// 这里用 Page 顶替 SettingsData(模板引用的 .Modules / .S3Info 都不在 Page 上),
|
||||
// 自检必须红,且错误信息里要能看到是哪一页。
|
||||
func TestSmokeRenderCatchesFieldDrift(t *testing.T) {
|
||||
srv := &Server{cfg: &config.Config{}}
|
||||
pages := make(map[string]pageSet, len(pageFiles))
|
||||
for key, def := range pageFiles {
|
||||
tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
|
||||
ParseFS(tmplFS, def.files...)
|
||||
if err != nil {
|
||||
t.Fatalf("解析模板 %s 失败: %v", key, err)
|
||||
}
|
||||
pages[key] = pageSet{t: tpl, entry: def.entry}
|
||||
}
|
||||
srv.pages = pages
|
||||
|
||||
orig := pageProbe["admin/settings"]
|
||||
pageProbe["admin/settings"] = func() any { return probePage("admin") }
|
||||
t.Cleanup(func() { pageProbe["admin/settings"] = orig })
|
||||
|
||||
err := srv.smokeRender()
|
||||
if err == nil {
|
||||
t.Fatal("页面数据缺字段时自检必须报错,否则它什么也挡不住")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "admin/settings") {
|
||||
t.Errorf("自检报错要指名是哪个页面,实际为:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@
|
||||
<td data-label="活动">{{if .Activity}}{{.Activity}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td data-label="差异">
|
||||
{{range .Fields}}
|
||||
<div style="font-size:12.5px;line-height:1.7">
|
||||
<div style="font-size:var(--fs-sm);line-height:1.7">
|
||||
<span class="muted">{{.Label}}</span>
|
||||
<span style="text-decoration:line-through;opacity:.65">{{if .From}}{{.From}}{{else}}(空){{end}}</span>
|
||||
<span style="opacity:.5">→</span>
|
||||
@@ -190,7 +190,7 @@
|
||||
<td class="num mono" data-label="坐标">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
|
||||
<td data-label="合并时删除">
|
||||
{{if gt .VisitCount 0}}
|
||||
<span class="muted" style="font-size:12.5px">还有到访,不能删</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">还有到访,不能删</span>
|
||||
{{else}}
|
||||
<input type="checkbox" form="merge-form" name="dp_{{.SrcPK}}" value="1">
|
||||
{{end}}
|
||||
@@ -213,7 +213,7 @@
|
||||
<td>{{if .DB.Name}}{{.DB.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
|
||||
<td data-label="差异">
|
||||
{{range .Fields}}
|
||||
<div style="font-size:12.5px;line-height:1.7">
|
||||
<div style="font-size:var(--fs-sm);line-height:1.7">
|
||||
<span class="muted">{{.Label}}</span>
|
||||
<span style="text-decoration:line-through;opacity:.65">{{if .From}}{{.From}}{{else}}(空){{end}}</span>
|
||||
<span style="opacity:.5">→</span>
|
||||
@@ -241,17 +241,17 @@
|
||||
</div>
|
||||
<div class="row" style="gap:24px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div style="flex:1 1 220px">
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:4px">备份新增</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:4px">备份新增</div>
|
||||
{{range .ActAdded}}<div>{{.Name}}</div>{{else}}<div class="muted">没有</div>{{end}}
|
||||
</div>
|
||||
<div style="flex:1 1 220px">
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:4px">仅站内有(勾上=合并时删除)</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:4px">仅站内有(勾上=合并时删除)</div>
|
||||
{{range .ActGone}}
|
||||
<label style="display:block"><input type="checkbox" form="merge-form" name="da_{{.SrcPK}}" value="1"> {{.Name}}</label>
|
||||
{{else}}<div class="muted">没有</div>{{end}}
|
||||
</div>
|
||||
<div style="flex:1 1 260px">
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:4px">名称不同</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:4px">名称不同</div>
|
||||
{{range .ActClash}}
|
||||
<div style="margin-bottom:6px">
|
||||
{{range .Fields}}<div><span style="text-decoration:line-through;opacity:.65">{{.From}}</span> → <strong>{{.To}}</strong></div>{{end}}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<input type="file" name="backup" accept=".rondbackup,.zip" required>
|
||||
<button class="btn primary" type="submit">上传并换底座</button>
|
||||
</div>
|
||||
<label class="muted" style="display:block;margin-top:10px;font-size:13px">
|
||||
<label class="muted" style="display:block;margin-top:10px;font-size:var(--fs-sm)">
|
||||
<input type="checkbox" name="force" value="1">
|
||||
即使与当前数据集对不上也强制替换(重合率偏低时会再让你确认一次)
|
||||
</label>
|
||||
|
||||
@@ -518,7 +518,7 @@
|
||||
<td class="muted mono">{{if .Span}}{{.Span}}{{else}}(无时间){{end}}
|
||||
{{if .DurationMin}}<br><span class="muted">{{dur .DurationMin}}</span>{{end}}</td>
|
||||
<td class="mv-route" data-label="路线" title="{{.FromLabel}}{{if .FromCity}} · {{.FromCity}}{{end}} → {{.ToLabel}}{{if .ToCity}} · {{.ToCity}}{{end}}">{{.FromLabel}}<span class="mv-city">{{if .FromCity}} · {{.FromCity}}{{end}}</span><br><span class="muted">→ {{.ToLabel}}<span class="mv-city">{{if .ToCity}} · {{.ToCity}}{{end}}</span></span></td>
|
||||
<td class="muted" data-label="方式">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{colorof .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
|
||||
<td class="muted" data-label="方式">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{csscolor .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
|
||||
<td class="num" data-label="里程">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
|
||||
<td class="ops">
|
||||
<details class="inline-edit">
|
||||
@@ -801,13 +801,11 @@
|
||||
// 注意 l10n 对象叫 flatpickr.l10ns(复数);且必须 try 住——这里抛错会把
|
||||
// 同一回调里后面的地图和地点搜索初始化一起炸掉。
|
||||
try {
|
||||
if (window.flatpickr) {
|
||||
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
|
||||
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
|
||||
window.rondDatetime.attachPicker(el);
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('flatpickr 初始化失败', e); }
|
||||
// 统一入口:一次覆盖 datetime-local 与 date(行程筛选的 mfrom / mto 就是 date);
|
||||
// 中文文案在 attachPicker 里设,不用各页自己调 localize。
|
||||
// 别再手写 querySelector('input[type=...]') —— 2026-10-09 的日期框 bug 就是这么漏掉 type=date 的。
|
||||
window.rondDatetime.attachAll();
|
||||
} catch (e) { console.error('日期控件初始化失败', e); }
|
||||
|
||||
// 融合表单的模式切换。required 不能写在 name/lat 等字段上——切到
|
||||
// 「补已有地点」时它们虽被隐藏,HTML5 仍会校验并拦住提交,所以校验交给后端。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
{{if .Rows}}
|
||||
<div class="card">
|
||||
<h2>待补全({{num .Total}}){{if gt .PageCount 1}}<span class="muted" style="font-size:13px;font-weight:400"> 第 {{.PageNo}} / {{.PageCount}} 页</span>{{end}}</h2>
|
||||
<h2>待补全({{num .Total}}){{if gt .PageCount 1}}<span class="muted" style="font-size:var(--fs-sm);font-weight:400"> 第 {{.PageNo}} / {{.PageCount}} 页</span>{{end}}</h2>
|
||||
<div class="card-sub">
|
||||
<strong>默认都是「不动」</strong>,你改过的才会写进去。
|
||||
活动可以直接打字(会提示已有的类型),也可以点「采纳本页建议」一键填上;
|
||||
@@ -79,7 +79,7 @@
|
||||
<td data-label="建议来源">{{if $row.Source}}{{$row.Source}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td data-label="高德参考">
|
||||
{{if $row.AmapType}}
|
||||
<span style="font-size:12.5px">{{if $row.AmapPoi}}{{$row.AmapPoi}}<br>{{end}}<span class="muted">{{$row.AmapType}}</span></span>
|
||||
<span style="font-size:var(--fs-sm)">{{if $row.AmapPoi}}{{$row.AmapPoi}}<br>{{end}}<span class="muted">{{$row.AmapType}}</span></span>
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<button class="btn primary" type="submit" id="mask-save">保存</button>
|
||||
<button class="btn" type="button" id="mask-reset">清除选择</button>
|
||||
</div>
|
||||
<div class="muted" id="mask-hint" style="font-size:12.5px;margin-top:6px">在下方地图上点选圆心</div>
|
||||
<div class="muted" id="mask-hint" style="font-size:var(--fs-sm);margin-top:6px">在下方地图上点选圆心</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="card">
|
||||
<h2>差异地图</h2>
|
||||
<div class="card-sub">在真实底图上核对差异落在哪些区域,可以缩放平移。</div>
|
||||
<div class="row" style="gap:16px;margin:2px 0 10px;font-size:12.5px;flex-wrap:wrap">
|
||||
<div class="row" style="gap:16px;margin:2px 0 10px;font-size:var(--fs-sm);flex-wrap:wrap">
|
||||
<span style="display:flex;align-items:center;gap:6px">
|
||||
<i style="width:13px;height:13px;border-radius:3px;background:#24a45a;display:inline-block"></i>叠加会新增
|
||||
</span>
|
||||
@@ -110,7 +110,7 @@
|
||||
⚠️ 快照里没有 <span class="mono">Model/~</span> 与 <span class="mono">Model/#</span> 两层数据,替换会把现有的这两层一并清掉。
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:8px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:8px">
|
||||
另外,站点不解析但会留存的 <span class="mono">Model/~</span>({{.TildeLayers}} 项)与
|
||||
<span class="mono">Model/#</span>({{.HashLayers}} 项)也会一并换成这份快照里的{{if .OtherLayers}},另有 {{.OtherLayers}} 项其它条目{{end}}。
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</label>
|
||||
<button class="btn primary" type="submit">分析差异</button>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12.5px;margin-top:8px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
|
||||
上传后<strong>先不写入</strong>:会先给出这份快照能补多少新区域、以及「整体替换」会丢掉多少已有区域,
|
||||
确认之后才真正导入。
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
|
||||
<div style="width:100%;max-width:368px">
|
||||
<div style="text-align:center;margin-bottom:22px">
|
||||
<div style="font-size:30px">{{.Settings.OwnerAvatar}}</div>
|
||||
<h1 style="font-size:19px;margin-top:6px">{{.Settings.SiteTitle}}</h1>
|
||||
<div class="muted" style="font-size:13px">后台管理</div>
|
||||
<div style="font-size:var(--glyph-xl)">{{.Settings.OwnerAvatar}}</div>
|
||||
<h1 style="font-size:var(--fs-lg);margin-top:6px">{{.Settings.SiteTitle}}</h1>
|
||||
<div class="muted" style="font-size:var(--fs-sm)">后台管理</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin:0">
|
||||
@@ -46,7 +46,7 @@
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:13px">← 返回主页</a></div>
|
||||
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:var(--fs-sm)">← 返回主页</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<form id="bulk-form" method="post" action="/admin/notes/bulk"></form>
|
||||
<div class="card" style="position:sticky;top:8px;z-index:3;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<label class="check" style="margin:0"><input type="checkbox" id="chk-all"> 全选本页</label>
|
||||
<span class="muted" style="font-size:12.5px">批量:</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">批量:</span>
|
||||
<select name="verdict" form="bulk-form" style="padding:5px 8px;border:1px solid var(--line);border-radius:8px">
|
||||
<option value="1">👍 推荐</option>
|
||||
<option value="-1">👎 踩雷</option>
|
||||
@@ -100,7 +100,7 @@
|
||||
data-verdict-btn data-src="{{.SrcPK}}" data-v="-1" title="标为踩雷">👎</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12px;margin-top:4px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:4px">
|
||||
地点 ID {{.SrcPK}}{{if .VisitCount}} · 到访 {{num .VisitCount}} 次{{end}}
|
||||
<span class="vote-tip" data-verdict-status="{{.SrcPK}}">{{if eq .Verdict 1}} · 已标推荐{{else if eq .Verdict -1}} · 已标踩雷{{end}}</span>
|
||||
{{if or .Up .Down}} · 访客 👍{{.Up}} 👎{{.Down}}{{end}}
|
||||
@@ -171,7 +171,7 @@
|
||||
<div class="item" style="grid-template-columns:1fr auto;gap:10px;align-items:center">
|
||||
<div>
|
||||
<div>{{if .Alias}}{{.Alias}}{{else}}<span class="muted">(无别名)</span>{{end}}{{if .Content}} · <span class="muted">{{.Content}}</span>{{end}}{{if .RondNote}} · <span class="muted">来自 rond:{{.RondNote}}</span>{{end}}</div>
|
||||
<div class="muted" style="font-size:12px">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm)">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/place/note">
|
||||
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:14px;margin:0 0 10px">用户名</h2>
|
||||
<h2 style="font-size:var(--fs-sm);margin:0 0 10px">用户名</h2>
|
||||
{{if eq .Flash "name"}}<div class="alert ok">用户名已更新</div>{{end}}
|
||||
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
|
||||
<form method="post" action="/admin/username">
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:14px;margin:0 0 10px">密码</h2>
|
||||
<h2 style="font-size:var(--fs-sm);margin:0 0 10px">密码</h2>
|
||||
{{if and .Flash (ne .Flash "name")}}<div class="alert ok">密码已更新</div>{{end}}
|
||||
<form method="post" action="/admin/password">
|
||||
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
一次几十张、几百 MB 都没有上传耗时或存储压力。
|
||||
</div>
|
||||
<div class="row" style="align-items:center;margin-bottom:10px">
|
||||
<span class="muted" style="font-size:12.5px">照片里的坐标是</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">照片里的坐标是</span>
|
||||
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="wgs84" checked> WGS-84(绝大多数手机)</label>
|
||||
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="gcj02"> 已经是 GCJ-02(部分国产手机会这样写)</label>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="row" style="align-items:center;gap:10px">
|
||||
<label class="btn primary" for="photo-input" style="cursor:pointer">+ 选择照片</label>
|
||||
<input type="file" id="photo-input" accept="image/*" multiple hidden>
|
||||
<span class="muted" id="pick-count" style="font-size:12.5px">可多选,JPEG / HEIC 都行</span>
|
||||
<span class="muted" id="pick-count" style="font-size:var(--fs-sm)">可多选,JPEG / HEIC 都行</span>
|
||||
</div>
|
||||
<div id="parse-status" class="alert info"></div>
|
||||
</div>
|
||||
@@ -505,12 +505,10 @@
|
||||
|
||||
function initPickers() {
|
||||
try {
|
||||
if (!window.flatpickr) return;
|
||||
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
|
||||
rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
|
||||
window.rondDatetime.attachPicker(el);
|
||||
});
|
||||
} catch (e) { console.error('flatpickr 初始化失败', e); }
|
||||
// 统一入口(datetime-local + date),中文文案在 attachPicker 里设。
|
||||
// 限定在 rowsBox 里:页面别处的输入前面已经挂过了。
|
||||
window.rondDatetime.attachAll(rowsBox);
|
||||
} catch (e) { console.error('日期控件初始化失败', e); }
|
||||
}
|
||||
|
||||
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</div>
|
||||
|
||||
{{if .Flash}}<div class="alert ok">设置已保存</div>{{end}}
|
||||
{{if .Warn}}<div class="alert err">{{.Warn}}</div>{{end}}
|
||||
|
||||
<form method="post" action="/admin/settings">
|
||||
|
||||
@@ -53,7 +54,7 @@
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="muted" style="font-size:12.5px;margin-top:8px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
|
||||
关闭的页面在访客导航中隐藏,直接输入网址也会得到 404。主页不受此开关影响。
|
||||
</div>
|
||||
<label class="f" style="max-width:320px;margin-top:14px">
|
||||
@@ -63,7 +64,7 @@
|
||||
{{range .Pages}}<option value="{{.Key}}" {{if eq $.Settings.VisitorHome .Key}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<div class="muted" style="font-size:12.5px;margin-top:6px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:6px">
|
||||
访客访问站点根路径时直接落到这里;你登录后访问根路径仍然看主页。
|
||||
选择了一个没对访客开放的页面时不会跳转(会留在主页)。
|
||||
</div>
|
||||
@@ -91,7 +92,7 @@
|
||||
<label class="check"><input type="checkbox" name="hide_work" {{if .Settings.HideWork}}checked{{end}}> 隐藏类型为「工作」的记录</label>
|
||||
<label class="check"><input type="checkbox" name="notes_public" {{if .Settings.NotesPublic}}checked{{end}}> 地点备注对访客公开(不勾选则只有你自己能看到)</label>
|
||||
<label class="check"><input type="checkbox" name="marks_public" {{if .Settings.MarksPublic}}checked{{end}}> 「推荐 / 踩雷」标记与票数对访客公开</label>
|
||||
<div class="muted" style="font-size:12.5px">
|
||||
<div class="muted" style="font-size:var(--fs-sm)">
|
||||
隐藏后匿名访客看不到这两类记录。你登录后可在任意公开页面用筛选器上的
|
||||
<span class="mono">含家/工作</span> 开关临时显示给自己看。
|
||||
</div>
|
||||
@@ -104,10 +105,10 @@
|
||||
<option value="10" {{if eq .Settings.MinDwellMin 10}}selected{{end}}>少于 10 分钟</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="muted" style="font-size:12.5px">该设置作用于全站统计与地图,可有效过滤路过点。</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm)">该设置作用于全站统计与地图,可有效过滤路过点。</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card card--quiet">
|
||||
<h2>地图底图</h2>
|
||||
<div class="card-sub">在 <span class="mono">conf/app.ini</span> 中配置,改完重启生效</div>
|
||||
<div class="kv"><span class="k">当前生效</span><span class="v">{{.MapInfo}}</span></div>
|
||||
@@ -121,20 +122,20 @@
|
||||
<div class="card-sub" style="margin-top:0">放大后底图自带的 POI 与路名标注太密、看不清自己的点时,换成<b>纯路网</b>。
|
||||
访客在地图上手动切换过的话,浏览器会记住他自己的选择。</div>
|
||||
{{end}}
|
||||
<div class="muted" style="font-size:12.5px;margin-top:8px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
|
||||
<div><span class="mono">map_provider</span>:<span class="mono">amap</span>(默认,免密钥)/ <span class="mono">tianditu</span>(需 <span class="mono">map_key</span>)/ <span class="mono">custom</span>(自备 <span class="mono">map_tile_url</span>)</div>
|
||||
<div><span class="mono">map_key</span>:填写后瓦片改由服务端代理,密钥不会出现在页面源码里。</div>
|
||||
<div><span class="mono">map_crs</span>:仅 <span class="mono">custom</span> 使用,填 <span class="mono">gcj02</span> 或 <span class="mono">wgs84</span>;站点数据本身是 GCJ-02,换成 WGS-84 底图(天地图 / OSM)时会自动纠偏。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card card--quiet">
|
||||
<h2>图片存储</h2>
|
||||
<div class="card-sub">地点图片存到哪儿。图片在<b>浏览器里</b>转成 WebP 后才上传,服务器只负责收下与转存</div>
|
||||
<label class="check"><input type="radio" name="media_backend" value="local" {{if ne .Settings.MediaBackend "s3"}}checked{{end}}> 服务器本地磁盘</label>
|
||||
<label class="check"><input type="radio" name="media_backend" value="s3" {{if eq .Settings.MediaBackend "s3"}}checked{{end}}> 对象存储(S3 兼容,如 Cloudflare R2)</label>
|
||||
<div class="kv" style="margin-top:8px"><span class="k">对象存储</span><span class="v">{{.S3Info}}</span></div>
|
||||
<div class="muted" style="font-size:12.5px;margin-top:8px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
|
||||
<div>凭据放在 <span class="mono">conf/app.ini</span>,不进数据库、也不会下发到前端:</div>
|
||||
<div><span class="mono">s3_endpoint</span>(R2 形如 <span class="mono">https://<账号>.r2.cloudflarestorage.com</span>)、<span class="mono">s3_bucket</span>、<span class="mono">s3_access_key</span>、<span class="mono">s3_secret_key</span>、<span class="mono">s3_region</span>(R2 填 <span class="mono">auto</span>)。</div>
|
||||
<div><span class="mono">s3_public_base</span>:桶的公开域名(自定义域或 r2.dev),拼上对象名就是 <span class="mono"><img src></span>;不填的话图片存得进去但浏览器取不到。</div>
|
||||
@@ -151,14 +152,15 @@
|
||||
<textarea name="track_script" rows="4"
|
||||
placeholder="https://example.com/analytics.js">{{.Settings.TrackScript}}</textarea>
|
||||
</label>
|
||||
<div class="muted" style="font-size:12.5px">
|
||||
<div class="muted" style="font-size:var(--fs-sm)">
|
||||
<div>填一个 <span class="mono">https://…</span> 地址会包成 <span class="mono"><script src=…></span>;
|
||||
需要初始化代码的服务(Umami、百度统计等)可以把整段 <span class="mono"><script></span> 贴进来。</div>
|
||||
<div>整段 <span class="mono"><script></span> 需要闭合标签配对(多一个 <span class="mono"></script></span> 会被判为粘贴错误);
|
||||
<div>整段脚本的 <span class="mono"><script></span> 与 <span class="mono"></script></span> 必须数量相同:
|
||||
多一个 <span class="mono"></script></span> 会提前闭合、少一个会把页面收尾吞进脚本,两种情况都拒收;
|
||||
<span class="mono">src</span> 只接受 <span class="mono">https://</span>、<span class="mono">http://</span> 或以 <span class="mono">/</span> 开头的地址,
|
||||
超过 4096 字符会被截断。填了但没勾开关不会生效。</div>
|
||||
<div><span class="mono">conf/app.ini</span> 的 <span class="mono">track_script</span> 会作为这里的默认值——
|
||||
它也需要开关打开才注入。</div>
|
||||
超过 4096 字符会被截断(并给出提示)。填了但没勾开关不会生效。</div>
|
||||
<div>这个框留空时回落到 <span class="mono">conf/app.ini</span> 的 <span class="mono">track_script</span>
|
||||
——它不会回填到上面这个框(改 ini 要重启)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}</h2>
|
||||
<div class="card-sub">时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
|
||||
区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选。</div>
|
||||
<form method="post" action="/admin/topics/save">
|
||||
<div class="card-sub">时间范围可精确到分钟;不圈显示区就是「时间范围内的地点画到哪算哪」。</div>
|
||||
<form method="post" action="/admin/topics/save" id="topic-form">
|
||||
{{if not .IsNew}}<input type="hidden" name="id" value="{{.Form.ID}}">{{end}}
|
||||
<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>
|
||||
@@ -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 120px"><span>排序</span><input type="number" name="sort_order" value="{{.Form.SortOrder}}" placeholder="小的在前"></label>
|
||||
</div>
|
||||
<div class="card-sub" style="margin:0 0 8px">区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域</div>
|
||||
<div class="row">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<div class="card-sub" style="margin:0 0 8px">显示区(可选):在图上圈一组圆,落在<strong>任一</strong>圆内的足迹才算这个专题的内容;
|
||||
不圈就是「时间范围内的地点画到哪算哪」。圈了之后,迷雾也只显示在这些圆的并集里。</div>
|
||||
{{/* areas 的初值直接渲染在这里:脚本没加载上时提交的仍是原值,不会把已有显示区静默清空。
|
||||
crs 由编辑器按底图坐标系填(天地图那类是 WGS-84),后端再换算成 GCJ-02。 */}}
|
||||
<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 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>
|
||||
@@ -85,20 +98,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/topic-areas.js?v={{.AssetV}}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function whenReady(cb) {
|
||||
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
|
||||
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 () {
|
||||
try {
|
||||
if (!window.flatpickr) return;
|
||||
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
|
||||
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
|
||||
window.rondDatetime.attachPicker(el);
|
||||
});
|
||||
} catch (e) { console.error('flatpickr 初始化失败', e); }
|
||||
// 统一入口:一次覆盖 datetime-local 与 date(中文文案在 attachPicker 里设)
|
||||
window.rondDatetime.attachAll();
|
||||
} catch (e) { console.error('日期控件初始化失败', e); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
|
||||
<div style="width:100%;max-width:368px">
|
||||
<div style="text-align:center;margin-bottom:22px">
|
||||
<div style="font-size:30px">🔐</div>
|
||||
<h1 style="font-size:19px;margin-top:6px">两步验证</h1>
|
||||
<div class="muted" style="font-size:13px">输入验证器 App 中的 6 位验证码</div>
|
||||
<div style="font-size:var(--glyph-xl)">🔐</div>
|
||||
<h1 style="font-size:var(--fs-lg);margin-top:6px">两步验证</h1>
|
||||
<div class="muted" style="font-size:var(--fs-sm)">输入验证器 App 中的 6 位验证码</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin:0">
|
||||
@@ -26,13 +26,13 @@
|
||||
<label class="f"><span>验证码</span>
|
||||
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6"
|
||||
autocomplete="one-time-code" autofocus required
|
||||
style="text-align:center;font-size:20px;letter-spacing:8px">
|
||||
style="text-align:center;font-size:var(--glyph-lg);letter-spacing:8px">
|
||||
</label>
|
||||
<button class="btn primary" type="submit" style="width:100%;justify-content:center">验证并登录</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:16px"><a class="muted" href="/admin/login" style="font-size:13px">← 重新登录</a></div>
|
||||
<div style="text-align:center;margin-top:16px"><a class="muted" href="/admin/login" style="font-size:var(--fs-sm)">← 重新登录</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<form method="post" action="/admin/twofa/disable" style="max-width:360px">
|
||||
<label class="f"><span>输入当前验证码以关闭</span>
|
||||
<input type="text" name="code" inputmode="numeric" maxlength="6" required
|
||||
style="text-align:center;letter-spacing:6px;font-size:16px">
|
||||
style="text-align:center;letter-spacing:6px;font-size:var(--fs-control)">
|
||||
</label>
|
||||
<button class="btn danger" type="submit">关闭两步验证</button>
|
||||
</form>
|
||||
@@ -35,13 +35,13 @@
|
||||
<input type="text" readonly value="{{.Secret}}" onclick="this.select()"
|
||||
style="font-family:monospace;letter-spacing:1px">
|
||||
</label>
|
||||
<div class="muted" style="font-size:12.5px">账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm)">账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/twofa/confirm" style="max-width:360px;margin-top:6px">
|
||||
<label class="f"><span>输入验证码确认开启</span>
|
||||
<input type="text" name="code" inputmode="numeric" maxlength="6" required
|
||||
style="text-align:center;letter-spacing:6px;font-size:16px">
|
||||
style="text-align:center;letter-spacing:6px;font-size:var(--fs-control)">
|
||||
</label>
|
||||
<button class="btn primary" type="submit">确认开启</button>
|
||||
</form>
|
||||
@@ -53,7 +53,7 @@
|
||||
<form method="post" action="/admin/twofa/setup">
|
||||
<button class="btn primary" type="submit">开始绑定验证器</button>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12.5px;margin-top:10px">
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-top:10px">
|
||||
需要一个 TOTP 验证器 App(Google Authenticator、Microsoft Authenticator、1Password、Aegis 等均可)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
</div>
|
||||
|
||||
{{if .Settings.Has "overview"}}
|
||||
{{/* 首屏主数:8 个数字全都在,但只让「去过的地方」占主位,城市/省份降为它下面的一行
|
||||
上下文。原来 8 个一样大一样重,扫过去不知道先看哪个。 */}}
|
||||
<div class="kpi-lead">
|
||||
<div class="kl-v">{{num .Overview.Places}}<small>个</small></div>
|
||||
<div class="kl-k">去过的地点</div>
|
||||
<div class="kl-x">走过 {{num .Overview.Cities}} 座城市、{{num .Overview.Provinces}} 个省级行政区</div>
|
||||
</div>
|
||||
<div class="kpis" style="margin-bottom:16px">
|
||||
<div class="kpi"><div class="v">{{num .Overview.Places}}<small>个</small></div><div class="k">去过的地点</div></div>
|
||||
<div class="kpi"><div class="v">{{num .Overview.Cities}}<small>座</small></div><div class="k">到过的城市</div></div>
|
||||
<div class="kpi"><div class="v">{{num .Overview.Provinces}}<small>个</small></div><div class="k">覆盖省份</div></div>
|
||||
<div class="kpi"><div class="v">{{num .Overview.Days}}<small>天</small></div><div class="k">有记录的日子</div></div>
|
||||
<div class="kpi"><div class="v">{{num .Overview.Visits}}<small>次</small></div><div class="k">累计到访</div></div>
|
||||
<div class="kpi"><div class="v">{{dur .Overview.DwellMin}}</div><div class="k">累计停留</div></div>
|
||||
@@ -29,11 +33,11 @@
|
||||
{{end}}
|
||||
|
||||
{{if and (.Settings.Has "map") .Points}}
|
||||
<div class="card">
|
||||
<div class="card card--hero">
|
||||
<h2>足迹地图</h2>
|
||||
<div class="card-sub">共 {{num (len .Points)}} 个地点{{if .Settings.HideHome}} · 已隐藏「家」{{end}}{{if .Settings.HideWork}} · 已隐藏「工作」{{end}}</div>
|
||||
<div id="home-map" class="map-preview"></div>
|
||||
<div style="margin-top:10px;font-size:12.5px" class="muted">滚轮被禁用,点击地图进入全屏模式查看细节与筛选。</div>
|
||||
<div style="margin-top:10px;font-size:var(--fs-sm)" class="muted">滚轮被禁用,点击地图进入全屏模式查看细节与筛选。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -46,7 +50,7 @@
|
||||
{{range .ActivityStats}}
|
||||
<div class="bar-row">
|
||||
<div class="nm" title="{{.Name}}">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{csscolor .Color}}"></div></div>
|
||||
<div class="vv">{{num .Count}} 次</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -77,7 +81,7 @@
|
||||
<h2>月度活跃度</h2>
|
||||
<div class="card-sub">每月到访次数</div>
|
||||
<div class="cols">
|
||||
{{range .Monthly}}<div class="col" style="height:{{.Pct}}%" title="{{.Key}}:{{.Count}} 次"></div>{{end}}
|
||||
{{range .Monthly}}<div class="col{{if eq .Count $.MaxMonthly}} peak{{end}}" style="height:{{.Pct}}%" title="{{.Key}}:{{.Count}} 次">{{if eq .Count $.MaxMonthly}}<span class="pv">{{num .Count}}</span>{{end}}</div>{{end}}
|
||||
</div>
|
||||
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
|
||||
</div>
|
||||
@@ -103,7 +107,7 @@
|
||||
{{range .Transports}}
|
||||
<div class="bar-row">
|
||||
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTransport}}%;background:{{colorof .Color}}"></div></div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTransport}}%;background:{{csscolor .Color}}"></div></div>
|
||||
<div class="vv">{{num .Count}} 次</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -118,7 +122,7 @@
|
||||
<div class="chips">
|
||||
{{range .Tags}}
|
||||
<a class="chip" href="{{$.TagLink .ID}}" title="{{num .Count}} 次">
|
||||
<span class="dot" style="background:{{colorof .Color}}"></span>{{.Name}}
|
||||
<span class="dot" style="background:{{csscolor .Color}}"></span>{{.Name}}
|
||||
<span class="muted">{{num .Count}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -156,7 +160,7 @@
|
||||
<div class="list">
|
||||
{{range .Recent}}
|
||||
<div class="item">
|
||||
<div class="ic" style="background:{{colorof .Color}}22">{{iconor .Icon "📍"}}</div>
|
||||
<div class="ic" style="background:{{csssoft .Color}}">{{iconor .Icon "📍"}}</div>
|
||||
<div>
|
||||
<div class="t">{{if .PlaceID}}<a href="{{$.PlaceLink .PlaceID}}">{{.PlaceName}}</a>{{else}}未命名地点{{end}}</div>
|
||||
<div class="m">{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}<span class="tag">{{$t}}</span>{{end}}{{end}}</div>
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
{{/* navitems 是导航链接本体,抽出来是因为它要渲染两遍:桌面上在 .nav-links 里平铺,
|
||||
窄屏整行一定放不下(实测 390 溢 63px、320 溢 133px,最深处的「统计」会被切成半个字),
|
||||
所以另收进 .nav-menu 那个折叠菜单。两份靠 display 二选一,DOM 里不会同时可见。
|
||||
|
||||
页面开关只管访客:站长登录后始终能访问全部页面(guardPage 也是这么放的),
|
||||
导航必须跟着一起放行,否则关掉页面开关后站长自己也只剩「主页」一个入口。 */}}
|
||||
{{define "navitems"}}
|
||||
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
|
||||
{{if or .IsAdmin (.Settings.HasPage "map")}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "places")}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "timeline")}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "stats")}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
|
||||
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "report")}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="/" title="{{.Settings.SiteTitle}}"><span class="avatar">{{.Settings.OwnerAvatar}}</span><span class="t">{{.Settings.SiteTitle}}</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
|
||||
{{if .Settings.HasPage "map"}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
|
||||
{{if .Settings.HasPage "places"}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
|
||||
{{if .Settings.HasPage "timeline"}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
|
||||
{{if .Settings.HasPage "stats"}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
|
||||
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
|
||||
{{if .Settings.HasPage "report"}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
|
||||
</div>
|
||||
<div class="nav-links">{{template "navitems" .}}</div>
|
||||
<details class="nav-menu">
|
||||
<summary aria-label="导航菜单">菜单</summary>
|
||||
<nav class="nav-menu-body">{{template "navitems" .}}</nav>
|
||||
</details>
|
||||
<div class="nav-right">
|
||||
{{if .User}}
|
||||
<span class="who">{{.User.DisplayName}}</span>
|
||||
@@ -23,17 +35,26 @@
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
/* 手机上导航装不下这几项,可以横向滑动(滚动条已隐藏)——右侧加一层渐隐,提示「还能往右滑」;
|
||||
滑到末端就收掉,免得让人以为后面还有。桌面端没有横向溢出,这段等于不做事。 */
|
||||
/* 1) 620px 以上:链接行万一还是放不下(以后加了入口),横向可滑 + 右缘渐隐提示。
|
||||
2) 620px 及以下:整行已经收进 .nav-menu,这里只管菜单开合——原生 <details> 不会
|
||||
点外面自动收、也不响应 Esc,补上;点链接后也收掉,免得盖着刚打开的页面。 */
|
||||
(function () {
|
||||
var el = document.querySelector('.nav-links');
|
||||
if (!el) return;
|
||||
if (el) {
|
||||
var update = function () {
|
||||
el.classList.toggle('overflow-right', el.scrollWidth - el.clientWidth - el.scrollLeft > 2);
|
||||
};
|
||||
update();
|
||||
el.addEventListener('scroll', update, { passive: true });
|
||||
window.addEventListener('resize', update);
|
||||
}
|
||||
var menu = document.querySelector('.nav-menu');
|
||||
if (!menu) return;
|
||||
document.addEventListener('click', function (e) { if (!menu.contains(e.target)) menu.open = false; });
|
||||
menu.addEventListener('click', function (e) {
|
||||
if (e.target.closest && e.target.closest('a')) menu.open = false;
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') menu.open = false; });
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -70,7 +91,7 @@
|
||||
<div class="chips" style="margin-bottom:{{if gt (len .Activities) 14}}8{{else}}12{{end}}px">
|
||||
{{range $i, $a := .Activities}}{{if or (lt $i 14) ($.IsAct $a.ID)}}
|
||||
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}" title="{{num $a.Count}} 次到访">
|
||||
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
|
||||
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
|
||||
<span class="muted">{{num $a.Count}}</span>
|
||||
</a>
|
||||
{{end}}{{end}}
|
||||
@@ -81,7 +102,7 @@
|
||||
<div class="chips">
|
||||
{{range $i, $a := .Activities}}{{if and (ge $i 14) (not ($.IsAct $a.ID))}}
|
||||
<a class="chip" href="{{$.ActLink $a.ID}}">
|
||||
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
|
||||
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
|
||||
<span class="muted">{{num $a.Count}}</span>
|
||||
</a>
|
||||
{{end}}{{end}}
|
||||
@@ -163,7 +184,7 @@
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<div class="big">🗺️</div>
|
||||
<div style="font-size:15px;color:var(--text);margin-bottom:6px">还没有导入任何数据</div>
|
||||
<div style="font-size:var(--fs-md);color:var(--text);margin-bottom:6px">还没有导入任何数据</div>
|
||||
<div>登录后台,上传 rond 导出的 <span class="mono">.rondbackup</span> 备份文件即可生成足迹地图。</div>
|
||||
<div style="margin-top:14px"><a class="btn primary" href="/admin">前往后台导入</a></div>
|
||||
</div>
|
||||
@@ -199,9 +220,9 @@
|
||||
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.Default.css?v={{.AssetV}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .Flatpickr}}
|
||||
{{/* 日期选择器的样式。**无条件加载**:筛选区(公共页)也有日期框,按页开关会被漏掉
|
||||
(见下面 datetime-input 那段的长注释)。 */}}
|
||||
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css?v={{.AssetV}}">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetV}}">
|
||||
{{/* accent-base 是站长选的主色原值:暗色模式会用 color-mix 把它提亮,否则深色底上的链接会看不清(原来的深绿在深底上只有 3.3:1)。 */}}
|
||||
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a;--accent-base:{{.Settings.AccentColor}}}</style>
|
||||
@@ -220,15 +241,33 @@
|
||||
{{if .HasMap}}
|
||||
<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}}
|
||||
{{/* 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>
|
||||
{{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/l10n/zh.js?v={{.AssetV}}"></script>
|
||||
{{/* 时间框的解析器与输入引导。必须在 flatpickr 之后、页面内联脚本之前:
|
||||
它不自己调 flatpickr,只提供 parseDate 与 attach,由各页 whenReady 调 attachPicker。 */}}
|
||||
<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(它会把提示文本与默认值写进地址栏)。
|
||||
全站都可能用到(报告页复制链接、后台活动改名),所以无条件引入。 */}}
|
||||
<script src="/static/js/prompt-box.js?v={{.AssetV}}"></script>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="chips" style="margin-bottom:10px">
|
||||
{{range $i, $a := .Activities}}{{if or (lt $i 12) ($.IsAct $a.ID)}}
|
||||
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}">
|
||||
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
|
||||
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
|
||||
</a>
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
<summary>展开其余 {{len .Activities}} 个类型</summary>
|
||||
<div class="chips">
|
||||
{{range $i, $a := .Activities}}{{if and (ge $i 12) (not ($.IsAct $a.ID))}}
|
||||
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}</a>
|
||||
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}</a>
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
</details>
|
||||
@@ -136,7 +136,7 @@
|
||||
<div class="map-legend">
|
||||
<div style="font-weight:500;color:var(--text-2)">类型分布</div>
|
||||
{{range .Legend}}
|
||||
<div class="lg"><i style="background:{{colorof .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
|
||||
<div class="lg"><i style="background:{{csscolor .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<div class="row" style="margin-top:12px;gap:10px">
|
||||
<label class="btn primary" for="imgs-input" id="imgs-pick" style="cursor:pointer">+ 选择图片</label>
|
||||
<input type="file" id="imgs-input" accept="image/*" multiple hidden>
|
||||
<span class="muted" id="imgs-count" style="font-size:12.5px"></span>
|
||||
<span class="muted" id="imgs-count" style="font-size:var(--fs-sm)"></span>
|
||||
</div>
|
||||
<div class="imgs-hint" id="imgs-hint"></div>
|
||||
</div>
|
||||
@@ -200,7 +200,7 @@
|
||||
<div class="list" style="max-height:280px;overflow-y:auto">
|
||||
{{range .Visits}}
|
||||
<div class="item" style="grid-template-columns:34px 1fr auto">
|
||||
<div class="ic" style="background:{{colorof .Color}}22;width:30px;height:30px;font-size:15px">{{iconor .Icon "📍"}}</div>
|
||||
<div class="ic" style="background:{{csssoft .Color}};width:30px;height:30px;font-size:var(--fs-md)">{{iconor .Icon "📍"}}</div>
|
||||
<div>
|
||||
<div class="t" style="font-weight:400">{{day .Arrival}}</div>
|
||||
<div class="m">{{hm .Arrival}} – {{if .Departure}}{{hm .Departure}}{{else}}进行中{{end}}{{if .Remark}} · {{.Remark}}{{end}}</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="row" style="margin-bottom:12px">
|
||||
<span class="muted" style="font-size:12.5px">排序</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">排序</span>
|
||||
<div class="chips">
|
||||
<a class="chip {{if eq .Sort ""}}on{{end}}" href="{{.Link .FilterBase "sort" ""}}">到访次数</a>
|
||||
<a class="chip {{if eq .Sort "dwell"}}on{{end}}" href="{{.Link .FilterBase "sort" "dwell"}}">停留时长</a>
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
{{if or .IsAdmin .Settings.MarksPublic}}
|
||||
<div class="row" style="margin-bottom:12px">
|
||||
<span class="muted" style="font-size:12.5px">标记</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">标记</span>
|
||||
<div class="chips">
|
||||
<a class="chip {{if eq .Verdict 0}}on{{end}}" href="{{.VerdictLink 0}}">全部</a>
|
||||
<a class="chip {{if eq .Verdict 1}}on{{end}}" href="{{.VerdictLink 1}}">👍 推荐</a>
|
||||
@@ -55,11 +55,11 @@
|
||||
{{if .IsHome}}<span class="tag">家</span>{{end}}
|
||||
{{if .IsWork}}<span class="tag">工作</span>{{end}}
|
||||
{{if eq .Verdict 1}}<span class="tag vote-up">👍 推荐</span>{{else if eq .Verdict -1}}<span class="tag vote-down">👎 踩雷</span>{{end}}
|
||||
{{if or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
|
||||
{{if or .Up .Down}}<span class="muted" style="font-size:var(--fs-sm)">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
|
||||
</td>
|
||||
<td class="muted" data-label="城市">{{if .City}}{{.City}}{{else}}—{{end}}</td>
|
||||
<td data-label="主要类型">
|
||||
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{colorof .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
|
||||
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{csscolor .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td data-label="图片">{{if .HasImage}}<span title="有图片">📷</span>{{else}}<span class="muted" title="还没有上传图片">—</span>{{end}}</td>
|
||||
<td class="num" data-label="到访">{{num .VisitCount}}</td>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<tbody>
|
||||
{{range .Transports}}
|
||||
<tr>
|
||||
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
||||
<td><span class="dot" style="display:inline-block;background:{{csscolor .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
||||
<td class="num" data-label="次数">{{num .Count}}</td>
|
||||
<td class="num" data-label="里程">{{km .Distance}} km</td>
|
||||
</tr>
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
|
||||
<h2 style="margin:0">推荐 & 踩雷({{if .IsAdmin}}我的结论{{else}}站长结论{{end}})</h2>
|
||||
<span class="muted" style="font-size:12.5px">推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}}</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}}</span>
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
只统计<strong>{{if .IsAdmin}}你自己{{else}}站长{{end}}</strong>在地点页或地图气泡里标的 👍/👎——按坐标键存,重新上传备份也不会丢。
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 {{if .IsAdmin}}我{{else}}站长{{end}}推荐的地点</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👍 {{if .IsAdmin}}我{{else}}站长{{end}}推荐的地点</div>
|
||||
{{if .Recommended}}
|
||||
<div class="chips">
|
||||
{{range .Recommended}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
||||
@@ -42,7 +42,7 @@
|
||||
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 {{if .IsAdmin}}我{{else}}站长{{end}}踩雷的地点</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👎 {{if .IsAdmin}}我{{else}}站长{{end}}踩雷的地点</div>
|
||||
{{if .Avoided}}
|
||||
<div class="chips">
|
||||
{{range .Avoided}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
||||
@@ -56,14 +56,14 @@
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
|
||||
<h2 style="margin:0">访客点赞</h2>
|
||||
<span class="muted" style="font-size:12.5px">👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票</span>
|
||||
<span class="muted" style="font-size:var(--fs-sm)">👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票</span>
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
访客在地点页点的 👍/👎,一人一票、可改可撤。与上面的站长结论<strong>互不影响</strong>:各存各的表,谁也不会覆盖谁。
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 被点赞最多的地点</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👍 被点赞最多的地点</div>
|
||||
{{if .VisitorUp}}
|
||||
<div class="chips">
|
||||
{{range .VisitorUp}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}<span class="muted"> · 👍 {{.Up}}{{if .Down}} · 👎 {{.Down}}{{end}}</span></a>{{end}}
|
||||
@@ -71,7 +71,7 @@
|
||||
{{else}}<div class="empty" style="padding:10px 0">还没有访客点赞</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 被点踩最多的地点</div>
|
||||
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👎 被点踩最多的地点</div>
|
||||
{{if .VisitorDown}}
|
||||
<div class="chips">
|
||||
{{range .VisitorDown}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}<span class="muted"> · 👎 {{.Down}}{{if .Up}} · 👍 {{.Up}}{{end}}</span></a>{{end}}
|
||||
@@ -84,15 +84,19 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>月度分布</h2>
|
||||
<div class="card-sub">每月到访次数,最高 {{num .MaxMonthly}} 次</div>
|
||||
<div class="card-sub">每月到访次数 · 点柱条只看那个月</div>
|
||||
<div class="cols">
|
||||
{{/* 点柱条直接筛出那个月;title 是悬停读数 */}}
|
||||
{{range .Monthly}}<a class="col" href="{{$.MonthLink .Key}}" title="{{.Key}}:{{.Count}} 次到访。点一下只看这个月" style="height:{{.Pct}}%"></a>{{end}}
|
||||
{{/* 点柱条直接筛出那个月;title 是悬停读数。最高的那根在柱顶标出次数(.pv),
|
||||
副标题里就不再重复写一遍「最高 N 次」。 */}}
|
||||
{{range .Monthly}}<a class="col{{if eq .Count $.MaxMonthly}} peak{{end}}" href="{{$.MonthLink .Key}}" title="{{.Key}}:{{.Count}} 次到访。点一下只看这个月" style="height:{{.Pct}}%">{{if eq .Count $.MaxMonthly}}<span class="pv">{{num .Count}}</span>{{end}}</a>{{end}}
|
||||
</div>
|
||||
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid g2">
|
||||
{{/* 这两张卡不做左右分栏:类型分布(60 多行)与一天中的分布(24 根柱)体量差太多,
|
||||
并排时右侧下面必然是一大片空白。各占整行 —— 列表像「城市排行」那样横铺开,
|
||||
24 根柱也终于有宽度可用。 */}}
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>类型分布</h2>
|
||||
<div class="card-sub">按到访次数排序</div>
|
||||
@@ -100,7 +104,7 @@
|
||||
{{range .ActivityStats}}
|
||||
<a class="bar-row" href="{{$.ActLink .ID}}" title="{{.Name}}:{{num .Count}} 次,累计 {{dur .Dwell}}。点一下按这个类型筛选">
|
||||
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{csscolor .Color}}"></div></div>
|
||||
<div class="vv">{{num .Count}} 次 · {{dur .Dwell}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -111,10 +115,12 @@
|
||||
<h2>一天中的分布</h2>
|
||||
<div class="card-sub">按到访开始时间统计</div>
|
||||
<div class="cols" style="height:130px">
|
||||
{{range $h, $c := .Hourly}}<div class="col" style="height:{{pct $c $.MaxHourly}}%" title="{{$h}} 时:{{$c}} 次"></div>{{end}}
|
||||
{{range $h, $c := .Hourly}}<div class="col{{if eq $c $.MaxHourly}} peak{{end}}" style="height:{{pct $c $.MaxHourly}}%" title="{{$h}} 时:{{$c}} 次"></div>{{end}}
|
||||
</div>
|
||||
<div class="cols-x">
|
||||
{{range $h, $c := .Hourly}}<span>{{if eq $h 0}}0{{else if eq $h 6}}6{{else if eq $h 12}}12{{else if eq $h 18}}18{{else}} {{end}}</span>{{end}}
|
||||
{{/* 24 根柱光看高度分不出上午/下午:换成一行 6 小时一段的标尺,段边界与柱子严格
|
||||
对齐(同一套 24 列网格),每段直接把时段与小时区间写出来,就不必再留坐标轴。 */}}
|
||||
<div class="cols-band">
|
||||
<span>深夜 0-6</span><span>上午 6-12</span><span>下午 12-18</span><span>晚上 18-24</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +187,7 @@
|
||||
<tbody>
|
||||
{{range .Transports}}
|
||||
<tr>
|
||||
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
||||
<td><span class="dot" style="display:inline-block;background:{{csscolor .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
||||
<td class="num" data-label="次数">{{num .Count}}</td>
|
||||
<td class="num" data-label="里程">{{km .Distance}} km</td>
|
||||
<td class="num" data-label="在途时间">{{dur .DwellMin}}</td>
|
||||
@@ -202,7 +208,7 @@
|
||||
<div class="bars">
|
||||
{{range .Tags}}
|
||||
<a class="bar-row" href="{{$.TagLink .ID}}" title="#{{.Name}}:{{num .Count}} 次到访。点一下按这个标签筛选">
|
||||
<div class="nm"><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{.Name}}</div>
|
||||
<div class="nm"><span class="dot" style="display:inline-block;background:{{csscolor .Color}}"></span> {{.Name}}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTag}}%"></div></div>
|
||||
<div class="vv">{{num .Count}} 次</div>
|
||||
</a>
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
{{range .Items}}
|
||||
<div class="tl-item">
|
||||
<div class="tm">{{hm .Arrival}}</div>
|
||||
<div class="ln"><i style="background:{{colorof .Color}}"></i></div>
|
||||
<div class="ln"><i style="background:{{csscolor .Color}}"></i></div>
|
||||
<div class="bd">
|
||||
<div>
|
||||
{{if .PlaceID}}<a href="{{$.PlaceLink .PlaceID}}">{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}</a>{{else}}未命名地点{{end}}
|
||||
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:11.5px"><span class="dot" style="background:{{colorof .Color}}"></span>{{.ActivityName}}</span>{{end}}
|
||||
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:var(--fs-xs)"><span class="dot" style="background:{{csscolor .Color}}"></span>{{.ActivityName}}</span>{{end}}
|
||||
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<tr>
|
||||
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
|
||||
<td class="muted" data-label="城市">{{.City}}</td>
|
||||
<td class="muted" data-label="类型">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
|
||||
<td class="muted" data-label="类型">{{if .Act}}<span class="dot" style="background:{{csscolor .Color}}"></span>{{.Act}}{{end}}</td>
|
||||
<td class="num" data-label="到访">{{num .Count}}</td>
|
||||
<td class="num muted" data-label="停留">{{dur .Dwell}}</td>
|
||||
<td class="muted mono" data-label="最近一次">{{.LastAt}}</td>
|
||||
@@ -79,9 +79,11 @@
|
||||
window.rondMap.full({
|
||||
id: 'map',
|
||||
points: {{.PointsJSON}},
|
||||
fitCircle: {{.FitCircleJSON}},
|
||||
fitBBox: {{.FitBBoxJSON}},
|
||||
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}},
|
||||
{{/* 圈了显示区的专题:迷雾只在**这些圆的并集**内显示(rond-map.js 给迷雾单独一个
|
||||
pane,再用 fog-clip.js 拼出多圆的 clip-path: path())。不限区域时是空数组。 */}}
|
||||
fogAreas: {{.Topic.AreasJSON}},
|
||||
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
|
||||
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)
|
||||
base := []Topic{
|
||||
{ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time},
|
||||
@@ -43,6 +78,14 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
CenterLat: lat, CenterLon: lon, RadiusKm: 30},
|
||||
{ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time,
|
||||
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 {
|
||||
@@ -59,19 +102,28 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// 多圆并集语义至少要被真正测到一轮(两个圆都非空),否则形同虚设
|
||||
unionRounds := 0
|
||||
for _, rd := range rounds {
|
||||
// 详情页路径:逐专题查询
|
||||
want := append([]Topic(nil), base...)
|
||||
placeSets := map[int64]map[int64]bool{}
|
||||
for i := range want {
|
||||
points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err)
|
||||
}
|
||||
want[i].PlaceCount, want[i].VisitCount = len(points), visits
|
||||
want[i].DayCount, want[i].CityCount = days, cities
|
||||
ids := map[int64]bool{}
|
||||
for _, p := range points {
|
||||
ids[p.ID] = true
|
||||
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
|
||||
@@ -104,4 +156,42 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -37,47 +37,91 @@ func TestTrackTag(t *testing.T) {
|
||||
//
|
||||
// 这段内容会被原样注入**每一个公开页面**,因此必须挡住三类输入:
|
||||
// 提前闭合 </script>(页面布局散架)、javascript: 伪协议、超长误粘。
|
||||
// 判据写的是「危险输入一律清空」,所以这里逐条钉住放行与拒绝的边界。
|
||||
// 判据写的是「危险输入一律不收」,所以这里逐条钉住放行与拒绝的边界。
|
||||
func TestCleanTrackScript(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantWarn string
|
||||
}{
|
||||
{"空串", "", ""},
|
||||
{"纯空白", " \n\t ", ""},
|
||||
{"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js"},
|
||||
{"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js"},
|
||||
{"http 也放行", "http://a/b.js", "http://a/b.js"},
|
||||
{"空串", "", "", ""},
|
||||
{"纯空白", " \n\t ", "", ""},
|
||||
{"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js", ""},
|
||||
{"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js", ""},
|
||||
{"http 也放行", "http://a/b.js", "http://a/b.js", ""},
|
||||
{"整段 script 原样保留(百度统计这类)",
|
||||
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`,
|
||||
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`},
|
||||
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`, ""},
|
||||
{"src 指向相对路径(放行站内相对)", `<script src="/static/a.js"></script>`, `<script src="/static/a.js"></script>`, ""},
|
||||
|
||||
// 脚本体里的 src= **不是标签属性**:以前对整段做 strings.Index,这里会被误伤、
|
||||
// 整段静默清空(页面还照样提示「设置已保存」)。实测复现过,钉住。
|
||||
{"脚本体里的 src= 赋值必须放行", `<script>var i=new Image();i.src=u;</script>`,
|
||||
`<script>var i=new Image();i.src=u;</script>`, ""},
|
||||
{"拼出来的 src 也要放行", `<script>var s=document.createElement('script');s.src=base+"/a.js";</script>`,
|
||||
`<script>var s=document.createElement('script');s.src=base+"/a.js";</script>`, ""},
|
||||
|
||||
// ↓ 必须拒的
|
||||
{"提前闭合 script", `abc</script><img src=x onerror=alert(1)>`, ""},
|
||||
{"大写闭合标签也挡", `abc</SCRIPT>`, ""},
|
||||
{"大写 SRC 伪协议", `<script src="javascript:alert(1)"></script>`, ""},
|
||||
{"data: 伪协议", `<script src="data:text/html,<script>alert(1)</script>">`, ""},
|
||||
{"src 指向相对路径(放行站内相对)", `<script src="/static/a.js"></script>`, `<script src="/static/a.js"></script>`},
|
||||
{"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096)},
|
||||
{"提前闭合 script", `abc</script><img src=x onerror=alert(1)>`, "", warnTrackUnbalanced},
|
||||
{"大写闭合标签也挡", `abc</SCRIPT>`, "", warnTrackUnbalanced},
|
||||
{"末尾未闭合的开标签也挡", `<script>x</script><script>y`, "", warnTrackUnbalanced},
|
||||
{"光一个开标签也挡", `<script>y`, "", warnTrackUnbalanced},
|
||||
{"大写 SRC 伪协议", `<script src="javascript:alert(1)"></script>`, "", warnTrackBadSrc},
|
||||
{"data: 伪协议", `<script src="data:text/html,<script>alert(1)</script>">`, "", warnTrackBadSrc},
|
||||
{"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096), warnTrackTruncated},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cleanTrackScript(c.in); got != c.want {
|
||||
got, warn := cleanTrackScript(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("%s:cleanTrackScript(%q) = %q,期望 %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// 定义性不变量:任何输入都不许产出「含 </script 的非空结果」。
|
||||
// 相对断言(比对某个具体长度)会被基准值一起膨胀而假绿,这里钉死语义。
|
||||
dangerous := []string{
|
||||
`abc</script>`, `ABC</ScRiPt>`, `<script src=javascript:alert(1)>`,
|
||||
`<script src=data:text/html,x>`, strings.Repeat("x", 9000) + `</script>`,
|
||||
}
|
||||
for _, in := range dangerous {
|
||||
if got := cleanTrackScript(in); strings.Contains(strings.ToLower(got), "</script") && got != "" {
|
||||
t.Errorf("cleanTrackScript 放过了危险内容 %q -> %q", in, got)
|
||||
if warn != c.wantWarn {
|
||||
t.Errorf("%s:说明码 = %q,期望 %q", c.name, warn, c.wantWarn)
|
||||
}
|
||||
}
|
||||
if len(cleanTrackScript(strings.Repeat("a", 9000))) > 4096 {
|
||||
t.Error("超长内容没有被截断到 4096")
|
||||
|
||||
// 定义性不变量:只要采纳了非空结果,里面的开闭 <script> 标签数必定**相等**。
|
||||
// 注意不是「不得含 </script」——百度统计那种整段贴进来的本来就带闭合标签,
|
||||
// 真正的保证是「闭合数既不会多、也不会少」(多了提前闭合、少了吞掉收尾)。
|
||||
inputs := []string{
|
||||
`abc</script>`, `ABC</ScRiPt>`, `<script>x</script><script>y`, `<script>y`,
|
||||
`<script src=javascript:alert(1)>`, `<script src=data:text/html,x>`,
|
||||
strings.Repeat("x", 9000) + `</script>`,
|
||||
`<script>a</script><script>b</script>`, `<script>var i=new Image();i.src=u;</script>`,
|
||||
}
|
||||
for _, in := range inputs {
|
||||
got, _ := cleanTrackScript(in)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(got)
|
||||
if open, shut := strings.Count(low, "<script"), strings.Count(low, "</script"); open != shut {
|
||||
t.Errorf("采纳了标签不配平的内容:%q -> open=%d shut=%d", in, open, shut)
|
||||
}
|
||||
}
|
||||
if got, _ := cleanTrackScript(strings.Repeat("a", 9000)); len(got) > 4096 {
|
||||
t.Errorf("超长内容没有被截断到 4096:len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrackWarnMessage 说明码必须是**固定文案**:它最终进跳转查询串,
|
||||
// 未识别的码一律不提示(避免把查询串原文回显到页面上)。
|
||||
func TestTrackWarnMessage(t *testing.T) {
|
||||
for _, code := range []string{warnTrackTruncated, warnTrackUnbalanced, warnTrackBadSrc} {
|
||||
if trackWarnMessage(code) == "" {
|
||||
t.Errorf("说明码 %q 应有对应提示文案", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []string{"", "unknown", "<script>alert(1)</script>"} {
|
||||
if msg := trackWarnMessage(code); msg != "" {
|
||||
t.Errorf("未识别的说明码 %q 不该产出提示,得到 %q", code, msg)
|
||||
}
|
||||
}
|
||||
if !trackWarnRejected(warnTrackUnbalanced) || !trackWarnRejected(warnTrackBadSrc) {
|
||||
t.Error("被拒的两类说明码必须被 trackWarnRejected 认出来")
|
||||
}
|
||||
if trackWarnRejected(warnTrackTruncated) || trackWarnRejected("") {
|
||||
t.Error("「截断」是采纳(只是改了内容),不该算作整段被拒")
|
||||
}
|
||||
}
|
||||
|
||||
+85
-15
@@ -7,6 +7,7 @@ import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -72,9 +73,6 @@ type Page struct {
|
||||
// HasMap 决定是否加载 Leaflet,避免没有地图的页面白下载一份地图库。
|
||||
HasMap bool
|
||||
|
||||
// Flatpickr 决定是否加载日期时间选择器(目前只有数据编辑页在用)。
|
||||
Flatpickr bool
|
||||
|
||||
// HasTopics 表示该数据集有专题,导航里才露出「专题」入口。
|
||||
HasTopics bool
|
||||
|
||||
@@ -521,6 +519,8 @@ type HomeData struct {
|
||||
MaxCity int
|
||||
MaxTransport int
|
||||
MaxTag int
|
||||
// MaxMonthly 是月度柱图里的最高值,用来在图上标出峰值那一根(首页与统计页口径一致)。
|
||||
MaxMonthly int
|
||||
}
|
||||
|
||||
type MapData struct {
|
||||
@@ -1102,6 +1102,8 @@ type SettingsData struct {
|
||||
MapLayers []MapLayerOption
|
||||
// S3Info 是对象存储配置状态的只读说明(配没配齐、桶名、公开域名)。
|
||||
S3Info string
|
||||
// Warn 是统计脚本这次没被原样采纳的提示(「已截断」/「已拒绝」)。
|
||||
Warn string
|
||||
}
|
||||
|
||||
// MapLayerOption 是后台「默认底图」下拉的一项。
|
||||
@@ -1335,16 +1337,41 @@ func transportColorNames() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func colorOf(name string) string {
|
||||
if c, ok := appleColors[name]; ok {
|
||||
return c
|
||||
// cssColor 给模板的内联 style 用,返回 CSS 变量名——只有走变量,配色才能跟着暗色模式走。
|
||||
// 变量定义在 app.css 的 :root 里,名字就是 appleColors 的键,两边必须同步增删。
|
||||
//
|
||||
// 那还需要「真实色值」的地方怎么办:导出/下发的 JSON(地图点)带的是原始色名,
|
||||
// 由 rond-map.js 自己解析——**那份调色板是故意独立的**:地图瓦片在暗色下保持浅色,
|
||||
// 所以标记必须用浅色底上那套值,不能跟着页面的暗色变量走。
|
||||
//
|
||||
// ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 上下文过滤器**不认识 var()**,
|
||||
// 普通 string 会被判成不安全、替换成 ZgotmplZ(实测踩过,页面上的圆点全变成透明)。
|
||||
// 而一旦挂了 template.CSS 就等于关掉这道过滤,所以色值必须先过 hexColorRe,
|
||||
// 只有「# + 6 位十六进制」才原样放行 —— 否则 `#"><img…` 这种能直接逃出 style 属性。
|
||||
func cssColor(name string) template.CSS {
|
||||
if _, ok := appleColors[name]; ok {
|
||||
return template.CSS("var(--cat-" + name + ")")
|
||||
}
|
||||
if len(name) == 7 && name[0] == '#' {
|
||||
return name
|
||||
if hexColorRe.MatchString(name) {
|
||||
return template.CSS(name)
|
||||
}
|
||||
return "#5b6169"
|
||||
return "var(--cat-none)"
|
||||
}
|
||||
|
||||
// cssColorSoft 是同一色的浅底(emoji 方块那种)。自定义色值没有对应变量,
|
||||
// 退回「hex + 22」这个 8 位带 alpha 的写法——它是合法 CSS,同样先过严格校验。
|
||||
func cssColorSoft(name string) template.CSS {
|
||||
if _, ok := appleColors[name]; ok {
|
||||
return template.CSS("var(--cat-" + name + "-soft)")
|
||||
}
|
||||
if hexColorRe.MatchString(name) {
|
||||
return template.CSS(name + "22")
|
||||
}
|
||||
return "var(--cat-none-soft)"
|
||||
}
|
||||
|
||||
var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
// iconOf 只保留 emoji 图标。rond 里的图标多为 SF Symbols 名称(如 house.fill),
|
||||
// 在网页上无法渲染,直接返回空串让模板退回默认样式。
|
||||
func iconOf(s string) string {
|
||||
@@ -1422,7 +1449,8 @@ func templateFuncs() template.FuncMap {
|
||||
}
|
||||
return b
|
||||
},
|
||||
"colorof": colorOf,
|
||||
"csscolor": cssColor,
|
||||
"csssoft": cssColorSoft,
|
||||
"icon": iconOf,
|
||||
"iconor": iconOr,
|
||||
"condition": stats.ConditionName,
|
||||
@@ -1544,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 是一个行程专题:某段时间、某个区域内的足迹打包展示。
|
||||
type Topic struct {
|
||||
ID int64
|
||||
@@ -1557,6 +1594,10 @@ type Topic struct {
|
||||
EndDate string
|
||||
// RangeLabel 是展示用的时间范围文案
|
||||
RangeLabel string
|
||||
// Areas 是显示区(一组圆),空表示不限区域。
|
||||
Areas []TopicArea
|
||||
// CenterLat / CenterLon / RadiusKm **已废弃**:早期只支持一个圆时的那三列。
|
||||
// 保留只为兼容「还没回填的老行」与「测试里直接构造 Topic 的调用方」——新代码一律走 Areas。
|
||||
CenterLat float64
|
||||
CenterLon float64
|
||||
RadiusKm float64
|
||||
@@ -1571,8 +1612,35 @@ type Topic struct {
|
||||
DwellMin int64
|
||||
}
|
||||
|
||||
// HasArea 报告专题是否限定了区域(中心点 + 半径)。
|
||||
func (t Topic) HasArea() bool { return t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) }
|
||||
// AreaList 归一显示区:Areas 为空时退回老的单圆。
|
||||
// 这样「还没回填的老行」和「测试里直接构造的 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 是后台专题管理页的数据。
|
||||
type TopicAdminData struct {
|
||||
@@ -1582,6 +1650,9 @@ type TopicAdminData struct {
|
||||
Flash string
|
||||
Err string
|
||||
IsNew bool
|
||||
// MapHomeJSON 是显示区编辑器的初始视野(生效数据集的地点包围盒),没有数据时是 null。
|
||||
// 让地图一上来就落在站长自己的地界上,而不是全国视野还要自己找。
|
||||
MapHomeJSON template.JS
|
||||
}
|
||||
|
||||
// TopicListData 是前台专题列表页的数据。
|
||||
@@ -1596,10 +1667,9 @@ type TopicData struct {
|
||||
Topic Topic
|
||||
Points []MapPoint
|
||||
IsAdmin bool
|
||||
// PointsJSON / FitCircleJSON / FitBBoxJSON 内联给地图脚本
|
||||
// (template.JS 跳过转义,避免被二次转义搞坏)。两个 fit 只有一个非 null。
|
||||
// PointsJSON / FitBBoxJSON 内联给地图脚本
|
||||
// (template.JS 跳过转义,避免被二次转义搞坏);FitBBoxJSON 为 null 时脚本退回点集贴合。
|
||||
PointsJSON template.JS
|
||||
FitCircleJSON template.JS
|
||||
FitBBoxJSON template.JS
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,36 @@ if [ -z "$RUN" ] && [ "$PKGS" = "./..." ]; then
|
||||
ok "纯净往返包已生成:$(basename "$CLEAN_OUT")"
|
||||
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=""
|
||||
[ -n "$RUN" ] && RUNARG="-run $RUN"
|
||||
|
||||
Reference in New Issue
Block a user