Commit Graph
27 Commits
Author SHA1 Message Date
tamakyi 6aee7e441b 给显示区编辑器补上长度参照:比例尺 + 直径标注
用户反馈「专题里的圆无论怎么缩放大小都一样,20km 却覆盖了整个南宁」。实测
三处画圆(编辑器 L.circle、改半径把手、前台迷雾裁剪圆)**全都是地理锚定的**:
编辑器圆直径 z9/z11/z13 = 178/710/2842px(期望 177/711/2847)、同一缩放下改半径
3/10/25/50km → 42/142/356/710px(期望 43/142/355/711)、把手距圆心 20.038km
(该圆半径 20km)、前台裁剪圆换算始终 ≈20km。也就是**复现不出「大小不变」**。

真正的问题是缺**参照物**:编辑器地图以前没有比例尺(前台专题页一直有),
圈圆时只能靠地名猜。用他自己的数据验证「20km 覆盖南宁」是对的——圆心 20km 内
落了他 477 个地点中的 45 个,最远到西乡塘的广西民族大学相思湖校区(14.5km)
与良庆的五象万象汇(10.2km),正是南宁建成区的跨度。

所以这一版补的是参照物,不改任何几何逻辑:
- 编辑器地图加 L.control.scale(跟前台一致,公制)。
- 半径旁边标出跨度:`spanLabel()` 把半径换成「约 40 公里宽」(<1 公里改用米,
  免得出「约 0.1 公里宽」这种别扭说法);圆列表每行也标,新圆半径旁边实时跟着变。
- 守卫 TestTopicAreaEditorGivesSizeReference 钉住这两样(变异验证过:去掉比例尺立刻红)。
  它顺手抓到我自己的疏漏——模板的 init 调用漏传了 spanId,靠脚本默认值兜着,已补上显式传参。

验证:topic-areas.test.js 55 条(新增 11 条 spanLabel 边界);无头实测比例尺在
(z10 显示 10 km)、新圆半径 3→「约 6 公里宽」/25→「约 50 公里宽」实时更新、
圆列表 20→「约 40 公里宽」,全程无 JS 报错。
2026-10-13 10:37:10 +08:00
tamakyi 9cf3d89c3c 记忆:本仓库推送要认证,非交互环境会卡在凭据弹窗(fetch 匿名) 2026-10-12 18:52:52 +08:00
tamakyi bf7121f11b 记忆索引:补数字框 step 那条(9030) 2026-10-12 18:14:20 +08:00
tamakyi d795c6b579 记忆:input[number] 的 step 相对 min 递增(原生提示 JS 拦不到)+ 通配守卫 2026-10-12 18:13:44 +08:00
tamakyi 829a318400 记忆:后台显示区编辑器(地图点选)+ HasMap 陷阱 + 记忆索引压缩到 8919 2026-10-12 17:35:55 +08:00
tamakyi 48938e6ae1 记忆索引压缩:两节合并 + 理由下沉 details,9285 → 见正文 2026-10-12 17:23:09 +08:00
tamakyi 2d46b8a0ca 记忆索引:收紧专题显示区那条(自己成了最长行) 2026-10-12 16:47:21 +08:00
tamakyi b40160867e 记忆:专题显示区一组圆(存储/查询/裁剪纪律)+ 前端单测挂进 verify.sh 2026-10-12 16:46:53 +08:00
tamakyi cc37c9d1c9 记忆:迷雾收口的像素级验证方法 + --clip 截图 pad 偏移坑 2026-10-10 10:51:51 +08:00
tamakyi eb35abb7f2 专题页的迷雾按专题圈的区域收口
以前专题的中心/半径只用来定视图,迷雾图层一直是全局的;管理页那句
「迷雾由地图按视野自动呈现,不需要单独圈选」其实是把「没做」写成了
「不用做」。现在专题圈了区域时,迷雾只在那个圆内显示。

做法是给迷雾单独一个 pane(zIndex 250,底图 200 之上)再对该 pane 做
clip-path: circle()——不能直接裁 tilePane,底图也在里面,会把地图一起挖空。

两个坑:
- 圆心必须先 project() 到底图坐标系(与 fitCircle 一致),否则整体偏移;
- 裁剪圆的注册必须排在 applyFit() 之后:视图未定时 latLngToContainerPoint()
  会抛「Set map center and zoom first.」,把后续图层添加一起打断——实测
  表现是 pane 建好了、迷雾瓦片一张都不出来,而且不报错。

迷雾是累积快照、没有时间维度,所以只按区域收口,不按专题的时间范围收口。
守卫 TestTopicFogIsClippedToArea 钉住两端与注册顺序。
2026-10-10 10:41:27 +08:00
tamakyi cd69efe8f1 日历改中文、修掉头部重叠;统计页那两张体量悬殊的卡不再并排
用户反馈:日历「空间显示有点异常」且是英文、和项目割裂;类型分布与一天中的分布仍是一左一右、
右边下面一大片空白。两件事的根因都不是表面看到的那个。

