修「填了显示名却说还没有中文名」,并给枚举拼错拦一道
用户报:把某行的导出值改成 MKPOICategoryCarRent、又手填了显示名「租车」, 保存成功、框里也回填了,页面却仍挂着「还没有中文名」。 两件事叠在一起: 1. 判断错了。Missing 只看内置表,没看有没有自定义覆盖 —— 填过了还照样提示。改成 categoryLabelMissing(v) && 没有覆盖。 补 TestCategoryLabelMissingRespectsOverride,并做变异验证(去掉那半个条件即报错)。 2. 值本身是拼错的。MKPOICategoryCarRent 不是真机的枚举,正确是 MKPOICategoryCarRental(少了个 al)——站内看不出任何异常,但导回手机后 那个地点显示不出类别。这正是加「显示名」时提醒过的风险,所以补一道防线: 编辑页把内置枚举名渲染给脚本,往 poi_category 写值的两个入口 (类别台账的「改名」、地点行内编辑的「自定义类别」)都过 okEnum(), 不在已知枚举里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」。 中文值和已知枚举不打扰。 另外:这次用脚本往模板写 JS 时,\n 被多转义一层成了真换行,JS 单引号字符串 含裸换行 ⇒ 整块 <script> 语法错误、页面交互全哑,而模板解析测试照样通过。 新增 inline_script_test.go 扫所有内联脚本块(现在 31 个),字符串跨行就报错 并指出文件行号,已做变异验证。
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInlineScriptHasNoMultilineStrings 守住「模板里的内联 JS 不许出现跨行字符串」。
|
||||
//
|
||||
// JS 的单引号字符串不能含裸换行;真出现一个,那一整块 <script> 会语法错误、
|
||||
// **整段交互静默失效**——页面没有任何提示,控制台之外看不出来,
|
||||
// 而 TestPageTemplatesParse 照样通过(模板本身是合法的)。
|
||||
//
|
||||
// 2026-09-20 就是这么栽的:用脚本改模板时 `\n` 被多转义了一层,写进去成了真换行,
|
||||
// 当时如果这条测试在,一眼就能看到。
|
||||
func TestInlineScriptHasNoMultilineStrings(t *testing.T) {
|
||||
checked := 0
|
||||
err := fs.WalkDir(tmplFS, "templates", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || filepath.Ext(path) != ".html" {
|
||||
return err
|
||||
}
|
||||
raw, err := fs.ReadFile(tmplFS, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, blk := range scriptBlocks(string(raw)) {
|
||||
checked++
|
||||
if bad, ok := unterminatedString(blk.text); ok {
|
||||
t.Errorf("%s:%d 内联 JS 里的字符串跨行了(第 %d 行起的这段):字符串不能含裸换行,"+
|
||||
"否则整块 <script> 语法错误、页面上所有交互静默失效。\n%s",
|
||||
path, blk.line+bad.line-1, blk.line+bad.line-1, strings.TrimSpace(bad.text))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历模板失败: %v", err)
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("一个内联 <script> 块都没扫到:模板目录或写法变了?")
|
||||
}
|
||||
t.Logf("扫了 %d 个内联脚本块", checked)
|
||||
}
|
||||
|
||||
type scriptBlock struct {
|
||||
line int // 块内容在整个文件里的起始行号
|
||||
text string // <script> 与 </script> 之间的内容
|
||||
}
|
||||
|
||||
// scriptBlocks 抠出每个 <script> 块的内容与它在文件里的起始行号。
|
||||
func scriptBlocks(s string) []scriptBlock {
|
||||
var out []scriptBlock
|
||||
line := 1
|
||||
for {
|
||||
i := strings.Index(s, "<script")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line += strings.Count(s[:i], "\n")
|
||||
j := strings.Index(s[i:], ">")
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
line += strings.Count(s[i:i+j+1], "\n")
|
||||
rest := s[i+j+1:]
|
||||
end := strings.Index(rest, "</script>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
start := line
|
||||
out = append(out, scriptBlock{line: start, text: rest[:end]})
|
||||
line += strings.Count(rest[:end], "\n") + strings.Count("</script>", "\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
|
||||
}
|
||||
@@ -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("显示名框应回填已保存的名字")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) + '」');
|
||||
});
|
||||
|
||||
@@ -753,6 +753,8 @@ type EditData struct {
|
||||
PlaceOptions []EditOption
|
||||
VisitOptions []EditOption // 最近到访(值=src_pk),供行程/天气表单选关联
|
||||
Categories []EditOption // 已有地点的类别(去重),供新建时下拉直选
|
||||
// KnownCategories 是内置表认得的类别枚举名,页面改「导出值」时拿它做提醒
|
||||
KnownCategories []string
|
||||
// ActivityRows / CategoryRows 是顶部「活动与类别」卡片用的台账(带用量)
|
||||
ActivityRows []EditActivity
|
||||
CategoryRows []EditCategory
|
||||
|
||||
Reference in New Issue
Block a user