用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
112 KiB
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。」
一、已改(两处)
- 去掉地图右下角的「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 两档都无控件重叠。 - 修掉我上一轮引入的回归:手机整页横向溢出。
/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、
.chip59×28(/timeline 37 个)、.btn.sm45×30。 - P1 iOS 聚焦自放大:/places、/timeline 各有 6 个输入框字号 14px(<16 就触发 Safari 缩放)。
- P1 对比度不足:
.sub2.96:1、.k3.18:1、.muted2.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)(投票按钮)。
- 两个关键坑:① 站长自定义主色是模板在 CSS 之后注入的
:focus-visible:全站原来一条都没有,Tab 走查看不出焦点。已补。
验证工具的两处增强(都沉淀进技能)
--color-scheme dark|light(CDPEmulation.setEmulatedMedia)用来验暗色。--press Tab,Escape,…(CDPInput.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 视野。
所以拖动时间轴时:所有点同时闪出来 + 地图跟着不停缩放。
改法
- 增量更新:
st.byId记住「点 id → 图层」,setPoints 只增删差集,其余点纹丝不动; 到访次数上限变了就顺手setRadius。 但「重新拉的数据」不能复用:popup 里存的是旧的对象,复用会让气泡显示上一次筛选的数字。 判据是对象引用(m.__p !== p)——时间轴只是在这批点上做子集筛选,引用相同; 换筛选是重新 fetch,引用不同 ⇒ 整体重建 + fit。这条规则让两条路径都不会出错。 {keepView: true}:时间轴拖动/播放时不重新 fit,否则回放时地图一直缩放很晕。- 入场动画:
- 单个点:冒出 + 淡入(
pt-in,transform-box: fill-box让缩放以自身为中心) - 聚合圆圈:只动透明度(它是 DOM 元素且 Leaflet 用 transform 定位,加 scale 会挪位)
- 聚合数字变化:弹内层 div(外层带定位 transform 不能动)
- 全部包在
prefers-reduced-motion: no-preference里
- 单个点:冒出 + 淡入(
三个坑(都靠实测才发现)
- markerCluster 是异步渲染子标记的:
addLayer之后立刻getElement()是 null ⇒ 第一版动画完全没生效。改成挂add事件,真正上屏时再打动画类。 - 聚合图标的 DOM 每轮数据变动都会重建(实测一轮加了 791 个元素): 拿元素本身当身份,每次都算「第一次见到」,永远触发不了。改用位置当身份, 并且量化到 40px 一格(聚合中心会随点集微微移动,不量化就会一片乱闪)。 量化后:新圈 24→18、数字轻弹 1→7,动静平衡了。
- 无头 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 前后逐位一致。
踩的坑
?open=里的号是 src_pk,不是 places.id。第一版排名子查询WHERE t.id=$2拿库内主键比 rond 编号 → 永远 ErrNoRows、页码不动。而这个 bug 在open=52上表现完全正常 (id=1997 ≠ 52 → 不跳页,而 52 恰好排在第 40 名、本来就该在第 1 页)—— 换个真正跨页的号(235,排名 61)才暴露。验证要挑「结果会变」的样本,别拿会巧合通过的。- debug=true 只让 CSS/JS 直读磁盘,模板与 Go 代码仍在二进制里(
tmplFS走 embed)。 但即便模板要重编,handler 的改动必须重启进程才算数:中途一个旧实例还占着 8080, 新进程只打一行bind: Only one usage of each socket address就退出, 于是「模板新、逻辑旧」,测出一堆莫名其妙的 FAIL。判据永远是netstat里的 PID 变了。 gofmt的字段对齐:往已对齐的 struct 字段组中间插字段、且带单列注释,gofmt 会重排整组 (注释会打断对齐组)。把新字段放到 struct 末尾、注释独立成行,就不会动到既有行。- 合成表单提交别用
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-cardz-index 5 < 导航 500,不盖导航;三个新 id 全局唯一。mapCard在 114 行声明、监听在 603 行注册,顺序正确。toWebP仅两处调用(photos.html:692 / place.html:420),都没传旧的quality。- 全站扫描 9 页 × 2 宽度:0 溢出 0 报错;
go test ./...全绿、build/vet 干净。
tmpfog 的最终结论(修正上一轮)
- 本地与远端全新
--mirrorclone 都是 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.txt56B=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(纯前端)。
做法
- 地点类型整块变
details.type-fold:12 个类型标签是面板里最高的一块。open由脚本按视口定 —— 桌面展开、窄屏收起(matchMedia('(min-width:621px)'),不记 localStorage:窄屏选完类型会整页重载, 重载后就该收回去)。标题里带已选类型名({{if .ActivityIDs}}列名字,否则「全部」), 展开时用 CSS 藏掉它(标签行自己就说明了选了什么)。箭头▸→[open]时rotate(90deg)。 - 「查询/重置」窄屏 sticky 粘在面板底部(
.filter-actions)。 - 图层开关 + 计数移到操作行之前,让操作行成为表单最后一个元素。
结构上的三个坑(都是量出来的,不是猜的)
| 现象 | 实测 | 结论 |
|---|---|---|
| 操作行 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);1440open=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 视口),窄屏展开后必须滚一点才看得到「停留/关键词」和图层开关。 这轮继续收,目标改成展开后完全不滚。
改了什么
- 「其它条件 + 图层开关」整块收进
details.adv-fold,标题里带上已生效的条件 (城市/标签/停留/关键词,无条件时显示「城市 / 标签 / 停留 / 关键词」)。act/hw两个 hidden 留在 details 外面——表单要始终带上它们,收起来也不能丢。 - 地点类型整块收进
details.type-fold,标题显示已选类型名(无选「全部」)。 两块的open都由脚本按matchMedia('(min-width: 621px)')定:桌面展开、窄屏收起。 注意模板里写了open属性当无 JS 时的默认,脚本会覆盖,所以桌面/窄屏都不受影响。 - 预设行窄屏单行横滑(
.map-panel .presets):flex-wrap:nowrap; overflow-x:auto, 隐藏滚动条。原来 7 个预设 +「含家/工作」在窄屏折两行。 - 表单里的「当前显示 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 边框)。 - 折叠条铺满整行:
barHits5 个采样点(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):
#activity-options末尾加哨兵<option value="__new__">+ 新增活动…</option>——makeSelect是克隆这个 select 的,所以每行都自动带上,后解析出来的行也有;- 每行「更多」里,活动下拉后面挂一个默认隐藏的「新建活动」
input + 添加(复用.input-btn), 选到__new__就地展开;回车等同点「添加」; createActivity()成功后把新选项插到所有活动下拉(含隐藏的选项源)里 —— 必须插在__new__之前,否则新建项会落到那个特殊项后面;- 客户端按名字去重:
/admin/edit/activity不做去重,同名会真的建两行,所以先在下拉里找同名项, 找到就直接选中、不建新的; - 提交守卫:还有行停在
__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 遍历顺序,不影响读取)。
三处「差异正常」,不是缺陷:
ZLOCATION.ZLASTARRIVALDATE_3 条与原版不同、2 条原版是哨兵-63114076800: 导出侧全部等于MAX(ZVISIT.ZARRIVALDATE_),而原版自己有 3 条缓存是陈旧的 ⇒ 导出是修正,不是错。- 真库导出缺
ZVISITZ_PK=191(真机那条「无地点、无时间、无留档」的全空行); 基线里 323 个地点、791 条位移一条不缺。用「未改动的往返包」时这条在(原版 1 / 往返 1) ⇒ 说明是 9-14 那次导入用的是更早的代码,或这条被删过;不是导出逻辑的问题。 想对齐就重导一次原包。 - 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)+ 复跑测试 + 复验导出包。
复查抓到的问题(都已修)
- 返回按钮文案退化成「← 返回」(
BackLabel)—— 我为了避免/places-xxx这种前缀误判, 改成「只取第一个路径段」,但没先剥掉查询串:/places?city=X整串都是第一段, 匹配不上任何前缀 ⇒ 所有「带筛选的列表」作为来源时按钮都变成光秃秃的「返回」。 修:先IndexAny(b, "?#")截断再分段。新增TestBackLabel11 个用例(含带查询串的)。 /admin/edit里back很脆:三个 GET 筛选表单没带 back、三个「重置」链接也是手拼的 ⇒ 一筛选/一重置,返回按钮就没了。修:三处表单加<input type="hidden" name="back" value="{{.BackRaw}}">(BackRaw是纯字符串版本 —— 非 href 的位置用template.URL反而会被当 URL 再处理一遍); 三处重置改走新的EditData.EditLink(clear...),与PageLink共用editFilterValues()。 行为变化:重置现在只清自己那一组(原来「地点筛选」的重置会连带清掉行程筛选、 「行程筛选」的重置会连带清掉到访搜索)—— 与这个页面「三个列表各自保留条件」的设计一致。 顺带修掉手拼导致的另一处:搜索词里有&/空格时重置链接会被拆坏(原?vq={{.VisitQuery}})。backQS会把一次性提示带下去:ok=/err=是重定向回来的一次性 flash, 挂进 back 会让它在下一页再弹一次。修:backQS同时Del("ok")/Del("err")。- 安全面收紧:
backFromRequest原来只挡\r\n\t与引号/尖括号/反斜杠, 现在挡所有控制字符(<0x20、0x7f)。 /map?q={{.Place.Name}}直接内插名字(place.html,既有):名字带&会拆断参数、 带#会把后面全当 fragment 吃掉。库里 368 个地点现在 0 个这种名字,但是个真隐患。 修:新增Page.MapLink(name)走url.QueryEscape,+TestMapLink(含A&B #咖啡 100%)。- 补全了 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/notes309、/timeline189、/stats75、/admin/settings55、/places39、/23(多数是页内正文链接,天然小; 真正该修的是 checkbox 13~15px 与 select 39px);小字号<12px——.tag11.5px (/stats29、/15、/topics/37)。 - 批量审计驱动收进技能:
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 会膨胀)。 .tag11.5 → 12px:/admin/edit的小字号从 40 → 0(那 40 处就是「补录」这类 tag)。- 导航 logo
.brand25×29 → 44×44(每页都被点名,是回首页的入口);导航只高了 1px(50→51)。 - 复测(390,访客 8 页 + 站长 14 页):横向溢出全 0、无 JS 报错;
小触控计数普遍小降(
/places39→36、/timeline189→186、/admin7→4)。/map那栏数字变大是时序抖动(聚合图标异步渲染),不是回归。
故意不修的(写清楚免得下次又被当 bug)
/stats的月份轴标签 10px(15 个25/7这种,/stats2940 处、20 处):/15.cols-x span{flex:1;overflow:hidden},390px 下每个标签只有约 24px; 提到 12px 会直接被裁掉尾字(比字小更糟)。要真修得改成隔月显示,属设计改动。/admin/notes305、/timeline186 个小触控:绝大多数是页内正文链接,天然就小。/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 个下拉、选中并收起,库activities31→32;随后经 UI 删除 → 回到 31 行 / max=212 / 无到访引用。地点行内:33 项((不修改)+31+哨兵)、提示「该地点到访当前类型:家 246」、选真实活动提交弹「把该地点全部 246 条到访的活动都改成「纪念馆」?」并未提交;到访行内下拉未回归;0 JS 报错。390/1440 横向溢出 0。 - 改动 3 个文件(+110 −32)+ 新测试 1 个未提交。
维护
MEMORY.md20.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.py390 站长 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 是第一段已单独跑过的纯净包导出)。
两个坑(都不是站点的)
- CDP 的 Enter 不带
text就不触发隐式提交:一开始「回车没反应」看着像页面 bug, 用一个最小对照页才证明是探针的问题 —— 已给技能脚本的--press补上text='\r'(tab/箭头不带才对),并写进 SKILL.md。 - 我第一版注入脚本用了对照页的
#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 是已单跑过的纯净包导出)。
三个坑(记下来免得再踩)
- CSS 括号:489 开 / 490 闭 —— 我改
.tw{container-type}那段时,new_string把整块规则都写进去了, 而old_string只覆盖了开头几行 ⇒ 旧块尾部重复了一份 + 多一个}。浏览器解析时提前关掉了 媒体查询,后面的规则变成顶层 → 症状是「媒体查询看着写了却不生效」。判据:python -c "s=open(f).read(); print(s.count('{'), s.count('}'))"必须相等; 再用 CSSOM 探针看每条规则挂在哪个conditionText下。 - AssetV 是启动时算的内容哈希:改完 CSS 不重启,页面里的
app.css?v=不变 ⇒ 浏览器一直用旧缓存, 量到的还是改前的样式(我因此白测一轮)。改 CSS/JS 后必须重启才能拿到新哈希。 - 探针里给
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。 - 两段式全量:
TestMovementCleanExportPASS →go test -count=1 -v ./...11 包 ok、0 FAIL。
取舍与遗留(等站长看效果)
- 整行后 bars 变宽:条从 242px → 790px(1440)。表格列也拉宽(省级行政区:省份列 476 /
城市 189 / 地点 189 / 到访 189)。若嫌空,可选:给这几张表加
max-width、 或只让表格整行而把 bars 两两配对(代价是卡片顺序微调)。 - 760
793 之间(699)现在会堆叠:这里本可以表格(表 min 640),属于阈值取 700 的保守代价; 要精确可给 places 表单独一条 639 阈值的容器查询。.tw640
三个坑
- 别并行跑两轮
audit/run_all.py:共用同一个 Chrome/CDP,会 websocket 断连, 输出大量「跑失败」(我第一轮就吃了,误以为是页面问题)。串行跑。 go test的 cwd 不是包目录时读不到static/css/app.css⇒ 用fs.ReadFile(staticFS, ...)与运行时代码同源(embed 是编译时快照,更贴合真实)。- 用「位置法」(最近的 grid 声明在表之前)判断表格是否在 g2 里会误伤已闭合的网格,
必须按
<div>/</div>/<table>顺序做栈配对。