一、日历英文
- `flatpickr.localize(l10ns.zh)` 原先只写在三个后台页的 whenReady 里;上一批把 flatpickr 放到
  公共页(筛选区)之后就漏了 ⇒ 日历全是 Sun/Mon/January…
- 修法:移到 datetime-input.js 的 attachPicker 里(localizeOnce(),全局只调一次),
  三个后台页那三行重复调用删掉。

二、日历头部重叠(就是用户说的「空间异常」)
- 月份下拉被撑满整行、压在年份输入上。根因是**站点全局的 input/select 样式扫进了日历内部**:
  那条规则带 width:100%、内边距、边框与 16px 控件字号,而日历内部的
  `input.numInput`(type=number)与 `select.flatpickr-monthDropdown-months` 都躲不开。
- ⚠️ 第一版修法(在后面补一条 `.flatpickr-calendar input{...}` 对冲)**是错的**:全局选择器带
  10 个 :not([type=…])、特异性 (0,10,1),普通对冲压不过,实测年份框仍是站点样式。
  正确做法是在**全局规则里排除** `input…:not(.numInput)` / `select:not(.flatpickr-monthDropdown-months)`,
  `input:focus` 的焦点环同理(否则年份框上套一圈主色光圈)。
- 实测:星期「周日 周一 …」、月份「十月」、月份与年份 overlap=0、无 JS 报错。

三、统计页排版
- `align-items: start` 只消掉了卡内空白,消不掉并排本身的空:类型分布 1411px 与
  一天中的分布 256px 并排,右侧下面必然空一片。改成单列(`<div class="grid">`),
  两张卡各占整行 1080px —— 24 根柱终于有宽度可用,列表也像「城市排行」那样横铺。

回归:./scripts/verify.sh PASS=349 FAIL=0 SKIP=0;JS 单测 45/45;移动端 390 溢出全 0;
桌面 1440 与改前一致(−15 是滚动条宽)。
2026-10-10 09:45:32 +08:00
tamakyi b19f27ab69 日期框补上日历弹层:flatpickr 改为无条件加载
用户实测:筛选区日期框换成文本框后手机上没了日历弹层、不好操作;多 18KB gzip 可以接受。

- layout.html 里 flatpickr(js + css + l10n)与 datetime-input.js 都改为**无条件加载**,
  不再用 {{if .Flatpickr}} 按页开关 —— 「按页开开关」正是 2026-10-09 日期框 bug 的根源
  (只给 type=datetime-local 挂了控件,后来加的 type=date 没人管)。
- 顺手删掉 `Page.Flatpickr` 字段与三处 `p.Flatpickr = true`(开关没了就不可能再漏设)。
- 守卫 TestDateInputsGoThroughAttachAll 相应改成:钉住「layout 无条件引入 flatpickr +
  datetime-input + attachAll」、并禁止再出现 {{if .Flatpickr}}。

实测(展开筛选面板后):390/320 下 /stats /map /places 的日历都在最上层(z-index 99999)、
完全在视口内、elementFromPoint 命中日历本身;打字 20260606 → 2026-06-06 不变。

回归:./scripts/verify.sh PASS=349 FAIL=0 SKIP=0;JS 单测 45/45;320/390/1440 溢出与改前一致。
2026-10-09 19:09:42 +08:00
tamakyi b69b827d6e 日期框换成自控输入(原生 date 会吞位)+ 气温图桌面端铺满 + 同行卡片不再被拉伸
三件事都由用户实测反馈,前两件先复现再动手。

一、日期框:敲 20260606 变成 202606/06(用户要「永远解决」)
- **实测复现**:写了个真实按键工具(CDP Input.dispatchKeyEvent 每字符带 text;
  技能里的 `--press` 只给 Enter 带 text,数字敲不进去)。在 /stats 的「起始日期」上敲 20260606:
  敲之前 `INPUT|date|`、attached=false;敲完显示 `202606/06`、`el.value` 是**空**(非法)—— 截图为证。
- **根因**:筛选区的起止日期是原生 `<input type="date">`,而挂载代码只写了
  `querySelectorAll('input[type=datetime-local]')` ⇒ **type=date 从没人管**。
  Chrome 的年段允许超过 4 位数字(年份上限 275760),8 位被切成 年=202606 + 月=06。
  原生分段状态读不到、`setSelectionRange` 对 date 类输入直接抛 ⇒ 修不了,只能换控件
  (用户也说了「如果当前控件无法实现就换别的」)。
