# 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 单引号字符串含裸换行 ⇒ **整块 `