From 693ee82ab8eee39a3bae62cbc30eb58668bb2692 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sun, 20 Sep 2026 12:22:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E3=80=8C=E5=A1=AB=E4=BA=86=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E5=8D=B4=E8=AF=B4=E8=BF=98=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=90=8D=E3=80=8D=EF=BC=8C=E5=B9=B6=E7=BB=99?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E6=8B=BC=E9=94=99=E6=8B=A6=E4=B8=80=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报:把某行的导出值改成 MKPOICategoryCarRent、又手填了显示名「租车」, 保存成功、框里也回填了,页面却仍挂着「还没有中文名」。 两件事叠在一起: 1. 判断错了。Missing 只看内置表,没看有没有自定义覆盖 —— 填过了还照样提示。改成 categoryLabelMissing(v) && 没有覆盖。 补 TestCategoryLabelMissingRespectsOverride,并做变异验证(去掉那半个条件即报错)。 2. 值本身是拼错的。MKPOICategoryCarRent 不是真机的枚举,正确是 MKPOICategoryCarRental(少了个 al)——站内看不出任何异常,但导回手机后 那个地点显示不出类别。这正是加「显示名」时提醒过的风险,所以补一道防线: 编辑页把内置枚举名渲染给脚本,往 poi_category 写值的两个入口 (类别台账的「改名」、地点行内编辑的「自定义类别」)都过 okEnum(), 不在已知枚举里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」。 中文值和已知枚举不打扰。 另外:这次用脚本往模板写 JS 时,\n 被多转义一层成了真换行,JS 单引号字符串 含裸换行 ⇒ 整块 之间的内容 +} + +// scriptBlocks 抠出每个 ") + if end < 0 { + break + } + start := line + out = append(out, scriptBlock{line: start, text: rest[:end]}) + line += strings.Count(rest[:end], "\n") + strings.Count("", "\n") + 1 + s = rest[end:] + } + return out +} + +// unterminatedString 找「单/双引号字符串里出现裸换行」的第一处。 +// 跳过 // 行注释、/* */ 块注释、反引号模板串,以及 Go 模板动作 {{…}} +// (动作里可能带引号,不该被当成 JS 字符串的边界)。 +func unterminatedString(js string) (scriptBlock, bool) { + var ( + quote byte // 当前所在字符串的引号,0 表示不在字符串里 + tick bool // 反引号模板串 + blockC bool // /* */ 里 + action int // 处于 {{…}} 里的深度 + line = 1 + ) + for i := 0; i < len(js); i++ { + c := js[i] + if c == '\n' { + line++ + if quote != 0 { + from := strings.LastIndex(js[:i], "\n") + 1 + return scriptBlock{line: line, text: js[from:i]}, true + } + continue + } + if blockC { + if c == '*' && i+1 < len(js) && js[i+1] == '/' { + blockC = false + i++ + } + continue + } + if action > 0 { + if c == '}' && i+1 < len(js) && js[i+1] == '}' { + action = 0 + i++ + } + continue + } + if tick { + if c == '`' { + tick = false + } + continue + } + if quote != 0 { + if c == '\\' { + i++ + } else if c == quote { + quote = 0 + } + continue + } + switch { + case c == '{' && i+1 < len(js) && js[i+1] == '{': + action++ + i++ + case c == '/' && i+1 < len(js) && js[i+1] == '/': + nl := strings.IndexByte(js[i:], '\n') + if nl < 0 { + return scriptBlock{}, false + } + line += strings.Count(js[i:i+nl], "\n") + i += nl + case c == '/' && i+1 < len(js) && js[i+1] == '*': + blockC = true + i++ + case c == '`': + tick = true + case c == '\'' || c == '"': + quote = c + } + } + return scriptBlock{}, false +} diff --git a/internal/web/taxonomy_test.go b/internal/web/taxonomy_test.go index d3e0e85..8faf56b 100644 --- a/internal/web/taxonomy_test.go +++ b/internal/web/taxonomy_test.go @@ -277,3 +277,44 @@ func TestCategoryLabelOverridesEverywhereThenResets(t *testing.T) { t.Error("恢复默认后不该再出现自定义名") } } + +// 填过显示名之后就不该再标「还没有中文名」——判断得看覆盖,不能只看内置表。 +// (2026-09-20 用户把「租车点」改成拼错的 MKPOICategoryCarRent、又手填了显示名, +// 页面却还挂着「还没有中文名」,就是这么来的。) +func TestCategoryLabelMissingRespectsOverride(t *testing.T) { + const val = "MKPOICategoryCarRent" // 真机没有这个枚举,内置表自然也没有 + f := newMovementFixture(t) + f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category) + VALUES ($1,692021,'显示名测试点',30,120,$2)`, f.dsID, val) + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692021`, f.dsID) + f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value=$1`, val) + }) + body := func() string { + rec := httptest.NewRecorder() + f.srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx)) + if rec.Code != http.StatusOK { + t.Fatalf("渲染失败: %d", rec.Code) + } + return rec.Body.String() + } + + if b := body(); !strings.Contains(b, "还没有中文名") || !strings.Contains(b, "填一个中文名") { + t.Fatal("既不在内置表、也没填过,应当提示去补一个中文名") + } + if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{ + "value": {val}, "label": {"租车"}, + }); got["custom"] != true { + t.Fatalf("应报告已自定义,实际 %v", got) + } + b := body() + if strings.Contains(b, "还没有中文名") { + t.Error("已经填过显示名了,不该再说「还没有中文名」") + } + if !strings.Contains(b, "已自定义") { + t.Error("填过显示名之后应当标成「已自定义」") + } + if !strings.Contains(b, `value="租车"`) { + t.Error("显示名框应回填已保存的名字") + } +} diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index d118737..40a03aa 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -1339,6 +1339,41 @@ }); }); + // 内置表认得的类别枚举。改「导出值」时拿它比对:真机只认它自己那套枚举名, + // 拼错的串(比如 CarRent 少了 al)站内看不出任何问题,导回手机后那个地点就显示不出类别。 + var KNOWN = [{{range .KnownCategories}}"{{.}}",{{end}}]; + + function enumTypo(v) { + if (KNOWN.indexOf(v) >= 0) return null; + for (var i = 0; i < KNOWN.length; i++) { + var k = KNOWN[i]; + if (k.indexOf(v) === 0 || v.indexOf(k) === 0) return k; // 「你是不是想写这个」 + } + return ''; + } + + // 要往 places.poi_category 写一个没记录过的枚举时拦一下。返回 true 表示可以继续。 + function okEnum(v) { + if (!/^MKPOICategory/.test(v)) return true; + var near = enumTypo(v); + if (near === null) return true; + var tip = near ? '\n\n你是不是想写「' + near + '」?' : ''; + return window.confirm('「' + v + '」不在已知的类别枚举里。' + tip + + '\n\n真机只认它自己那套枚举名:写错的串在站内看着一切正常,' + + '但导出回手机后这个地点就显示不出类别了。'); + } + + // 地点行内编辑里的「自定义类别」写的是同一列、同样的坑,用同一套提醒。 + // 用捕获阶段监听:地点那句「批量改到访活动」的确认也走捕获,两边互不影响。 + document.addEventListener('submit', function (e) { + var f = e.target; + if (!f || !f.querySelector) return; + var sel = f.querySelector('select.cat-select'); + if (!sel || sel.value !== '__custom__') return; + var box = f.querySelector('.cat-custom input'); + if (!okEnum(box ? (box.value || '').trim() : '')) e.preventDefault(); + }, true); + document.querySelectorAll('#cat-rows tr').forEach(function (tr) { var input = tr.querySelector('input[name="cat-value"]'); var from = tr.dataset.value; @@ -1346,6 +1381,7 @@ var v = (input.value || '').trim(); if (!v) { toast('类别不能为空;要摘掉请用「清空」', true); return; } if (v === from) { toast('类别没变', true); return; } + if (!okEnum(v)) return; post('/admin/edit/category/rename', { from: from, to: v }, function (d) { reload('已把 ' + count(tr, '地点') + ' 个地点的类别改成「' + (d.label || v) + '」'); }); diff --git a/internal/web/view.go b/internal/web/view.go index 44bdd45..9f64dc9 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -753,6 +753,8 @@ type EditData struct { PlaceOptions []EditOption VisitOptions []EditOption // 最近到访(值=src_pk),供行程/天气表单选关联 Categories []EditOption // 已有地点的类别(去重),供新建时下拉直选 + // KnownCategories 是内置表认得的类别枚举名,页面改「导出值」时拿它做提醒 + KnownCategories []string // ActivityRows / CategoryRows 是顶部「活动与类别」卡片用的台账(带用量) ActivityRows []EditActivity CategoryRows []EditCategory