- **做法**:`datetime-input.js` 加「只日期」模式(`SPEC.date`:年/月/日三段,输出 `Y-m-d`),
  并提供**统一入口 `attachAll(root)`** 按 `el.type` 分派;`attach` 里先把控件换成 `text`
  (引导会写 `2026-06-` 这种半成品,而原生控件对非法值一律置空)。
  `datetime-input.js` 改为**无条件引入**(公共页筛选区也要用;flatpickr 仍按需加载 ——
  没有它时只挂输入引导、不给日历,省 60 多 KB),挂载在 body 末尾**立刻**跑一次
  (别只等 DOMContentLoaded,否则会先露出原生框再变文本框)。三个后台页改用 `attachAll`,
  顺带覆盖行程筛选的 `mfrom/mto`(date 类走 `enableTime:false`,实测无时间选择器)。
- **验证**:真实按键敲 20260606 → `value="2026-06-06"`;端到端 `from=2025-08-01&to=2025-08-31`
  筛出 **186 次**,与月度图峰值月(186)完全吻合(排除「解析成空导致恒 0」)。
  门禁 `TestDateInputsGoThroughAttachAll`:模板里再出现按 type 挑的选择器即红(做过变异验证);
  JS 单测补 11 条逐键模拟(45/45)。

二、逐月平均气温图在桌面端很小
- 量出来的:10 个月固定 36px 宽,只占 1042px 里的 **396px**,右边 600 多像素全空。
- 窄屏保持「固定 36px + 横滚」(挤在一起月份标签会糊),宽屏改 `flex: 1 1 36px; max-width: 96px`
  并把轨道从 84px 加到 130px、字号提到 `--fs-sm`。

三、类型分布与一天中的分布同行高度不齐
- grid 默认 `align-items: stretch`,把 256px 的「一天中的分布」拉到与 1411px 的「类型分布」一样高,
  卡片内部留一大片空白。改成 `.grid { align-items: start }`。

回归:./scripts/verify.sh **PASS=349 FAIL=0 SKIP=0**;移动端 390 / 桌面 1440 溢出与改前一致。

⚠️ 两个验证纪律(都写进 details 三十二):
- 验收跑着的时候别并发跑测试/变异实验(两次踩到,其中一次变异正好被验收抓到,那轮结果作废)。
- **变异验证「内嵌文件」(templates/static)之后必须 `go clean -cache`**:`//go:embed` 的内容
  在「改坏→还原」后,构建缓存会继续给出变异期间编译的那份 —— 实测同一个测试
  `go test ./internal/web/` 绿、`go test ./...` 红,`go clean -testcache` 不管用。
2026-10-09 18:29:55 +08:00
tamakyi 3077250a82 记忆:完整回归结论 + 验收必须在安静环境跑
- 记下本次完整验证链:verify.sh 348 例 0 skip、fog 包 -race 22 例、JS 34/34、320/390 溢出全 0。
- ⚠️ 新纪律(details 三十二):`db.Open` 的 ping 写死 10s,库被压着时
  `TestOpenCreatesMissingDatabase` 会以「建库后首次连接超时」失败 —— 实测与 CDP 审计并发时 10.02s 超时,
  停掉审计单独跑 4.1s 就过。这条测试失败还会把临时库 `rond_test_autocreate_*` 留在库里
  (清库的 defer 在 Open 成功之后才注册),要手动 DROP 清掉。
- 记下「原有代码受影响」专项检查的方法与结果(自定义属性 0 处用了没定义、模板函数无真孤儿、
  19 个模板 diff 只有预期改动;`.g3/.g4` 是 HEAD 里的既有死类)。
2026-10-09 17:02:05 +08:00
tamakyi ab7ccd22ce 前端改造:设计令牌 + 卡片层级 + 图表可读性 + 类型色收口 + 窄屏导航
先出方案(frontend-plan-2026-10-09.md)再动手,四批按顺序落地。方法上先存基线、再用同一份
探针在改动前后逐值 diff —— 这批里有 6 处问题只有「量」才看得见,看截图全是正常的。

A 设计令牌(app.css 为主)
- 字阶收敛到 6 档 --fs-xs/sm/md/lg/xl/2xl = 11/13/15/21/26/34px。原来 app.css 有 19 种字号、
  模板里另有 54 处内联字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶。
- 两个「字外档」不是漏的:--fs-control: 16px(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页,
  属功能约束);--glyph-*(✕/箭头/空态图标按控件尺寸定,硬塞进字阶只会把按钮撑坏)。
- 字重收敛到 400/500/600(删掉 650 与 700)。
- 圆角 4 档 --r-*、阴影 3 档 --sh-1/2/3(暗色整套覆盖)、间距 --sp-1..5、
  卡片内边距单列 --card-pad(与折叠卡片标题行的负 margin 绑在一起,改一个即改两处)。
- 门禁 TestNoBareFontSizePx:静态扫 app.css 与全部模板,出现 font-size: <数字>px 即红。

B 卡片分主次
- .card--hero / .card(默认)/ .card--quiet 三档。quiet 走「页面底色 + 细边 + 无阴影」——
  第一版拿 --bg-2 当底色,实测它与页面底 #f6f7f8 只差 4/255,看不出边界、像破了个洞。
