Files
rond-with-you/.workbuddy/memory/2026-09-18.md
T
tamakyi 3f1b737a78 记忆:把 .workbuddy/memory 纳入版本控制
用户要求这份记忆跟着仓库走,而不是只留在本机。

.gitignore 里挡路的是 `.workbuddy/`。改成反忽略六行,并且**只放行 .md**:
别用 `!.workbuddy/memory/**`——它会把 memory 下任何文件都放行,本项目真发生过
`web.test.exe` 混进暂存。复核用 `git check-ignore -q --no-index` 逐条打靶:
memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED,
17 个 .md 全 included;`git add -An .workbuddy/` 恰好 16 个文件。
其余 .workbuddy 内容(计划、本地设置、临时产物)继续忽略。记忆里没有真凭据
(唯一命中是 2026-09-14.md 里的 `password=rond-admin`,那是项目默认口令)。

顺带做两件整理:

1. 取证了注入上限。以前按「字节数」判断 MEMORY.md 是否超限是错的:
   WorkBuddy 的 `getWorkingMemoryContent()` 用 `MAX_MEMORY_CHARS = 1e4` 比 JS
   `content.length`,即**字符数**(中文 1 字算 1);超了 substring 截断、尾部静默丢失。
   本 details 与每日日志都不参与注入,只有 MEMORY.md 受约束。
   现已从 6604 字符收到上限的 66%,并把这条上限写进索引抬头。
2. 把「备份合并」「导出/恢复三条铁律」两段长文迁进 MEMORY-details.md 的卅一/卅二节,
   索引只留关键词指针。行尾统一为 LF(仓库 .gitattributes 是 `* text=auto eol=lf`)。
2026-09-30 18:31:08 +08:00

