时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关

时间框(flatpickr allowInput 的手输体验)
- flatpickr 把 <input type=datetime-local> 就地改成 type=text,原生分段编辑器被替换掉;
  allowInput 又是「整串解析」语义:实测 parseDate('2026') 只出年份、'202666' 才出月份,
  于是「年份很长才跳下一位」;'20260606' 还会被静默错解成 2030-12-06 且不报错。
- 新增 static/js/datetime-input.js:自定义 parseDate(纯数字只收 4/6/8/12/14 位,
  歧义与非法日期一律拒)+ 输入引导(段满即补分隔符,敲完 4 位年份立刻跳月份位)。
  按标准格式不做自动补零、不前滚;越界值原样保留交报错,不静默改成合法值。
- 三处页面(编辑/照片/专题)共用 attachPicker,配置与原先一致,模板净减 5 行。

弹窗
- window.prompt 会把提示文本与默认值写进地址栏并留进历史(confirm/alert 无此问题)。
- 新增 static/js/prompt-box.js 替代:原生 <dialog>.showModal(),遮罩/Esc/焦点齐全,
  旧浏览器降级成页面顶部一条。已换 report.html 复制链接兜底与 edit.html 活动改名。
- 门禁 no_prompt_test.go:静态扫模板禁止 prompt(,并钉住 JS 与 CSS 都在。

统计脚本
- 原来只能改 conf/app.ini(要重启),现在后台「站点设置」可开关 + 填写,默认关闭。
- cleanTrackScript 挡住注入面:开闭 <script> 标签数不配平(提前闭合会让页面散架)、
  src 的 javascript:/data: 伪协议、超长截断。注意不能用「出现 </script 就拒」当规则——
  百度统计、Umami 贴的就是完整 HTML,正常用法会被挡死。

门禁
- datetime-input.test.js:34 条断言(node 直跑,无依赖)。
- TestCleanTrackScript:逐条边界 + 定义性不变量(任何输入都不得产出含 </script 的非空结果)。
- 三项均做过变异验证,改坏实现都会被对应测试抓到。

实测:真实站点逐键 2→20→202→2026-→2026-06-→2026-06-06T;
统计脚本六个场景(默认关/只填不开/开+URL/后台与登录页不挂/危险输入/关掉恢复)全部符合预期。
全量 internal/web 204 例 0 skip,其余包全绿,gofmt 干净。
This commit is contained in:
tamakyi
2026-10-08 18:27:59 +08:00
parent 5c7cf1a0e1
commit 4015ddf373
17 changed files with 1142 additions and 18 deletions
+92
View File
@@ -0,0 +1,92 @@
# 2026-10-08
## 排查:模板字段报「can't evaluate field」= 二进制比源码旧
现象:`渲染 admin/index 失败: template: index.html:116:31: executing "content" at <.ImgRows>: can't evaluate field ImgRows in type web.AdminData`
结论:不是代码 bug,是跑的二进制比模板旧。取证链:
- 字段在 `view.go:669` + `handlers_admin.go:177` 都有,引入于 `4c3032c`(2026-10-01 01:15)
- `go version -m rond.exe` → `vcs.revision=d99c63f`,`2026-09-30T09:35:59Z`
- `strings rond.exe | grep -c ImgRows` = 0(同批 `FogMasks` 有)
- `git show d99c63f:internal/web/view.go | grep -c ImgRows` = 0
**机制**:`conf/app.ini` 的 `debug=true` 让 `server.go:488` 的 `pageSet()` 每次请求都
`ParseFiles` 重读磁盘 ⇒ **模板是磁盘新版、结构体是二进制旧版**,改 Go 不重启就错配。
(与「debug=false 下模板走 go:embed,改 html 必须重编」是同一根轴的两面。)
处置:`go build -o rond.exe .` 重启即可,无需改代码。
后续可加固:`pageSet` 命中字段解析失败时提示「模板比二进制新,请重新编译」。
## 排查:时间框手动输入不跳段(flatpickr allowInput)
现象:想输 `2026/6/6`,实际要打到 `202666` 才进月份段。
实测取证(最小复现页 + 真派发按键,见下「无头测不到原生分段」):
- flatpickr 把原 `datetime-local` **就地改成 `type="text"`**(实测 `el.type === 'text'`、
`altInput` 未生成、`sameNode=true`)⇒ 原生分段编辑器被彻底替换掉,谈不上自动跳段
- 项目三处配置一致:`admin/edit.html:807`、`admin/photos.html:512`、`admin/topics.html:99`
- `inst.parseDate(s,'Y-m-dTH:i')` 实测:
- `2026` → y=2026 mo=**1**;`20266` → 同上;**`202666` → mo=6**(用户看到的就是这个)
- `2026/6/6` → 正常 y/mo/d=2026/6/6(**带分隔符能解析**);`20260606` → y=**2030** mo=**12**(错)
- `setDate` 回写:`2026` → `2026-01-01T00:00`;`202666` → `2026-06-01T00:00`;`2026-6-6` → `2026-06-06T00:00`
**根因**:`allowInput` 是「整串解析」语义,不是「逐段消费」语义。年份要满 4 位才被认,
之后月份位还要再吃 2 位才出结果 ⇒ 表现为「年份很长才跳下一位」。
**无头测不到原生分段**:`--headless=new` 下原生 `datetime-local` 的
`el.shadowRoot` 是 `null`(`shadow:false`),`sr.querySelectorAll('input.edit')` 直接抛
TypeError;且真派发按键后 `input.value` 恒为 `''`、`selectionStart` 为 `null`
—— UA 内部 shadow 段落在无头下不暴露。**要对比原生行为必须用有头 Chrome,
本机 CDP 脚本测不了,别把「原生 value 恒空」误当成原生没有分段。**
修复方向(未落地,等批准):`parseDate` 换成自定义解析器,接受
`2026/6/6`、`2026-6-6`、`20260606`、`2026` 等写法并做**歧义拒绝**
(`20260606` 必须判非法,不能悄悄解析成 2030-12-6)。服务端格式约束是
`2006-01-02T15:04`(`handlers_edit.go:1717`、`handlers_topics.go:26`),解析器须产出同一形状。
## 落地:时间框跳段 + 无歧义解析(方案 B,已完成)
用户拍板:**只做严格标准格式**(`xxxx年xx月xx日` 的数字形态,月/日/时/分一律两位、
不自动补零、不做前滚)。理由:少一套猜测逻辑、行为完全可预测。
**新增** `internal/web/static/js/datetime-input.js`(+ 同目录 `.test.js`,34 断言纯 node 跑):
- `parseDateStrict(dateStr)`:flatpickr 的 `parseDate` 钩子。接受 `2026`/`2026/6/6`/
`2026-6-6`/`20260606`/`2026-06-06T09:30`/`202606060930` 等;
**纯数字只收 4/6/8/12/14 位**(5/7/9/10/11 位一律拒——`2026060630` 到底是
「日=30」还是「时06分30」说不清)。也拒 `2026/2/30`、`2026/13/6`、尾部残留。
- `layout(raw)`:把框里值按 [年4][月2][日2][时2][分2] 切分排版,**段满即补分隔符**,
于是敲完第 4 位年份立刻变 `2026-` 且光标跳到月份位(= 原版做不到的那件事)。
逐位取数时**必须跳过分隔符**(`2026-0` 的数字是 `20260` 不是 `2020`,否则整体错位一格)。
- `placeCaret(pos)`:**同步设一次 + 微任务再补一次**。flatpickr 自己监听 input/setDate
会在我们之后改 `selectionStart`,只设一次会被它抢走 ⇒ 下次按键插到分隔符**前面**
(实测退化成 `2026-6`)。
- `attachPicker(el)`:三处页面共用的挂载入口,`_rondPickerReady` 守卫防重复
(照片页 `initPickers` 会被反复调)。配置与原先完全一致,只多 `parseDate` + 引导。
- 越界(月 66、日 45、时 25)**原样保留交解析器报错,绝不静默改成合法值**——
把 13 月悄悄改成 1 月比报错坏得多。
**改动面**:`layout.html` 加一行 script(放 `.Flatpickr` 块内、flatpickr 之后);
`admin/edit.html`、`admin/photos.html`、`admin/topics.html` 三处配置收敛成
`window.rondDatetime.attachPicker(el)`(模板净减 5 行)。
**验证**(真实站点 `localhost:8080`,CDP 派发真按键):
逐跳 `2→20→202→2026-→2026-06-→2026-06-06T` 正确;
`20260606`/`202606060930`/`2026/6/6`/`2026-6-6` 四种写法解析全 OK,
`2026666`/`20260230` 正确拒绝,`errs=0`;照片页动态框连调三次 attachPicker
(二三次返 null)+ 跳段正常。Go 测试 14 包全绿,`gofmt` 干净。
变异验证两次都被抓到:① `sep=''` ⇒ 跳段退化成 `2026`;② 放行 7 位纯数字。
## 环境坑(下次直接照抄)
- ⚠️ **`site_cdp.py` 的会话签名算法已过期**,签出来的 cookie 服务一律 303。
`internal/auth/auth.go` 现在是 `hmac(secret, payload+"\x00"+**完整** password_hash)`,
且 **secret 用裸字符串不是 hex 解码**(`server.go:118` 是 `[]byte(cfg.SecretKey)`)。
过期脚本还在用「前 16 位摘要」⇒ 本地验后台页必须自己签。
- Git Bash 下传 `--base http://...` 给 python 会被吃掉 `//`(`Cannot navigate to invalid URL`),
须加 `MSYS_NO_PATHCONV=1`。
- flatpickr 挂载后 input **`type` 被改成 `text`** ⇒ CDP 探针里
`querySelectorAll('input[type=datetime-local]')` 一律数到 0,必须用
`input.flatpickr-input`。
- 自定义 `parseDate` 返回 `false` 时 flatpickr 会走
`errorHandler(new Error("Invalid date provided: ..."))` 并清空 `selectedDates`
——不是静默忽略,所以「拒绝」在页面上表现为框被清空。
+108
View File
@@ -782,3 +782,111 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP*
⚠️ 变异验证踩的坑(老坑,载入史册):**直接 `go test -run …` 会因为缺 `ROND_TEST_CONF`/`ROND_SRC`
静默 skip 并报 ok** —— 我第一次做变异就是这么「假绿」的(0.17s 就 ok,明显不对)。
变异验证必须走 `./scripts/verify.sh --pkg … --run …`(它会拦住 SKIP)。
## 三十八、时间框(flatpickr allowInput)的手输体验(2026-10-08)
**症状**:手输 `2026/6/6` 时,年份位要打到 `202666` 才进月份位。
**根因(实测 flatpickr 4.6.13,两条)**:
1. flatpickr 把 `<input type=datetime-local>` **就地改成 `type="text"`**
(`altInput` 默认 false、不新建节点、`sameNode=true`)⇒ 原生分段编辑器被替换掉。
2. `allowInput` 是**整串解析**语义,不是逐段消费。实测 `inst.parseDate`:
`2026`→2026-**1**月、`20266`→同上、**`202666`→2026-**6**月**(就是用户看到的)、
`2026666`→**2031**-6月、`20260606`→**2030**-**12**月(静默错解不报错)。
**落地**:`internal/web/static/js/datetime-input.js`(纯逻辑,无依赖,附 `.test.js`
34 条断言 `node` 直跑)。三处页面共用 `rondDatetime.attachPicker(el)`,
脚本挂在 `layout.html` 的 `{{if .Flatpickr}}` 块内、flatpickr **之后**。
- 严格标准格式(用户拍板):年 4 位、月/日/时/分各 2 位,**不自动补零、不前滚**。
- 段满即补分隔符 ⇒ 敲完第 4 位年份立刻 `2026-` 且光标跳月份位。
- 解析器纯数字只收 4/6/8/12/14 位(5/7/9/10/11 位拒:说不清是「日=30」还是「时06分30」)。
- 越界(月 66、日 45)**原样保留交解析器报错,绝不静默改值**。
### 三个必踩的实现坑
1. **挪光标必须「同步 + 微任务」两次**。flatpickr 自己监听 `input`/`setDate`,
会在我们之后改 `selectionStart`;只同步设一次会被它抢走 ⇒ 下次按键插到分隔符
**前面**(实测退化成 `2026-6`,月份位错位一格)。
2. **逐位取数必须跳过分隔符**。框里是 `2026-0` 时数字是 `20260` 不是 `2020`;
直接 `replace(/[-T:]/g,'')` 会让整体位次错一格。
3. **改 `el.value` 就必须同步 `_rondLaidOut`**,否则下一轮拿旧串去排,越排越乱;
reject 分支则**必须 return 且一个字符都不动**。
### CDP 验输入行为时的坑
- 派发字符必须 `keyDown` **带 `text`/`unmodifiedText`**,否则 Chrome 不执行默认动作。
- 挂载后 input 已改成 `type=text` ⇒ `querySelectorAll('input[type=datetime-local]')`
一律数到 **0**,要用 `input.flatpickr-input`。
- 自定义 `parseDate` 返回 `false` 时 flatpickr 走
`errorHandler("Invalid date provided: …")` 并**清空 `selectedDates`**,
所以「被拒绝」在页面上表现为**框被清空**,不是静默不动。
- 折叠卡片 / 未开 `details` 里的输入框量到 0 个 —— 先展开再量。
### 本机环境(验后台页必须自己签 cookie)
`site_cdp.py` 的会话签名算法**已过期**(还在用「口令摘要前 16 位」)。
现行 `internal/auth/auth.go` 是 `hmac(secret, payload+"\x00"+**完整**password_hash)`,
且 **secret 是裸字符串不是 hex 解码**(`server.go:118`:`[]byte(cfg.SecretKey)`)。
过期脚本签的 cookie 一律 303 到登录页。另:Git Bash 传 `--base http://…`
会被吃掉 `//`(报 `Cannot navigate to invalid URL`),须加 `MSYS_NO_PATHCONV=1`。
## 三十九、页面弹窗:不用 window.prompt(2026-10-08)
**症状**:`window.prompt` 会把提示文本与默认值写进地址栏
(Chrome 拼成 `当前页?新的活动名称=吃饭` 并留进历史)——刷新即丢、分享带原文。
`confirm` / `alert` **没有**这个毛病,只有 `prompt` 有。
**替换物**:`static/js/prompt-box.js` 的 `rondPrompt.ask(title, value, {required, readonly})`
⇒ `Promise<string|null>`。用原生 `<dialog>.showModal()`:居中、带 `::backdrop` 遮罩、
Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器降级成页面顶部一条
(`.prompt-fallback`)。样式在 `app.css` 的 `dialog.prompt-box` / `.prompt-fallback`。
**已换的两处**:`report.html` 复制链接的兜底(`readonly: true`,让用户全选 Ctrl+C)、
`admin/edit.html` 活动改名(`required: true`,空值视作取消;监听器改成 `async`)。
脚本在 `layout.html` **无条件**引入(不挂 `.Flatpickr` 条件,报告页也要用)。
**门禁** `internal/web/no_prompt_test.go`:静态扫全部模板禁止 `prompt(` 调用
(注释与字符串先 strip 掉,否则注释里写「为什么不用 prompt」会误判);
另一条钉住 `prompt-box.js` 与 CSS 都在(否则模板能过、点击时才 TypeError)。
变异验证:把 report.html 改回 `window.prompt` ⇒ FAIL,命中 2 处。
### 验这个的坑(无头 Chrome)
⚠️ **`window.prompt` 与 `showModal()` 打开的模态 dialog 都会阻塞 CDP 求值**:
在同一个 `Runtime.evaluate` 里既触发弹窗又读值必然 `WebSocketTimeoutException`;
`Runtime.evaluate` 里 `.click()` 触发 prompt 同理卡死。
可行做法:**用 `Input.dispatchMouseEvent` 派发真实点击**(不占 CDP 通道),
或「先触发 → `time.sleep` → 截图 → 用 Input 派 Esc 关闭 → 再求值」。
(`Page.addScriptToEvaluateOnNewDocument` 传空 source 也会把 CDP 卡住,别用。)
移动端 390×844 实测:弹层宽 356、按钮高 44(触控下限)、输入框 16px(防 iOS 聚焦缩放)、
横向溢出 0。输入框字号必须 ≥16px,这条已在 CSS 里注明原因。
## 四十、统计脚本做成后台开关(2026-10-08)
原来只能改 `conf/app.ini` 的 `track_script`(要重启),现在后台「站点设置」可开关+ 填写。
**形状**:`Settings.TrackEnabled bool` + `Settings.TrackScript string`(JSON 存settings 表)。
**默认关闭**(统计脚本会把访问信息发到第三方,不该默认开)。
`pageBase` 里:开关开 → 用后台填的内容,为空则回落到 ini 的 `track_script`
(保留「改 ini 就能挂上」的旧用法,但**开关始终是唯一总闸**);
开关关 → `p.TrackScript` 为空,layout 的 `{{if and .TrackScript …}}` 自然不输出。
后台页与登录页仍由 `ne .Nav "admin"` 挡着(已实测)。
⚠️ **加 setting 字段要改三处**:`Default()`(显式写出默认值,别指望零值恰好对)、
`Load()` 的逐项覆盖(`Save()` 用整体 JSON,新增字段自动持久化)、
`settingsSubmit()` 的表单读取。`Load()` 里布尔项一律用 `present[key]` 判:
历史配置没有这个键时若直接套零值,语义会悄悄反转。
### `cleanTrackScript` 的判据(这里踩过一次)
⚠️ **不能用「出现 `</script` 就拒」当规则** —— 百度统计、Umami 贴进来的就是完整
HTML,一行里必然带 `</script>`,一拒就把正常用法挡死了(我第一版就是这么写的,
被自己的测试当场抓住)。真正的注入面只有一种:**未闭合的 `<script>` 里出现
`</script>` 会提前闭合**,后面内容当正文渲染。判据改成**开闭标签数配平**
(`shut > open` 才拒),配平则整段原样放行。
另挡 `src=` 的 `javascript:` / `data:` 伪协议(只放行 http/https 与 `/` 开头),
超 4096 截断。
门禁 `TestCleanTrackScript` 除了逐条边界,还钉了**定义性不变量**:
任何输入都不得产出「含 `</script` 的非空结果」。