- 首屏 KPI 从「4×2 等重白盒」改成 .kpi-lead(主数 + 一行上下文)+ 5 个常规指标,8 个数字一个没少。

C 图表说人话
- 月度分布:柱底加基线;峰值柱不透明拉满、柱顶标次数(.pv),副标题里不再重复写「最高 N 次」。
- 一天中的分布:把「0/6/12/18 四个数字」换成 6 小时一段的标尺(深夜/上午/下午/晚上),
  用同一套 24 列网格,段边界与柱子严格对齐。
- 首页月度图同样加峰值锚点(给 HomeData 补 MaxMonthly,与统计页同口径 maxOf)。
- 空态没动:查下来已经统一(8 页共用 emptystate、局部走 .empty)。

D1 类型色语义化(结论与计划不同,按实测改了)
- 先算对比度:白底上 yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57,
  6 个色连图形对象 3:1 都到不了;暗底只有 indigo 2.90 不够。
- 所以**没有**把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是文字色,只会更难读。
  这也解释了全站为什么一直只用 8px 小圆点做身份标记。浅色那 6 个色一个没动:
  它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 实际做的:色值收口成 --cat-* / --cat-*-soft(26 个变量);模板 colorof → csscolor / csssoft(19 处);
  暗色只提亮 indigo(2.90 → 3.55:1);colorOf 成了死代码,删掉。
- ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 过滤器不认识 var(),普通 string 会被静默
  换成 ZgotmplZ —— 页面上所有圆点当场变透明,而且不报错、模板测试照过。挂了它等于关掉那道过滤,
  所以数据侧色值先过严格校验(只有 # + 6 位 hex 放行),否则 #"><img… 能逃出 style 属性。
  守卫 TestCSSColorSurvivesTemplateSanitizer 把两个方向都钉住(做过变异验证)。

D2 窄屏导航
- 改前 ≤620px 靠横向滚动兜底:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴后面的按钮,
  渐隐提示实际看不出来。现在链接本体抽成 {{define "navitems"}} 渲染两遍:桌面 .nav-links 平铺、
  窄屏 .nav-menu(原生 <details>,不靠 JS 也能开合);JS 只补点外面 / Esc / 点链接后收起。
- ⚠️ 顶栏 --z-nav 抬到 1200:菜单是 .nav 的子元素、自己抬不动层叠上下文,不抬就会被地图页的
  --z-map-overlay(1100) 盖住。已在 /map 上用 elementFromPoint 实测。
- 守卫 TestNarrowNavCollapsesToMenu 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。

途中发现并一并修掉的(都只有量 DOM 才看得见)
- Leaflet 的 .leaflet-container 字号是 0.75rem、跟着根字号漂:根 14 → 13px 后地图署名/比例尺
  掉到 9.8px(低于本站 11px 下限)、弹窗正文也跟着缩。已按自己的字阶钉死。
- h1..h4 原来只设了字重与 margin、没设字号,靠浏览器默认的 2em/1.5em/1.17em —— 同样跟着根字号漂:
  /stats 那两个 h2 从 21px 变成 19.5px,比同页卡片标题大一圈。已显式给出字号。
- <b>/<strong> 浏览器默认 700,比全站最高层级(标题 600)还重,已钉成 600。

回归
- ./scripts/verify.sh:PASS=348 FAIL=0 SKIP=0
- internal/fog 加 -race:PASS=22 FAIL=0 SKIP=0
- JS 单测 34/34
- 移动端 320 / 390 溢出全 0(改前 /admin 在 320 还溢 34px);桌面 1440 与改前一致(−15 是滚动条宽)
- 全站 7 个页面都没有低于 11px 的文字

说明:A/B 与 C/D 本想拆两笔提交,但 app.css / layout.html / view.go 里两批改动交织,按文件拆不开、
按 hunk 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
2026-10-09 17:01:08 +08:00
tamakyi d4e4257a23 启动模板自检 + 站长导航放行 + 统计脚本关口收紧
排查:/admin/settings 渲染到一半就断
- 现象:页面在「统计脚本」那张卡片处截断,没有保存按钮、没有 </form></html>,而 HTTP 仍是 200。
- 根因:跑的是旧二进制(改动前编译),磁盘上的模板却是新的。debug=true 下 pageSet()
  每次渲染都从磁盘重解析模板,于是新 settings.html 引用了旧 setting.Settings 里没有的
  TrackEnabled —— 执行期报错,而 render 只把错误写进日志,客户端只看到半张页面。
- 判据:strings <二进制> | grep -c cleanTrackScript(旧 0 / 新 2)。别用 strings | grep
  TrackScript —— config 里本来就有这个名字,旧二进制也命中,是个假信号。

启动自检(防复发)
- template_smoke.go:pageProbe 给 24 个页面各配一份最小可用数据,New() 里全部渲染一遍,
  任一页失败就拒绝启动。pageProbe 与 pageFiles 双向校验,新加页面必须补探针,
  漏配按失败处理(不是省事)。
- smokeOne 用 recover 兜住方法里的 panic,报「哪一页坏了」而不是崩栈。
- 实测:往 settings.html 插一个不存在的字段 → 启动即报
  「模板自检未通过…admin/settings: can't evaluate field …」,退出码 1。
- 门禁 template_smoke_test.go:一条跑全量自检,一条变异验证(探针换成字段更少的类型
  必须报错且点名该页)。

站长导航被页面开关误伤
- guardPage 只拦访客(站长始终能访问全部公开页),但导航写的是 {{if .Settings.HasPage "x"}},
  没有 IsAdmin 分支 ⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
  改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
- 门禁 nav_visibility_test.go:三向不变量(站长仍有全部入口 / 访客一个都没有 /
  全开放时访客也看得到),做过变异验证。
- 访客侧「只有主页」是按后台配置应有的状态,没有动数据。

统计脚本关口收紧(cleanTrackScript)
- src= 只在 <script …> 开标签内部找。原来对整段做 strings.Index,脚本体里一句
  `img.src=u` 就被命中、整段被判危险而清空 —— 页面照样提示「设置已保存」,站长看不出没挂上。
- 开闭标签从「只拒 shut > open」改成严格配平:末尾未闭合的 <script> 会把页面收尾整段吞进脚本。
- 不再静默:签名改成 (script, warn),说明码经 ?warn= 回显成人话;判为危险时整段不采纳、
  库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
- 门禁 TestCleanTrackScript 重写:逐条边界 + 定义性不变量(采纳了非空结果时,
  里面开闭标签数必定相等)+ TestTrackWarnMessage(未识别码不得回显)。变异验证做过。
- 文案订正:settings.html 的「conf/app.ini 的 track_script 会作为这里的默认值」有歧义 ——
  它不回填文本框,而是「这个框留空时、注入那一刻才回落到 ini」。

其余
- bin/ 目录删除(15 个 exe / 412MB),二进制只留仓库根 rond.exe;
  技能 rond-web-verify 与 memory details 十八里 bin/rond.exe 的写法同步改掉。
- 验收:./scripts/verify.sh 全绿且 0 skip(PASS=346 FAIL=0 SKIP=0)。
2026-10-09 11:06:41 +08:00
tamakyi 4015ddf373 时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(flatpickr allowInput 的手输体验)
- flatpickr 把 <input type=datetime-local> 就地改成 type=text,原生分段编辑器被替换掉;
  allowInput 又是「整串解析」语义:实测 parseDate('2026') 只出年份、'202666' 才出月份,
  于是「年份很长才跳下一位」;'20260606' 还会被静默错解成 2030-12-06 且不报错。
- 新增 static/js/datetime-input.js:自定义 parseDate(纯数字只收 4/6/8/12/14 位,
  歧义与非法日期一律拒)+ 输入引导(段满即补分隔符,敲完 4 位年份立刻跳月份位)。
  按标准格式不做自动补零、不前滚;越界值原样保留交报错,不静默改成合法值。
- 三处页面(编辑/照片/专题)共用 attachPicker,配置与原先一致,模板净减 5 行。

弹窗
- window.prompt 会把提示文本与默认值写进地址栏并留进历史(confirm/alert 无此问题)。
- 新增 static/js/prompt-box.js 替代:原生 <dialog>.showModal(),遮罩/Esc/焦点齐全,
  旧浏览器降级成页面顶部一条。已换 report.html 复制链接兜底与 edit.html 活动改名。
- 门禁 no_prompt_test.go:静态扫模板禁止 prompt(,并钉住 JS 与 CSS 都在。

统计脚本
- 原来只能改 conf/app.ini(要重启),现在后台「站点设置」可开关 + 填写,默认关闭。
- cleanTrackScript 挡住注入面:开闭 <script> 标签数不配平(提前闭合会让页面散架)、
  src 的 javascript:/data: 伪协议、超长截断。注意不能用「出现 </script 就拒」当规则——
  百度统计、Umami 贴的就是完整 HTML,正常用法会被挡死。

门禁
- datetime-input.test.js:34 条断言(node 直跑,无依赖)。
- TestCleanTrackScript:逐条边界 + 定义性不变量(任何输入都不得产出含 </script 的非空结果)。
- 三项均做过变异验证,改坏实现都会被对应测试抓到。

实测:真实站点逐键 2→20→202→2026-→2026-06-→2026-06-06T;
统计脚本六个场景(默认关/只填不开/开+URL/后台与登录页不挂/危险输入/关掉恢复)全部符合预期。
全量 internal/web 204 例 0 skip,其余包全绿,gofmt 干净。
2026-10-08 18:27:59 +08:00
tamakyi 5c7cf1a0e1 对访客公开的措辞按身份分支:访客不该看到「我的结论」
用户发现未登录也显示「推荐 & 踩雷(我的结论)」,访客没法解读「谁的我的」。
顺着把访客可见模板与 static/js 全过了一遍,同病共四处——都是**写模板时站在站长视角下笔、
而那一块又对访客公开**:

1. stats.html:标题 → {{if .IsAdmin}}我的结论{{else}}站长结论{{end}};
   副标题「只统计你自己…」→ 站长「你自己」/ 访客「站长」;两列「我推荐/我踩雷的地点」同理。
2. stats.html 访客块:「与我的结论互不影响:你标你的,访客投访客的」——对访客说不通,
   改成对两边都成立的「与上面的站长结论互不影响:各存各的表,谁也不会覆盖谁」。
3. home.html:「我的标签」→「足迹标签」(同级卡片全是中性的:足迹地图/城市足迹/天气足迹…)。
4. report.html:「这一年里,你第一次踏足的这些城市」→ 去掉人称(年报是站长的回顾)。

已经写对的照它学:place.html 的投票区早就是 {{if .IsAdmin}} 分支(站长「我的结论」、
访客「大家怎么说」),rond-map.js 的气泡同理;「含家/工作」chip 与一串「只有你自己能看到」
本来就在站长分支里。

门禁 TestVisitorPagesUseThirdPersonCopy:以**访客 ctx(无 user)**渲染 /、/stats、/report,
逐个禁用词断言,同时断言站长那份**仍旧说「我的」**(别把站长自己的视角也改掉);
前置需打开 marks_public(访客才看得到那一块),用完还原。

⚠️ 变异验证时踩了老坑:直接 `go test -run …` 因缺 ROND_TEST_CONF/ROND_SRC 会**静默 skip 并报 ok**
(0.17s 就 ok,一眼假绿)。改用 ./scripts/verify.sh 后变异被正确抓住
(visitor_copy_test.go:85 报「我的结论」),还原后转绿。

全量验收 PASS=339 FAIL=0 SKIP=0。
2026-10-01 02:15:49 +08:00
tamakyi ff46a9cbb8 统计页把「我的结论」与「访客点赞」拆成两块
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。
他看到的没错:那张卡的**列表全是站长结论**(verdictPlaces = place_votes),访客票只在副标题里
露了个总数(👍 N · 👎 M)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。

- 新增 votes.TopVisitorVoted:按访客票数列地点榜。HAVING 只算**至少有一票的那个方向**,
  否则 0 票的地点会把榜塞满;同票数按 visit_count DESC, p.id 排,刷新之间顺序不跳。
  票按坐标键挂,所以 join 回当前数据集的地点才定位得到(与 Tallies/verdictPlaces 同一口径)。
- 「推荐 & 踩雷(我的结论)」只讲站长,副标题只留「推荐 N · 踩雷 M」,标题与列表都改成「我」的口吻。
- 新增「访客点赞」卡:副标题总票数,两列列 👍/👎 票最多的地点并带票数,各有空态。
- 加载拆成两个 pageJob(verdicts / visitorvotes)并行,两块互不依赖就不该串着等。
- 两块共用同一个可见性开关(IsAdmin || Settings.MarksPublic),与地点页投票区一致。

用例(stats_votes_split_test.go)按卡片标题切出两段 HTML,断言「我标的地点只出现在站长块、
访客顶的地点只出现在访客块」,并核对两边各自的计数——比单看总数更能证明没串台。

⚠️ 又一次同一个坑:两张票表都按坐标键读、与数据集无关,place_visitor_votes 还没有 user_id、
不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。新增 isolateVoteState 按 place_key
清两张表(前置 + cleanup 各一次)。

顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。
全量验收 PASS=338 FAIL=0 SKIP=0。
2026-10-01 01:47:48 +08:00
tamakyi 4c3032cf48 防复发:把「删数据集带走站点自建数据」做成结构性门禁 + 常驻体检
用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:

1) 归属铁律(写进 schema.sql 抬头 + README 新章节)
   判断标准只有一句:**这份记录能不能从备份重新导入?**
   能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可挂
   `REFERENCES datasets(id) ON DELETE CASCADE`;
   不能(place_images/place_notes/place_votes/topics)⇒ 只留 dataset_id 当出处、**不许挂外键**。

2) 两道门禁(dataset_ownership_test.go),都做了变异验证:
   - 运行时:查 pg_constraint,凡挂 datasets 的表必须在白名单里;**反向也查**
     (白名单里的表不许被顺手摘掉,否则删数据集会留成片孤儿行)。
   - 静态:扫 schema.sql 的 CREATE TABLE。只看运行时有盲点——新建的库走 CREATE TABLE,
     开发库可能压根没跑过那次迁移。变异(把 topics 的 CASCADE 补回去)当场红。
   - 变异验证顺带发现:扫描必须先跳过 `--` 注释行,规矩的说明文字里就写着
     `REFERENCES datasets(id)`,不跳过会把自己写的文档当成违规。
   - 还发现运行时那道单独看**不够**:夹具每次跑迁移,迁移自己就把坏外键摘了,
     它永远不会在不健康的库上报警 ⇒ 补了「注入事故原样的外键再跑迁移,断言被摘掉」
     的自愈用例(并先确认变异真的生效,防假绿)。

