diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index ecf1626..6557b69 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -8,6 +8,7 @@ import ( "log" "math" "net/http" + "sort" "strconv" "strings" "time" @@ -134,6 +135,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { // 已有类别去重:新建表单、每行编辑的下拉、顶部类别台账都用它(提交值仍是原始标识符); // 顺手带上地点数,台账要按用量排序、也要知道改一下会影响多少行 catNames := s.categoryLabels(ctx) + d.KnownCategories = knownCategoryNames() crows, err := s.db.QueryContext(ctx, `SELECT poi_category, count(*) FROM places WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' GROUP BY 1 ORDER BY 1`, ds.ID) if err != nil { @@ -149,9 +151,11 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { return } d.Categories = append(d.Categories, EditOption{Label: c, Name: categoryLabel(c, catNames)}) + // Missing 是「还得你补一个中文名」:自己是枚举、内置表没有**且**你也没填过。 + // 只看内置表会把已经填好的又标成「还没有中文名」(2026-09-20 用户就这么撞上了)。 d.CategoryRows = append(d.CategoryRows, EditCategory{ Value: c, Label: categoryLabel(c, catNames), Places: n, - Custom: catNames[c] != "", Missing: categoryLabelMissing(c), + Custom: catNames[c] != "", Missing: categoryLabelMissing(c) && catNames[c] == "", }) } crows.Close() @@ -554,6 +558,17 @@ func categoryLabelMissing(v string) bool { return !ok } +// knownCategoryNames 列出内置表认得的枚举名(排序后)。页面改「导出值」时拿它提醒: +// 真机只认它自己那套枚举,拼错的串站内看不出问题,导回手机后那个地点就显示不出类别了。 +func knownCategoryNames() []string { + out := make([]string, 0, len(poiCategoryText)) + for k := range poiCategoryText { + out = append(out, k) + } + sort.Strings(out) + return out +} + // categoryLabels 读站点自定义的类别显示名。读不到就是空 map,各处自动退回内置名。 func (s *Server) categoryLabels(ctx context.Context) map[string]string { out := map[string]string{} diff --git a/internal/web/inline_script_test.go b/internal/web/inline_script_test.go new file mode 100644 index 0000000..ea09511 --- /dev/null +++ b/internal/web/inline_script_test.go @@ -0,0 +1,150 @@ +package web + +import ( + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// TestInlineScriptHasNoMultilineStrings 守住「模板里的内联 JS 不许出现跨行字符串」。 +// +// 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