From 9c76437a76dd90c2050ecb172d78883bbf4e0c8c Mon Sep 17 00:00:00 2001 From: tamakyi Date: Mon, 12 Oct 2026 18:11:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=EF=BC=9A=E6=98=BE=E7=A4=BA=E5=8C=BA?= =?UTF-8?q?=E5=8D=8A=E5=BE=84=E8=BE=93=E5=85=A5=E6=A1=86=E8=A2=AB=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=8E=9F=E7=94=9F=E6=A0=A1=E9=AA=8C=E8=AF=AF?= =?UTF-8?q?=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:输入任意正常半径(3、3.5、7.25…)提交时弹 「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」。 根因: 的 **step 是相对 min 递增**的,不是「step 的整数倍」。 min=0.05 + step=0.5 ⇒ 合法值只有 0.05 / 0.55 / 1.05 …,于是 3 和 3.5 全被判非法。 而这是**浏览器原生**提示:JS 与后端都拦不到(我们自己的校验根本没机会说话)。 实测对照(同一元素换 step,其余不动): step=0.5 :0.05✓ 1✗ 3✗ 3.5✗ 7.25✗ 13.37✗ step=0.05 :0.05✓ 1✓ 3✓ 3.5✓ 7.25✓ 13.37✗ ← 仍会拦任意小数 step=any :全 ✓ ← 采用 保留 min/max(真实边界,且给 spinner 范围),只把 step 放开交给 JS/后端校验, 理由与写法都记在模板注释里。 同一 bug 类加了通配守卫,防的是下次换个页面又踩: - TestNumberInputStepNeverMismatchesMin:扫**所有**模板的数字框, 凡「有 min 且有数值 step」就要求 min 是 step 的整数倍(或改用 any); - TestTopicAreaRadiusInputAllowsAnyStep:列表里的输入框是 JS 动态建的,模板扫不到, 单独钉住 step='any'。 变异验证过:把 step 改回 0.5,守卫立刻红。 实测(无头):输入 3.5 后 validationMessage 为空、form.checkValidity() 为真 ⇒ 回车提交不再弹气泡;隐藏字段正确更新为 radiusKm:3.5;越界值只给我们的中文提示 (stepMismatch=false、rangeUnderflow=true,说明「step 误判」这一类已消除)。 全站其它数字框(如迷雾遮罩的 radius_m,min=50/step=50)本来就合法,未改动。 --- internal/web/static/js/topic-areas.js | 5 +- internal/web/templates/admin/topics.html | 4 +- internal/web/topic_areas_test.go | 75 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/internal/web/static/js/topic-areas.js b/internal/web/static/js/topic-areas.js index 3fb979c..6900b05 100644 --- a/internal/web/static/js/topic-areas.js +++ b/internal/web/static/js/topic-areas.js @@ -271,7 +271,10 @@ inp.type = 'number'; inp.min = String(MIN_KM); inp.max = String(MAX_KM); - inp.step = '0.5'; + // step 必须 any:它是相对 min 递增的,min=0.05 + step=0.5 的合法值只有 + // 0.05/0.55/1.05…,填 3 或 3.5 会被浏览器判非法并弹原生提示(「两个最接近的 + // 有效值分别为 3.05 和 3.55」)—— 那是浏览器行为,JS 与后端都拦不到。 + inp.step = 'any'; inp.value = String(a.radiusKm); inp.addEventListener('input', function () { areas = setRadius(areas, idx, parseFloat(inp.value)); diff --git a/internal/web/templates/admin/topics.html b/internal/web/templates/admin/topics.html index c29b260..2f10e0d 100644 --- a/internal/web/templates/admin/topics.html +++ b/internal/web/templates/admin/topics.html @@ -37,7 +37,9 @@
+ {{/* step 必须是 any:它是相对 min 递增的,min=0.05 + step=0.5 的合法值只有 + 0.05/0.55/1.05…,用户填 3 或 3.5 会被浏览器判非法并弹原生提示。 */}} +
diff --git a/internal/web/topic_areas_test.go b/internal/web/topic_areas_test.go index d75bea9..4a55154 100644 --- a/internal/web/topic_areas_test.go +++ b/internal/web/topic_areas_test.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "fmt" + "io/fs" "math" "net/http" "net/http/httptest" @@ -497,3 +498,77 @@ func TestTopicsAdminPageLoadsLeaflet(t *testing.T) { "显示区编辑器会静默不出现(脚本在、编辑器在,就是 window.L 是 undefined)") } } + +// ---------- 数字输入框:step 是相对 min 递增的 ---------- +// +// 的合法值**不是** 0.5 的倍数,而是 +// min + n*step = 0.05 / 0.55 / 1.05 …:用户填 3 或 3.5 会被 Chrome 判非法,提交时弹 +// 「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」——这是**浏览器原生**提示, +// JS 与后端都拦不到,只能在源头避免(2026-10-12 专题显示区的半径输入框就是这么踩的)。 +// +// 规则:要么 step="any"(随便填,交给 JS/后端校验),要么 min 必须是 step 的整数倍。 +// 这条守卫扫**所有模板**里的数字框,不是只盯着出问题那个 —— 否则下次换个页面又会踩。 +func TestNumberInputStepNeverMismatchesMin(t *testing.T) { + tagRe := regexp.MustCompile(`]*>`) + attr := func(tag, name string) string { + m := regexp.MustCompile(name + `="([^"]*)"`).FindStringSubmatch(tag) + if m == nil { + return "" + } + return m[1] + } + checked := 0 + for _, dir := range []string{"templates/*.html", "templates/admin/*.html"} { + names, err := fs.Glob(tmplFS, dir) + if err != nil { + t.Fatalf("列举 %s 失败: %v", dir, err) + } + for _, name := range names { + body, err := tmplFS.ReadFile(name) + if err != nil { + t.Fatalf("读取 %s 失败: %v", name, err) + } + for _, tag := range tagRe.FindAllString(string(body), -1) { + if !strings.Contains(tag, `type="number"`) { + continue + } + step := attr(tag, "step") + if step == "" || step == "any" { + continue + } + minRaw := attr(tag, "min") + if minRaw == "" { + continue + } + mn, e1 := strconv.ParseFloat(minRaw, 64) + st, e2 := strconv.ParseFloat(step, 64) + if e1 != nil || e2 != nil || st <= 0 { + continue + } + checked++ + if r := math.Mod(mn, st); r > 1e-9 && st-r > 1e-9 { + t.Errorf("%s 里 min=%q step=%q 是非法组合:step 相对 min 递增,合法值只有 min+n*step "+ + "⇒ 正常输入会被浏览器判非法并弹原生提示。改成 step=\"any\",或让 min 是 step 的整数倍。\n %s", + name, minRaw, step, tag) + } + } + } + } + t.Logf("扫过 %d 个「有 min 且有小数 step」的数字框", checked) +} + +// 编辑器里列表的半径输入框是 JS 动态建的,模板守卫扫不到,单独钉一下。 +func TestTopicAreaRadiusInputAllowsAnyStep(t *testing.T) { + js, err := staticFS.ReadFile("static/js/topic-areas.js") + if err != nil { + t.Fatalf("读取 topic-areas.js 失败: %v", err) + } + src := string(js) + if !strings.Contains(src, "inp.step = 'any'") { + t.Error("topic-areas.js 里半径输入框必须 inp.step = 'any' —— " + + "写死小数 step 会和 min 组合出「正常值被判非法」的原生提示") + } + if regexp.MustCompile(`inp\.step\s*=\s*'[0-9]`).MatchString(src) { + t.Error("topic-areas.js 里半径输入框的 step 别写死数字(见 step 相对 min 递增那条)") + } +}