3) 常驻体检:后台首页显示「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 >0 直接报警。
   事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**,等照片看不见了才追。

自愈用例又抓出一个此前漏掉的缺口:place_notes / place_votes 上的同类外键迁移并不会摘
(原先只写了 place_images 与 topics 两句 DROP)⇒ 补成五张自建数据表全覆盖。

⚠️ 排序坑(本项目第二次踩):摘外键那段最初放在 place_images 建表**之前**,
开发库毫无症状、**全新库起不来**(ALTER 报「关系不存在」),被
TestFreshDatabaseServesAppQueries 抓住。整段已挪到 place_images 之后,并把坑写进注释。

顺带订正「删除数据集」的二次确认文案(专题改完之后它还在说专题会一起删)。
全量验收 PASS=336 FAIL=0 SKIP=0。
2026-10-01 01:15:04 +08:00
tamakyi 6b665b6056 图片恢复:坐标来源全部合并,认不出时说清已查过哪些来源
真数据实测(扫描 40 个对象、只挂回 7 张)暴露了第一版的漏洞:
报错「数据集 #1 里没有编号 389 的地点」——来源能读出来,却不是上传那会儿的编号空间。

根因:srcPKCoords 是「第一个能读到的来源就返回」,而**换基线会覆盖 baselines/<id>.sqlite**,
当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 .bak 留档里。
教训很直白:**「能读出数据」不等于「读对了那一版」**。

