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 递增那条)")
+ }
+}