用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
1574 lines
112 KiB
Markdown
1574 lines
112 KiB
Markdown
# 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, "&", "&")`。因为返回值是
|
||
`template.URL`,Go 在 href 里**还会过一次 `urlNormalizer` 做属性转义** ⇒ 手工写的 `&`
|
||
被二次转义成 **`&amp;`**,浏览器解出来是「字面的 `&`」,参数名变成 `amp;vpage`
|
||
⇒ **带任何筛选时翻页都没反应、筛选还丢了**(`git show HEAD` 确认这行早就在)。
|
||
实测确认:`/admin/edit?q=家` 的翻页链接解析出来是 `?q=家&vpage=2`(坏)。删掉那句
|
||
ReplaceAll 后变成 `?q=家&vpage=2` ✓。
|
||
|
||
新增回归 `internal/web/pagelink_test.go`:断言翻页链接**不含 `&`**、含 `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>` 顺序做栈配对。
|