diff --git a/.workbuddy/memory/2026-10-09.md b/.workbuddy/memory/2026-10-09.md index 3c2bcfb..dbedf27 100644 --- a/.workbuddy/memory/2026-10-09.md +++ b/.workbuddy/memory/2026-10-09.md @@ -217,3 +217,41 @@ at <.Settings.TrackEnabled>: can't evaluate field TrackEnabled in type setting.S + +--- + +## 第三批:日期框(用户报的硬 bug)+ 两处观感 + +**用户报**:① 逐月平均气温图在桌面端很小;② 日期框敲 `20260606` 变成 `202606/06`(要「永远解决」); +③ 类型分布与一天中的分布同行高度不齐、下面一大片空白。 + +### ② 日期框:**实测复现**后定位(写了真实按键工具 `storage/_type.py`) +技能里的 `--press` 只给 Enter 带 `text`,数字敲不进去(Chrome 不执行默认动作),所以单独写了一个 +CDP 打字脚本(每字符 keyDown(text) + keyUp)。用它复现: +- 敲之前 `INPUT|date|`、`attached=false`;敲完 `202606/06`、`value=""` —— 截图为证。 +- **根因**:筛选区的「起始/结束日期」是原生 ``,而挂载代码只写了 + `querySelectorAll('input[type=datetime-local]')` ⇒ **type=date 从没人管**。 + Chrome 的年段允许 >4 位数字(年份上限 275760),8 位被切成 年=202606 + 月=06。 + 原生分段状态读不到、`setSelectionRange` 对 date 类输入直接抛 ⇒ 修不了,只能换控件。 + +**做法**:`datetime-input.js` 加只日期模式(`SPEC.date`:年/月/日,输出 `Y-m-d`)+ **统一入口 +`attachAll(root)`** 按 `el.type` 分派;`attach` 里先把控件换成 `text`(引导会写 `2026-06-` 这种半成品, +原生控件对非法值一律置空)。`datetime-input.js` 改为**无条件引入**(公共页筛选区也要用), +挂载在 body 末尾**立刻**跑一次(别只等 DOMContentLoaded,否则先露原生框再变文本框)。 +三个后台页改用 `attachAll`(顺带覆盖 `mfrom/mto`,且 date 类走 `enableTime:false`)。 + +**验证**:真实按键敲 20260606 → `value="2026-06-06"` ✓;端到端 `from=2025-08-01&to=2025-08-31` +筛出 **186 次**,与月度图峰值月(186)完全吻合 ✓(排除「解析成空导致恒 0」)。 +守卫 `TestDateInputsGoThroughAttachAll`(模板出现按 type 挑的选择器即红;变异验证过)。 + +### ① 气温图:10 个月只用了 1042px 里的 396px +`.temp-col` 固定 36×84。改成宽屏 `flex: 1 1 36px; max-width: 96px` + 轨道 130px + 字号提到 `--fs-sm`; +窄屏保持固定宽 + 横滚(否则月份标签糊成一团)。 + +### ③ 同行卡片高度不齐:grid 默认 stretch +「类型分布」1411px 把「一天中的分布」也拉成 1411px,后者内部一大片空白。 +`.grid { align-items: start }` 后两项各按自然高(256px)。 + +**回归**:verify.sh 348 例 0 skip、JS 单测 **45/45**(新增 11 条逐键模拟)、移动端 390 / 桌面 1440 审计溢出与改前一致。 +⚠️ **纪律(又踩一次)**:验收跑着的时候别并发跑别的(我并发跑了变异测试,verify 当场把注入的旧写法抓成 FAIL —— +守卫是对的,但那轮结果得作废重跑)。这条上次已写进 details 三十二,这次是实锤。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index 65518bf..3f80d08 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -539,6 +539,12 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块 2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉**单独跑 4.1s 就过**(环境抖动,不是代码问题)。 另外这条测试失败时会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` **成功之后**才注册), 跑完记得手动 `DROP DATABASE` 清掉,别当成「库被占用 / 测试有 bug」。 + 同理:**变异实验也别和验收并发**(2026-10-09 实锤:变异期间正好被验收抓到,那轮结果直接作废)。 +- ⚠️ **变异验证「内嵌文件」(`templates/`、`static/`)之后必须 `go clean -cache` 再跑验收**。 + `//go:embed` 的内容虽然参与构建,但「改坏 → 还原」之后构建缓存会继续给出**变异期间编译的那份内嵌内容**: + 2026-10-09 实测同一条守卫 `go test ./internal/web/` 绿、`go test ./...` 红(同一份磁盘文件、同一个测试); + `go clean -testcache` **不管用**(它只清测试结果),`go clean -cache` 之后立刻一致。 + 判据:两种调用方式结果不一致时,先清构建缓存再下结论。**只变异 `.go` 文件没有这个问题**(Go 源码参与缓存键)。 --- @@ -1025,5 +1031,33 @@ HTML,一行里必然带 ``,一拒就把正常用法挡死了(我 - ⚠️ 顶栏 `--z-nav: 1200`,**必须高于 `--z-map-overlay`(1100)**:菜单是 `.nav` 的子元素、 自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(`/map` 上用 `elementFromPoint` 实测验证过)。 +## 四十四、日期控件:原生 `type=date` 也不能用(2026-10-09) + +⚠️ **`` 与 `datetime-local` 一样不可靠。** Chrome 的年段允许超过 4 位数字 +(年份上限 275760),所以敲 `20260606` 会被切成 **年=202606 + 月=06** —— zh-CN 下显示 `202606/06`, +而且 `el.value` 是**空**(非法)⇒ 筛选条件静默丢掉。原生分段状态读不到、 +`setSelectionRange` 对 date 类输入直接抛,所以「修原生」这条路不通,只能换控件。 + +**规矩**: + +- 日期字段一律走 `rondDatetime.attachAll(root)`(在 `datetime-input.js` 里):它按 `el.type` + 自动分派只日期(输出 `Y-m-d`)或带时间(输出 `Y-m-dTH:i`)。 + ⚠️ **不要手写 `querySelector('input[type=...]')`** —— 2026-10-09 的 bug 就是 + 「只挂了 datetime-local,后来加的 type=date 没人管」。 +- `datetime-input.js` **无条件引入**(公共页筛选区也要用;flatpickr 仍按需加载 —— + 没有它时只挂输入引导、不给日历)。挂载要在 body 末尾**立刻**跑一次, + 别只等 `DOMContentLoaded`,否则用户会先看到一个原生日期框再变文本框。 +- 门禁 `TestDateInputsGoThroughAttachAll`:模板里出现 `input[type=date]` / + `input[type=datetime-local]` 这种选择器即红,并钉住「layout 无条件引入 + 四个页面都调 attachAll」。 +- JS 单测:`layoutTo(spec, raw)` 是纯函数,逐键模拟敲 `20260606` 必须得 `2026-06-06` + (`datetime-input.test.js`,node 直跑,45 条)。 + +**同批修的两处观感问题**(都属于「只有量才看得见」): + +- `.grid` 必须 `align-items: start`。grid 默认 `stretch`,同一行里内容少的那张卡会被拉到与最高的 + 一样高,卡片内部留一大片空白(/stats 的「类型分布」1411px vs「一天中的分布」256px)。 +- `.temp-strip` 的柱子窄屏固定 36px + 横滚;**宽屏要 `flex: 1 1 36px; max-width: 96px` 并把轨道加高**。 + 原来固定 36×84,10 个月只占 1042px 里的 396px,右边 600 多像素全空 —— 看着就是「图表很小」。 + diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 06c1373..0709146 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -39,6 +39,7 @@ - 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。⚠️ **新模板+旧二进制**=「渲染到一半仍 200」;启动自检 `smokeRender` 会拒启(四十一)。 - 字阶/圆角/阴影**只走令牌**,裸 `font-size: 13px` 有静态守卫 `TestNoBareFontSizePx`;卡片三档 `.card--hero/--quiet`。⚠️ 改**根字号**要查 Leaflet(`.75rem` 会跟着漂),(四十二)。 - 类型色走 `csscolor/csssoft`(**必须返回 `template.CSS`**,否则 `var()` 被换成 `ZgotmplZ` 且不报错);这套色连 3:1 都不到,**只能当圆点标记、别当文字色**。窄屏导航收进 `.nav-menu`,顶栏 `--z-nav` 必须高于地图浮层(四十三)。 +- 日期控件一律走 `attachAll`(原生 `type=date` 的年段会吞 6 位数字 ⇒ 敲 20260606 变 `202606/06` 且值为空);模板里出现 `input[type=date]` 选择器有守卫。`.grid` 必须 `align-items:start`(四十四)。 - 拼查询串走返回 `template.URL` 的方法(`PlaceLink`/`EditPlaceLink`/`MapLink`/`EditLink`),勿手工转义。 - 窄屏覆盖写 `app.css` **末尾那块**;宽表堆叠类名**成对** `class="tb tb-stack"`;触控 44px。`tb_stack_test.go` - 加表格列:`PlaceCols`/`scanPlace` **成对**、新列追加最后;CJK 挤换行只抬该表 `min-width`。 diff --git a/internal/web/frontend_fixes_test.go b/internal/web/frontend_fixes_test.go index 30f5b85..5a78388 100644 --- a/internal/web/frontend_fixes_test.go +++ b/internal/web/frontend_fixes_test.go @@ -191,6 +191,76 @@ func TestNarrowNavCollapsesToMenu(t *testing.T) { } } +// ---------- 日期输入必须走统一入口 attachAll ---------- +// +// 2026-10-09 用户报的 bug:筛选区是原生 ,而挂载代码只写了 +// querySelectorAll('input[type=datetime-local]') —— **类型变体没人管**,于是 Chrome 的年段 +// 吞下 6 位数字,敲 20260606 变成 202606/06、el.value 还是空的(筛选条件静默丢掉)。 +// +// 守卫三件事: +// +// ① 模板里不许再出现按 type 挑日期框的选择器 —— 那个选择器只能待在 datetime-input.js 里; +// ② layout.html 必须**无条件**引入 datetime-input.js(公共页的筛选框也要用,那边不加载 flatpickr); +// ③ 统一入口 attachAll 必须存在,且四类页面都调它。 +func TestDateInputsGoThroughAttachAll(t *testing.T) { + js, err := staticFS.ReadFile("static/js/datetime-input.js") + if err != nil { + t.Fatalf("读取 datetime-input.js 失败: %v", err) + } + if !strings.Contains(string(js), "function attachAll(") { + t.Fatal("datetime-input.js 里没有 attachAll —— 统一入口没了,以后新加的输入类型又会被漏掉") + } + + layout, err := tmplFS.ReadFile("templates/layout.html") + if err != nil { + t.Fatalf("读取 layout.html 失败: %v", err) + } + lay := string(layout) + // flatpickr 那段是「按需加载」的,datetime-input.js 不能跟着一起被关进去 + if i := strings.Index(lay, "{{if .Flatpickr}}"); i >= 0 { + if j := strings.Index(lay[i:], "{{end}}"); j > 0 { + if strings.Contains(lay[i:i+j], "datetime-input.js") { + t.Error("datetime-input.js 被关进 {{if .Flatpickr}} 了 —— 公共页不加载 flatpickr," + + "筛选区的 就没人改造成文本框,原生那个吞位的老毛病会原样回来") + } + } + } + if !strings.Contains(lay, "rondDatetime.attachAll(") { + t.Error("layout.html 没有调用 attachAll —— 日期框不会在页面加载时被改造") + } + + names, err := fs.Glob(tmplFS, "templates/*.html") + if err != nil { + t.Fatalf("列举模板失败: %v", err) + } + adminNames, err := fs.Glob(tmplFS, "templates/admin/*.html") + if err != nil { + t.Fatalf("列举后台模板失败: %v", err) + } + sels := []string{"input[type=datetime-local]", "input[type=date]"} + for _, name := range append(names, adminNames...) { + b, err := tmplFS.ReadFile(name) + if err != nil { + t.Fatalf("读取 %s 失败: %v", name, err) + } + for _, sel := range sels { + if strings.Contains(string(b), sel) { + t.Errorf("%s 里又出现手写的日期选择器 %q —— 一律走 rondDatetime.attachAll(),"+ + "否则新加的输入类型会被漏掉(2026-10-09 的日期框 bug 就是这么来的)", name, sel) + } + } + } + for _, name := range []string{"templates/admin/edit.html", "templates/admin/photos.html", "templates/admin/topics.html"} { + b, err := tmplFS.ReadFile(name) + if err != nil { + t.Fatalf("读取 %s 失败: %v", name, err) + } + if !strings.Contains(string(b), "attachAll(") { + t.Errorf("%s 没有调用 attachAll(它自己那批日期框会退回原生控件)", name) + } + } +} + // ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ---------- // // 改前 app.css 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 / diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index 0b034dd..dc1a9c9 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -244,7 +244,10 @@ b, strong { font-weight: 600; } font: inherit; font-size: var(--glyph-xs); line-height: 1; } .card[data-card] > h2 .card-toggle:hover { color: var(--text); } -.grid { display: grid; gap: 16px; } +/* align-items: start 是必须的:grid 默认 stretch,同一行里内容少的那张卡会被拉到与 + 最高的一样高,卡片内部留一大片空白(/stats 的「类型分布」60 多行 vs「一天中的分布」 + 24 根柱就是这么来的)。改成各按自然高度,短的那张下面露的是页面底色,而不是空白卡面。 */ +.grid { display: grid; gap: 16px; align-items: start; } /* grid 子项默认 min-width:auto(= 不小于自身 min-content),而卡片里常常放着 带 min-width 的表格——不加这条,卡片会拒绝收缩、把**整页**顶宽,出现横向滚动条 (/stats、/report 在手机上整页 692px 就是这么来的)。压到 0 之后由卡片内部的 @@ -355,6 +358,14 @@ a.col:hover { opacity: 1; filter: brightness(1.12); } /* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */ .temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; } .temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; } +/* 宽屏反过来:柱子要跟着卡片长、轨道也要加高。原来固定 36×84,10 个月只占 1042px 里的 + 396px —— 右边 600 多像素全空着,整张图看着就「很小」。上限 96px 是防止只有两三个月时 + 柱子宽得不像柱状图。 */ +@media (min-width: 720px) { + .temp-col { flex: 1 1 36px; max-width: 96px; } + .temp-col .tc-track { height: 130px; } + .temp-col .tc-v, .temp-col .tc-m { font-size: var(--fs-sm); } +} .temp-col .tc-v { font-size: var(--fs-xs); color: var(--text-2); font-variant-numeric: tabular-nums; } .temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; } .temp-col .tc-bar { width: 100%; border-radius: var(--r-xs) var(--r-xs) 0 0; min-height: 3px; } diff --git a/internal/web/static/js/datetime-input.js b/internal/web/static/js/datetime-input.js index ed55857..adef6f0 100644 --- a/internal/web/static/js/datetime-input.js +++ b/internal/web/static/js/datetime-input.js @@ -1,28 +1,40 @@ -/* datetime-input:修掉 flatpickr allowInput 的「整串解析」毛病。 +/* datetime-input:把 换成**我们完全掌控**的 + * 文本输入 + 手输引导。 * - * 背景(实测 flatpickr 4.6.13,见 memory/2026-10-08.md): - * 1. flatpickr 会把 就地改成 type="text" - * (altInput 默认 false、不新建节点),原生那套分段编辑器被整个替换掉, - * 所以「敲满一段自动跳下一段」这个行为根本不存在。 - * 2. allowInput 的语义是「拿到整串再 parseDate」,不是「逐段消费」。实测: - * parseDate('2026') → 2026 年 1 月 - * parseDate('202666') → 2026 年 6 月 ← 用户要打到这个长度才「看到月份」 - * parseDate('20260606') → 2030 年 12 月 ← 静默错解,且不报错 - * 年份得吃满 4 位才被认,之后月份位还要再吃 2 位 ⇒ 正是「年份很长才跳下一位」。 + * 为什么不用原生控件(两次踩过,都是实测): + * 1. flatpickr 会把 type=datetime-local 就地改成 type=text(altInput 默认 false、不新建节点), + * 原生那套分段编辑器被整个替换掉;而 allowInput 的语义是「拿到整串再 parseDate」, + * 实测 parseDate('2026') → 1 月、'202666' → 2026-06、'20260606' → 2030-12(静默错解)。 + * 2. **原生 type=date 自己也不可靠**:Chrome 的年段允许超过 4 位数字(年份上限 275760), + * 于是敲 20260606 会被切成 年=202606 + 月=06,框里显示 202606/06(zh-CN 用 / 分隔), + * 而 el.value 是**空**=非法 ⇒ 筛选条件静默丢掉。2026-10-09 用户报的就是这条。 + * 原生分段状态读不到、光标也设不了(setSelectionRange 对 date 类输入直接抛), + * 所以「修原生」这条路走不通,只能换掉控件。 * - * 这里做两件事: - * parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、 - * 2026 等写法;解析不出来就是 null,绝不猜。 - * attach 按键引导:年份满 4 位自动补分隔符并把光标推到月份段, - * 月/日/时/分满 2 位同理,让手输 behave 像原生分段。 + * 于是这里做三件事: + * parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、2026 等, + * 解析不出来就是 null,绝不猜(交给 flatpickr 的 parseDate 钩子)。 + * attach 输入引导:年满 4 位自动补分隔符并把光标推到月份段,其余段满 2 位同理。 + * attachAll **统一入口**:一次覆盖 type=datetime-local 与 type=date 两种输入。 + * 2026-10-09 的事故根因就是「只挂了 datetime-local」,后来加的 type=date + * 筛选框没人管 —— 以后加日期字段一律走 attachAll,别在页面里散着手写选择器。 * - * 产出格式恒为 'Y-m-dTH:i'(即 2026-06-06T09:30),与服务端 - * handlers_edit.go / handlers_topics.go 里的 "2006-01-02T15:04" 同一形状。 + * 产出格式:datetime 恒为 'Y-m-dTH:i'(2026-06-06T09:30),只日期恒为 'Y-m-d'(2026-06-06), + * 分别与服务端 handlers_edit.go 的 "2006-01-02T15:04" 和筛选区的 "2006-01-02" 同一形状。 */ (function (global) { 'use strict'; var DATE_FMT = 'Y-m-dTH:i'; + var DAY_FMT = 'Y-m-d'; + + /* 两种版式。段长/分隔符/输出格式只差这几个数,算法完全共用。 */ + var SPEC = { + dt: { LEN: [4, 2, 2, 2, 2], SEP: ['-', '-', 'T', ':', ''], fmt: DATE_FMT }, + date: { LEN: [4, 2, 2], SEP: ['-', '-', ''], fmt: DAY_FMT } + }; + + function specFor(opts) { return (opts && opts.dateOnly) ? SPEC.date : SPEC.dt; } function pad2(n) { return n < 10 ? '0' + n : String(n); } @@ -137,6 +149,7 @@ * 严格分段格式:年 4 位、月/日/时/分各 2 位,一律补零,敲满一段立刻补分隔符并跳下一段。 * 于是「2026」+「06」+「06」+「09」+「30」会边敲边变成 * 2026-06-06T09:30,敲完 4 位年份光标就已经在月份位(这正是原版做不到的那件事)。 + * 只日期版少掉时分两段,输出 2026-06-06。 * * 不做的事(有意为之): * - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。 @@ -144,8 +157,6 @@ * - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。 * 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。 */ - var SEG_LEN = [4, 2, 2, 2, 2]; // 年月日时分 - var SEP_AFTER = ['-', '-', 'T', ':', '']; // 各段填满后的合法范围(只判能明确非法的:月 13、日 32、时 24、分 60) var SEG_FULL_MAX = [ @@ -172,68 +183,91 @@ return new Date(y, mo, 0).getDate(); } + /* 原始输入里,紧跟第 n 位数字之后的那个分隔符是什么(没有就返回空)。 + * 用来在「段未填满」时把用户已经敲好的分隔符原样带回来, + * 避免把 '2026-6' 硬补零成 '2026-06'(用户 1 月就该敲 01,但 + * 6 月可以只敲 6 —— 这两种都得能往下走)。 */ + function sepAfterDigits(raw, n) { + var seen = 0; + for (var k = 0; k < raw.length; k++) { + var ch = raw.charAt(k); + if (ch >= '0' && ch <= '9') { + seen++; + if (seen === n) { + var nx = raw.charAt(k + 1); + if (nx && (nx === '-' || nx === 'T' || nx === ':')) return nx; + return ''; + } + } + } + return ''; + } + + /* 按位切分并排出标准版式。返回 null 表示「用户敲的这段已越界,原样保留」。 + * 年份固定 4 位;月/日(时/分)固定 2 位 —— 不猜、不前滚、不自动补零。 + * + * 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时 + * 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020), + * 否则位次会整体错位一格——这是实测踩出来的。 + * + * 导出成纯函数(layoutTo)是为了能在 node 里直接跑断言,不用起浏览器。 */ + function layoutTo(spec, raw) { + var chars = String(raw).split(''); + var digits = ''; + for (var k = 0; k < chars.length; k++) { + var ch = chars[k]; + if (ch >= '0' && ch <= '9') digits += ch; + } + if (!digits) return { value: '', caret: 0 }; + if (digits.length > spec.LEN.reduce(function (a, b) { return a + b; }, 0)) return null; + var out = '', pos = 0, year = 0, mon = 0; + for (var i = 0; i < spec.LEN.length; i++) { + var want = spec.LEN[i]; + var have = digits.slice(pos, pos + want); + var full = have.length === want; + var check = full ? SEG_FULL_MAX[i] : SEG_PREFIX_MAX[i]; + if (check && check(have, year, mon)) return null; + // 本段还没填满 → 保持原样让用户继续敲。 + // 若原始输入里这一段后面已经带了分隔符(用户手敲 '2026-6'), + // 就把它带回来,光标停在分隔符之后,而不是硬补零。 + if (!full) { + var tailSep = sepAfterDigits(raw, pos + have.length); + var val = out + have + tailSep; + return { value: val, caret: val.length }; + } + // 填满 → 结算本段 + if (i === 0) year = +have; + if (i === 1) mon = +have; + out += have; + pos += want; + var sep = spec.SEP[i] || ''; + // 数字用完了:本段后面还有段要填就立刻补分隔符、把光标推到下一段起点。 + // 这一步就是「敲完 4 位年份自动跳到月份位」的实现。 + if (pos >= digits.length) { + if (!sep) return { value: out, caret: out.length }; + return { value: out + sep, caret: out.length + sep.length }; + } + out += sep; + } + return { value: out, caret: out.length }; + } + function attach(el, opts) { opts = opts || {}; + if (el._rondAttached) return; + el._rondAttached = true; + var spec = specFor(opts); - /* 按位切分并排出标准版式。返回 null 表示「用户敲的这段已越界,原样保留」。 - * 年份固定 4 位;月/日/时/分固定 2 位 —— 不猜、不前滚、不自动补零。 - * - * 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时 - * 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020), - * 否则位次会整体错位一格——这是实测踩出来的。 */ - function layout(raw) { - // 只保留数字。必须跳过分隔符:用户敲到 '2026-0' 时数字是 20260 而不是 2020, - // 否则位次整体错位一格 —— 这是实测踩出来的。 - var chars = String(raw).split(''); - var digits = ''; - for (var k = 0; k < chars.length; k++) { - var ch = chars[k]; - if (ch >= '0' && ch <= '9') digits += ch; - } - if (!digits) return { value: '', caret: 0 }; - if (digits.length > 14) return null; - var out = '', pos = 0, year = 0, mon = 0; - for (var i = 0; i < SEG_LEN.length; i++) { - var want = SEG_LEN[i]; - var have = digits.slice(pos, pos + want); - var full = have.length === want; - var check = full ? SEG_FULL_MAX[i] : SEG_PREFIX_MAX[i]; - if (check && check(have, year, mon)) return null; - // 本段还没填满 → 保持原样让用户继续敲。 - // 若原始输入里这一段后面已经带了分隔符(用户手敲 '2026-6'), - // 就把它带回来,光标停在分隔符之后,而不是硬补零。 - if (!full) { - var tailSep = sepAfterDigits(raw, pos + have.length); - var val = out + have + tailSep; - return { value: val, caret: val.length }; - } - // 填满 → 结算本段 - if (i === 0) year = +have; - if (i === 1) mon = +have; - out += have; - pos += want; - var sep = SEP_AFTER[i] || ''; - // 数字用完了:本段后面还有段要填就立刻补分隔符、把光标推到下一段起点。 - // 这一步就是「敲完 4 位年份自动跳到月份位」的实现。 - if (pos >= digits.length) { - if (!sep) return { value: out, caret: out.length }; - return { value: out + sep, caret: out.length + sep.length }; - } - out += sep; - } - return { value: out, caret: out.length }; + /* 先把控件换成 text。引导过程会往框里写 '2026-06-' 这种**半成品**, + 而 type=date / datetime-local 对非法值一律置空(值当场消失), + 只有 text 我们才拿得住。提交格式不变(date → Y-m-d、datetime → Y-m-dTH:i)。 */ + if (el.type !== 'text') { + el.type = 'text'; + el.setAttribute('inputmode', 'numeric'); // 手机上直接给数字键盘 + el.setAttribute('autocomplete', 'off'); + if (!el.placeholder) el.placeholder = opts.dateOnly ? '2026-06-06' : '2026-06-06T09:30'; } - /* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。 - * - * 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate, - * 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走, - * 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。 - * 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。 - * - * 交接判据是「值里除我们自己补的以外还有没有别的分隔符」: - * 一旦用户亲手敲了 / 或 :,立刻彻底放手(否则会出现 '2026-/6/6' 这种 - * 我们补的 '-' 和用户的 '/' 混排的乱串),改由 fromSeparated 解他的写法。 */ /* 分隔符是否全是标准套(- T :)。'2026-6-6' 算(横杠是我们和用户通用的), * '2026/6/6' 不算(外来写法,交给 fromSeparated 原样解)。 */ function isStandardSep(v) { @@ -246,6 +280,12 @@ return true; } + /* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。 + * + * 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate, + * 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走, + * 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。 + * 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。 */ function onInput() { var cur = el.value; if (!cur) { el._rondLaidOut = ''; return; } @@ -256,7 +296,7 @@ 段还没填满就原样留着(他还在敲)。 */ if (isStandardSep(cur)) { applyLayout(cur); return; } /* 外来写法(/ . 空格)。只做一件最小修复:把「紧跟在分隔符后面、 - 与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-/', + 与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-', 双分隔符会让 fromSeparated 判非法。除此之外一个字符都不动, 保持用户原样。 */ var fixed = cur.replace(/([-T:])([/.])/g, '$2').replace(/([-T:])\1/g, '$1'); @@ -267,29 +307,8 @@ el._rondLaidOut = ''; } - /* 原始输入里,紧跟第 n 位数字之后的那个分隔符是什么(没有就返回空)。 - * 用来在「段未填满」时把用户已经敲好的分隔符原样带回来, - * 避免把 '2026-6' 硬补零成 '2026-06'(用户 1 月就该敲 01,但 - * 6 月可以只敲 6 —— 这两种都得能往下走)。 */ - function sepAfterDigits(raw, n) { - var seen = 0; - for (var k = 0; k < raw.length; k++) { - var ch = raw.charAt(k); - if (ch >= '0' && ch <= '9') { - seen++; - if (seen === n) { - // 找到第 n 位数字,看它后面紧跟的是不是分隔符 - var nx = raw.charAt(k + 1); - if (nx && (nx === '-' || nx === 'T' || nx === ':')) return nx; - return ''; - } - } - } - return ''; - } - function applyLayout(cur) { - var r = layout(cur); + var r = layoutTo(spec, cur); // 越界(月份敲成 66、日敲成 45):原样保留用户输入,交给解析器报错。 // 这里必须 return 且**不动 el.value**——el.value 一旦被我们改写却没同步 // _rondLaidOut,下一轮就会拿旧串去排,越排越乱。 @@ -317,9 +336,9 @@ el.addEventListener('input', onInput); - /* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01T00:00。 - * 不这么做的话用户只填了年份就点走,flatpickr 拿到 '2026' 会按原版规则 - * 补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */ + /* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01(只日期) + * 或 2026-01-01T00:00(带时间)。不这么做的话用户只填了年份就点走, + * flatpickr 拿到 '2026' 会按原版规则补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */ function onBlur() { var cur = el.value.trim(); if (!cur) return; @@ -330,47 +349,69 @@ el.addEventListener('blur', onBlur); function formatOut(d) { - return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + - 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()); + var day = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); + if (opts.dateOnly) return day; + return day + 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()); } el._rondFormatOut = formatOut; } - function init(selector, opts) { - if (!global.flatpickr) return; - var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; - Array.prototype.forEach.call(els, function (el) { - attachPicker(el, opts); - }); - } - /* 三处页面(编辑页 / 照片页 / 专题页)共用的挂载入口。 配置刻意与原先完全一致,只多了 parseDate 与输入引导两件事: parseDate 修掉 allowInput 的「整串解析」与静默错解 attach 让手输 behave 像原生分段(敲满 4 位年份就跳到月份位) 提交格式仍是 'Y-m-dTH:i',服务端那条 "2006-01-02T15:04" 解析不受影响。 */ function attachPicker(el, opts) { - if (!global.flatpickr || el._rondPickerReady) return null; - el._rondPickerReady = true; - var inst = global.flatpickr(el, { - enableTime: true, - time_24hr: true, - allowInput: true, - // 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度 - minuteIncrement: 5, - dateFormat: DATE_FMT, - parseDate: parseDateStrict - }); + opts = opts || {}; + var dateOnly = !!opts.dateOnly; + if (!global.flatpickr) { attach(el, opts); return null; } + if (!el._rondPickerReady) { + el._rondPickerReady = true; + global.flatpickr(el, { + enableTime: !dateOnly, + time_24hr: true, + allowInput: true, + // 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度 + minuteIncrement: 5, + dateFormat: dateOnly ? DAY_FMT : DATE_FMT, + parseDate: parseDateStrict + }); + } attach(el, opts); - return inst; + return el._rondPicker; + } + + /* ---------- 统一入口 ---------- + * 覆盖两种日期输入:带时间的走 flatpickr + 引导,只日期的走(有 flatpickr 就一并给日历, + * 没有就只挂引导——公共页不加载 flatpickr,见 layout.html 的说明)。 + * ⚠️ 以后再加日期字段一律走这里,别在页面里手写 querySelector('input[type=...]'): + * 2026-10-09 的事故就是只挂了 datetime-local、新加的 type=date 没人管。 */ + function attachAll(root, opts) { + root = root || document; + var els = root.querySelectorAll('input[type=datetime-local], input[type=date]'); + Array.prototype.forEach.call(els, function (el) { + var o = { dateOnly: el.type === 'date' }; + if (global.flatpickr) attachPicker(el, o); + else attach(el, o); + }); + return els.length; + } + + function init(selector, opts) { + var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; + Array.prototype.forEach.call(els, function (el) { attachPicker(el, opts); }); } global.rondDatetime = { DATE_FMT: DATE_FMT, + DAY_FMT: DAY_FMT, + SPEC: SPEC, + layoutTo: layoutTo, parseDate: parseDateStrict, attach: attach, attachPicker: attachPicker, + attachAll: attachAll, init: init, pad2: pad2 }; -})(window); \ No newline at end of file +})(window); diff --git a/internal/web/static/js/datetime-input.test.js b/internal/web/static/js/datetime-input.test.js index 93873b2..6c17546 100644 --- a/internal/web/static/js/datetime-input.test.js +++ b/internal/web/static/js/datetime-input.test.js @@ -80,6 +80,35 @@ console.log('\n=== round-trip:解析结果必须能原样喂回 formatDate === eq(bad, [], 'round-trip 一致'); })(); +console.log('\n=== 手输引导 layoutTo:逐键模拟(这才是这次 bug 的现场) ==='); +(function () { + const D = api.SPEC.date, T = api.SPEC.dt; + const lay = (spec, raw) => { const r = api.layoutTo(spec, raw); return r ? r.value : null; }; + // 逐键模拟:每敲一位,就把「当前框里的值 + 新键」再喂回去 —— 与浏览器里 input 事件一致 + const type = (spec, keys) => { + let v = ''; + for (const k of keys) { const n = lay(spec, v + k); v = (n === null) ? (v + k) : n; } + return v; + }; + + // ⚠️ 回归:2026-10-09 用户报的就是这个 —— 原生的 type=date 里敲 20260606 变成了 202606/06 + eq(type(D, '20260606'), '2026-06-06', '只日期模式逐键敲 20260606 → 2026-06-06'); + eq(lay(D, '20260606'), '2026-06-06', '只日期模式一次性粘 8 位 → 2026-06-06(没有时间尾巴)'); + eq(type(D, '2026'), '2026-', '只日期:敲完年份就补横杠、光标进月份段'); + eq(type(D, '202606'), '2026-06-', '只日期:敲完月份就补横杠、光标进日段'); + eq(type(D, '20260606'), '2026-06-06', '只日期:敲完日段不再补尾巴'); + + // 重构后带时间那条必须一字不变(原来 34 条断言的语义) + eq(type(T, '202606060930'), '2026-06-06T09:30', '带时间模式逐键敲 12 位 → 2026-06-06T09:30'); + eq(lay(T, '20260606'), '2026-06-06T', '带时间模式一次性粘 8 位 → 后面还留个 T 等时分'); + + // 越界一律「不接管」,原样留给解析器报错 + eq(lay(D, '20261306'), null, '只日期:月 13 → 不接管'); + eq(lay(D, '20260632'), null, '只日期:日 32 → 不接管'); + eq(lay(D, '20260230'), null, '只日期:2026-02-30 不存在 → 不接管'); + eq(lay(D, '202606061'), null, '只日期:9 位没有第 4 段可放 → 不接管'); +})(); + console.log('\n=== 汇总 ==='); console.log(' pass=' + pass + ' fail=' + fail); process.exit(fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index 71a3210..b1a8542 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -801,13 +801,11 @@ // 注意 l10n 对象叫 flatpickr.l10ns(复数);且必须 try 住——这里抛错会把 // 同一回调里后面的地图和地点搜索初始化一起炸掉。 try { - if (window.flatpickr) { - if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); - document.querySelectorAll('input[type=datetime-local]').forEach(function (el) { - window.rondDatetime.attachPicker(el); - }); - } - } catch (e) { console.error('flatpickr 初始化失败', e); } + if (window.flatpickr && flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); + // 统一入口:一次覆盖 datetime-local 与 date(行程筛选的 mfrom / mto 就是 date)。 + // 别再手写 querySelector('input[type=...]') —— 2026-10-09 的日期框 bug 就是这么漏掉 type=date 的。 + window.rondDatetime.attachAll(); + } catch (e) { console.error('日期控件初始化失败', e); } // 融合表单的模式切换。required 不能写在 name/lat 等字段上——切到 // 「补已有地点」时它们虽被隐藏,HTML5 仍会校验并拦住提交,所以校验交给后端。 diff --git a/internal/web/templates/admin/photos.html b/internal/web/templates/admin/photos.html index ac25e23..a6515e3 100644 --- a/internal/web/templates/admin/photos.html +++ b/internal/web/templates/admin/photos.html @@ -505,12 +505,10 @@ function initPickers() { try { - if (!window.flatpickr) return; - if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); - rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) { - window.rondDatetime.attachPicker(el); - }); - } catch (e) { console.error('flatpickr 初始化失败', e); } + if (window.flatpickr && flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); + // 统一入口(datetime-local + date)。限定在 rowsBox 里:页面别处的输入前面已经挂过了。 + window.rondDatetime.attachAll(rowsBox); + } catch (e) { console.error('日期控件初始化失败', e); } } // 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标 diff --git a/internal/web/templates/admin/topics.html b/internal/web/templates/admin/topics.html index f9058ae..e7bbf68 100644 --- a/internal/web/templates/admin/topics.html +++ b/internal/web/templates/admin/topics.html @@ -93,12 +93,10 @@ } whenReady(function () { try { - if (!window.flatpickr) return; - if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); - document.querySelectorAll('input[type=datetime-local]').forEach(function (el) { - window.rondDatetime.attachPicker(el); - }); - } catch (e) { console.error('flatpickr 初始化失败', e); } + if (window.flatpickr && flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); + // 统一入口:一次覆盖 datetime-local 与 date(见 datetime-input.js 顶部说明) + window.rondDatetime.attachAll(); + } catch (e) { console.error('日期控件初始化失败', e); } }); })(); diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 6c138d3..16015d6 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -246,10 +246,25 @@ {{if .Flatpickr}} -{{/* 时间框的解析器与输入引导。必须在 flatpickr 之后、页面内联脚本之前: - 它不自己调 flatpickr,只提供 parseDate 与 attach,由各页 whenReady 调 attachPicker。 */}} - {{end}} +{{/* 日期输入的解析器与手输引导。**无条件引入**:公共页的筛选区也有 , + 那边不加载 flatpickr(省 60 多 KB),走纯输入引导 —— 关键是原生 date 自己也不可靠 + (Chrome 的年段能吞 6 位数字,敲 20260606 会变成 202606/06 且 value 为空)。 + 必须在 flatpickr 之后、页面内联脚本之前。 */}} + + {{/* 输入弹层:替代 window.prompt(它会把提示文本与默认值写进地址栏)。 全站都可能用到(报告页复制链接、后台活动改名),所以无条件引入。 */}}