改了三处:
1. 来源全部读入合并(先读的优先):库里 places → entity_raw 里 ZLOCATION 的整行留档
   (站点上删掉的地点,places 没了、留档可能还在)→ baselines/<id>.sqlite 及其每一份 .bak-*。
   读不动的文件跳过换下一个,不再「第一份成功就返回」。
2. 同一个编号保留多组候选坐标,逐个试到能在当前数据集里对上为止
   (站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出)。
3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条按**数据集归并**上报
   (「数据集 #1:31 张(编号 389、390…),已查来源 …」),不再刷「另有 31 张」;
   完整清单(前 30 条)进服务端日志。

新增用例:当前底座缺编号、换基线留档里才有(正是用户遇到的形状)⇒ 必须救得回;
以及「真的认不出」时要带出已查来源。全量验收 PASS=332 FAIL=0 SKIP=0。
2026-10-01 00:11:04 +08:00
tamakyi 770e752a7a 图片恢复支持对象存储(R2);专题也改成归人不归数据集
R2 上的图怎么救:库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
给 internal/media 加了 ListObjects(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 路径式访问、请求只到桶;查询串必须**先百分号编码再签名**(canonicalQuery 按原样取
  RawQuery,两边得是同一份字节,prefix 里的 / 要写成 %2F)。
- ⚠️ R2 的 API Token 默认只给对象读写,没有 s3:ListBucket 就 403。列桶失败**不让整个
  恢复失败**:本地那半照救,原因进 Warnings、页面用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录 ∪ 桶里 .webp(同键以本地为准);行的 storage 按「对象实际在哪」写,
  bytes 用桶里报的 Size。另加一条 warning:挂回了却没配 s3_public_base ⇒ 行有了页面空白,
  这比「没恢复」更难懂。

专题(用户拍板「一并做」):topics.dataset_id 同样带 CASCADE,删数据集带走专题,而专题
只存在这张表里、没有恢复来源。外键摘掉,所有查询 dataset_id → user_id(loadTopics、
topicPage、保存/删除/开关、pageBase.HasTopics);**内容仍照当前生效数据集算**
(topicContent 不动)——变的是「这份定义归谁」。Dataset 补 UserID 供前台按属主定位
用户级数据;管理专题不再要求有生效数据集。

导出链路未改动:place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出,
全量验收(含导出保真、ZRAW 夹取、编号对齐那批)PASS=331 FAIL=0 SKIP=0。
2026-09-30 23:31:17 +08:00
tamakyi 496c992e5e 图片不再随数据集删除:加「图片挂载恢复」,place_images 摘掉外键
用户报「之前上传的照片找不到了,应该是因为清了应用数据换基线」。查下来与 App 侧无关,
是站点自己两套口径打架:

- place_images.dataset_id 带 ON DELETE CASCADE,而读取按 place_key 坐标键、跨数据集。
- 于是删掉旧数据集时图片**行**被级联删了,**文件却留在 media 目录里**。
- 实测(本机):datasets 只剩 155 号、place_images 0 行、盘上 4 个 .webp 好端端躺着。
  同一批附属数据里备注(17)与结论(3+1)因为早就迁到坐标键,毫发无损——只有图片倒霉。

三件事:

1) 恢复:后台首页「图片挂载恢复 → 扫描并恢复」(POST /admin/images/recover,
   internal/web/image_recovery.go)。只插行、不删文件、不看数据集,按 object_key 判重可反复点。
   认地点两处来源:数据集还在就查它的 places;数据集已删就读它留下的 baselines/<id>.sqlite
   还原「地点号 → 坐标」。对象键两种历史布局都认(<ds>/<YYYYMM>/<srcPK>-<hash>.webp 与
   <ds>/<srcPK>/<hash>.webp);判据先看文件名有没有 `<数字>-` 前缀,再看中间目录——
   地点号也可能刚好 6 位,只看位数会认错。

2) 防复发:CREATE TABLE place_images 去掉外键 + 幂等 DROP CONSTRAINT;删图接口不再按
   dataset_id 过滤(数据集删了也得能删图);「删除数据集」的二次确认文案改成实话。

