# 2026-09-20
### 补全页:手工填过的值自动进下拉候选
用户报「手工填了之前没有的活动/类别,后面同类地点还得再手工打一遍,效率低」。
根因:下拉候选是**页面渲染时定好的**(活动来自 `activities` 表、类别来自静态表),
本页新填的值在提交前进不了候选。
做法(全部在 `templates/admin/fillup.html` 的脚本里,后端没动):
- `sessionStorage['rond-fillup-newopts']` 存 `{act:[],cat:[]}`;本页文本框里的值 + 从别处带来的值,
一起补进同类下拉,插在**建议之后、真候选之前**(不沉到列表最底下),标成「新填:X」,带 `data-local` 标记
- 本页打字即生效;翻页/「用高德分析」重载也留得住(重载时读回来重新 paint);提交成功后清掉
- 先 paint 再恢复草稿:草稿里选中的可能就是新填的值,选项不在跟前 `select.value` 设不进去
- 草稿保存改成**按 name 合并**(`delete` 掉本页明确清空那些),翻页不再覆盖上一页;分页链接也接了保存
- **重画候选前要留住「这一格已选中的新填值」再还回去**,否则改一下文本框会把别的行选好的值抹成空
**两个坑**:
1. 文本框监听**不能委托到 `document`**:`new Event('input')` 默认**不冒泡**,探针第一次就是这么假绿的。
直接 `box.addEventListener` 更稳,也少一层隐式约定。(用户真实打字是冒泡的,但别赌这个)
2. 验收时我用了**新的** `ROND_CLEAN_OUT` 文件名,`-p 1` 下 `internal/rond` 按字母序**排在 `internal/web` 之前**,
导出文件还没生成 → `TestExportRoundtrip` 等 4 个测试全红。**这是假警报**,
先跑 web 的 `TestMovementCleanExport` 生成文件、或沿用上次的文件名即可。
提交 `ffee082`。
### 数据编辑页加「活动与类别」卡片
用户要求「在数据编辑页添加一个模块,对活动和类别进行更改、删除和新增」。先摸清现状:
- **活动**:`activities` 表,31 条。后端 create/update/delete **三个端点早就有**,
但 UI 只藏在「到访记录」卡片里——要先在下拉选中某个活动,再点按钮弹 `prompt/confirm`,
入口隐蔽、也看不见「哪些活动一条到访都没挂」(实测有「学习」0 条)。
- **类别**:**根本没有表**,就是 `places.poi_category`。16 种值在用(12 个 MapKit 枚举 + 4 个中文)。
候选来源还两套:编辑页/照片页读 `DISTINCT`(只有用过的才出现),补全页读静态表 `fillupCategories()`。
→ 库里真在用的中文类别(一般道路/彩票店/租车点/美食街)在补全页下拉里选不到。这是**已存在的 bug**,记下了。
**用户拍板的选择**(问过才动手):类别**不加字典表**、不预置空字典项,只做批量改名/合并/清空;
活动**要合并**。
实现:
- 新端点 `category/rename`(改成已有值就是合并,同一条 UPDATE)、`category/clear`(写 NULL)、
`activity/merge`(事务内把到访转过去 + 抄目标活动的 `is_home/is_work` + 删源活动 + `pruneEntityRaw`)
- `EditData` 加 `ActivityRows`/`CategoryRows`;编辑页原来那条 `DISTINCT poi_category` 查询
顺手改成 `GROUP BY` 带计数(一份数据两用,不额外查)
- 卡片 `data-card="taxonomy"` 放页首,是**唯一默认展开**的卡片(脚本里给 `taxonomy` 开了小灶)
**自己踩的坑(值得记)**:表我写成 `class="tb-stack"`,漏了配对的 `tb`。
CSS 全是 `.tw > table.tb.tb-stack …`,结果一条堆叠规则都没命中,390 宽下表还是 640px 底宽——
**而且守卫测试没拦住**(它只查 `tb-stack` 在不在),页面也不横滑(横滑在 `.tw` 里滚),
是探针量到「表宽 560 > 卡片宽 362」才发现的。已给 `wide_table_layout_test.go` 补上
「有 tb-stack 必须有 tb」的判据,并做了变异验证(去掉 tb → 守卫报错)。
另外宽度**别用行内 min-width** 写死,堆叠态的 `min-width:0` 会被 inline 压过去。
实测(CDP,全程拦截 fetch 保证不动真实数据):默认展开、31 活动/16 类别行、「家」引用 250、
改名没变→只提示不请求、删除确认带条数、清空确认、合并同一个→拒绝、合并确认带两个名字和条数、0 次请求。
### 补:补全页的类别候选取「在用的 ∪ 静态表」
上一轮记下的「两页候选不一致」用户决定修掉(原话:不然使用时会有异常)。
改动只在 `internal/web/fillup.go`:
- 新增 `(*Server).usedCategories(ctx, dsID)`:`SELECT poi_category, count(*) … GROUP BY 1 ORDER BY 2 DESC, 1`
- `fillupCategories(inUse []fillupCategory)` 现在三段拼:**在用的**(按地点数降序,标签 `中文名(在用 N)`)
→ 真机用过的 12 种(`(真机用过)`)→ 其余常见 MapKit 类别。用 `push` + `seen` 去重,
**同一个值只出现一次**,在用的那份信息更准、顶掉静态说明。
- 标签统一走 `poiCategoryLabel`(MKPOICategoryX→中文;未知枚举去掉前缀;中文值原样返回)。
**实测真实数据**:36 项 = 16 在用 + 18 未用过的静态 +(不动)+(其它「自己写」),零重复。
前几项:餐厅(在用 51)/ 商店(在用 29)/ 公共交通(在用 22)/ 酒店(在用 14)/ 地标(在用 10)/
**一般道路(在用 5)**/ 菜市场(在用 4)…;这个数据集里静态表的 12 种恰好全都在用,所以「真机用过」组为空。
写测试时踩了两次自己的口径错误(都不是产品的错):
1. 数「值出现几次」时把整页数了——**下拉每行渲染一份**,30 行就是 30 次。改成先截出第一个 `` 再数。
2. 断言「餐厅(在用 1)」——**夹具导的是真实备份**,那类别本来就有 51 个地点在用。改成只断言 `value="X">中文(在用 ` 前缀,不钉数字。
### ⚠️ 更正一条错误结论:真机**从没**写过非枚举的类别值
用户问「类别这里部分显示中文、部分显示 MKPOICategoryBeach,直接改会有影响吗」,
顺手把数据核了一遍,结果**推翻了我 2026-09-19 的判断**(那条写在 `knownMKCategories` 附近:
「真机自己也会写中文类别(一般道路/彩票店/美食街/租车点)⇒ 这列本来就是自由文本」)。
我从「库里看到中文值」直接推成了「真机写的」,**没查这些值到底是谁写进去的**。
实测(活跃数据集):
```sql
-- 真机留档里的 ZCATEGORY_ 分布
SELECT COALESCE(NULLIF(r.raw->>'ZCATEGORY_',''),'--empty--'), count(*)
FROM entity_raw r WHERE r.dataset_id=… AND r.entity='ZLOCATION' GROUP BY 1;
-- → 201 空 + 12 种 MKPOICategoryXxx,非枚举值 **0 条**
-- 站内值与留档不一致的地点:**只有 2 个**(公园 1 + 美食街 1)
-- 4 个中文类别全部落在「真机本来就没给类别」的地点上(留档为空,或那行站内新建、根本没有留档)
```
⇒ **结论**:`ZCATEGORY_` 在真机那边就是 MapKit 枚举串;中文值是**站内**(补全页手填 / 高德建议 / 自定义)写进去的。
当时我还把这句错误结论当成依据安抚用户「写中文完全安全」,**这句得撤回**:
写中文不会坏数据(导出侧不校验、站内显示原样),但 **app 那边很可能显示不出类别**,
而且我们**没有任何实测样本**能保证 app 怎么处理未知值 —— 只能说「不损坏」。
**教训**:看到「库里某个值不符合预期形状」时,先分成「谁写的」——查 `entity_raw` 里的留档
(站内新建的行**没有留档**,`raw IS NULL` 就是最强指纹),别把「站内写进去的」当成「真机的行为」。
### 类别「显示名」可维护(枚举 ↔ 中文对照)
用户:「MKPOICategoryBeach 下边标注海滩,我希望能够改,一一对应的或者现在没有对应值的让我来填写补充」。
根因:中文名是 **Go 里硬编码的两张 map**,用户根本改不了;而且两张表还对不上。
**先合并两套中文名**(这次的关键清理):
- `view.go` 的 `poiNames`(38 条,给补全页静态项 + GPX)与 `handlers_edit.go` 的 `poiCategoryText`(33 条,给编辑页)
**冲突 3 处**:FoodMarket 菜市场/食品市场、Cafe 咖啡馆/咖啡厅、FitnessCenter 健身房/健身中心;
还有 `ATM`/`Atm` 大小写各写一份、一条永远匹配不上的死条目 `MKPOICategoryMarina2`。
- 合并成 `poiCategoryText` 一张(42 条,冲突处取编辑页那套),删掉 `poiNames`/`poiName`。
- **连带清掉两个模板函数** `catName` / `poiname`(它们绑在旧函数上)。模板里算不出显示名(要读站点覆盖),
所以改成在 Go 侧算好塞进数据:`FillupRow.CatLabel`、`PlaceData.CategoryLabel`。
**新增可维护层**:
- 表 `category_labels(value PK, label)`,**站点级、不挂 dataset_id**(值本身是稳定键)。
内置表降级为「默认值」,`categoryLabel(v, custom)` 的兜底顺序:自定义 > 内置 > 去掉前缀的原值。
- 端点 `POST /admin/edit/category/label`;**传空 label = 删除覆盖行、恢复默认**(UPSERT/ DELETE 两条路)。
- 类别台账那行改成两个带前缀标签的输入框:`导出值`(会写回手机)+ `显示名`(只影响显示),
外加「保存显示名」按钮;`Missing`(是 MKPOICategory* 但内置表没有)提示「填一个中文名」。
placeholder 分两种:缺名 →「填一个中文名」;否则「留空=默认「X」」。
**必须记住的不变量**:显示名**绝不写进 `places.poi_category`**——那是导出回手机的值,
中文跟着写进去就污染数据了。测试 `TestCategoryLabelNeverTouchesValue` 专门钉这一条。
实测:类别行「导出值=MKPOICategoryBeach / 显示名=空 / placeholder=留空=默认「海滩」」,
点保存发出 `value=MKPOICategoryBeach&label=海边`;390 宽卡内无溢出;访客地点页副标题照常显示「海滩」。
### 修:「填了显示名却还说没有中文名」+ 枚举拼错拦一道
用户报:原来「租车」那行,把导出值改成 `MKPOICategoryCarRent`、再手工添了显示名「租车」,
保存后页面仍显示「还没有中文名」。
**根因 1(真 bug)**:`Missing` 判定只看内置表(`categoryLabelMissing(v)`),
**没看有没有自定义覆盖** ⇒ 填过了还照样提示。改成 `categoryLabelMissing(c) && catNames[c] == ""`。
补了回归测试 `TestCategoryLabelMissingRespectsOverride`,并做了变异验证(去掉那半个条件 → 测试报错)。
**根因 2(用户真正踩的坑)**:`MKPOICategoryCarRent` **不是真机的枚举**,正确是 `MKPOICategoryCarRental`
(少了个 `al`)。站内看不出任何异常,但导回手机后那个地点显示不出类别——正是上一轮提醒过的风险。
**加的一道防线**:编辑页把内置枚举名渲染成 JS 数组 `KNOWN`,`okEnum(v)` 在**写 poi_category 的两个入口**
(类别台账的「改名」按钮、地点行内编辑的「自定义类别」提交)都拦一下:
不在 KNOWN 里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」(双向前缀匹配)。
中文值不拦(不是枚举),已知枚举不拦(返回 true 不打扰)。
实测:普通提交/自定义中文/已知枚举都不拦,拼错的才拦。
**数据现状(用户的数据,等他们决定)**:地点 id=2290 `大理星羽阁精品民宿(古城店)`
(原来是「租车点」)的类别现在是 `MKPOICategoryCarRent`;还有一条孤儿覆盖行
`category_labels.MKPOICategoryCarRent → 租车`。正确做法是把导出值改成 `MKPOICategoryCarRental`
(内置名就是「租车」,覆盖行随即可删)。
### ⚠️ 教训:用脚本改模板时 `\n` 会被多转义一层
我通过 `Bash` + Python heredoc 往 `edit.html` 写 JS 时,写的是 `'\n\n你去…'`,
结果落到文件里变成**真换行** ⇒ JS 单引号字符串含裸换行 ⇒ **整块 `