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