3) 报告同类陷阱(未改):topics 仍带 CASCADE,专题随数据集一起删且没有恢复来源。

测试踩到的坑:图片「按坐标键跨数据集读」+「按 object_key 判重」都不看数据集,用例之间会
互相污染两处——盘上文件(报「应当 2 张、实际 10 张」)与库里的行(Already:1 让兜底分支
根本没跑到,用例差点假绿)。加 isolateImageState(换空 DataDir + 按 place_key 清历史行),
并给夹具补收尾清理(外键没了必须自己清)。

新增用例 4 条;验收:./scripts/verify.sh 全量 PASS=323 FAIL=0 SKIP=0。
2026-09-30 19:45:51 +08:00
tamakyi 52a82631da 换基线:说清「新底座里多出来的到访不会进导出包」,并把它钉成用例
写文案时去核这条,先把一条写错的断言纠正了:导出条数本来就不等于站点条数。

tableWriter.finish() 是两段:先删「基线里有、PG 已没有」的行(known − seen),
再把「导入时进不了 PG 但留档还在」的行(w.skipped × raws)按原样补回——否则
「导入→导出」一趟会凭空少数据。实测 823 条到访的数据集导出包里是 824 条,差值正常。
(第一版成功路径用例断言「导出 == 站点」,红在 824 != 823 就是这个原因;已改成更锋利的
「换底座前后导出包里 ZVISIT 的 UUID 集合逐条一致」。)

同一段代码定下了换基线的语义:新底座里站点没有的行会被清掉(进 known 没进 seen)。
所以换一份更新的手机包**不会**把手机新记录带进导出包,要带得先走「分析差异」合并。
页面提示与 flash 文案都写明了这点,并新增用例
TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave 钉住它(站内补一条到访 → 导成包 →
从站内删掉 → 换底座 → 断言导出里既没有那条的派生 UUID、UUID 集合也与换之前逐条一致)。

验收:./scripts/verify.sh 全量 PASS=319 FAIL=0 SKIP=0。
2026-09-30 19:15:48 +08:00
tamakyi 6ff6f944a1 换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /admin/baseline(后台首页加入口)就是给同一份数据的底座换代用的。

三道闸,顺序不能挪:
1. 重合判据:新包里的到访 UUID 与站点侧带 UUID 的到访比。一条都对不上直接拒
   (连 force 也不给过)——干跑只验结构,一份不相干的包照样导得出,但换下去站点
   每行都在新底座里认不出身份,全按新行插到最大编号之后,真机上就是「这些到访
   全变成新的」。重合率不到一半要显式确认;判据没过不当场报错了事:包已经收进
   uploads,页面给一个「我确认,强制替换」按钮,不必重传几十 MB。
2. 干跑导出:底座缺表/缺实体编号在这步挡住,不会出现「换完了才发现导不出、
   原来那份已经不知去哪」。
3. 旧底座留档 + 记档,换错了可一键回滚。

两个坑:
- 留档名不能只用秒级时间戳。「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把
  刚生成的留档覆盖掉,于是回滚回了个寂寞(TestRollbackBaselineRestoresPrevious 当场红)。
  改用 baselineBackupName():20060102-150405 + 冲突自增。
- ensureBaseline 必须先看记档。只认 datasets.sha256(= 最初那份包)的话,底座一丢就静默
  重建回旧包,上一次换基线白做且毫无提示。抽出 ensureBaselineTo(ctx, dsID, dst) 共用;
  「重建」入口写临时文件再 rename,不做破坏性删除。

回归用例 7 条(internal/web/baseline_swap_test.go):成功路径断言「换底座前后导出包里
ZVISIT 的 UUID 集合逐条一致」——编号会换、身份不该换,谁在底座里没对上立刻亮;
记档优先重建带反证(清掉记档后同一条路必须走不通);部分重合需确认、完全对不上 force
也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、页面渲染到底。

验收:./scripts/verify.sh 全量 PASS=318 FAIL=0 SKIP=0。
2026-09-30 19:00:18 +08:00
tamakyi 11e00a5b60 记忆:记下本次抽出的跨项目技能 workbuddy-memory-versioning 2026-09-30 18:32:14 +08:00
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