1574 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-09-18 工作日志
## 配置 R2 并真机验证 SigV4(承接 09-17 的地点图片功能)
站长指令:**「我填写了 r2参数,帮重启一下服务」**。
### 一、先发现一个会让图片全变破图的配置坑
站长填的 `s3_public_base` 是**裸域名**(`r2-life-backend.337845818.best`,没写 `https://`)。
这个值会被直接拼进 `<img src>`,裸域名得到的是**相对地址**,浏览器会把当前页面的路径接上去,
图全变破图——而且不报错,很难查。
改法选了「让代码容错」而不是「只改配置」:`internal/config/config.go` 加 `normalizeBaseURL()`,
没写协议就补 `https://`(空串与已带 `://` 的原样返回)。这样站长怎么写都对,
文档里也不必再强调格式。
### 二、用真实 R2 验证手写的 SigV4(关键收获)
之前 SigV4 只对过 AWS 官方文档的向量(`TestSignV4MatchesAWSExample`)。这次用站长真桶跑通了全链路:
| 步骤 | 结果 |
|---|---|
| `PUT`(SigV4 签名) | `{"ok":true,"storage":"s3","url":"https://r2-life-backend.337845818.best/9/309/…webp"}` |
| 公开域名取图 | HTTP 200、`Content-Type: image/webp`、**字节完全一致** |
| 自定义响应头 | `Cache-Control: public, max-age=31536000, immutable` 也带过去了(说明签全量头的做法没问题) |
| `DELETE`(SigV4) | `{"ok":true}`,随后公开地址 **404** |
| 页面渲染 | 访客视角页面里就是 R2 的绝对地址,「图片 N」按钮正常出现 |
⇒ **Cloudflare R2 上签名的路径式 PUT/DELETE 一次通过**,手写 SigV4(含 RFC 3986 逐段转义)
在真实服务上验证完毕,不再只有离线证据。
### 三、站长自己传的两张图反过来验证了前端转码
清理测试残留时发现 `place_images` 多了两行(src_pk 286 / 309),创建时间是 **23:41、23:42**——
是站长本人在浏览器里试功能时传的**真实照片**,两张都落成 `local`、`WEBP (1920,1080)`,
35KB / 13KB。说明浏览器端 canvas 转码在真实相册照片上工作正常,且长边按 2000px 上限压过了。
本地 `/media/` 取回均为 200 + `image/webp`。
**顺带印证了「逐行记 storage」这个设计**:这两张是切成 s3 之前传的,留在本地;
之后新传的走 R2,两类图各自按自己那一列取,页面都不受影响。
### 四、当前本地环境状态
- `media_backend = s3`(已切,与站长刚配好 R2 的意图一致;要改回本地在
`/admin/settings → 图片存储` 点一下「服务器本地磁盘」即可,即时生效)。
- 服务在 `http://localhost:8080` 跑着;`bin/rond.exe` 与 `dist/rond-linux-amd64` 均已重编。
- 全量测试绿、`go vet` 干净。
## 第二轮:路径规则 / 上传交互 / 隐藏区域提示 + 完整性检查 + 文档重写
站长指令:**「文件夹名称的创建规则是怎样的?一个月内上传的图片都保存在一个文件夹比较好吧?
另外图片上传的按钮是上传之后立即生效吗?因为没有任何提示,还以为要点击哪里保存,而且按钮
样式相比其他地方有点显得突兀。还有就是被管理员加入黑名单的区域,游客直接访问会 404,不太
友好,能不能优化一下,提示一下再跳转首页比较好吧?做完检查一下源码完整性和做一次完整测试,
生成新的 readme.md 和 app.example.ini」**
### 一、对象名规则:按上传月份分目录
原来 `dataset/src_pk/hash.webp`,站长质疑「几百个地点各占一个目录」,有道理——多数目录是空的。
改成 **`<数据集>/<上传月份>/<地点号>-<内容哈希>.webp`**:
- 按月归堆最好找、最好整月备份/迁移
- 文件名带地点号,在对象存储控制台一眼认出是谁的
- 带内容哈希,同图重复上传天然去重
- **没去掉 src_pk**:删图要按 key 精确删,若只按内容哈希共享对象,删一个地点的图就会把
另一个地点的引用打断(refcount 那套不值得为个人站点加)
本地存储删掉最后一张时会顺手收空目录(`media.pruneEmptyDirs`,只删空的、不越出根目录)。
旧布局的 key 存在 DB 里,**照旧能取**,不用迁移。
### 二、上传交互
站长反馈「没有提示,以为要按保存」「按钮样式突兀」——那是个裸的 `<input type=file>`。
- 换成与站点一致的 `<label class="btn primary">+ 选择图片</label>`(原生输入框 `hidden`)
- 文案里直接写明**选中即上传、不用保存**
- 实时反馈分级上色:`转码中 n/m → 已上传 N 张,已即时生效`(绿)/ 失败原因(红)
- 显示 `已有 N / 12 张`
- **页头的「图片 N」按钮和画廊改成从缩略图墙反推重建**(`syncGallery()`):
服务端渲染时还没有图的话,原来传完也看不到访客入口,站长会以为「传了但没生效」
CDP 实测:上传前无按钮、`LABEL.btn primary`、原生框已隐藏;传两张后 hint 变绿、
计数 `已有 2 / 12 张`、页头按钮现补出「图片 2」、画廊建好且 2 张图、0 JS 报错。
### 三、被隐藏的地点:404 改成友好提示页
新建 `internal/web/notice.go` 的 `pausePage()`:一句人话 + 「返回首页」按钮 + 可选自动跳转,
`guardPage`(页面未对访客开放)也统一走它。地点被区域规则挡住时给 403 + 提示,
**4 秒后自动回首页**;不存在的 ID 仍是 404;站长不受影响。
**取舍**:原来 404 的好处是访客分不清「被藏起来」和「不存在」,现在能反推 ID 存在。
已在代码注释和 README 里写明,想换回去只改一处(`placeHiddenPage` → `http.NotFound`)。
### 四、完整性与格式:找到 gofmt 报警的真凶
`gofmt -l` 报了 18 个文件,但**多数是假象**——仓库里存的是 LF,而 Windows 的
`core.autocrlf=true` 把工作区换成 CRLF,gofmt 把行尾问题当成了格式问题。
用「转成 LF 再判定」的办法筛出**真正需要格式化的 12 个**,其中一个是我自己之前编辑
`config.go` 时手滑把两行并成了一行。
处理:加 `.gitattributes`(`* text=auto eol=lf` + 二进制白名单)让工作区也保持 LF,
再把源码转成 LF 并跑 `gofmt -w`。**`gofmt -l` 现在是真的干净**(之前是噪声里藏着真问题)。
顺带发现**已有测试 `TestExampleConfigDocumentsEveryKey` 的正则是 `[a-z_]+`,
匹配不了带数字的 `s3_*`**,所以「样例里写了但代码不读」这类坑它一直查不出来——已改成
`[a-z0-9_]+`。本来我另写了一个同类测试,发现重复后删掉,只保留修好的那个,
另加 `TestExampleConfigLoads`(断言文档承诺的默认值)。
### 五、文档重写
- **README.md**:按路由表逐条核对功能全貌后重写,补上了原来完全没提的
**数据编辑、照片导入、专题、迷雾遮罩区、rondbackup 回导、`/healthz`**;
修掉一个结构缺陷(「地点别名与备注 / 推荐踩雷 / 备注批量管理」那组 bullet 掉在
「世界迷雾」标题下面,像是丢了个 `##` 标题);新增「地点图片」「照片导入」两节,
以及坐标来源留档那段踩坑背景。
- **conf/app.example.ini**:补上 6 个 `s3_*` 与说明,并注明「默认底图」「图片存哪边」
是在**后台**切换、不在这个文件里。
### 六、本轮其余修复
发现 `TestPhotoImportWithImage` 的断言还按旧路径(`ds/src_pk/`)写,改成按月目录。
测试互不干扰:图片相关的测试用 `setMediaBackend()` + `t.Cleanup` 还原存储设置。
**结果**:`go test ./internal/...` 全绿;`gofmt -l` 干净;`go vet` 干净;JS 语法 OK;
纯净往返差异仍为 **3**;12 个页面 CDP 检查 JS 报错全 0;`bin/rond.exe` 与
`dist/rond-linux-amd64` 均已重编(本轮改了 `config.go`,**部署需重新编译**)。
## 第三轮:前端优化点评审(+ 去掉地图上的 Leaflet 角标)
站长指令:**「现在的功能已经很完善了,前端还有什么能够优化的吗?从美观性、适用性、实用性、
移动端适配等方面考量。另外我不想看到地图右下角的 leaflet。」**
### 一、已改(两处)
1. **去掉地图右下角的「Leaflet」**:`rond-map.js` 的 `full()` 改成
`attributionControl: false` + `L.control.attribution({prefix: false, position:'bottomright'})`。
**底图版权(© 高德地图 / © OpenStreetMap)必须保留**——上游瓦片条款要求,已在代码注释与报告里说明。
顺带量出角标**原本和图例叠在一起**(角标 z-index 800 > 图例 400),把 `.map-legend` 抬到
`bottom: 26px` 让开。实测 1440/390 两档都无控件重叠。
2. **修掉我上一轮引入的回归:手机整页横向溢出**。`/stats`、`/report` 在 390px 下整页 692px。
根因:上轮加的 `.tw > table.tb { min-width:640px }` 里,`.tw` 是 `overflow-x:auto` 但没写
`min-width:0`,于是把 640 报给父级;卡片又是 grid 子项(默认 `min-width:auto`)拒绝收缩
→ 640 + 卡片内边距 36 = **678px**(与实测完全吻合)。修法:`.grid > * { min-width: 0; }`。
修完 7 个页面 × 2 档视口的 `scrollWidth` 全部等于 `clientWidth`。
### 二、审计发现的清单(未改,已出报告)
用的是新写的移动端体检脚本(已沉淀进技能 `scripts/audit/`),390×844 真实视口实测:
- **P0:地图接口失败 → 无限自我刷新**。`map.html` 里 `fetch('/api/points').catch(() => form.submit())`,
失败就整页重提交,新页面又走同一条路。**实测:8 秒内文档重新加载 17 次、接口被拒 16 次、零提示**。
对 1C1G 的机器是刷新风暴。建议改成叠一层「加载失败,点击重试」+ 指数退避,并判非 2xx。
- **P1 触控目标偏小**:导航链接 40×31、`.chip` 59×28(/timeline 37 个)、`.btn.sm` 45×30。
- **P1 iOS 聚焦自放大**:/places、/timeline 各有 6 个输入框字号 14px(<16 就触发 Safari 缩放)。
- **P1 对比度不足**:`.sub` 2.96:1、`.k` 3.18:1、`.muted` 2.81:1(AA 要 4.5),全出在 `--text-3` 色阶。
- **P1 无安全区适配**:没有 `viewport-fit=cover`,全站没用过 `env(safe-area-inset-*)`。
- **P1 画廊无手势**:全站没有 `touchstart`/`touch-action`/双指缩放,手机上不能滑动切换、不能放大。
- **P2**:无暗色模式、无 `:focus-visible`、未处理 `prefers-reduced-motion`、无 Service Worker(无离线)、
移动端地图控件三个都堆在右下角占 180px、长列表无「回到顶部」、点按高亮是浏览器默认蓝。
### 三、验证方式(可复用)
给无头验证脚本加了 **`--inject <file>`**:在每个文档加载前注入 JS,用来破坏 `fetch`、
改 `matchMedia`、打桩计数。跨导航计数必须用 `sessionStorage`(`window` 上的会被导航重置——
第一版就是这么把计数读成 0 的)。
**复检结果**:7 个页面 × 2 档视口,横向溢出 0、JS 报错 0;编译与测试通过。
产物 `tmpfog/report-frontend-review.html`。
## 第四轮:按评审顺序分批落地(全四批)
站长指令:**「按你提出的顺序进行」**。四批都做完了(离线缓存见文末说明)。
### 第一批(P0)地图接口失败不再无限刷新
`map.html` 里的 `.catch(() => form.submit())` 是罪魁:失败就整页重提交,新页面又走同一条路。
实测**改前 8 秒文档重载 17 次、接口被拒 16 次、零提示**。
改成:`loadJSON()` 统一判 `r.ok`(原来 500 也会走进 catch)→ 有限次自动重试(1s/3s)
→ 地图底部弹状态条 + 「重试」按钮;**任何情况下都不重新加载页面**。
轨迹接口也补了失败处理(安静失败 + 取消勾选,避免开关停在「已选但没线」)。
**改后实测:12 秒内文档只加载 1 次、请求 3 次即停、提示与按钮都在**;点重试 → 请求 3→6。
### 第二批(P1)移动端四项
- **触控目标**:窄屏给导航链接 / `.btn` / `.chip` 加 `min-height: 44px`(原 40×31、45×30、59×28)。
- **iOS 聚焦自放大**:窄屏控件字号提到 16px。**这里踩了个坑**:基础规则
`input[type=…], select, textarea` 的特异性高于媒体查询里的单选择器,直接写 `font-size` 压不过
—— 改用可继承的自定义属性 `--field-fs`(表格里再局部设回 14px),一次解决。
- **对比度**:`--text-3` 从 `#8b9199`(白底 3.18:1)改成 `#666d75`(白底 5.24、浅灰底 4.63),
页面副标题 / KPI 标签 / 筛选计数全达标。
- **安全区**:三个页面加 `viewport-fit=cover`,把 `env(safe-area-inset-*)` 提成变量用在顶栏、
正文、地图页容器、画廊。顺带把顶栏高度提成 `--nav-h`,修掉窄屏地图顶部 4px 的缝。
### 第三批 画廊手势 / 地图控件 / 回到顶部
- **画廊重写成单张查看**(原来是竖着堆一页):左右滑动切换、左右按钮、键盘方向键、
双击(或轻点两下)放大到 2 倍并可拖动看局部、Esc / 点空白关闭。
**踩坑**:滑动与双击判定放在两个 `touchend` 监听器里时,滑动结束后会被另一个当成「点按」,
连滑两次就误触发放大 —— 必须在**同一个** touchend 里按位移分流。
- **窄屏迷雾浓度控件收成可展开的小标签**:右下角控件区从 180px 降到 162px。
- **回到顶部**:`layout.html` 全局加一个按钮,滚动超过 600px 才出现;
地图页整页不滚动,所以它自带不会出现。滚动动画尊重 `prefers-reduced-motion`。
### 第四批 暗色模式 + 键盘焦点
- **暗色模式**(跟随 `prefers-color-scheme`):把硬编码的浅色面提成变量
(`--surface`/`--glass`/`--glass-strong`/`--row-hover`/`--on-accent`/三种 alert),
再写一套暗色变量。色值是算过的:主文字 13.6:1、次要 8.0:1、弱化 5.4:1。
- **两个关键坑**:① 站长自定义主色是模板在 CSS **之后**注入的 `:root{--accent:…}`,
同特异性下它胜出 → 暗色块必须用 `html:root` 才盖得住,并用 `color-mix` 从
`--accent-base` 提亮(深绿在深底上只有 3.3:1);② Leaflet 自带控件的选择器是
`.leaflet-container .leaflet-control-attribution`(两级),覆盖时也得带前缀。
- 用脚本**系统性排查了「引用了但从未定义的 CSS 变量」**,揪出 `--bg-2`(兜底是浅色
`#fafbfb`,暗色下行内编辑表单还是白的)和此前的 `var(--card, #fff)`(投票按钮)。
- **`:focus-visible`**:全站原来一条都没有,Tab 走查看不出焦点。已补。
### 验证工具的两处增强(都沉淀进技能)
- `--color-scheme dark|light`(CDP `Emulation.setEmulatedMedia`)用来验暗色。
- `--press Tab,Escape,…`(CDP `Input.dispatchKeyEvent`)用来验键盘可达性。
**程序 `focus()` 不会命中 `:focus-visible`,必须派发真实按键** —— 实测连按 3 次 Tab
后 `:focus-visible` 命中、描边 2px。
- 审计脚本的颜色解析要认 `color(srgb 0.52 0.70 0.67)` 这种写法(`color-mix()` 的 computed 值),
否则会把对比度算成 1.2:1 的假警报。
### 暂缓的一项:Service Worker 离线缓存
报告里列为「可选」,**没做**——本项目对「陈旧缓存」特别敏感(历史上被旧 JS/CSS 坑过多次),
而 SW 一旦缓存策略写错,老访客会长期看到旧页面且难以自愈。要做建议单开一轮,
并配版本更新 + 跳过等待的流程,先在小范围验证。
- 注意:**本地开发库现在会往站长的真桶传图**。本地调试若不想写远端,先把存储切回 local。
## 第五轮:气温不跟筛选走的修复 + 交互评审
站长指令:**「1. 统计页面筛选之后出行方式和气温好像还是汇总的,并不是筛选区间的内容
2. 有无更现代更美观的交互方式?」**
### 一、取证:只有气温是 bug,出行方式其实是好的
对比 `/stats` 与 `/stats?from=2026-09-01&to=2026-10-01`:
- **出行方式跟着筛选变**(未筛 未知387/电🐔332/地铁22/高铁21 → 9 月 电🐔23/未知11/步行3)✓
- **气温完全没变**(阴 168 两个链接一模一样)✗
顺手写了个脚本**审计 stats.go 里所有收 Filter 的函数**(正则提取 `f.` 的实际使用),
结论:`Filter` 相关函数里**只有 `WeatherStats` 真的忽略了筛选**(声明了 Filter 却只用 DatasetID),
另有 `HourlyTemp` 干脆只收 datasetID。其余 12 个函数都正确走 `visitsWhere`/`movementsWhere`。
### 二、修法与关键取舍
新增 `Filter.weatherWhere()`:
- **时间用观测时刻 `w.at`**,不是所属到访的 arrival —— 筛一段时间要的是「这段时间里经历的
天气」,横跨边界的到访只算落在区间内的小时。
- 城市 / 类型 / 标签 / 区域 / 关键词这些条件通过 `EXISTS (… visits v …)` 挂到那次到访上
(实测 557/557 行都带 visit_src 且能落到 places)。
- **只按时间筛时不加 EXISTS**:删到访时 `weather.visit_src` 是被**置空**而不是删行
(`editVisitDelete` 里 `UPDATE weather SET visit_src=NULL`),这些失去归属的行仍属于
「某段时间的天气」;一旦加 EXISTS,删一次到访就会让它那几天的天气从统计里凭空消失。
这条用 `hasVisitConds()` 判定,并写了白盒测试钉住 SQL 形状。
- `HourlyTemp` 改成收 `Filter`,调用点同步。
**实测**:气温从「阴 168 小时 / 26.4℃」变成「阴 27 小时 / 23.3℃」✓。
测试:`TestWeatherWhereSemantics`(白盒,验 SQL 形状与占位符编号)+
`TestWeatherStatsRespectsFilter`(与手写 SQL 对账,含时间与城市两种筛选)。
### 三、附带发现:`Temps`(逐月气温)算了但没渲染
`HourlyTemp` 每次 `/stats` 都会跑一遍,但**没有任何模板引用 `.Temps`** —— 纯死查询
(本地一次 ~2~4ms,加上它页面总耗时 ~30ms)。要么补一个「逐月气温」图,要么删掉这个查询。
## 第六轮:按选定方向做交互改造(A/B/C/D 全做)
站长在四个方向上都选了(筛选体验统一 / 图表可交互 / 地图时间轴回放 / 收尾)。
### A. 筛选体验统一
- **改动即生效**:下拉与日期 `change` 就提交,关键词回车即可(不自动提交——每敲一个字都跳转很难受)。
**关键点:用 `form.requestSubmit()` 而不是 `form.submit()`** —— 后者不触发 submit 事件,
会把地图页自己那个「拦下提交、改走 AJAX」的处理器绕过,导致地图页筛选变成整页刷新。
- **已选条件可单独摘除**:`Page.RemoveFilter(key, value)`(`key="date"` 时同时摘 from/to;
多值参数如 `act` 只摘指定那一个)+ `Page.ActiveChips()` 生成标签,`layout.html` 的公共
`filters` 模板渲染成一行「已选 … ×」,右侧「全部清空」。以前只能靠「重置」全清。
- **顶部细进度条** `#load-bar`:整页 GET 慢网下点了没反应会让人以为卡住。带 8 秒兜底
(跳转被阻止时不会一直挂着)与 `pageshow` 收回(bfcache)。
- 公共 `filters` 模板只被 places / stats / timeline 用;**地图页有自己的 `#filter-form`**,
也单独接了同样的即时生效(走它的 AJAX 路径)。
- 实测:列表页下拉/日期都触发提交、关键词不触发、已选 5 个条件且摘除链接正确(各只去掉自己那一项);
地图页改动 → 1 个 `/api/points` 请求、无整页刷新、进度条收回。
### B. 图表可交互
- 月度柱条、类型分布、城市排行、标签排行、迷雾点亮率的城市行 → **整行可点并下钻为筛选**
(城市→city、类型→act、标签→tag、月份→from/to,新增 `Page.MonthLink`)。
星期分布没有对应筛选维度,只补悬停读数。
- 悬停 title 带准确数值 + 「点一下会怎样」的说明。
- **踩坑**:城市排行原来只有城市名是 `<a>`,整行再套 `<a>` 就是链接套链接(非法 HTML)——去掉了内层。
- 图表行触控:桌面 4px 内边距(~29px),手机 8px(~37px)。没做到 44px 是刻意的:
「类型分布」有 60 多行,再翻倍会把图表撑得没法看。已写进注释。
- 实测:62 个可点行、链接正确、20 个可点月柱、无嵌套链接、0 报错、无横向溢出。
### C. 地图时间轴回放
- `MapPoint` 加 `First int64 \`json:"ft"\``(首次到访 Unix 秒;注意 `"t"` 已被 `LastAt` 占用),
`toPoint` 填充;`/api/points` 实测 327/327 个点都带时间,跨度 2025-02 → 2026-09。
- 地图底部一条:播放/暂停 + 滑块 + 「截至 X 月 · N/327 个点」+ 全部。
语义是**累加**(一路走到这一刻为止),与左侧日期区间的「只看这段时间」是两回事,所以放在
地图底部而不是筛选面板里。
- **两个坑**:① `input[type=range]` 的可取值是 `min + k*step`,`max` 不是 step 整数倍时
**根本取不到 max** —— 全量时少算 7 个点(320/327)。两端都对齐到「天」才对。
② 截止语义改成 `ft < cut + 一天`(含当天),否则拖到最左是空地图。
- 拖动只在「天」变化时才重画(`requestAnimationFrame` + 天数去重),几百个点也跟手。
- 实测:满量 327/327、拖到中间 255/327、播放/暂停正常、点「全部」回到全量、窄屏与右下角
版权不重叠。
### D. 逐月气温图 + 空状态
- 把一直算着却没渲染的 `Temps` 用上:新增 `stats.TempBucket`(原来借用 `Bucket` 把平均气温
塞进 `Dwell`,读代码的人猜不到)与 `TempChart()`(高度按区间最低~最高温铺满,不按 0℃ 起算,
否则季节变化看不出来)。
- 横向可滚动的温度条,配色冷蓝→暖红(`tempColor`),月份标签用短写 `26-09` 避免挤在一起。
- **筛选后柱子也跟着变**(6-10 月只剩 26-08、26-09 两根)。
- 空状态:places / timeline 筛空时除了文案再给「清空筛选条件」按钮。
## 第七轮:统计页标签下拉在筛选后缩水(字段遮蔽)
站长指令:**「统计数据这里,标签如果选择了之后,这里下拉栏就只剩全部标签和被选择的这项,
其他都不见了。」**
### 根因:Data 结构体的字段遮蔽
- `pageBase` 把**全量标签**放进 `Page.Tags` 供下拉用;
- 统计页又把**筛选后**的 `TagStats` 赋给了 `d.Tags`,而 `StatsData` 自己声明了 `Tags` 字段
—— **它会遮蔽内嵌的 `Page.Tags`**;
- 公共筛选模板里的 `{{range .Tags}}` 于是在统计页解析到外层那个(筛选后的),
下拉就只剩「全部标签 + 当前选中的」。
复现:未筛 7 项 → `?tag=50` 只剩 3 项。**只有统计页会同时用到公共 `filters` 模板**,
所以表现只出现在统计页(地点库/时间线/地图的下拉都是好的),与站长的观察完全一致。
### 修法:改名消除撞名,而不是只改统计页那一处
把下拉专用字段改名为 `Page.TagOptions`(`view.go` 声明、`handlers_public.go` 赋值、
`ActiveChips` 里的一处读取、`layout.html` 与 `map.html` 两个下拉模板)。
这样「给下拉用的」与「排行用的」从名字上就分开了,将来也不会再撞。
写了个脚本审计**所有内嵌 `Page` 的 Data 结构体的字段遮蔽**,发现三处 `Tags` 遮蔽:
`StatsData`(有害,本次修的就是它)、`HomeData`、`EditData`(这两处不渲染公共筛选模板,无害)。
### 验证
- 新增 `internal/web/filter_options_test.go`:用准备好的 `StatsData`(`TagOptions` 两个标签、
`Tags` 只有一个)渲染 `filters` 模板,断言标签下拉恒为 3 项、且未被选中的那个仍在。
**并把模板临时改回 `.Tags` 验证这道测试确实会失败**(报「乙 从下拉里消失了」,2 项),
再恢复 → 通过。测完即证明测试咬得住这个 bug。
- 实测各页各下拉在筛选下都不再缩水:地点库/时间线/地图 标签 9→9、统计页 标签 9→9、
城市 14→14、最短停留 5→5。
- 顺带确认「标签统计」图表仍跟着筛选(未筛 吃饭/螺蛳粉/羊肉粉店 → `tag=50` 吃饭/烧烤店)。
- `go test -count=1 ./internal/...` 全绿;`gofmt` 干净;`dist/rond-linux-amd64` 已重编。
## 第八轮:专题在访客视角下报 500 / 统计全为 0(SQL 参数与占位符错位)
站长指令:**「测试环境里我原来的有的专题"大理"现在内容都变成 0 了,点进去之后报错:
服务器内部错误: expected 3 arguments, got 4」**
### 根因(`handlers_topics.go` 的 topicContent)
到访点位那条 SQL 用 `regionSQL("p", regions, exclude, &args)` 追加了访客区域条件与参数;
紧接着的**「天数」SQL 只写了 `$1..$3`(+ area),却把整份 `args` 传了进去** ——
参数比占位符多,pgx 直接报 `expected N arguments, got M`。
连锁反应解释了两个现象:
- `fillTopicStats` 里 `if err != nil { return }` **把错误吞了** → 专题列表静默显示「0 个地点」;
- 打开专题页 `topicPage` 不吞错 → 500。
**为什么只在访客视角**:`visitorRegions(p)` 对管理员返回空,站长自己点一遍是好的。
本地数据库其实早就配了 `regions=["河池市"]`(黑名单),但我一直用站长身份请求,所以一直没复现。
改用无 cookie 的访客请求后,**一比一复现**(500 + 列表 0 0 0)。
### 修法
- 记录 `baseArgs := len(args)`(到 area 为止这条查询自己用到的参数个数),
天数查询用 `args[:baseArgs]` 重凑参数,**并补上它本来就该有的区域条件**(`regionSQL`),
这样占位符与参数对齐,且访客的天数统计不再把被隐藏区域算进去。
- `fillTopicStats` 失败时 `log.Printf` 出来——正是那个静默的 0 让这个 bug 藏了很久。
### 新增:访客视角 + 配好区域的「全站冒烟测试」
`internal/web/visitor_pages_test.go` 的 `TestVisitorPagesWithRegionFilter`:
临时给 `regions` 配上一项(跑完还原),用**没有登录态的上下文**把 11 个公开页面/接口
(含专题列表与专题详情)各请求一次,只要求不 500。
**这类 bug 已经咬过两次**(一次 movements 里程没跟区域走,一次就是这个),
两次都只在访客视角复现,所以这个组合必须专门测。
**验证这道测试咬得住**:`git show HEAD:internal/web/handlers_topics.go` 换回修复前的版本后,
测试立刻失败并报出与站长一模一样的
`/topics/x 访客访问返回 500:服务器内部错误: expected 3 arguments, got 4`;换回修复版即通过。
### 实测
访客 `/topics`:大理 **地点 17 到访 17 天数 2**(原来 0 0 0);访客 `/topics/3` → 200(原来 500);
站长视角不受影响。全量测试绿、vet/gofmt 干净、`dist/rond-linux-amd64` 已重编。
## 第九轮:地图时间轴的动画美化
站长指令:**「地图上这个时间轴能不能动画做美化一点,现在都是一下子直接显示」**
### 根因(为什么是"一下子")
`full()` 里的 `rondMap.setPoints` **每次都把整个标记组删掉重建**,而且**每次都重新 fit 视野**。
所以拖动时间轴时:所有点同时闪出来 + 地图跟着不停缩放。
### 改法
1. **增量更新**:`st.byId` 记住「点 id → 图层」,setPoints 只增删差集,其余点纹丝不动;
到访次数上限变了就顺手 `setRadius`。
**但「重新拉的数据」不能复用**:popup 里存的是旧的对象,复用会让气泡显示上一次筛选的数字。
判据是**对象引用**(`m.__p !== p`)——时间轴只是在这批点上做子集筛选,引用相同;
换筛选是重新 fetch,引用不同 ⇒ 整体重建 + fit。这条规则让两条路径都不会出错。
2. **`{keepView: true}`**:时间轴拖动/播放时不重新 fit,否则回放时地图一直缩放很晕。
3. **入场动画**:
- 单个点:冒出 + 淡入(`pt-in`,`transform-box: fill-box` 让缩放以自身为中心)
- 聚合圆圈:只动透明度(它是 DOM 元素且 Leaflet 用 transform 定位,加 scale 会挪位)
- 聚合数字变化:弹内层 div(外层带定位 transform 不能动)
- 全部包在 `prefers-reduced-motion: no-preference` 里
### 三个坑(都靠实测才发现)
1. **markerCluster 是异步渲染子标记的**:`addLayer` 之后立刻 `getElement()` 是 null
⇒ 第一版动画完全没生效。改成挂 `add` 事件,真正上屏时再打动画类。
2. **聚合图标的 DOM 每轮数据变动都会重建**(实测一轮加了 791 个元素):
拿元素本身当身份,每次都算「第一次见到」,永远触发不了。改用**位置**当身份,
并且**量化到 40px 一格**(聚合中心会随点集微微移动,不量化就会一片乱闪)。
量化后:新圈 24→18、数字轻弹 1→7,动静平衡了。
3. **无头 Chrome 默认 `prefers-reduced-motion: reduce`**:动画被 CSS 和 JS 双重跳过,
连测三轮都显示「没生效」。给验证脚本加了 `--reduced-motion no-preference` 才测出来。
### 验证
- 放大到单点可见(z=16):新增 18 个点,**18 个都带动画类**,动画结束后类被清掉。
- 全局聚合视图:新圈淡入 + 数字轻弹都有触发;聚合圈定位 transform 未被破坏。
- 拖动时**视野保持不变** ✓;换筛选时**仍然重新 fit** ✓(327 → 87 → 327)。
- 放大后点开气泡,内容是新一次筛选的数据(「河池市老政府大院2栋 · 到访 1 次」)✓。
- 全量测试绿、vet/gofmt 干净。
## 第十轮:地点详情页加「编辑」直达后台行内编辑
站长指令:**「管理员在地点页面点进地点详情的时候能否给一个编辑按钮,这样就不一定要从后台管理的数据编辑进入了」**
### 做法:`?open=<src_pk>`(不是搜索词,也不用新页面)
- `place.html` 页头对 `{{if .IsAdmin}}` 加一个 `编辑 → /admin/edit?open={{.Place.SrcPK}}`,
访客看不到(实测访客的页头只有「返回地点库 / 在地图中查看」)。
- `editPage` 读 `?open=`,用 `row_number() OVER (ORDER BY p.visit_count DESC, p.id)`(**与列表完全同一
个 ORDER BY**)算出这一行在当前筛选下的名次 → 反推页码。行号必须按**筛选后**的顺序算:
筛选一变顺序就变;筛选条件下找不到就停在第 1 页,不猜。
- 模板:`<tr id="place-row-{{.SrcPK}}">`,目标行加 `class="located"`(`--accent-soft` 淡底,
**用底色不用动画**——动画会被 reduced-motion 跳过,那时就完全看不出来了)、
`<details class="inline-edit" open>`;一段小脚本滚动到视口中间,并且**先借 h2 的 click 展开
places 卡片**(这页卡片默认折叠,折叠状态下目标行根本不可见)。
- **保存后回到原位是白拿的**:`redirectEdit` 靠 Referer 返回,`?open=` 就在 URL 里 →
回来仍是那一页、那一行展开着。实测:`ok=地点已更新` → 落点 `/admin/edit?open=235`、
第 2/10 页、located + open 都在,且库里 lat/lon/coord_source/src_lat 前后逐位一致。
### 踩的坑
1. **`?open=` 里的号是 src_pk,不是 places.id**。第一版排名子查询 `WHERE t.id=$2` 拿库内主键比
rond 编号 → 永远 ErrNoRows、页码不动。而这个 bug **在 `open=52` 上表现完全正常**
(id=1997 ≠ 52 → 不跳页,而 52 恰好排在第 40 名、本来就该在第 1 页)——
换个真正跨页的号(235,排名 61)才暴露。**验证要挑「结果会变」的样本**,别拿会巧合通过的。
2. **debug=true 只让 CSS/JS 直读磁盘,模板与 Go 代码仍在二进制里**(`tmplFS` 走 embed)。
但即便模板要重编,**handler 的改动必须重启进程才算数**:中途一个旧实例还占着 8080,
新进程只打一行 `bind: Only one usage of each socket address` 就退出,
于是「模板新、逻辑旧」,测出一堆莫名其妙的 FAIL。判据永远是 `netstat` 里的 PID 变了。
3. **`gofmt` 的字段对齐**:往已对齐的 struct 字段组中间插字段、且带单列注释,gofmt 会重排整组
(注释会打断对齐组)。把新字段放到 struct 末尾、注释独立成行,就不会动到既有行。
4. **合成表单提交别用 `FormData`**:真实 `<form>` 没有 enctype ⇒ 浏览器发 urlencoded,
而 `editPlaceUpdate` 用的是 `ParseForm`(只认 urlencoded)。用 `fetch(...,{body:new FormData(form)})`
会发 multipart,字段全空 → 假 FAIL「地点标识无效」。改成 `new URLSearchParams(new FormData(form))`
后一次通过。这条已写进 rond-web-verify 技能文档。
### 验证
- 站长:页头出现「编辑 → /admin/edit?open=235」;访客:没有这个按钮 ✓
- `?open=235`(排名 61)→ 第 2/10 页、第 21 行、located、编辑框 open、已滚到视口内、卡片被展开 ✓
- 保存往返:`ok=地点已更新`,落回 `/admin/edit?open=235`,行仍展开 ✓,库值逐位未变 ✓
- 边界:`/admin/edit`(第 1 页、无高亮、滚动 0)、`?page=3`(仍第 3 页)、
`?open=235&q=民宿`(按筛选后名次定位、命中)、`?open=235&src=photo`(被筛掉 → 不跳页不报错)、
`?open=99999`(不存在的号 → 不跳页不报错)✓
- 页头按钮 390/768/1280 都在一行内(301px,`.row` 带 flex-wrap),无横向溢出 ✓
- 全量测试绿、vet/gofmt 干净、备份往返差异仍 **3**、全站 7 页 × 2 档宽度 0 溢出 0 报错 ✓
## 第十一轮:照片导入页对齐 + hover 联动地图 + WebP 压缩 + tmpfog 清理
站长四条:① 图片导入页太简陋、UI 与其他页不一致;确认与修改环节鼠标移到某一行时地图要突出那个点。
② 点开「更多」弹出的灰底块比基础信息行短,突兀。③ WebP 压缩比再大一些。④ tmpfog 是否运行必需(已误同步进仓库)。
### ① 上传区与视觉统一(photos.html)
- 裸 `<input type=file>`(浏览器默认样式)→ 抄 `place.html` 那一套:`<label class="btn primary" for=...>`
+ 隐藏 input + 右侧「已选 N 张」提示。
- 进度/结果那行原本是一句 `.card-sub` 文本 → `#parse-status.alert.info` + CSS `:empty{display:none}`
(脚本照旧直接写 `textContent`,不用切类名)。
- 新增 4 个 KPI(读取到 / 含坐标 / 反查成功 / 待补坐标时间);行内「地图 / 更多」从裸文字链改成
`btn sm`(与其他后台表格一致);底部按钮行改成 `.row`。
### ②「更多」块比主行短 —— 是 `colSpan = 8`,主行是 9 列
`td2.colSpan = 8` 少了一列,灰底自然比上面窄一格。改成 9。顺带把底色 `var(--line-soft)`(那是
**描边色**)换成 `var(--bg-2)`(真正的「面」色),并给三组字段加了小标题(地点属性 / 到访属性 / 标签)。
### ③ hover 划过某一行 → 图上那个点跳出来
- `rond-map.js`:`markers()` 的 index 键改成 `p.id !== undefined ? p.id : p.key`(照片页的点
本来就没有库内 id,用 `key: 'row-N'`);抽 `maxVisit()`/`baseStyle()`;`preview()` 把 `byId`/`max`
存进 state;新增 `rondMap.highlight(id, key)`:放大 +8、换 `#e8590c`、白描边 3、浮出名字、
`bringToFront()`,只在点不在视野里时才 `panTo`(否则划过一排行地图乱飘)。key 传 null 复原。
- **图必须看得见这个联动才有意义**:总览图从「表单上面一张独立卡片」挪进「2. 确认与修改」卡片里,
做成 `position: sticky`,粘住时收矮 200→132(窄屏 170→110)并隐藏说明文字——不收矮的话
200px 的浮层会挡住从下面滚过去的半个表格。容器高度变了要 `map.invalidateSize()`(实测
瓦片 10/10 无缺块)。`.card` 没有 overflow,所以 sticky 有效。
### ④ 顺手发现并修掉的真 bug:`preview()` 重复调用必抛错
Leaflet 不允许对已有地图的容器再 `L.map()`。照片页反查完调一次 `drawMap()`,点「重新反查」
又调一次 → 第二次抛 `Map container is already initialized.`,**被外层的 catch 接住,
于是「重新反查」明明成功却报一句失败**。改成 state 里有 setPoints 就复用实例(先
`invalidateSize()` 再换点)。实测:修前第二次抛错、修后两次都 ok,且重查后状态正确显示
「重新反查完成:1/1 个成功」。
### ⑤ WebP:体积大头是像素数,不是质量(真机照片实测量出来的)
在真机原图(3072×4096,13.2MB / 5.7MB)上把各档位都量了一遍:
| 档位 | 13MB 那张 | 5.7MB 那张 |
|---|---|---|
| 2000px q0.82(旧默认) | 810KB | 299KB |
| 1600px q0.74 / q0.62 / q0.5 | 483 / 436 / **391KB** | 162 / 136 / 115KB |
| 1280px q0.72 / q0.6 | 338 / 305KB | 118 / 100KB |
| 1024px q0.72 | 217KB | 80KB |
q0.74→q0.5 只掉 19% 而画面明显糊;同样 q0.72 把长边 1600→1024 掉 55%。**所以阶梯走到底
还超标时该缩尺寸、不该继续压质量。**最终策略:长边 1440 + 阶梯 `[0.72, 0.58]` 命中 220KB 即停,
走到底还超标就整张缩到 1080 重编一次(最多 3 次编码)。实测:13MB 那张 810→252KB(-69%),
5.7MB 那张 299→137KB(-54%,仍留在 1440px)。新产出上传/删除都正常(服务端 6MB 单张上限不受影响)。
### ⑥ tmpfog
**不是运行必需**:全仓 grep 无引用,是开发期的探针/抓取产物(110 个文件,含 2.9MB 的
rondbackup 副本与生产页抓取)。已 `git rm -r --cached tmpfog` + `.gitignore` 加 `/tmpfog/`,
本地文件保留没动。
**但它在 `init` 起就进了历史**,且 `tmpfog/_ck.txt` 是 `1|<10位>|<43位>` 形态的会话凭据
(库里 `rond_session=uid|token` 就是这种),已提醒站长:要么改一次 `secret_key` 让旧会话全废,
要么做历史重写;不改的话这个串一直留在仓库历史里。
### 验证
- 端到端(注入假 EXIF + canvas 造图,6 张):KPI 6/6/6/0;图 200→132 粘住、`stuck` 类、
说明收起、整块 145px;hover 半径 13→21、`#e8590c`、描边 3、POI 名浮出、移开复原;
「更多」宽 1042 == 主行 1042、`colSpan 9`、底色 `rgb(250,251,251)`、三组小标题;0 报错 0 溢出。
- 390 视口同样通过(图 170→110,更多块 640 == 表宽)。
- KPI 联动:清掉一行到达时间 → 待补 0→1。
- 瓦片:粘住收矮后 10/10 张加载成功(`invalidateSize` 生效)。
- 全量测试绿、vet/gofmt 干净、备份往返差异仍 **3**、全站 8 页 × 2 档宽度 0 溢出 0 报错。
### ⑦ 收尾:记忆与技能维护
- **MEMORY.md 超注入预算被截断**,已重写:8 节(其中「照片导入坐标可信度」一节极长)→ **13 节**
16143B / 9380 字符(旧版约 13k+ 字符)。做法:把两条**只属于验证手法**的条目
(`Range.getClientRects()` 数真实行盒、无头 Chrome 默认 `prefers-reduced-motion: reduce`)
迁到 `rond-web-verify` 技能,MEMORY 只留「改代码前必须知道的结论」。
- **修正 MEMORY 里的过时参数**:第八节还写着旧的 `2000px / q0.82`,已改为本次实测定案的
`1440px + [0.72,0.58] + 220KB 目标 + 1080 兜底`。
- **修正技能里的一条冲突建议**:`rond-web-verify` 原写「判断折行用
`valueRect.height / lineHeight`」——该法在容器有 padding 或含多个块级子元素时会报出「5 行」的
假阳性,与 MEMORY 的实测结论相反。已补上 `Range.getClientRects()` 按 top 以半字号聚类的正确做法。
- 清掉我建的临时脚本 `tmpfog/_rewrite_memory.py` 与 `__pycache__`;`tmpfog/` 已不在 git 跟踪中。
- 交付物:`storage/photos-import-new.png`(1280×900,3 张样张)——粘住态地图 132px + 橘色
`#e8590c` 高亮、KPI 3/3/3/0、「更多」块 1042 == 主行 1042、`window.__errs` 空。
## 第十二轮:审 6edbb21(今天的改动)—— 找出 1 个 P1 + 3 个 P2/P3
工作站长已把改动提交为 `6edbb21 bugs fix`(11:55),工作区干净。逐文件审 + 实测量。
### 🔴 P1 KPI 会停在错值(不是「没更新」,是「显示别的批次的数字」)
`updateKpis()` 只在 `finishLookup()` 和 `rowsBox` 的 change 监听里被调。**其余 5 条会改数据的路径全都没调**:
纯 code path 3 条(`!withGPS` 提前 return / `!coords.length` 提前 return / `lookup` 的 `.catch`),
加「重新反查未成功的」成功后(photos.html 618-633,只更新 status 与地图)、加跨批次。
实测两处:
- 注入 3 张无 EXIF 的图 → 表格 6 行、状态行「已读取 3 张,但都没有可用的 GPS 坐标。」,
**KPI = 0/0/0/0**。最该有用的「待补坐标/时间」本该是 3。
- 先把 KPI 预置成 7/7/7/7,再导入 2 张无 GPS 的 → **KPI 仍是 7/7/7/7**(上一批的残值)。
这是「显示错误数字」,比显示 0 更有害。证据图 `storage/photos-kpi-bug.png`。
修法(建议):四个数字全部从 DOM 推导 —— total=`Object.keys(rowRefs).length`、
gps=lat/lon 非空行数、addr=`addrEl.textContent !== '—'` 行数(与 retry 的判据同源)、
todo 沿用现有逻辑;然后只需在 `drawMap()` 与 retry 里各调一次,就不可能再漏。
### 🟠 P2 展开「更多」后 hover 高亮消失
`mouseenter/mouseleave` 只挂在主行 `tr`,鼠标下移进同一行的 `tr2` 时会触发主行 `mouseleave`
→ 高亮被清、进 `tr2` 又不会重新高亮。改字段的整段时间图上没有高亮,与需求有缺口。一行修:两个监听也挂 `tr2`。
### 🟠 P2 `syncStuck` 每次 scroll 都强制样式+布局
photos.html 595-601:每个 scroll 事件都 `getComputedStyle(root).getPropertyValue('--nav-h')`
+ `getBoundingClientRect()`。自定义属性会触发样式解析,滚动中途 `getBoundingClientRect` 强制同步布局。
`--nav-h` 只算一次(resize 时更新)即可。
### 🟡 P3 三处小的
- `preview()` 复用判据用 `_state[id].setPoints` 是否存在。当前不可达(`_state[id]` 要么不存在、
要么已带 setPoints),但 config 就绪前连调两次会走到 `L.map()` 抛
`Map container is already initialized.`。判据换 `old.map` + 加 pending 队列更稳。
- `place-image.js` 兜底档结果被无条件返回,没和阶梯档比体积。实测两种极端图(纯噪声 2000×2600
632KB→274KB、渐变图不变)都是兜底更小,**没出问题**;稳妥做法是比 `blob.size` 取小。
- `edit.html` 的 `{{if eq .SrcPK $.OpenSrcPK}}` 在 `OpenSrcPK=0` 时对每行求值。实测 src_pk∈[1,368]
无 0 值所以不触发,但加 `and $.OpenSrcPK` 更防御。
- `k-gps` 口径是「EXIF 里有 GPS 的照片数」,手工补坐标后不刷新(只有 k-todo 刷)。
### 核对过、确认没问题(避免误报)
- `?open=`:`234`(第2页)/`121`(第4页)/`310`(第1页) 都精确落到目标页 + `located` + 展开;
`99999`/`abc`/`234&q=zzzzz` 全部安全停在筛选后第 1 页,无 500。
- `row_number() OVER (ORDER BY p.visit_count DESC, p.id)` 与列表查询 `ORDER BY` **逐字一致**。
占位符 `$len(args)` 追加后立即 `args[:len-1]` 回退,与 `placeRawJoin`/`where` 编号无冲突。
- `markers()` 的 key 改成 `p.id !== undefined ? p.id : p.key`:`MapPoint.ID` 有 `json:"id"`,
全屏地图/时间轴的点仍全按 id 走,**没有回归**(否则时间轴的 `prev[p.id]` 会全塌成一个键)。
- `state4.apply()`(重建 markers 却不传 index)只在 `full()` 里被调(661/838 行),
`preview()` 的状态不走 apply ⇒ **`byId` 陈旧问题不存在**。
- `colSpan=9` 与 9 个 `<th>` / 9 个 `<col>` 一致。`--bg-2`/`.kpis`/`.alert.info`/`--nav-h` 都在;
`#map-card` z-index 5 < 导航 500,不盖导航;三个新 id 全局唯一。
- `mapCard` 在 114 行声明、监听在 603 行注册,顺序正确。
- `toWebP` 仅两处调用(photos.html:692 / place.html:420),都没传旧的 `quality`。
- 全站扫描 9 页 × 2 宽度:**0 溢出 0 报错**;`go test ./...` 全绿、build/vet 干净。
### tmpfog 的最终结论(修正上一轮)
- 本地与**远端全新 `--mirror` clone** 都是 16 个提交、tmpfog 命中 0、HEAD 同为 `6edbb21`、
refs 只有 `main`;`git fsck --unreachable` 无输出。**当前确实干净。**
- 12:00 的重写痕迹:`.git/ORIG_HEAD`(12:00:18)、`logs/HEAD` 清零(12:00:19)、
`objects/pack` 重写为唯一新包(12:00:43)。
- `_ck.txt` 56B=`1|<exp>|<mac>`、exp=2026-10-01 10:56 未过期,**但 uid=1 在库里不存在,
实测该 cookie 被 302 到登录页 ⇒ 死票**。所以**上一轮「改 secret_key」的建议过重,不需要做**。
教训:凭据能不能用要实际打一次接口。
## 第十三轮:修掉第十二轮列的全部 6 项(站长说「全做」)
改了 4 个文件(+63/-25):`photos.html`、`edit.html`、`place-image.js`、`rond-map.js`。
**全部是 JS/HTML,没动 Go 逻辑**,所以备份往返不受影响。模板走 `//go:embed`,
必须**停进程 → 重编 `bin/rond.exe` → 重启**才生效(`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,
改用系统进程管理接口按名停止)。
### 改动与实测
| 项 | 做法 | 实测 |
|---|---|---|
| P1 KPI | `updateKpis()` 去掉全部入参,四个数字**从 DOM 现算**(total=rowRefs 行数、gps=lat/lon 非空、addr=`addrEl.textContent !== '—'`);只在 `chain.then` 开头与 `drawMap()` 首行各调一次 | 预置 7/7/7/7 后导 3 张无 GPS → **3/0/0/3**(原来 0/0/0/0 与 7/7/7/7 两种错法都消失);手工补坐标 → 含坐标 1;再补到达时间 → 待补 2 |
| P2 更多块 hover | 主行与「更多」行都打 `dataset.rowIndex`,改成 `rowsBox` 上委托 `mouseover`(按 `closest('tr')` 找行号)+ `mouseleave` 复原;`highlightRow` 加 `hoveredRow` 去重防闪 | 「更多」块内 hover → 高亮保持(`#e8590c/3` 命中 1);回主行仍保持;离开 tbody → 3 个点全复原灰 |
| P2 scroll 性能 | `--nav-h` 提到 `measureNav()`,只在脚本加载与 `resize` 时算 | 粘住仍生效(`stuck`、高 132、瓦片 10/10) |
| P3 WebP | 兜底档与阶梯档比 `blob.size` 取小 | 噪声图 阶梯 632KB → 兜底 273KB,`notLargerThanLadder:true` |
| P3 `?open=` | 两处加 `and $.OpenSrcPK` | 234/121 仍精确落页+展开;无参时不误加 `located`;99999/abc/筛选空集都安全 |
| P3 preview 复用 | 判据从 `old.setPoints` 换成 `maps[opts.id]`(容器是否已被 Leaflet 认领),未就绪时挂 `st.previewPending`,`setPoints` 建好后补画 | 同容器连画两次 → 无异常、`byId` 有 1 个点、**视野跟到新点 28.21**;重建后 `highlight` 仍命中 |
验证:`gofmt` 干净、`go vet ./...` 干净、`go test ./...` 全绿、全站 9 页 × 2 宽度 **0 溢出 0 报错**。
对照图 `storage/photos-kpi-bug.png`(修前 0/0/0/0)与 `storage/photos-kpi-fixed.png`(修后 3/0/0/3)。
### 顺带发现
- `rond-map.js` 里 `full()` 写的 `_state[id] = { pending: true, points }` —— **这个 `pending` 字段全仓没有任何读取方**,
是死字段(写于 623 行、之后再没被读过)。没动它,但下次改这块别再被它误导(第一轮就是被它带偏,
以为 `preview()` 的复用判据存在竞态窗口)。
- `--eval-file` 的两个坑(已写进 `rond-web-verify` 技能):① 脚本必须包在函数里,裸写顶层 `return`
是语法错误且**什么都不打印**,白等两轮 4 分钟扫描;② 返回值被 JSON 编码,`grep '"key"'` 匹配不上
(实际是 `\"key\"`),要么裸子串匹配、要么让探针返回不含引号的纯文本行。
## 第十四轮:移动端地图筛选面板改成可折叠(站长要求)
站长原话:「移动端的地图界面下那个筛选占据面积很大,能不能做成可以折叠的」。
改 2 个文件(`templates/map.html`、`static/css/app.css`),纯 HTML/CSS/JS,**没动 Go 逻辑**。
面板在窄屏本来就 `max-height:44%` + `overflow-y:auto`,但**常开**:390×844 上它有 349px 高、
盖住小半个地图,而「全部」区间有 327 个点,多数时候站长只是看地图。
### 做法
- `map.html`:`.map-panel` 加 `id`,里面新增一条 `.panel-fold` 按钮(`筛选` + `#pf-count` 数量 + `#pf-caret` 箭头),
**只在 ≤620px 显示**(桌面 `display:none`,面板照旧常开)。折叠脚本**同步执行**(放在面板之后的独立 `<script>`,
与 `edit.html` 的卡片折叠同一套做法),免得手机上先闪一下整块面板再收起。
- 状态存 `localStorage['rond-map-fold']`,**没记过时窄屏默认收起**(桌面无影响——`collapsed` 类在桌面无对应规则,实测是惰性的);
开关只切 class,收起后长什么样全由窄屏媒体查询决定。
- 数量写两处:表单里原有的 `#pt-count` 与收起条上的 `#pf-count`,统一走 `setCount()`(`loadPoints` 成功时调一次)。
- CSS:`.panel-fold` 基础态 `display:none`;窄屏 `.map-panel.collapsed { max-height:none; padding:4px 13px }` 藏 `#filter-form`,
收起后是一条 54px 的栏;展开时 `.pf-n` 隐藏(表单里那句更完整)。
- **窄屏上限 44% → 50%**:折叠条自己占 44px,不补这 6% 的话展开态能看到的筛选内容反而比改动前更少
(实测内容窗 323px → 加条后 279px;提到 50% 后回到 327px,持平)。
### 实测(CDP,站长会话)
| 场景 | 结果 |
|---|---|
| 390 默认 | `collapsed=true`、面板 **54px**、`#filter-form` `display:none`、按钮 44px、`aria-expanded=false`、caret ▸、收起条显示「327 个地点」 |
| 390 点开 | **397px**(=50% 上限)、表单 `display:block`、caret ▾、`#pf-count` 隐藏 |
| 390 再点 | 回到 54px,caret ▸ |
| 320 / 390 / 430 | 收起均 54px、展开 397 / 397 / 397,**0 横向溢出 0 报错** |
| 1440 桌面 | 按钮 `display:none`、表单常开、面板 286×733、`max-height` 仍是 `calc(100% - 24px)` —— **无回归** |
| 1440 + 注入 `collapsed` 类 | 类存在但**无副作用**(表单照样可见、面板 733)——桌面确实是惰性类 |
| 状态恢复 | 注入 `rond-map-fold=0` → 展开;`=1` → 收起(`--inject` 预置 localStorage 走通恢复分支) |
| 访客 390 | 默认收起、地图正常、无报错 |
| 地图本体 | 两种状态都 `leaflet-container` 在、瓦片 24 张(1440 为 60 张)、控制条 5 个 |
`gofmt` 干净、`go vet ./...` 干净;已重编 `bin/rond.exe` 并重启(8080 健康检查 200)。
截图证据:`storage/map-fold-closed.png`(收起)/ `map-fold-open.png`(展开)/ `map-fold-desktop.png`(桌面未变)。
### 遗留(站长没要求,先不做)
窄屏展开时表单内容 786~887px、可视窗只有 395px ⇒ **要滚才能看到「查询/重置」和图层开关**(改动前也是这样)。
真要治得收内容:把 12 个类型 chips 也塞进 `details`,或把关键词+查询提到顶部。窄屏 chip 有 44px 触控下限,是主要高度来源。
### 顺带
- `git status` 只有这两个文件 —— 第十三轮的 4 个文件已在 09-18 12:00 那次历史重写里进了 `b327bc1 first release`。
- **MEMORY.md 超体积被截断**,压了一轮(16.8KB → 14.0KB):`.rondbackup` 列语义细节挪进技能 `rond-backup-parse`
(补了 3 条:派生缓存列/哨兵 `-63114076800`、`ZVISIT.ZRAW` 是采样点数、`ZIDENTIFIER_/ZUID_` 必须确定性 UUID),
MEMORY.md 只留本站导出代码的规矩。另外删了 `storage/_ck.txt`(uid=1 的死票)与本轮全部 `_eval_*`/`_inject_*` 残渣。
- `rond-web-verify` 技能补一句:`--eval-file` 的**真语法错误是会报 traceback 的**
(`JS 异常: ... SyntaxError`,**行号 0-based**,本轮报 36 实为第 37 行),别一律当成「静默失败」。
## 第十四轮补:按建议收内容(站长「按你建议的改,不用提交」)
第十四轮留下的问题是「展开后表单 836px、可视窗 395px,要滚才能看到查询/重置」。这轮把内容
收进 608px,并把两条操作路径都钉在可视区内。仍只动 `map.html` + `app.css`(纯前端)。
### 做法
1. **地点类型整块变 `details.type-fold`**:12 个类型标签是面板里最高的一块。`open` 由脚本按视口定
—— **桌面展开、窄屏收起**(`matchMedia('(min-width:621px)')`,不记 localStorage:窄屏选完类型会整页重载,
重载后就该收回去)。标题里带**已选类型名**(`{{if .ActivityIDs}}` 列名字,否则「全部」),
展开时用 CSS 藏掉它(标签行自己就说明了选了什么)。箭头 `▸` → `[open]` 时 `rotate(90deg)`。
2. **「查询/重置」窄屏 sticky 粘在面板底部**(`.filter-actions`)。
3. **图层开关 + 计数移到操作行之前**,让操作行成为表单**最后一个元素**。
### 结构上的三个坑(都是量出来的,不是猜的)
| 现象 | 实测 | 结论 |
|---|---|---|
| 操作行 sticky 不生效 | 放中间时 `actB` 随滚动 441→407→309;挪到表单末尾后**恒定 441** | **sticky 元素后面还挂着一大截内容就粘不住**(`form.appendChild(acts)` 现场对照实验定的案) |
| 折叠条两侧漏内容 | 全宽扫描 5 个点,起初只有 x=48 命中 bar,其余命中内容 | `<button>` 在 Chrome 里 `display:flex` + `width:auto` **仍是收缩到内容宽**(量到只有 75px 宽)→ 必须 `width:100%` |
| 粘性条上方 13px 有内容穿过 | 面板留 13px padding 时折叠条粘在 59→72(内容区顶),`top:0` 补不回 | 把内边距**从 `.map-panel` 挪到 `#filter-form`**,面板 `padding:0`,两条 bar 都粘到面板内边缘(59 / 454),全宽扫描 5 点全部命中 bar |
### 实测(CDP,站长会话)
- 390 展开:内容 **836 → 608px**,`查询` 的 bottom=454 而面板 bottom=455 ⇒ **不滚动就可见**;
最大滚动量 440 → **213px**;两条 bar 在 scroll 0/100/150 分别恒定在 59 与 454。
- 全宽扫描(x=10/60/150/250/340,y=面板内上沿+2 / 下沿-2):**全部命中 `button.panel-fold` / `div.row.filter-actions`**,无穿孔。
- 折叠条 / 操作行宽度 9→381,面板 8→382:铺满不越界。
- 类型折叠:390 `open=false`(内容 608 → 手动展开 848,+240);1440 `open=true`(表单 737 → 收起后 527,-210);
箭头在 `open` 时 `matrix(0,1,-1,0,0,0)`(=90°)、右边缘距面板右 10px。桌面面板 286×739、`innerScroll=false` 仍不滚。
- 带类型筛选 `/map?act=202`:标题显示「地点类型 家」,计数 3;下拉 AJAX 查询 `min=60` → 327 → **65**,
收起条同步「65 个地点」,粘条位置不变,瓦片 24 张,0 报错。
- 320/390/430/1440 × 访客:0 横向溢出、0 JS 报错。`gofmt`/`go vet` 干净,已重编重启(8080 换主 12968)。
- 截图:`storage/fold2-open-top.png`(展开顶部)/ `fold2-open-mid.png`(中途,两 bar 各自粘住)/
`fold2-act.png`(带类型筛选,标题显示「家」)/ `fold2-desktop.png`(桌面,顺序为 图层→计数→查询)。
- 旧的 `map-fold-*.png` 已删(布局已变,避免误看)。
### 遗留
窄屏展开时「停留/关键词」和图层开关仍在可视区下方(内容 608 vs 窗 395),要滚一点才看得到——
这是 44px 触控下限决定的,除非再收内容(例如把关键词行也收进 details)。站长未要求,先不做。
---
## /map 筛选面板:继续收内容(窄屏展开后不再需要滚动)
上一轮收完还剩 608 vs 395(390 视口),窄屏展开后必须滚一点才看得到「停留/关键词」和图层开关。
这轮继续收,目标改成**展开后完全不滚**。
### 改了什么
1. **「其它条件 + 图层开关」整块收进 `details.adv-fold`**,标题里带上已生效的条件
(城市/标签/停留/关键词,无条件时显示「城市 / 标签 / 停留 / 关键词」)。
`act` / `hw` 两个 hidden **留在 details 外面**——表单要始终带上它们,收起来也不能丢。
2. **地点类型整块收进 `details.type-fold`**,标题显示已选类型名(无选「全部」)。
两块的 `open` 都由脚本按 `matchMedia('(min-width: 621px)')` 定:桌面展开、窄屏收起。
注意模板里写了 `open` 属性当无 JS 时的默认,脚本会覆盖,所以桌面/窄屏都不受影响。
3. **预设行窄屏单行横滑**(`.map-panel .presets`):`flex-wrap:nowrap; overflow-x:auto`,
隐藏滚动条。原来 7 个预设 +「含家/工作」在窄屏折两行。
4. **表单里的「当前显示 N 个地点」窄屏整行隐藏**(`.map-count`),计数只由粘顶条上的
`#pf-count` 承担;桌面反过来(折叠条 `display:none`,计数留在表单里)。
`#pf-count` 改成服务端渲染初值 `{{num (len .Points)}} 个地点`,不再等 `/api/points` 才有数字。
`setCount()` 同时写两处(`ptCount` 做了空值保护)。
### 实测(CDP,站长会话,`--await --eval-file`)
| 视口 | 内容高 | 面板高 | needScroll | 预设行 | 备注 |
| --- | --- | --- | --- | --- | --- |
| 320×844 | 327 | 373 | **0** | 单行,横溢 159 | 改前 452(超 57) |
| 390×844 | 275 | 321 | **0** | 单行,横溢 89 | 改前 400(超 5) |
| 430×844 | 275 | 321 | **0** | 单行,横溢 49 | 改前 400 |
| 1440×900 | 758 | 760 | 0 | 两行(wrap) | 桌面不受影响 |
- 窄屏 `cntDisplay=none`(表单计数行已隐藏)、`pfDisplay=block`、`pfText="327 个地点"`。
- 粘住验证:`actsTop/actsBottom` 在 `panel.scrollTop=0` 与滚到底**完全相等**(315/378),
折叠条恒定在 59(面板顶 58 = 1px 边框)。
- 折叠条铺满整行:`barHits` 5 个采样点(3%/20%/50%/80%/97%)**全部命中 `BUTTON.panel-fold`**。
- 桌面:`barDisplay=none`、`cntDisplay=block`、`tfOpen/afOpen=true`、`presWrap=wrap`。
- 访客 390:同结构,`pfText="240 个地点"`(访客看不到含家/工作),needScroll 0。
- 全部视口瓦片 16~60 张、`mapOK=true`、**0 JS 报错**。
### 两个坑(已写进技能)
- **探针点击会静默落空**:`site_cdp.py` 是「导航后固定 sleep 再求值」,可能落在 HTML 解析中途——
DOM 已在、但内联脚本还没挂监听,`btn.click()` 什么也不做且 `errs` 为空。同一条命令 390 成功、
320 失败就是这个。改成 `for (i<40 && 未达成) { el.click(); await wait(100); }` 轮询即可。
- **并行编辑同一文件会丢更新**:一次性发多条 Edit 到同一个 `.html`,各自「读-改-写」整份文件,
互相覆盖——有两条报了成功但磁盘上根本没落。**同一文件必须串行改,改完 grep 复核。**
(这轮返工了一次:模板里 `map-count` 的类名就没写进去。)
### 现状
- `git status`:只有 `internal/web/static/css/app.css`、`internal/web/templates/map.html` 两个 M,
**按站长要求未提交**。
- 探针存为技能脚本 `rond-web-verify/scripts/probe_map_panel.js`,临时 `storage/_*` 已清。
- 截图:`storage/map-panel-390-folded.png`(收起)/ `map-panel-390-open.png`(展开)。
---
## 三项:含家/工作挪出预设行 + 专题「累计停留」核查 + 筛选条类型计数失真
### 1. 「含家/工作」移出预设行(站长要求)
窄屏预设行改成单行横滑后,管理员专用的这个 chip 被滑到屏幕外且**没有任何提示**。
模板里把它从 `.presets` 里拿出来,放成独立的 `.map-panel .hw-row`。
实测(`probe_map_panel.js`):
| 视口 | 站长 needScroll | slack | 访客 |
| --- | --- | --- | --- |
| 320×844 | **30** | -30 | 0(无该行) |
| 360 / 375 / 390 / 430 | 0 | 22 | 0 |
| 1440×900 | 0 | 21 | — |
| 1440×844 | 32 | — | — |
- `presetN` 从 7 变 6、`hwInPresets=false`、`hwRow=true`,确认搬出来了。
- **窄屏一行 chip ≈ 54px**(`.chip{min-height:44px}` 触控下限 + 上下外边距),
而 320 宽的面板只余 24px ⇒ 任何新行都会让 320 需要滚动。
- 320 那 51px 是**两个 `input[type=date]` 折成两行**造成的(276px 内容宽放不下,与本次改动无关)。
注入 `#filter-form input[type=date]{min-width:0}` 可压回单行(每个 134px、`clip=0`)、
320 归零,但会让日期框只剩 134px,**没有采纳**,留给站长定。
### 2. 专题「累计停留」7小时38分 —— 核查结论:**页面没算错,是语义不同**
站长新建专题「大理」(`topics#3`,dataset 9,窗口 08-13 07:00 → 08-15 23:59,无限定区域),
KPI 显示 `累计停留 7h38m`。用真机库对账:
- 窗口内 17 次到访,`sum(least(coalesce(duration_min,0),10080)) = 458 分 = 7h38m` —— **逐条相加完全吻合**。
- 17 条的 `duration_min` 与 `departure-arrival` 分钟数**每一项都相等**(没有截断/估算)。
- 站长期望的 **38h04m** 是「首到达 → 末离开」的跨度:
`2026-08-14 08:31` → `2026-08-15 22:35 = 38h04m`。
- 站点**没有**任何「跨度」KPI:首页/统计页的 `累计停留` 同样取自 `Overview.DwellMin`
(`sum(LEAST(duration_min,10080))`),`Overview.FirstAt/LastAt` 只用来算日历和「开始记录于」。
- 差值来源可解释:08-14 19:30 → 08-15 10:05 之间**没有任何到访记录**(当晚没记住宿),
在途与未记录时间本来就不算「停留」。这也正说明为什么跨度会明显大于累计停留。
### 3. 筛选条上的类型计数不随筛选走 —— **真 bug,已修**
**根因**:`stats.DB.Activities(ctx, datasetID)` 的计数子查询只按 dataset 过滤:
```sql
(SELECT count(*) FROM visits v WHERE v.activity_id=a.id)
```
既没有时间/城市/标签,也没有区域限制;而且 `pageBase` 只传 `ds.ID`,
`p.From/p.To/p.City/...` 一个都没进去。所以 `/timeline`、`/stats`、`/places`
(三个都用 `{{template "filters" .}}`)上的「家 250 / 工作 138」永远是全库全时段。
**修法(分面计数)**:新增 `stats.DB.ActivityCounts(ctx, f Filter)`,把 `f` 的
`ActivityIDs` 清空后走 `visitsWhere` 再 `GROUP BY activity_id`:
- **必须清掉 ActivityIDs**:否则选中「餐厅」之后其它类型全变 0,标签就没意义了;
- 其它条件(时间/城市/标签/最短停留/关键词/区域)照常生效;
- 区域限制免费拿到(`Regions` 在 `visitsWhere` 里)⇒ 访客不能再从计数反推被隐藏区域的量级;
- `pageBase` 里算失败只 `log` 并保留全量数字,不因为一个角标把整页 500。
**实测(站长会话,1440)**:`/timeline?city=贵阳市` → 家0/工作0/餐厅8/公共交通4/旅游景点19/
商场6/酒店7/户外活动7,与 SQL 全对;`/places` 同样;`/stats` 同样且 KPI 未变(84 次)。
`&act=198` 时餐厅仍显示 8、其它类型保持各自的城市数(**没有被清零**);
`&act=198&min=60` 进一步收窄正确;`from/to` 生效。无筛选时仍是 250/138/76/59/50,无回归。
`go build` / `go vet` / `gofmt -l` 干净,`go test ./internal/stats/ ./internal/web/` 通过,已重编重启。
---
## 照片导入页「更多」里的活动不能新增(站长反馈)
### 结论:不是回归,是**从来没做过**
`git log --oneline -- internal/web/templates/admin/photos.html` 只有 `b327bc1 first release` 一次提交;
搜历史(`activity_custom` / `activity_new` / `新增活动` / `activityAdd`)全部 0 命中,
`new-activity` / `activity-add` / `__new__` 各 1 次命中都只在 `admin/edit.html`。
所以「以前有、现在没了」不成立。
**为什么类别能新增而活动不能**:两者性质不同 ——
- **类别**是地点上的一个自由文本标签:`category-options` 里有个 `__custom__`(「自定义…」),
选到它就展开一个文本框 `category_custom_<i>`,随表单一起提交,**不需要先建实体行**;
- **活动**是 `activities` 表里的实体(到访存 `activity_id`),必须先 POST 建行拿到 id,
才能填进 `activity_<i>`。照片页的 `activity-options` 只列了已有活动,没有建行入口。
### 改法(只动 `photos.html`,未改 Go)
照搬 `edit.html`(那里早有「新建活动」输入框 + 添加按钮 → `POST /admin/edit/activity`):
1. `#activity-options` 末尾加哨兵 `<option value="__new__">+ 新增活动…</option>`
—— `makeSelect` 是克隆这个 select 的,所以**每行都自动带上**,后解析出来的行也有;
2. 每行「更多」里,活动下拉后面挂一个默认隐藏的「新建活动」`input + 添加`(复用 `.input-btn`),
选到 `__new__` 就地展开;回车等同点「添加」;
3. `createActivity()` 成功后把新选项插到**所有**活动下拉(含隐藏的选项源)里
—— **必须插在 `__new__` 之前**,否则新建项会落到那个特殊项后面;
4. **客户端按名字去重**:`/admin/edit/activity` 不做去重,同名会真的建两行,所以先在下拉里找同名项,
找到就直接选中、不建新的;
5. **提交守卫**:还有行停在 `__new__` 上就 `preventDefault` + 提示。否则 `activity_<i>` 会带着
`"__new__"` 提交,`resolveActivity` 解析不出整数 → 静默当成「未指定」,这行的活动白白丢掉。
### 实测(CDP + `--inject fake-exif.js` 造出真实的一行)
```
源选项尾=212/租车,__new__/+ 新增活动… 每行下拉含新增项=true
选新增前/后 新建框 display=none -> block 空名字点添加 → sel 仍为 __new__(不建)
创建后 sel.value=213 选中项=TEST临时活动… 下拉末尾=212/租车,213/TEST…,__new__/+ 新增活动…
新建框自动隐藏=true 同名再建:源选项数 34 -> 34(未重复建)
未点「添加」就提交 → 被拦=true errs=[]
```
创建用的测试活动已删(`activities` id 213,无到访引用),`max_pk=31 / max_raw_pk=30` 复原
—— 顺带确认:`nextSrcPK` 取三处 max+1,新 pk 必然大于 `entity_raw` 里的最大 Z_PK,
所以 `clearStaleRaw` 对新行是空操作,**「建一行再删掉」不会带走任何留档行**。
### 同类场景(未改,站长说了再动)
`admin/edit.html:273`(地点详情里「编辑某条到访」的行内表单)的类型下拉同样只有已有活动。
本次只补了照片导入页——那是站长实际卡住的地方。
---
## 行内编辑到访补活动新增 + 项目完整测试 + 导出包结构对比
### 1. `admin/edit.html:273` 补齐(站长要求「一起补齐」)
地点详情里「编辑某条到访」的行内表单,类型下拉原本只有已有活动。照照片导入页那套补:
- 下拉里加 `__new__` 哨兵;后面挂一个默认隐藏的「新建活动」`input + 添加`(复用 `.input-btn`);
- 抽了三个共用函数:`findActivityByName`(按名字去重)、`addActivityOption`(插到**所有**
`select[name="activity_id"]` 里、且在哨兵之前)、`createActivity`;
- **顺手修了顶部那个「新建活动」**:以前只往 `#activity-select` 里挂,现在走 `addActivityOption`
⇒ 在顶部建完,下面每条到访的行内下拉**当场就能选到**,不必刷新页面;
- 提交守卫走 `document.addEventListener('submit', …, true)`(捕获阶段),
拦下 `activity_id === '__new__'` 的提交 —— 否则 `resolveActivity` 解析不出整数、
静默把类型清成「(无)」。
实测(CDP,`[data-card="visits"] details.inline-edit` 里取第一行):每行下拉含新增项 ✓、
选到哨兵后新建框 `none→block` ✓、空名字不建 ✓、建完 `sel.value=1234` 并选中新活动 ✓、
**页面 41 个 `activity_id` 下拉全部挂上新选项(含顶部主表单)** ✓、同名再建 33→33 不重复 ✓、
未点添加就提交被拦 ✓、0 JS 报错。测试活动已删(`activities` 回到 31 条)。
### 2. 项目完整测试
环境变量(缺了会 skip,不算真跑过):
`ROND_DSN` / `ROND_SRC` / `ROND_TEST_CONF` / `FOG_SRC` / `ROND_EXPORT`。
`go test -count=1 ./...` → **全部 ok、0 跳过**(`gofmt -l` / `go vet` 也干净)。
`internal/web` 那条 e2e 会往真库导一份基线再清掉:跑完核对 `users like 'e2e-%'` = 0、
`datasets` = 1、`is_active` = 9、真库 `867 visits / 368 places / 791 movements / 31 acts`
与导出包行数一一对应 ⇒ **无残留**。跑之前先 `pg_dump` 了一份(已删,需要时一条命令再生成)。
**踩到的坑(重要)**:`TestExportRoundtrip` 要求 `ROND_EXPORT` 指向
**「未改动的往返包」**,指到真库的导出会大量报「导出多出到访」(真库比基线多 43 条站点新建行)
—— 那不是 bug,是我把变量指错了。正确顺序:
```
ROND_CLEAN_OUT=… go test ./internal/web/ -run TestMovementCleanExport # 产纯净往返包
ROND_EXPORT=… go test ./internal/rond/ -run TestExportRoundtrip # 再比
```
干净往返:visits 823/823、places 323/323、acts 30/30、moves 791/791、weather 557/557、
rawvisits 2246/2246,**坐标偏差 0.000000000、时间偏差 0s**。
### 3. 导出包 vs 原版结构对比
新增工具 **`tools/verify_export_structure.py`**(与已有的 `tools/verify_rondbackup.py` 分工:
那个比字段取值、这个比结构)。用法 `all <原版rb> <导出rb> <原版fwss> <导出fwss>`。
**rondbackup**:zip 3 个条目一致;31 表 / 47 索引一致;**31 条 CREATE TABLE 逐字符一致**;
列定义一致;`integrity_check=ok`、`foreign_key_check` 违例 0;两次导出 `LifeEasy.sqlite`
**sha256 完全相同**(确定性 ✓,随机 UUID 的坑没有复发)。
原版是「活快照」(主库 356KB + wal 3.1MB),导出写的是已 checkpoint 的完整库 ⇒
主库变大、`-wal/-shm` 变 0 字节,这是**更自洽**的形态。
**fwss**:`Model/*/` 63 个瓦片全部满足:文件名可逆(md5(10进制id)[:4] + 两段掩码)、
长度 `32768+515n`、槽位表非零槽恰为 `1..n` 的一个排列、块尾 24 位大端 = 置位数×2+1
(10542 个块、670565 格,**0 违例**);`Model/~/` 52、`Model/#/` 54 与原版**逐字节一致**;
两次导出解压后逐条目字节一致(zip 内条目顺序会变,是 Go map 遍历顺序,不影响读取)。
**三处「差异正常」,不是缺陷**:
1. **`ZLOCATION.ZLASTARRIVALDATE_` 3 条与原版不同、2 条原版是哨兵 `-63114076800`**:
导出侧**全部**等于 `MAX(ZVISIT.ZARRIVALDATE_)`,而**原版自己有 3 条缓存是陈旧的**
⇒ 导出是修正,不是错。
2. **真库导出缺 `ZVISIT` Z_PK=191**(真机那条「无地点、无时间、无留档」的全空行);
基线里 323 个地点、791 条位移**一条不缺**。用「未改动的往返包」时这条**在**(原版 1 / 往返 1)
⇒ 说明是 9-14 那次导入用的是更早的代码,或这条被删过;**不是导出逻辑的问题**。
想对齐就重导一次原包。
3. **2 条 `ZMOVEMENT.ZEND_` 晚于终点到访的到达时间**(超 26.0 秒 / 47.0 秒):
`movementWindow` 对手填时间有 1 分钟容差(表单只到分钟),而**改到访时间时不回头校验
引用它的行程** —— 这两条的终点到访到达时间被后手改成了整分钟(`ZVISIT 213/207` 的
到达都是 `.000000`,行程结束还是真机的小数秒),原先合法的行程就变成越界。
属**已知缺口**(`TestExportRoundtrip` 那句「时间最大偏差 47.000117s」就是它),
不是格式问题。修法:改到访时间后把引用它的行程 clamp 回窗口内,或在导出时 clamp。
结论:**改过的库导出的包,软件能读回去**(结构、格式不变量、自洽性、确定性全过)。
---
## 改到访时间后夹行程 + 404 中间页补齐
### 1. `clampVisitMovements`:改到访时间后把引用它的行程夹回窗口
**成因回顾**:`movementWindow` 允许手填时间有 1 分钟容差(表单只到分钟),而
**改到访的到达/离开时间时不会回头校验引用它的行程** ⇒ 把终点到访的到达改早之后,
原先合法的行程就变成越界(实测 2 条:movement 228 超 26 秒、406 超 47 秒)。
**改法**(`handlers_edit.go`):
- 抽出纯函数 `clampMovementWindow(start, end, lo, hi)`:`lo` = 起点到访的离开(无则到达)、
`hi` = 终点到访的到达;两端到访缺失的那一侧不夹;夹完强制 `end > start`
(窗口被压没了就把结束推到开始之后 1 分钟 —— 真机 791/791 条 ZSTART_/ZEND_ 都有值,
宁可略微越界也不能写 NULL)。
- `clampVisitMovements` 用一条 LEFT JOIN 查出**引用这条到访的两侧**行程,只 UPDATE 值真的变了的行
(`started_at`/`ended_at`/`duration_min`)。
- 挂进 `editVisitUpdate`(唯一会改到访时间的路径 —— `UPDATE visits SET` 全仓只有三处:
清活动、主更新、切收藏)。**顺手给 `execer` 接口补了 `QueryContext`**(原来只有
Exec/QueryRow,遍历行做不到;`*sql.DB` 与 `*sql.Tx` 本来就都有)。
- 新增回归测试 `internal/web/clamp_movement_test.go`:7 个用例(都在窗口内 / 起点被改晚 /
终点被改早 / 两端都越界 / 窗口被压没了 / 两端都没时间 / 只缺终点),**不依赖数据库**,
所以不带 `ROND_DSN` 也会跑。
**对线上那两条的实测修复**(重存一次到访即触发):
| movement | end 改前 | end 改后 | duration |
| --- | --- | --- | --- |
| 228 | 11:50:26.000 | **11:50:00.000** | 11 → 10 |
| 406 | 18:14:47.000 | **18:14:00.000** | 11 → 11 |
到访本身**没被动**(`keepStoredTime` 生效:提交值与库里同分钟 ⇒ 沿用库里那份,
207/213 的到达/离开、activity、remark 全部原样)。PG 侧复算:
`start_viol=0 / end_viol=0 / null_times=0`;重新导出后结构验收里
「ZEND_ 晚于终点到达」从 2 → **0**。
**残留(未改,属设计取舍)**:行程表单那条路仍有 1 分钟容差
(`end <= hi + 60s` 才收),所以**手工把行程结束填到终点到达的下一分钟**仍会产生
≤60 秒的越界。要不要连那里也夹,等站长定 —— 夹了会把用户填的 11:51 静默变成 11:50:26。
### 2. 404 中间页:只覆盖了「存在但不给看」,不存在的一直是纯文本
站长访问 `/places/22122` 看到 Go 默认那行 `404 page not found`。查下来:
`pausePage`(站点样式的「停一下」页)只用在三处 —— `placeHiddenPage`(403 区域隐藏)、
`guardPage`(页面未对访客开放)、系统提示;而 `placePage` / `topicPage` 里判断
「没数据 / ID 不是数字 / 查不到」三处**全是 `http.NotFound`**(纯文本),
而且**根本没有 `/` 兜底路由** ⇒ 任何打错的地址都是白底一行字。
**改法**:`notice.go` 新增 `notFoundPage(w, title, msg)` —— 状态码仍是 **404**
(脚本/搜索引擎照旧当不存在),但给人看的是站点样式的中间页(🧭 图标、返回首页按钮、
4 秒自动回首页,与 `placeHiddenPage` 的节奏一致)。替换了 `placePage` 的 3 处、
`topicPage` 的 3 处,并在 `server.go` 末尾注册 `/` 兜底(更具体的模式优先,静态资源不受影响)。
**刻意保留的**:`fog.go` / `tiles.go` / `handlers_images.go` 里那些 404 **不动** ——
瓦片和图片是给浏览器/爬虫取的资源,不是给人看的页面。
**实测**(`curl -w` 打状态码与 content-type):
| 地址 | 结果 |
| --- | --- |
| `/places/22122` | 404 · text/html · 612B · `<h1>这个地点不存在</h1>` |
| `/places/abc` | 404 · text/html · 582B · `<h1>这个地址不对</h1>` |
| `/topics/99999` | 404 · text/html · 588B · `<h1>这个专题不存在</h1>` |
| `/zzz-not-exist` | 404 · text/html · 612B · `<h1>这个页面不存在</h1>`(兜底) |
| `/static/img/nope.png` | 404 · **text/plain**(资源未受影响)✓ |
未对外展示的专题对访客仍走「不存在」那套话术,**刻意不区分** —— 否则等于告诉访客
「这个 ID 存在但你没资格看」。截图:`storage/notfound-404.png`。
### 3. 回归与验收
`gofmt -l` / `go vet` 干净;`go test -count=1 ./...` **全部 ok、0 跳过**
(含新的 `TestClampMovementWindow`)。重新导出的包结构验收
(`tools/verify_export_structure.py all …`):**通过 ✓**,`ZEND_` 越界 0;
干净往返包也 **通过 ✓ 且无任何告警**。真库 867 visits / 368 places / 791 movements /
31 activities,无 e2e 残留。
---
## 地点库「图片」列 + 照片导入自定义类别即时复用
### 1. 地点库加「是否有图片」列
- `stats.Place` 加 `HasImage bool`;`placeCols` 末尾追加
`EXISTS (SELECT 1 FROM place_images pi WHERE pi.dataset_id=p.dataset_id AND pi.src_pk=p.src_pk)`,
`scanPlace` 同步多扫一列。**放在列尾**是为了 `PlaceCols`/`ScanPlace` 的另外三个调用方
(geojson/gpx 导出、单地点查询、投票页)不用改。
- `place_images` 上本来就有 `idx_place_images_place(dataset_id, src_pk, position)`,所以这个
EXISTS 是走索引的,不必把图片行取回来。
- 模板:`places.html` 表头加 `<th>图片</th>`(放在「主要类型」后面),单元格
`📷` / `—`(都带 title)。
**踩到的坑:加一列把「城市」挤成两行了**。`table.tb` 是 `width:100%` 而 `.tw > table.tb`
只给到 640px 下限,8 列时「河池市」这种 3 字城市只剩 54px ⇒ 折行(这就是记忆里那条
「CJK 把列榨干」)。实测对照表(注入 `<style>` 逐个量):
| 方案 | 表宽 | 城市列宽 | 城市行数 |
| --- | --- | --- | --- |
| 现状 | 640 | 54 | **2** ✗ |
| min-width 700 | 700 | 59 | **1** ✓ |
| 图片列 padding 收到 4px | 640 | 54 | 2 ✗ |
| 收窄 + min-width 670 | 670 | 57 | 2 ✗ |
结论:**只有抬下限有效**(多出来的宽度是按内容比例分的,收窄自己那列不会把空间让给城市)。
改法:给这张表加类 `places-table`,只给它 `min-width:700px` —— 不动全局的 640,
否则列少的表会平白多出横向滚动。桌面 1280 实测表宽 1042(>700)⇒ 下限不生效、零影响;
窄屏 `doc溢出 = 0`(还在 `.tw` 里滚),320/390 城市列都回到 1 行。
顺手确认:站长侧 3 个地点有图(与 SQL 一致)、访客侧 2 个 —— 差的那个是「上班」(河池市,
落在访客区域黑名单里),符合预期。
### 2. 照片导入:自定义类别现在建完就对所有行生效
站长的问题:一次导入多张时,第 1 张新建的类型/活动「不会立即生效」,后面同类型的还得
重新输入一遍吗?
查清两点:
- **活动**:上一轮已经会传播(`addActivityOption` 挂到所有 `select[name^="activity_"]` +
隐藏选项源,且插在 `__new__` 之前)⇒ 第 2 行直接从下拉里选,**不用重打**。
- **类别**:**原来是必须重打**。类别是自由文本(存的就是 `poi_category` 原串),
选项来自服务端渲染的 `DISTINCT poi_category`,页面加载后新建的类别不会进任何下拉。
改法(`photos.html` 与活动同一套形状):新增 `categoryExists / addCategoryOption / promoteCategory`,
在自定义类别输入框 **失焦(change) 或回车**时把该文字挂到所有 `select[name^="category_"]`
(含隐藏源、插在 `__custom__` 之前),当前行改成直接选中它。因为类别的 value 就是那串文字本身,
所以不需要建任何实体行 —— 这点与活动不同(活动必须 POST 建行拿 id)。
实测(CDP 造 2 张照片):第2行类别下拉 17 → 18 且含新类别 ✓、隐藏源也有且排在「自定义…」之前 ✓、
第2行直接选中两项(活动/类别)都成功 ✓、同名类别再输入不重复建(18 → 18)✓、
提交字段 `category_0 = category_1 = 新类别`、`activity_1 = 1655` ✓、0 报错。
顺带给照片页的活动新建框加了 `.act-new` 类(原来没有可定位的标记,探针没法找)。
**遗留**:`admin/edit.html` 的类别下拉(`.cat-select` 那套)**没改** —— 那里每存一次都整页刷新,
新类别会随 `DISTINCT poi_category` 重新进下拉,不存在「一批里反复重打」的场景。
### 3. 一个探针陷阱:别用 emoji 当 grep 模式
我用 `grep -c "📷"` 数页面上的图片标记,13 页全是 0,一度以为功能没生效。实际上那一行
渲染得好好的(`grep "海之尚酒店"` 能抓到,同行就有 `<span title="有图片">📷</span>`)——
**📷 是 U+1F4F7,超出 BMP,命令行参数在 Git Bash 里被编码搞坏,模式匹配不上、静默 0 命中**。
改用 ASCII 模式(`title="有图片"`)后统计准确(站长 3 / 访客 2,与 SQL 一致)。
---
## 筛选状态在「列表 → 详情 → 编辑 → 返回」链路里保留(back 参数)
站长:在地点库等处筛选后,点进详情、编辑完再返回,筛选就没了。
### 做法:加一个 `back` 参数贯穿链路(不用 session/cookie)
- `Page.Back`(来自 `?back=`,**只放行站内路径**)与 `Page.BackQS`(当前页面地址编码后的
`?back=` 参数值),在 `pageBase` 里一次性填好;辅助方法 `PlaceLink` / `EditPlaceLink` /
`BackLabel` 都返回 **template.URL**(理由同 `EditData.PageLink`:href 里再经 URL 上下文
转义会把 `=` 编成 `%3d`)。
- 列表页的详情链接改用 `Page.PlaceLink`:`places.html`、`timeline.html`、`stats.html`
的推荐/踩雷 chip;地图弹窗走 `rondMap.full/preview` 的 `opts.back`,`rond-map.js` 里
`backSuffix()` 拼到「查看详情」上。
- 详情页:「← 返回XX」用 `.Back`(标签按来源算:地点库/时间线/地图/统计/数据编辑),
「编辑」用 `EditPlaceLink`(优先把 **列表** 交下去,站长要的是「改完回地点库」)。
- `admin/edit.html` 页头加「← 返回XX」按钮(`{{if .Back}}`),`EditData.PageLink` 让
**翻页也带着 back**,否则在编辑页翻一页返回按钮就没了。
- `BackQS` 用 `url.Values.Encode()` 生成并**剔除 back 自己**:详情页地址里本来就有 back,
原样再编码一层会套娃。
- 安全:`backFromRequest` 挡掉 `//evil.com`(协议相对)、非 `/` 开头、引号/尖括号/反斜杠。
实测 `//evil.com` / `https://evil.com` / `javascript:alert(1)` / 带引号想跳出属性的都回退
到 `/places` ✓(返回值当 template.URL 用,绕过 html/template 转义,这里必须挡死)。
**实测**(CDP + fetch 真取回返回地址):`/places?city=河池市&sort=dwell` → 详情
`/places/2141?back=...` → 返回链接 `href="/places?city=河池市&sort=dwell"`、文案「← 返回地点库」,
fetch 回来验证 **城市选中 ✓ / 排序 chip 选中 ✓ / 共 87 个 · 第 1 / 3 页 ✓**;
带 `page=2` 时 back 里也有 `page%3D2` ✓;时间线进来标签变「← 返回时间线」✓;
地图弹窗链接 `?back=%2Fmap%3Fq%3D...` ✓(canvas 标记点不到,用 `window.__map.eachLayer` +
`openPopup()` 打开的)。
### 顺带查出并修掉一个既有的翻页 bug(不是这次引入的)
`EditData.PageLink` 结尾有一句 `strings.ReplaceAll(url, "&", "&amp;")`。因为返回值是
`template.URL`,Go 在 href 里**还会过一次 `urlNormalizer` 做属性转义** ⇒ 手工写的 `&amp;`
被二次转义成 **`&amp;amp;`**,浏览器解出来是「字面的 `&amp;`」,参数名变成 `amp;vpage`
⇒ **带任何筛选时翻页都没反应、筛选还丢了**(`git show HEAD` 确认这行早就在)。
实测确认:`/admin/edit?q=家` 的翻页链接解析出来是 `?q=家&amp;vpage=2`(坏)。删掉那句
ReplaceAll 后变成 `?q=家&vpage=2` ✓。
新增回归 `internal/web/pagelink_test.go`:断言翻页链接**不含 `&amp;`**、含 `q=/%2Fplaces/vpage=2`
等等号形态、裸 `&` 恰好 3 个。做了变异测试(把 ReplaceAll 加回去 → 测试 FAIL,恢复 → PASS),
确认它真能抓住这个 bug。
### 两个操作教训
- **python 直接 `open(path,'w')` 回写 Go 文件会把 LF 变 CRLF**(Windows 上 `newline=None`
把 `\n` 翻成 `\r\n`),`gofmt -l` 立刻报这个文件。改完用 `open(p,'rb')` 读、`replace(b'\r\n',b'\n')`
再写回修好(这次 1480 行)。临时改代码做变异测试后,务必跑一遍 `gofmt -l`。
- 给页面加链接时**优先用返回 template.URL 的方法**,不要在模板里手工拼查询串。
### 验收
`gofmt -l` / `go vet` 干净;`go test -count=1 ./...` **全部 ok、0 跳过**(含两个新测试)。
---
## back 改动的完整复查(站长要求「再做一次完整检查」)
复查方式:逐行读 diff + 全站审计(`audit/run_all.py`)+ 复跑测试 + 复验导出包。
### 复查抓到的问题(都已修)
1. **返回按钮文案退化成「← 返回」**(`BackLabel`)—— 我为了避免 `/places-xxx` 这种前缀误判,
改成「只取第一个路径段」,但**没先剥掉查询串**:`/places?city=X` 整串都是第一段,
匹配不上任何前缀 ⇒ 所有「带筛选的列表」作为来源时按钮都变成光秃秃的「返回」。
修:先 `IndexAny(b, "?#")` 截断再分段。新增 `TestBackLabel` 11 个用例(含带查询串的)。
2. **`/admin/edit` 里 `back` 很脆**:三个 GET 筛选表单**没带 back**、三个「重置」链接也是手拼的
⇒ 一筛选/一重置,返回按钮就没了。修:三处表单加 `<input type="hidden" name="back" value="{{.BackRaw}}">`
(`BackRaw` 是纯字符串版本 —— 非 href 的位置用 `template.URL` 反而会被当 URL 再处理一遍);
三处重置改走新的 `EditData.EditLink(clear...)`,与 `PageLink` 共用 `editFilterValues()`。
**行为变化**:重置现在只清自己那一组(原来「地点筛选」的重置会连带清掉行程筛选、
「行程筛选」的重置会连带清掉到访搜索)—— 与这个页面「三个列表各自保留条件」的设计一致。
顺带修掉手拼导致的另一处:搜索词里有 `&`/空格时重置链接会被拆坏(原 `?vq={{.VisitQuery}}`)。
3. **`backQS` 会把一次性提示带下去**:`ok=`/`err=` 是重定向回来的一次性 flash,
挂进 back 会让它在下一页再弹一次。修:`backQS` 同时 `Del("ok")`/`Del("err")`。
4. **安全面收紧**:`backFromRequest` 原来只挡 `\r\n\t` 与引号/尖括号/反斜杠,
现在挡**所有控制字符**(`<0x20`、`0x7f`)。
5. **`/map?q={{.Place.Name}}` 直接内插名字**(place.html,既有):名字带 `&` 会拆断参数、
带 `#` 会把后面全当 fragment 吃掉。库里 368 个地点现在 0 个这种名字,但是个真隐患。
修:新增 `Page.MapLink(name)` 走 `url.QueryEscape`,+ `TestMapLink`(含 `A&B #咖啡 100%`)。
6. **补全了 back 的覆盖面**:专题页地图(`topic.html` 的 `full({back:...})`,
现在从专题点进详情会显示「← 返回专题」)、主页最近更新(`home.html` → `PlaceLink`,
显示「← 返回首页」)。`BackLabel` 相应加了 `/topics` 与 `/admin` 两档。
### 全站审计(`audit/run_all.py`,390 站长 14 页 + 390 访客 8 页 + 1440 站长 14 页)
- **横向溢出:全部 0**(1440 那栏显示 `-15`,那是滚动条宽度,不是溢出)。
- **JS 报错 0、缺 alt 0、无名控件 0**。
- 遗留的**既有**小问题(都不是这次改动引入的,列进 P2/P3):
iOS 聚焦自放大输入框 —— `/admin` 的 `backup`/`fwss` 文件框与 `/admin/settings` 的
`accent_color`(13.3px < 16px);触控目标偏小 —— `/admin/notes` 309、`/timeline` 189、
`/stats` 75、`/admin/settings` 55、`/places` 39、`/` 23(多数是页内正文链接,天然小;
真正该修的是 checkbox 13~15px 与 select 39px);小字号 `<12px` —— `.tag` 11.5px
(`/stats` 29、`/` 15、`/topics/3` 7)。
- 批量审计驱动收进技能:`rond-web-verify/scripts/audit/run_all.py`。
### 复跑
`gofmt -l` / `go vet` 干净;`go test -count=1 ./...` **全部 ok、0 跳过**(新增 3 个测试文件里共
`TestEditPageLink` / `TestBackLabel` / `TestMapLink`);重导包 + `verify_export_structure.py all`
**通过 ✓**(`ZEND_` 越界 0,只剩那条已解释的 `ZVISIT 191`)。
---
## 收拾审计发现的移动端问题 + 统一提交(commit 0027551)
### 根因:那个「窄屏触控」CSS 块写在基础规则**之前**
`app.css` 里两份 `@media (max-width:620px)` 里写了 `.chip{padding:9px 12px}`、
`.btn,.btn.sm{padding:10px 14px}`、`.check input{width:20px;height:20px}`,
但 `.chip`/`.btn`/`.check input` 的**基础规则在文件中部、位置更靠后** ——
CSS 同特异性下靠后的赢,所以这三条**一直是死代码**(审计量到的 checkbox 就是 15px 而不是 20px)。
修法:把这几条挪到**文件末尾**新增的一个块里(并注明「必须放最后」),
原来那处只留基础规则里没有的属性(min-height / display / `--field-fs`)。
### 具体改动(都在末尾那个窄屏块里)
- **iOS 聚焦自放大**:`input[type=file]` 与 `input[type=color]` 被基础规则
`input:not([type=file])…` 排除在外,也就没吃到 `--field-fs`,窄屏仍 13.3px ⇒ 一提上来就 16px。
实测 `/admin` 的两个备份文件框、`/admin/settings` 的 `accent_color` 都变成 16px,
三页的 `iOS自放大` 从 2/1/… 全部归 **0**。
- **checkbox 20px / select 44px**:`/admin/settings` 一屏几十个开关,原来 15px;
select 39px 差 5px 到 44px。表格里的 select 用 `table.tb select, .photo-table select{min-height:0}`
排除(那张表列多行密,抬到 44px 会膨胀)。
- **`.tag` 11.5 → 12px**:`/admin/edit` 的小字号从 **40 → 0**(那 40 处就是「补录」这类 tag)。
- **导航 logo `.brand` 25×29 → 44×44**(每页都被点名,是回首页的入口);导航只高了 1px(50→51)。
- 复测(390,访客 8 页 + 站长 14 页):**横向溢出全 0**、无 JS 报错;
小触控计数普遍小降(`/places` 39→36、`/timeline` 189→186、`/admin` 7→4)。
`/map` 那栏数字变大是**时序抖动**(聚合图标异步渲染),不是回归。
### 故意不修的(写清楚免得下次又被当 bug)
- **`/stats` 的月份轴标签 10px**(15 个 `25/7` 这种,`/stats` 29~40 处、`/` 15~20 处):
`.cols-x span{flex:1;overflow:hidden}`,390px 下每个标签只有约 24px;
提到 12px 会直接**被裁掉尾字**(比字小更糟)。要真修得改成隔月显示,属设计改动。
- **`/admin/notes` 305、`/timeline` 186 个小触控**:绝大多数是页内正文链接,天然就小。
- **`/map` 的 `input[type=range]`(时间轴)13.3px** 被审计点名「iOS 自放大」是**误报** ——
滑块不弹键盘,iOS 不会因它缩放;且它本来就被基础规则排除在外。
### 提交
`0027551 筛选在列表→详情→编辑之间保留,修复编辑页翻页丢筛选,收拾移动端触控与字号`
(13 个文件、+305 −22,含新测试 `internal/web/pagelink_test.go`)。
提交前 `-count=1 ./...` 全绿 0 跳过、`gofmt`/`vet` 干净;**未 push**。
---
## 访客看不到「图片」按钮:JS 从站长专属 DOM 反推数据(commit 96e7631)
站长报「地点详情页访客看不到图片按钮,只有管理员登录才能看」。
### 取证(先分清「没渲染」还是「被删了」)
`/places/2254`(src_pk 309,3 张图):
| 身份 | 原始 HTML(无 cookie 拉) | CDP 求值后的 DOM |
|---|---|---|
| 访客 | **有** `id="imgs-open">图片 3` | 按钮 **null**、`#imgs-grid` 不存在 |
| 站长 | 有 | 按钮在(图片 3)、墙内 3 张 |
⇒ 服务端渲染得好好的,是 JS 在 `load` 时删掉的。**`git log -S syncGallery` 指向 `b327bc1 first release`**,
不是新引入的回归。
**根因**:`place.html` 的 `cellURLs()` 从 `#imgs-grid .imgs-cell img` 反推地址,而那块缩略图墙整块
包在 `{{if .IsAdmin}}` 里 ⇒ 访客页 `querySelectorAll` 得到 0 张 ⇒ `syncGallery()` 走
`if (!urls.length) { btn.remove(); return; }` 把服务端渲染的按钮删了。
### 修法
- 新增 `PlaceData.ImageURLs []string`(`view.go` + `handlers_public.go` 里随 `Images` 一起填),
模板里 `var serverURLs = {{if .ImageURLs}}{{json .ImageURLs}}{{else}}[]{{end}};`
(注意 `json` 帮助函数对 nil 切片 `Marshal` 出 `null`,会 `urls.length` 报错,所以用 `if` 兜成 `[]`)。
- `cellURLs()` 判据改成**「缩略图墙在不在」**(`document.getElementById('imgs-grid')` 存不存在),
不是「墙里有几张」——站长把图删光时墙还在、只是空了,按张数回退会把删掉的图又数回来。
- 回归 `internal/web/place_image_test.go` 的 `TestVisitorPlacePageCarriesImageURLs`:
按访客身份(`context.Background()`,无登录态)调 `placePage`,要求页面里有按钮 + 把图片地址发给了浏览器。
**做了变异验证**:把 `serverURLs` 临时改成 `[]` ⇒ 测试报「访客页面没有把图片地址发给浏览器(缺 /media/9/visitor-page-test.webp)」。
### 实测(390 访客)
- 按钮回归「图片 3」;点开 `1 / 3` → 下一张 `2 / 3` → 上一张×2 **回绕到 `3 / 3`** → 关闭 `hidden=true`、背景滚动锁解除;0 JS 报错。
- 第 2 张真加载:`1200x900` 原始、显示 `362x272`;截图 `storage/cmp/visitor-gallery.png`。
- 按钮行 320 折成两行(98px)、390/430 单行 314px,**横向溢出全 0**。
- 全量 `go test -count=1 ./...` 11 包全绿、0 跳过,`gofmt`/`vet` 干净;已重编重启(8080 换主)。
### 顺带的同类扫描
写了脚本按行内 `{{if}}`/`{{end}}` 深度计数,找出「**条件块内**声明的 `id` 被 JS `getElementById` 读」的组合:
只有 `place.html` 命中(imgs-*),其余都是「本页元素被本页脚本读」的正常形态 ⇒ 没有第二处同类 bug。
## 记忆与技能维护
- **`MEMORY.md` 被截断注入**(28.6KB)⇒ 重写整理:23 节并成 18 节,合并「下拉新增 XX」的两处重复、
合并 back 的两节、把长尾细节指向技能与日志,**28.6KB → 20.5KB**,关键标记(nextSrcPK/keepNilCols/
哨兵/template.URL/--field-fs/s3_public_base/ImageURLs/…)逐个核对未丢。
- 技能 `rond-web-verify`:① 新增「双身份:访客页与站长页的 DOM 不一样」一节(含上面那个
「拉原始 HTML 对比 CDP 后的 DOM」的定性手法);② **纠正一条错的**——原文说「模板永远读 embed」,
实测 `debug=true` 时 `pageSet()` 每次渲染都从磁盘 ParseFiles:往 `<h1>` 插 `data-probe` 不重编,
刷新即出现在响应里,删掉立即消失。已改成「模板热加载只在 debug=true;**改 Go 任何模式都要重启**」。
---
## 地点编辑/新建里的「活动」:下拉形态对齐 + 行内批量改到访(站长反馈)
站长报「已有地点编辑和新建里只有类别没有活动能选,图片批量上传就可以」。
### 实测澄清(三处表单逐字段核对,站长会话拉原始 HTML + CDP 量取)
| 表单 | 类别 | 活动 | 形态 |
| --- | --- | --- | --- |
| `POST /admin/edit/place`(新增地点/补到访) | ✓ 类别+自定义类别 | ✓ 有(31 项)+ 常显「新建活动」框 | 下拉里**没有**「+ 新增活动…」 |
| `place/update` 行内编辑 ×40 | ✓ 类别+自定义类别 | ✗ **完全没有** | — |
| `visit/update` 行内编辑 ×40 | ✗ | ✓ 类型(含哨兵) | 与照片页一致 |
| `admin/photos` 每行「更多」 | ✓ | ✓ 活动(33 项,含哨兵) | 与照片页一致 |
⇒ 站长的表述只有一半成立(新建卡其实有活动,只是形态与类别/照片页不一样)。问过之后定的口径:
**两处都改,活动下拉对齐照片页那套;行内编辑的活动 = 批量改该地点名下全部到访**
(`places` 表没有活动列,活动只能挂在到访上,所以必须选一个语义)。
### 改动
- `admin/edit.html`:① 新建地点卡的活动下拉补 `+ 新增活动…` 哨兵,旁边那个「新建活动」框改成**默认隐藏、选哨兵才展开**(类名换成 `visit-act-new`/`visit-act-add`/`visit-act-name`),**删掉原来那对 `#activity-add`/`#new-activity-name` 监听**——三处下拉现在共用同一套 document 级委派处理器,留着会建两条活动;② 地点行内编辑加「活动(改该地点全部到访)」下拉(默认「(不修改)」)+ 隐藏新建框 + `data-visit-count` + 「该地点到访当前类型:家 246」提示;③ 提交守卫文案改成中性(「改回默认值」),并加一条:`place/update` 选了真实活动时 `confirm` 一次(改的是历史记录)。
- `handlers_edit.go`:`editPlaceUpdate` 解析 `activity_id`(空=不修改;哨兵→err),在**同一个事务**里先 `updatePlaceRow` 再 `UPDATE visits SET activity_id/is_home/is_work WHERE place_id=(子查询)`,flash 报「该地点 N 条到访的活动已改为「X」」;`updatePlaceRow` 改收 `execer`;`editPlaceCreate` 也补了哨兵拦截。新增 `activityNewValue` 常量。
- 地点列表查询加了一列活动构成(相关子查询 `string_agg`),`EditPlace.ActivityText` 渲染成那句提示。
- 导出无需改动:`ZVISIT.ZACTIVITY_` 本来就在 `managedCols` 里,PG 改了活动导出自然跟着走。
### 验收
- 新增 `internal/web/place_activity_test.go`:批量改(含 is_home/is_work、别的地点不受影响)、空值不修改、哨兵/不存在 id 被拒、模板契约(三个类名 + 「(不修改)」+ 哨兵)。**两次变异验证**:跳过批量 UPDATE → 「246 条到访只有 0 条换成纪念馆」FAIL;去掉 `place_id` 范围 → 「别的地点的到访也变了:8 → 577」FAIL(第一次把 `$2` 变成未使用参数,报的是 500,说明变异本身要写成语义等价才算数)。
- 全量 `go test -count=1 -v ./...`(带 ROND_DSN/ROND_TEST_CONF/ROND_SRC/FOG_SRC/ROND_CLEAN_OUT/ROND_EXPORT):11 包全 ok、**0 FAIL 0 SKIP**;纯净包往返 `TestExportRoundtrip` 等 6 个用例 PASS。
- 无头实测(重编 + 重启,8080 换主 PID 2736):活动下拉尾项 `__new__/+ 新增活动…`、选哨兵后框 `none→block` 且焦点进输入框、空名字点添加不建任何东西、停在哨兵提交被拦(alert 文案已变);真实建一个「TEST临时活动A」→ 下拉变 34 项、新选项传播到全部 81 个下拉、选中并收起,库 `activities` 31→32;随后经 UI 删除 → 回到 31 行 / max=212 / 无到访引用。地点行内:33 项((不修改)+31+哨兵)、提示「该地点到访当前类型:家 246」、选真实活动提交弹「把该地点全部 246 条到访的活动都改成「纪念馆」?」并**未提交**;到访行内下拉未回归;0 JS 报错。390/1440 横向溢出 0。
- 改动 3 个文件(+110 −32)+ 新测试 1 个**未提交**。
### 维护
- `MEMORY.md` 20.4KB 又被截断注入(只到第十一节)⇒ 拆成:`MEMORY.md`(3.9K 字符的硬规矩索引)+ `MEMORY-details.md`(原文全量),两边同步本节结论。
### 提交前的 bug 复核轮(站长要求「测一下有无引入 bug」)
自查思路:先把自己最可能写错的地方列出来逐个证伪 —— ①内部 id / rond 编号搞混;②空值语义把
历史到访清掉;③事务缺失;④越权;⑤性能;⑥前端契约;⑦探针伪影误判。
- **补强回归**:`place_activity_test.go` 增加 4 项断言 —— 导出链路核对(导出包 SQLite 里那 246 条
到访的 `ZACTIVITY_` 必须等于新活动的 **src_pk**,站点内部 id 与 rond 编号是两套号)、
行内那一行的「当前类型」文案与 SQL 一致、越权(别的数据集的活动 id 必须被拒)、以及原有用例。
- **4 次变异验证**(都能 FAIL 才算测试有效):跳过批量 UPDATE;丢掉 `place_id` 范围(顺手保留
`$2`,否则先撞 lib/pq 的参数类型错,断言根本没跑);把 `queryVisits` 的 `activity_id` 换成
`NULL::bigint`(模拟「站点改了、导出没跟上」);去掉 `resolveActivity` 的 `dataset_id` 条件
(越权用例立刻抓到)。
- **实测数据**:`places`→`activity_id` 子查询 `EXPLAIN ANALYZE` 执行 6.5ms/40 行(页级可忽略);
caption 与 SQL 逐字符一致(`公共交通 6 · 餐厅 1`、`商场 4 · 餐厅 1`);到访行内表单提交**不弹**
地点那条确认(确认只在 `action=/admin/edit/place/update` 上);390/1440 横向溢出 0;行内新建框
展开后 input 467px(390 下 276px)+ 按钮 53px 同行。
- **伪影澄清**:探针在**闭合的 `<details>`** 里量到 input 宽 0(外层 form 仍报 280),展开后正常 ⇒
不是布局 bug,已写进技能 `rond-web-verify`。
- **顺手修**:`TestMovementCleanExport` 不建 `ROND_CLEAN_OUT` 的父目录 —— 目录不存在时报
「open …: The system cannot find the path specified.」,看着像导出坏了(我清 `storage/verify`
时自己撞上)。加 `os.MkdirAll(filepath.Dir(out))`。
- 最终两段式验收:纯净包导出 PASS → 全量 `-count=1 -v ./...` 11 包 ok、**0 FAIL**(唯一 SKIP 是
第一段已单独跑过的 `TestMovementCleanExport`);测试库 `users like 'e2e-%'`=0、`is_active` 仍为 3。
- 重编重启(8080 换主 PID 17088),新二进制上复核:33 项/尾哨兵、选哨兵框展开、守卫拦提交、
行内 33 项(首「(不修改)」)、确认文案「把该地点全部 246 条到访的活动都改成「纪念馆」?」。
已提交 commit(含新增测试与脚手架修复)。
---
## 地点库 / 时间线 / 统计:窄屏筛选卡默认折叠(站长要求「像地图一样」)
站长原话:「地点、时间线、统计在移动端都能像地图一样默认折叠起来」。取证后发现这三页**共用**
`layout.html` 里的 `{{define "filters"}}`(`{{template "filters" .}}` 只出现在 places/stats/timeline
三处 ⇒ 就是指这个筛选卡),所以按地图页 `.panel-fold` 那套一次改到位。
### 实现
- `layout.html`:`filters` 表单加 `filters-card` 类,第一子元素插入折叠条(`#filters-fold` +
`#filters-fold-caret`,`type="button"`),摘要文案 = `{{.RangeLabel}}`〔+ `· N 个条件`,N 取
`len .ActiveChips`〕——收起状态也得看得出「现在筛的是什么」。折叠脚本**紧跟表单**(与地图页同款
位置):先 `apply()` 再挂监听,默认值 `!matchMedia('(min-width:621px)').matches`,状态记
`localStorage['rond-filters-fold']`。
- `app.css`:规则加在**文件末尾那个窄屏块**(基础规则在文件中部,靠后的赢——项目既有教训)。
展开时折叠条 `margin:-18px -18px 14px`(卡片 padding 是 18px)贴到卡片上沿并补圆角、
`top:var(--nav-h)`(导航是 sticky,写 0 会被压在它下面);收起时卡片 `padding:0`、
只留那一 条(`> :not(.panel-fold){display:none}`)。整组规则在媒体查询里 ⇒ 桌面端 `.collapsed` 无效。
- 不新增任何服务器字段:RangeLabel / ActiveChips 模板里本来就有。
### 实测(无头 Chrome)
- 390 三页(站长 + 访客):`collapsed=true`、折叠条 44px 高、内容 `display:none`、
摘要「筛选 全部时间 ▸」/「… · 1 个条件 ▸」;点击展开 `caret ▾`、内容回来;滚到中段折叠条
`top=50` 恒定(= `--nav-h`,真粘住了);`FormData` 里 `from/to/city/tag/min/q/act` 一个不少。
- **省下的高度**:筛选卡 945px → 46px(三页都是),文档高分别少 ~900px。
- 桌面 1440:折叠条 `display:none`、`collapsed=false`、内容可见 ⇒ 零影响。
- 320/350 极窄:摘要 192px 不溢出、条高仍 44;全站 `--overflow` 审计 320/1440 **溢出全 0**。
- 全站审计(`run_all.py` 390 站长 14 页 / 390 访客 8 页 / 1440):溢出全 0、0 JS 报错;
同会话 A/B(注入旧 CSS 放出来)小触控 折叠态 31/181/82 vs 展开态 35/185/86 ⇒ **只减不增**
(审计本来就跳过 display:none,没有假阳性)。
- **回车提交没被破坏**:折叠条是 `type="button"`,表单里唯一的 submit 还是「查询」⇒ 关键词回车
仍然提交(实测 1440 与 390 展开后回车都跳成 `?…&q=xqtest`)。
- 新增回归 `internal/web/filters_fold_test.go`(三页 × 五个挂载点 + 筛选控件仍在),
**变异验证**:把 `id="filters-fold"` 改名 ⇒ 立刻 FAIL。
- 全量 `go test -count=1 -v ./...`:11 包 ok、0 FAIL(唯一 SKIP 是第一段已单独跑过的纯净包导出)。
### 两个坑(都不是站点的)
1. **CDP 的 Enter 不带 `text` 就不触发隐式提交**:一开始「回车没反应」看着像页面 bug,
用一个最小对照页才证明是探针的问题 —— 已给技能脚本的 `--press` 补上 `text='\r'`
(tab/箭头不带才对),并写进 SKILL.md。
2. 我第一版注入脚本用了对照页的 `#q`,在站点上 `getElementById` 拿到 null 抛异常 ⇒ 焦点丢了、
Enter 打到 body。**注入脚本里的选择器要跟页面实际 DOM 对齐**,报错在 `--eval` 的
`# JS 报错` 里能看到(这次就是靠它定位的)。
### 行尾
`layout.html`/`app.css` 工作区是 CRLF(仓库共 13 个文件都这样,含我没碰过的 login.html 等,
历史遗留)——两边都**统一 CRLF、无混合**,且 `.gitattributes` 是 `* text=auto eol=lf`,
提交时 git 归一成 LF,`git diff --ignore-cr-at-eol` 与普通 diff 数字一致 ⇒ 只有内容改动。
---
## 窄屏宽表:横向滚动改成一屏一行的「字段 + 值」(站长要求「信息更直观」)
站长原话:「目前很多列表在移动端需要左右滑动才能看到所有信息,我希望能够让信息更直观的展现
(不能影响原有功能)」。先量后改:390 下**还有 7 张表要横滑 316~376px**(首页天气、地点库 8 列、
统计 3 张、专题详情、后台数据集表、数据说明、专题管理、报告页 2 张)——每张都是 `.tw` + `min-width
640/700px`。
### 实现
- 10 张表加 `tb-stack` 类;每个非首格 `<td>` 加 `data-label="字段名"`(首格是这行的主角,
留空当小标题;操作格给「操作」)。共 8 个模板、48 处 `td` 开标签 + 10 处 table class。
机械替换用脚本做,**锚点必须命中恰好一次**、按文件真实行尾(LF/CRLF)匹配、二进制写回。
- CSS(`app.css` 末尾,纯媒体查询 `@media (max-width:620px)`):隐藏 thead、行列都改 `display:block`、
每格 `padding-left:5.6em` + `::before{content:attr(data-label)}` 绝对定位在左 → **值换行时悬挂缩进**
(这样格内的行内元素不会被 flex 拆成互不相邻的块)。数字列取消右对齐。
- 断点用 **620 而不是容器查询**:容器查询虽然能顺手治掉「统计页在宽屏也被塞进 g2 窄卡片」那种情况,
但实测会把 /stats 三个卡片撑高 1300px(文档 4886 → 6173)——属于臃肿,得不偿失,所以只治移动端。
### 实测
- 390/620 十张表**需横滑全部 0**、每行每格都有标签(逐行核对:30/5/7/12/17/6/1… 行全过);
621/700/1440 仍是表格(`thead=table-header-group`,横滑量与改动前一模一样 ⇒ 桌面零变化)。
- 128 个链接/按钮**滚动到可见后逐个 elementFromPoint 点测,0 个点不到**;被裁切格 0。
- 全站审计(390 站长 14 页 / 390 访客 8 页 / 320 / 1440):**溢出全 0**、0 JS 报错、小触控与小字数
与改动前逐页一致;320 下「露外元素」大幅下降(/places 277→2、/stats 114→12、/topics/3 118→7、
/admin 46→4),剩下的都是**有意的**横滑容器(热力图 `.heat`、月份轴、Leaflet 瓦片)。
- 新增 `internal/web/tb_stack_test.go`(渲染 7 个页面、逐行核对「每格都有 data-label + 格子数 == 表头列数」,
检查 9 张表);**变异验证**:去掉一个 `data-label` ⇒ 立刻 FAIL。
- 两段式验收 + 全量 `go test -count=1 -v ./...`:11 包 ok、0 FAIL(唯一 SKIP 是已单跑过的纯净包导出)。
### 三个坑(记下来免得再踩)
1. **CSS 括号:489 开 / 490 闭** —— 我改 `.tw{container-type}` 那段时,`new_string` 把整块规则都写进去了,
而 `old_string` 只覆盖了开头几行 ⇒ 旧块尾部**重复了一份 + 多一个 `}`**。浏览器解析时提前关掉了
媒体查询,后面的规则变成顶层 → 症状是「媒体查询看着写了却不生效」。判据:
`python -c "s=open(f).read(); print(s.count('{'), s.count('}'))"` 必须相等;
再用 CSSOM 探针看每条规则挂在哪个 `conditionText` 下。
2. **AssetV 是启动时算的内容哈希**:改完 CSS 不重启,页面里的 `app.css?v=` 不变 ⇒ 浏览器一直用旧缓存,
量到的还是改前的样式(我因此白测一轮)。改 CSS/JS 后**必须重启**才能拿到新哈希。
3. 探针里给 `td` 加 `display:flex` 会把格内每个行内元素拆成独立 flex item(标签与名字之间多出 8px、
`.tag` 之间被撑开);用「绝对定位标签 + 悬挂缩进」才不会动原有行内排版。
---
## 桌面小瑕收尾:宽表卡片整行 + 堆叠判据改成容器查询
上一轮留下的已知瑕疵:/stats 三张表与 /report「南北两端」被放在 g2 的半宽卡片里(视口 1440 时卡片
仅 494px),桌面端仍要横滑 146px。站长要求「改成整行」,改完先看效果、**暂不提交**。
### 改动(3 文件 + 新测试)
- `stats.html` 三组、`report.html` 一组 `<div class="grid g2">` → `<div class="grid">`(单列即整行)。
同伴的 bars 卡片跟着整行——不整行的话栅格只剩一格、右侧留半屏空白,更难看。
- `app.css`:把原来 `@media (max-width:620px)` 里的 tb-stack 堆叠规则搬进
`@container (max-width: 699px)`,并给 `.tw` 加 `container-type: inline-size`。
**判据从视口换成卡片自己**——原来的媒体查询漏掉一整段:视口 621~793 之间卡片只有 528~770px,
既放不下 640/700 的表格、又没窄到 620 以下,照样横滑 112px(改前 172px)。
阈值取 699 = `places-table` 的 min-width 700(另一档是 640);容器 700 时表格恰好放下。
### 实测(无头 Chrome)
- 形态切换点:1440/1200/1024/900/793 → 表格(容器 1042~700,横滑 0);
760/721/700/699/660/621/600/390 → 堆叠(横滑 0)。改前 1440 的三张表 + 南北两端都横滑 146。
- 全站横滑:/、/places、/stats、/timeline、/report、/topics/3、/admin、/admin/topics 在
1440 / 760 / 621 三种宽度下**全部 0**。
- **A/B 逐值对比**(`git stash` 掉本次改动 → 重启 → 跑同一组审计 → `stash pop`):
390 站长(14 页)、390 访客(8 页)、1440 溢出、320 溢出,四组「溢出 / 露外元素 / 小触控 /
小字 / iOS自放大」**完全一致** ⇒ 本次改动没碰动任何既有页面。
- 新增 `internal/web/wide_table_layout_test.go`:用 div 栈解析渲染后的 HTML,断言每张 tb-stack
表都不在任何 g2 网格里;顺带钉住 app.css 的两个挂载点(`.tw{container-type}` 与 `@container`)。
**两次变异验证**:把某组 grid 改回 g2 → FAIL(提示「宽表被放在 g2 网格里」);
删掉 container-type → FAIL。
- 两段式全量:`TestMovementCleanExport` PASS → `go test -count=1 -v ./...` 11 包 ok、0 FAIL。
### 取舍与遗留(等站长看效果)
- 整行后 bars 变宽:条从 242px → 790px(1440)。表格列也拉宽(省级行政区:省份列 476 /
城市 189 / 地点 189 / 到访 189)。若嫌空,可选:给这几张表加 `max-width`、
或只让表格整行而把 bars 两两配对(代价是卡片顺序微调)。
- 760~793 之间(`.tw` 640~699)现在会堆叠:这里本可以表格(表 min 640),属于阈值取 700 的保守代价;
要精确可给 places 表单独一条 639 阈值的容器查询。
### 三个坑
1. **别并行跑两轮 `audit/run_all.py`**:共用同一个 Chrome/CDP,会 websocket 断连,
输出大量「跑失败」(我第一轮就吃了,误以为是页面问题)。串行跑。
2. `go test` 的 cwd 不是包目录时读不到 `static/css/app.css` ⇒ 用 `fs.ReadFile(staticFS, ...)`
与运行时代码同源(embed 是编译时快照,更贴合真实)。
3. 用「位置法」(最近的 grid 声明在表之前)判断表格是否在 g2 里会**误伤已闭合的网格**,
必须按 `<div>/</div>/<table>` 顺序做栈配对。