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

112 KiB
Raw Blame History

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 —— 纯死查询 (本地一次 24ms,加上它页面总耗时 ~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 过滤:

(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 2940 处、/ 1520 处): .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。 判据从视口换成卡片自己——原来的媒体查询漏掉一整段:视口 621793 之间卡片只有 528770px, 既放不下 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 两两配对(代价是卡片顺序微调)。
  • 760793 之间(.tw 640699)现在会堆叠:这里本可以表格(表 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> 顺序做栈配对。