# 2026-09-18 工作日志 ## 配置 R2 并真机验证 SigV4(承接 09-17 的地点图片功能) 站长指令:**「我填写了 r2参数,帮重启一下服务」**。 ### 一、先发现一个会让图片全变破图的配置坑 站长填的 `s3_public_base` 是**裸域名**(`r2-life-backend.337845818.best`,没写 `https://`)。 这个值会被直接拼进 ``,裸域名得到的是**相对地址**,浏览器会把当前页面的路径接上去, 图全变破图——而且不报错,很难查。 改法选了「让代码容错」而不是「只改配置」:`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 里,**照旧能取**,不用迁移。 ### 二、上传交互 站长反馈「没有提示,以为要按保存」「按钮样式突兀」——那是个裸的 ``。 - 换成与站点一致的 ``(原生输入框 `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 `**:在每个文档加载前注入 JS,用来破坏 `fetch`、 改 `matchMedia`、打桩计数。跨导航计数必须用 `sessionStorage`(`window` 上的会被导航重置—— 第一版就是这么把计数读成 0 的)。 **复检结果**:7 个页面 × 2 档视口,横向溢出 0、JS 报错 0;编译与测试通过。 产物 `tmpfog/report-frontend-review.html`。 ## 第四轮:按评审顺序分批落地(全四批) 站长指令:**「按你提出的顺序进行」**。四批都做完了(离线缓存见文末说明)。 ### 第一批(P0)地图接口失败不再无限刷新 `map.html` 里的 `.catch(() => form.submit())` 是罪魁:失败就整页重提交,新页面又走同一条路。 实测**改前 8 秒文档重载 17 次、接口被拒 16 次、零提示**。 改成:`loadJSON()` 统一判 `r.ok`(原来 500 也会走进 catch)→ 有限次自动重试(1s/3s) → 地图底部弹状态条 + 「重试」按钮;**任何情况下都不重新加载页面**。 轨迹接口也补了失败处理(安静失败 + 取消勾选,避免开关停在「已选但没线」)。 **改后实测:12 秒内文档只加载 1 次、请求 3 次即停、提示与按钮都在**;点重试 → 请求 3→6。 ### 第二批(P1)移动端四项 - **触控目标**:窄屏给导航链接 / `.btn` / `.chip` 加 `min-height: 44px`(原 40×31、45×30、59×28)。 - **iOS 聚焦自放大**:窄屏控件字号提到 16px。**这里踩了个坑**:基础规则 `input[type=…], select, textarea` 的特异性高于媒体查询里的单选择器,直接写 `font-size` 压不过 —— 改用可继承的自定义属性 `--field-fs`(表格里再局部设回 14px),一次解决。 - **对比度**:`--text-3` 从 `#8b9199`(白底 3.18:1)改成 `#666d75`(白底 5.24、浅灰底 4.63), 页面副标题 / KPI 标签 / 筛选计数全达标。 - **安全区**:三个页面加 `viewport-fit=cover`,把 `env(safe-area-inset-*)` 提成变量用在顶栏、 正文、地图页容器、画廊。顺带把顶栏高度提成 `--nav-h`,修掉窄屏地图顶部 4px 的缝。 ### 第三批 画廊手势 / 地图控件 / 回到顶部 - **画廊重写成单张查看**(原来是竖着堆一页):左右滑动切换、左右按钮、键盘方向键、 双击(或轻点两下)放大到 2 倍并可拖动看局部、Esc / 点空白关闭。 **踩坑**:滑动与双击判定放在两个 `touchend` 监听器里时,滑动结束后会被另一个当成「点按」, 连滑两次就误触发放大 —— 必须在**同一个** touchend 里按位移分流。 - **窄屏迷雾浓度控件收成可展开的小标签**:右下角控件区从 180px 降到 162px。 - **回到顶部**:`layout.html` 全局加一个按钮,滚动超过 600px 才出现; 地图页整页不滚动,所以它自带不会出现。滚动动画尊重 `prefers-reduced-motion`。 ### 第四批 暗色模式 + 键盘焦点 - **暗色模式**(跟随 `prefers-color-scheme`):把硬编码的浅色面提成变量 (`--surface`/`--glass`/`--glass-strong`/`--row-hover`/`--on-accent`/三种 alert), 再写一套暗色变量。色值是算过的:主文字 13.6:1、次要 8.0:1、弱化 5.4:1。 - **两个关键坑**:① 站长自定义主色是模板在 CSS **之后**注入的 `:root{--accent:…}`, 同特异性下它胜出 → 暗色块必须用 `html:root` 才盖得住,并用 `color-mix` 从 `--accent-base` 提亮(深绿在深底上只有 3.3:1);② Leaflet 自带控件的选择器是 `.leaflet-container .leaflet-control-attribution`(两级),覆盖时也得带前缀。 - 用脚本**系统性排查了「引用了但从未定义的 CSS 变量」**,揪出 `--bg-2`(兜底是浅色 `#fafbfb`,暗色下行内编辑表单还是白的)和此前的 `var(--card, #fff)`(投票按钮)。 - **`:focus-visible`**:全站原来一条都没有,Tab 走查看不出焦点。已补。 ### 验证工具的两处增强(都沉淀进技能) - `--color-scheme dark|light`(CDP `Emulation.setEmulatedMedia`)用来验暗色。 - `--press Tab,Escape,…`(CDP `Input.dispatchKeyEvent`)用来验键盘可达性。 **程序 `focus()` 不会命中 `:focus-visible`,必须派发真实按键** —— 实测连按 3 次 Tab 后 `:focus-visible` 命中、描边 2px。 - 审计脚本的颜色解析要认 `color(srgb 0.52 0.70 0.67)` 这种写法(`color-mix()` 的 computed 值), 否则会把对比度算成 1.2:1 的假警报。 ### 暂缓的一项:Service Worker 离线缓存 报告里列为「可选」,**没做**——本项目对「陈旧缓存」特别敏感(历史上被旧 JS/CSS 坑过多次), 而 SW 一旦缓存策略写错,老访客会长期看到旧页面且难以自愈。要做建议单开一轮, 并配版本更新 + 跳过等待的流程,先在小范围验证。 - 注意:**本地开发库现在会往站长的真桶传图**。本地调试若不想写远端,先把存储切回 local。 ## 第五轮:气温不跟筛选走的修复 + 交互评审 站长指令:**「1. 统计页面筛选之后出行方式和气温好像还是汇总的,并不是筛选区间的内容 2. 有无更现代更美观的交互方式?」** ### 一、取证:只有气温是 bug,出行方式其实是好的 对比 `/stats` 与 `/stats?from=2026-09-01&to=2026-10-01`: - **出行方式跟着筛选变**(未筛 未知387/电🐔332/地铁22/高铁21 → 9 月 电🐔23/未知11/步行3)✓ - **气温完全没变**(阴 168 两个链接一模一样)✗ 顺手写了个脚本**审计 stats.go 里所有收 Filter 的函数**(正则提取 `f.` 的实际使用), 结论:`Filter` 相关函数里**只有 `WeatherStats` 真的忽略了筛选**(声明了 Filter 却只用 DatasetID), 另有 `HourlyTemp` 干脆只收 datasetID。其余 12 个函数都正确走 `visitsWhere`/`movementsWhere`。 ### 二、修法与关键取舍 新增 `Filter.weatherWhere()`: - **时间用观测时刻 `w.at`**,不是所属到访的 arrival —— 筛一段时间要的是「这段时间里经历的 天气」,横跨边界的到访只算落在区间内的小时。 - 城市 / 类型 / 标签 / 区域 / 关键词这些条件通过 `EXISTS (… visits v …)` 挂到那次到访上 (实测 557/557 行都带 visit_src 且能落到 places)。 - **只按时间筛时不加 EXISTS**:删到访时 `weather.visit_src` 是被**置空**而不是删行 (`editVisitDelete` 里 `UPDATE weather SET visit_src=NULL`),这些失去归属的行仍属于 「某段时间的天气」;一旦加 EXISTS,删一次到访就会让它那几天的天气从统计里凭空消失。 这条用 `hasVisitConds()` 判定,并写了白盒测试钉住 SQL 形状。 - `HourlyTemp` 改成收 `Filter`,调用点同步。 **实测**:气温从「阴 168 小时 / 26.4℃」变成「阴 27 小时 / 23.3℃」✓。 测试:`TestWeatherWhereSemantics`(白盒,验 SQL 形状与占位符编号)+ `TestWeatherStatsRespectsFilter`(与手写 SQL 对账,含时间与城市两种筛选)。 ### 三、附带发现:`Temps`(逐月气温)算了但没渲染 `HourlyTemp` 每次 `/stats` 都会跑一遍,但**没有任何模板引用 `.Temps`** —— 纯死查询 (本地一次 ~2~4ms,加上它页面总耗时 ~30ms)。要么补一个「逐月气温」图,要么删掉这个查询。 ## 第六轮:按选定方向做交互改造(A/B/C/D 全做) 站长在四个方向上都选了(筛选体验统一 / 图表可交互 / 地图时间轴回放 / 收尾)。 ### A. 筛选体验统一 - **改动即生效**:下拉与日期 `change` 就提交,关键词回车即可(不自动提交——每敲一个字都跳转很难受)。 **关键点:用 `form.requestSubmit()` 而不是 `form.submit()`** —— 后者不触发 submit 事件, 会把地图页自己那个「拦下提交、改走 AJAX」的处理器绕过,导致地图页筛选变成整页刷新。 - **已选条件可单独摘除**:`Page.RemoveFilter(key, value)`(`key="date"` 时同时摘 from/to; 多值参数如 `act` 只摘指定那一个)+ `Page.ActiveChips()` 生成标签,`layout.html` 的公共 `filters` 模板渲染成一行「已选 … ×」,右侧「全部清空」。以前只能靠「重置」全清。 - **顶部细进度条** `#load-bar`:整页 GET 慢网下点了没反应会让人以为卡住。带 8 秒兜底 (跳转被阻止时不会一直挂着)与 `pageshow` 收回(bfcache)。 - 公共 `filters` 模板只被 places / stats / timeline 用;**地图页有自己的 `#filter-form`**, 也单独接了同样的即时生效(走它的 AJAX 路径)。 - 实测:列表页下拉/日期都触发提交、关键词不触发、已选 5 个条件且摘除链接正确(各只去掉自己那一项); 地图页改动 → 1 个 `/api/points` 请求、无整页刷新、进度条收回。 ### B. 图表可交互 - 月度柱条、类型分布、城市排行、标签排行、迷雾点亮率的城市行 → **整行可点并下钻为筛选** (城市→city、类型→act、标签→tag、月份→from/to,新增 `Page.MonthLink`)。 星期分布没有对应筛选维度,只补悬停读数。 - 悬停 title 带准确数值 + 「点一下会怎样」的说明。 - **踩坑**:城市排行原来只有城市名是 ``,整行再套 `` 就是链接套链接(非法 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=`(不是搜索词,也不用新页面) - `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 页,不猜。 - 模板:``,目标行加 `class="located"`(`--accent-soft` 淡底, **用底色不用动画**——动画会被 reduced-motion 跳过,那时就完全看不出来了)、 `
`;一段小脚本滚动到视口中间,并且**先借 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`**:真实 `
` 没有 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) - 裸 ``(浏览器默认样式)→ 抄 `place.html` 那一套:`