diff --git a/.workbuddy/memory/2026-10-08.md b/.workbuddy/memory/2026-10-08.md new file mode 100644 index 0000000..3b83944 --- /dev/null +++ b/.workbuddy/memory/2026-10-08.md @@ -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` + ——不是静默忽略,所以「拒绝」在页面上表现为框被清空。 \ No newline at end of file diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index f8159ff..93d3f68 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -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 把 `` **就地改成 `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`。用原生 `.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` 的判据(这里踩过一次) + +⚠️ **不能用「出现 ``,一拒就把正常用法挡死了(我第一版就是这么写的, +被自己的测试当场抓住)。真正的注入面只有一种:**未闭合的 `` 会提前闭合**,后面内容当正文渲染。判据改成**开闭标签数配平** +(`shut > open` 才拒),配平则整段原样放行。 +另挡 `src=` 的 `javascript:` / `data:` 伪协议(只放行 http/https 与 `/` 开头), +超 4096 截断。 +门禁 `TestCleanTrackScript` 除了逐条边界,还钉了**定义性不变量**: +任何输入都不得产出「含 `, + // 需要初始化代码的服务(百度统计、Umami 等)可以把整段 ,一拒就把正常用法挡死了。 +// +// 真正的注入面只有一种:**未闭合的 会提前闭合**, +// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」: +// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。 +// +// 另外挡住:javascript: / data: 这类伪协议的 src(点击即执行), +// 以及超长内容(多半是误粘了整份 HTML 文档,截断)。 +func cleanTrackScript(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + const maxLen = 4096 + if len(v) > maxLen { + v = v[:maxLen] + } + low := strings.ToLower(v) + // 开闭标签数必须配平,否则有人能靠 提前闭合、把后面的内容 + // 从「脚本」变成「正文」注入到页面里。 + open := strings.Count(low, " open { + return "" + } + // src 只放行 http/https 与站内相对路径,挡掉 javascript: / data: 伪协议 + if idx := strings.Index(low, "src="); idx >= 0 { + rest := strings.TrimLeft(v[idx+4:], "\"'") + if rest != "" && !strings.HasPrefix(rest, "http://") && + !strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") { + return "" + } + } + return v +} + // ---------- 修改口令 ---------- func (s *Server) passwordPage(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index 2befcbb..e3ff2b2 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -29,13 +29,22 @@ const ( func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) { ctx := r.Context() - p := Page{Nav: nav, Title: title, User: userFrom(ctx), PageNo: 1, AssetV: assetVersion, - TrackScript: trackTag(s.cfg.TrackScript)} + p := Page{Nav: nav, Title: title, User: userFrom(ctx), PageNo: 1, AssetV: assetVersion} sets, err := s.sets.Load(ctx) if err != nil { return p, err } p.Settings = sets + // 统计脚本:后台「站点设置」里勾了开关才注入(默认关闭)。 + // conf/app.ini 的 track_script 只在这一个开关打开时充当默认内容, + // 这样既保留了原来「改 ini 就能挂上」的用法,也让开关成为唯一的总闸。 + track := sets.TrackScript + if track == "" { + track = s.cfg.TrackScript + } + if sets.TrackEnabled { + p.TrackScript = trackTag(track) + } ds, err := s.datasetFor(ctx) if err != nil { return p, err diff --git a/internal/web/no_prompt_test.go b/internal/web/no_prompt_test.go new file mode 100644 index 0000000..6312c83 --- /dev/null +++ b/internal/web/no_prompt_test.go @@ -0,0 +1,124 @@ +package web + +import ( + "io/fs" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// TestNoWindowPrompt 守住「页面里不许再出现 window.prompt」。 +// +// window.prompt 会把提示文本与默认值写进地址栏:Chrome 把它拼成 +// `当前页?新的活动名称=吃饭` 这样的 URL 并留进历史记录——刷新即丢, +// 分享链接还带着原文。confirm / alert 没有这个毛病,只有 prompt 有。 +// +// 2026-10-08 修掉了两处(report.html 复制链接的兜底、edit.html 活动改名), +// 统一换成 static/js/prompt-box.js 的 rondPrompt.ask():原生 居中弹层, +// 带遮罩、Esc 关闭、焦点自动进输入框,不碰地址栏;旧浏览器降级成页面顶部一条。 +// +// 只扫代码,不扫注释——注释里可以写「为什么不用 prompt」。 +func TestNoWindowPrompt(t *testing.T) { + // 匹配 prompt( 的调用形态,排除: + // - 注释与字符串里出现的 "prompt"(stripped 之后再找) + // - 别的对象上的方法(rondPrompt.ask 不是 prompt) + callRe := regexp.MustCompile(`(?:^|[^\w.$])(?:window\s*\.\s*)?prompt\s*\(`) + + checked := 0 + hits := 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++ + code := stripComments(blk.text) + for _, ln := range strings.Split(code, "\n") { + if !callRe.MatchString(ln) { + continue + } + hits++ + t.Errorf("%s:%d 模板里还有 window.prompt 调用:%s\n"+ + "它会把提示文本与默认值写进地址栏并留进历史(刷新即丢、分享带原文)。\n"+ + "换成 window.rondPrompt.ask(title, value, {required/readonly})。", + path, blk.line, strings.TrimSpace(ln)) + } + } + // 内联属性(onclick="return confirm(...)")里也可能有 prompt + for _, ln := range strings.Split(string(raw), "\n") { + if strings.Contains(ln, " 块都没扫到:模板目录或写法变了?") + } + t.Logf("扫了 %d 个内联脚本块,命中 %d 处 prompt", checked, hits) +} + +// TestPromptBoxScriptExists 保证 rondPrompt 真的存在。 +// +// 上面那条门禁只是「不许用 prompt」,若哪天 static/js/prompt-box.js 被删了、 +// 模板里的 rondPrompt.ask 变成 undefined,页面上的改名/复制功能会在点击时 +// 抛 TypeError —— 而模板解析测试照样通过。这条把「文件在 + 导出接口在」钉住。 +func TestPromptBoxScriptExists(t *testing.T) { + // staticFS 是 `//go:embed static`,所以路径要带 static/ 前缀 + raw, err := fs.ReadFile(staticFS, "static/js/prompt-box.js") + if err != nil { + t.Fatalf("读不到 static/js/prompt-box.js: %v", err) + } + src := string(raw) + if !strings.Contains(src, "rondPrompt") { + t.Error("prompt-box.js 里没有导出 rondPrompt,模板里的 rondPrompt.ask 会是 undefined") + } + if !strings.Contains(src, "ask:") && !strings.Contains(src, "ask :") { + t.Error("prompt-box.js 没有导出 ask 函数") + } + // CSS 也要在: 的 ::backdrop 遮罩与面板样式都在里面, + // 缺了会变成「无遮罩的白板」,不算功能坏但会明显走样。 + css, err := fs.ReadFile(staticFS, "static/css/app.css") + if err != nil { + t.Fatalf("读不到 static/css/app.css: %v", err) + } + if !strings.Contains(string(css), "dialog.prompt-box") { + t.Error("app.css 里没有 dialog.prompt-box 样式,弹层会没有边框/圆角/遮罩") + } + if !strings.Contains(string(css), "prompt-fallback") { + t.Error("app.css 里没有 prompt-fallback 样式,旧浏览器的降级形态会没样式") + } +} + +// stripComments 去掉 JS 行注释与块注释,避免注释里提到 prompt( 被误判。 +func stripComments(s string) string { + var b strings.Builder + b.Grow(len(s)) + lines := strings.Split(s, "\n") + for i, ln := range lines { + if i > 0 { + b.WriteByte('\n') + } + // 粗略但够用:行注释、以及块注释的起止标记 + if idx := strings.Index(ln, "//"); idx >= 0 { + ln = ln[:idx] + } + ln = strings.ReplaceAll(ln, "/*", "") + ln = strings.ReplaceAll(ln, "*/", "") + b.WriteString(ln) + } + return b.String() +} diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index 64ce704..0290cb1 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -1026,6 +1026,55 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten 328~388px(2026-09-19 真机上就是这么炸的,比改之前还糟)。现在宽表卡片已被要求不放 g2 (见 wide_table_layout_test.go),卡片宽度只由视口决定,两种判据在这些页面上等价, 那就用兼容性好的那个。若页面/卡片的内边距变了,这里的 800 要跟着重算。 */ +/* ---------- 轻量输入弹层(替代 window.prompt) ---------- + * + * 为什么不用 prompt():它会把提示文本与默认值写进地址栏(Chrome 会把 + * 「?新的活动名称=吃饭」这类内容拼到 URL 上并留进历史),刷新即丢、 + * 分享出去还带着原文。confirm/alert 不污染地址栏,只有 prompt 有这个毛病。 + * + * 定位:原生 居中、带 ::backdrop 遮罩、按 Esc 关闭、焦点自动进输入框, + * 这几件事正好是 prompt 给的,手写一遍不划算;旧浏览器不支持时降级成页面顶部一条。 + * showModal() 会让页面其余部分 inert,天然挡住「弹层开着还能点底层」。 + */ +dialog.prompt-box { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0; + min-width: 0; width: min(420px, calc(100vw - 32px)); + background: var(--surface); + color: var(--text); + box-shadow: 0 8px 28px rgba(16, 24, 40, .18); +} +dialog.prompt-box::backdrop { background: rgba(16, 24, 40, .38); } +.prompt-box .pb-body { padding: 14px 16px 12px; } +.prompt-box .pb-title { font-size: 14.5px; font-weight: 500; margin-bottom: 8px; } +/* 输入框字号必须 ≥16px:iOS Safari 聚焦时会自动放大页面,16px 以下会把布局顶歪 */ +.prompt-box .pb-input { + width: 100%; font-size: 16px; font-family: inherit; color: var(--text); + background: var(--surface); border: 1px solid var(--line); + border-radius: 7px; padding: 7px 10px; min-height: 40px; +} +.prompt-box .pb-input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } +.prompt-box .pb-acts { display: flex; gap: 8px; justify-content: flex-end; padding: 0 16px 14px; } +/* 触控目标下限 44px(窄屏见文件末尾的覆盖块) */ +.prompt-box .pb-acts .btn { min-height: 38px; } +@media (max-width: 700px) { + .prompt-box .pb-acts .btn { min-height: 44px; } +} + +/* 不支持 的旧浏览器:降级成页面顶部一条提示, + 至少不丢内容、也不会把地址栏弄脏。 */ +.prompt-fallback { + position: fixed; inset: 8px 8px auto 8px; z-index: 2000; + display: flex; gap: 8px; align-items: center; + padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); + background: var(--surface); box-shadow: 0 8px 28px rgba(16, 24, 40, .18); +} +.prompt-fallback input { + flex: 1; min-width: 0; font-size: 16px; font-family: inherit; + border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px; min-height: 40px; +} + @media (max-width: 800px) { .tw > table.tb.tb-stack { min-width: 0; } .tw > table.tb.tb-stack thead { display: none; } diff --git a/internal/web/static/js/datetime-input.js b/internal/web/static/js/datetime-input.js new file mode 100644 index 0000000..ed55857 --- /dev/null +++ b/internal/web/static/js/datetime-input.js @@ -0,0 +1,376 @@ +/* datetime-input:修掉 flatpickr allowInput 的「整串解析」毛病。 + * + * 背景(实测 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 位 ⇒ 正是「年份很长才跳下一位」。 + * + * 这里做两件事: + * parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、 + * 2026 等写法;解析不出来就是 null,绝不猜。 + * attach 按键引导:年份满 4 位自动补分隔符并把光标推到月份段, + * 月/日/时/分满 2 位同理,让手输 behave 像原生分段。 + * + * 产出格式恒为 'Y-m-dTH:i'(即 2026-06-06T09:30),与服务端 + * handlers_edit.go / handlers_topics.go 里的 "2006-01-02T15:04" 同一形状。 + */ +(function (global) { + 'use strict'; + + var DATE_FMT = 'Y-m-dTH:i'; + + function pad2(n) { return n < 10 ? '0' + n : String(n); } + + function validYMD(y, mo, d) { + if (y < 1000 || y > 9999) return false; + if (mo < 1 || mo > 12) return false; + if (d < 1 || d > 31) return false; + // 月末天数要真判:2 月 30 日 / 4 月 31 日这类「格式对但日期不存在」不能放过 + var probe = new Date(y, mo - 1, d); + return probe.getFullYear() === y && probe.getMonth() === mo - 1 && probe.getDate() === d; + } + + function validHM(h, mi) { + return h >= 0 && h <= 23 && mi >= 0 && mi <= 59; + } + + // 把「已经确定」的年/月/日/时/分拼成合法日期;缺时间则补 00:00 + function build(y, mo, d, h, mi, hasTime) { + if (!validYMD(y, mo, d)) return null; + if (hasTime && !validHM(h, mi)) return null; + var dt = new Date(y, mo - 1, d, hasTime ? h : 0, hasTime ? mi : 0, 0, 0); + return isNaN(dt.getTime()) ? null : dt; + } + + /* 从纯数字串里按位取:严格按 [年4][月2][日2][时2][分2] 切。 + * 长度只收 4/6/8/12/14 这几种能唯一解读的组合: + * 5/7/9/10/11 位一律拒(如 '2026060630' 到底是「日=30」还是「时=06 分=30」说不清)。 + * 这条规则专门用来干掉「20260606 被静默解成 2030-12-06」那类脏读。 */ + function fromDigits(s) { + if (!/^[0-9]+$/.test(s)) return null; + var y, mo, d, h = 0, mi = 0, hasTime = false; + switch (s.length) { + case 4: y = +s; mo = 1; d = 1; break; + case 6: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = 1; break; + case 8: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = +s.slice(6, 8); break; + case 12: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = +s.slice(6, 8); + h = +s.slice(8, 10); mi = +s.slice(10, 12); hasTime = true; break; + case 14: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = +s.slice(6, 8); + h = +s.slice(8, 10); mi = +s.slice(10, 12); hasTime = true; break; + default: return null; + } + return build(y, mo, d, h, mi, hasTime); + } + + /* 带分隔符的写法:先把分隔符统一成 '- ',再按「T 或空格切出时间」两半, + * 日期半边按 - 切、时间半边按 : 切。段数不足 3/2 时右侧补默认值(补录常见只写到月)。 + * 任何多余分隔符或残留字符都判非法。 */ + function fromSeparated(s) { + // 年月日之间的分隔允许 / - . 或空白;日与时间之间允许 T 或空白或 - + var norm = s.trim().replace(/[\/\.]/g, '-'); + var timeSep = norm.search(/[T\s]/); + var datePart, timePart; + if (timeSep < 0) { + datePart = norm; timePart = ''; + } else { + datePart = norm.slice(0, timeSep); + timePart = norm.slice(timeSep).replace(/^[T\s]+/, ''); + } + // 日期半边还可能带 '-' 接时间(2026-06-06-09:30),补一次。 + // 时间侧必须带冒号或够 4 位纯数字,否则 '2026/6/6/9' 这种多一段的会被误读成「日期+小时」 + if (timePart === '') { + var m = /^(\d{4}-\d{1,2}-\d{1,2})-(\d{1,2}:\d{1,2}|\d{4})$/.exec(datePart); + if (m) { datePart = m[1]; timePart = m[2]; } + } + // 时间半边必须整段都是「时分」,允许 0930(无冒号)/ 9:30 / 9(只有小时) + if (timePart !== '' && !/^\d{2}:?\d{2}$|^\d{1,2}:\d{1,2}$|^\d{1,2}$/.test(timePart)) return null; + var tp; + if (/^\d{4}$/.test(timePart)) tp = [timePart.slice(0, 2), timePart.slice(2, 4)]; + else tp = timePart.split(':'); + if (tp.length > 2) return null; + + // 日期半边可能是「无分隔符的纯数字」(20260606T0930 这种混写), + // 那就按位取——但位数必须能唯一解读,否则交给 fromDigits 判非法 + var y, mo, d; + if (/^\d+$/.test(datePart)) { + var bits = datePart.length; + if (bits !== 4 && bits !== 6 && bits !== 8) return null; + y = +datePart.slice(0, 4); + mo = bits >= 6 ? +datePart.slice(4, 6) : 1; + d = bits >= 8 ? +datePart.slice(6, 8) : 1; + } else { + var dp = datePart.split('-'); + if (dp.length > 3) return null; + if (!dp[0] || !/^\d{4}$/.test(dp[0])) return null; + y = +dp[0]; + mo = dp[1] ? dp[1] : '1'; + d = dp[2] ? dp[2] : '1'; + if (!/^\d{1,2}$/.test(mo) || !/^\d{1,2}$/.test(d)) return null; + mo = +mo; d = +d; + } + + if (timePart === '') return build(y, mo, d, 0, 0, false); + var h = +tp[0]; + var mi = tp[1] ? +tp[1] : 0; + return build(y, mo, d, h, mi, true); + } + + // flatpickr 的 parseDate 钩子:签名 (dateStr, format) -> Date | false + // 必须返回 false 而不是 null(flatpickr 内部判的是 !e,null 也行,但统一 false 更清楚) + function parseDateStrict(dateStr) { + if (typeof dateStr !== 'string') return false; + var s = dateStr.trim(); + if (!s) return false; + // 'today' 是 flatpickr 的默认约定,交给它自己走(返回 false 会让它回落到当前时间) + if (s === 'today') return false; + var d = /^[0-9]+$/.test(s) ? fromDigits(s) : fromSeparated(s); + return d ? d : false; + } + + /* ---------- 输入引导:让手输 behave 像原生分段 ---------- + * + * 严格分段格式:年 4 位、月/日/时/分各 2 位,一律补零,敲满一段立刻补分隔符并跳下一段。 + * 于是「2026」+「06」+「06」+「09」+「30」会边敲边变成 + * 2026-06-06T09:30,敲完 4 位年份光标就已经在月份位(这正是原版做不到的那件事)。 + * + * 不做的事(有意为之): + * - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。 + * 原生分段也是这样:1 月要敲 01。少一套猜测逻辑,行为完全可预测。 + * - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。 + * 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。 + */ + var SEG_LEN = [4, 2, 2, 2, 2]; // 年月日时分 + var SEP_AFTER = ['-', '-', 'T', ':', '']; + + // 各段填满后的合法范围(只判能明确非法的:月 13、日 32、时 24、分 60) + var SEG_FULL_MAX = [ + null, + function (s) { return +s > 12; }, + function (s, y, mo) { return +s > daysInMonth(y, mo); }, + function (s) { return +s > 23; }, + function (s) { return +s > 59; } + ]; + + // 各段「还没填满时」的前缀上限:已经不可能合法就当场停手。 + // 月 '1' 放行(可能是 10~12)、'2' 放行(20 不存在但等第 2 位更自然)、 + // '3'~'9' 拦下(13~19 不存在),'0' 放行(01~09)。 + var SEG_PREFIX_MAX = [ + null, // 年固定 4 位 + function (s) { return +s > 1; }, // 月:首位 >1 即不可能 + function (s) { return +s > 3; }, // 日:首位 >3 即不可能 + function (s) { return +s > 2; }, // 时:首位 >2 即不可能 + function (s) { return +s > 5; } // 分:首位 >5 即不可能 + ]; + + function daysInMonth(y, mo) { + if (mo < 1 || mo > 12) return 31; + return new Date(y, mo, 0).getDate(); + } + + function attach(el, opts) { + opts = 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 }; + } + + /* 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) { + var seps = v.match(/[-T:/.\s]/g); + if (!seps) return true; // 纯数字 + for (var i = 0; i < seps.length; i++) { + var c = seps[i]; + if (c !== '-' && c !== 'T' && c !== ':') return false; + } + return true; + } + + function onInput() { + var cur = el.value; + if (!cur) { el._rondLaidOut = ''; return; } + // 纯数字(刚开始敲)⇒ 接管,按标准版式排 + if (/^[0-9]+$/.test(cur)) { applyLayout(cur); return; } + /* 带标准分隔符 ⇒ 仍接管。但**只在数字已按标准位数填满各段时**才重排, + 否则用户手敲的 '2026-6-6' 会被强行补零成 '2026-06-06-'。 + 段还没填满就原样留着(他还在敲)。 */ + if (isStandardSep(cur)) { applyLayout(cur); return; } + /* 外来写法(/ . 空格)。只做一件最小修复:把「紧跟在分隔符后面、 + 与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-/', + 双分隔符会让 fromSeparated 判非法。除此之外一个字符都不动, + 保持用户原样。 */ + var fixed = cur.replace(/([-T:])([/.])/g, '$2').replace(/([-T:])\1/g, '$1'); + if (fixed !== cur) { + el.value = fixed; + placeCaret(fixed.length); + } + 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); + // 越界(月份敲成 66、日敲成 45):原样保留用户输入,交给解析器报错。 + // 这里必须 return 且**不动 el.value**——el.value 一旦被我们改写却没同步 + // _rondLaidOut,下一轮就会拿旧串去排,越排越乱。 + if (!r) return; + var val = r.value; + el._rondLaidOut = val; + if (val === cur) { placeCaret(r.caret); return; } + el.value = val; + placeCaret(r.caret); + } + + /* 光标落到 r.caret(= 刚补的分隔符之后,也就是下一段起点)。 + * 同步先设一次让本轮内就位,再用微任务补一次压过 flatpickr。 */ + function placeCaret(pos) { + if (pos == null) return; + try { el.setSelectionRange(pos, pos); } catch (e) { return; } + var apply = function () { + if (document.activeElement !== el) return; + if (el.selectionStart === pos && el.selectionEnd === pos) return; + try { el.setSelectionRange(pos, pos); } catch (e) { /* 已失焦 */ } + }; + if (window.Promise) Promise.resolve().then(apply); + else setTimeout(apply, 0); + } + + el.addEventListener('input', onInput); + + /* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01T00:00。 + * 不这么做的话用户只填了年份就点走,flatpickr 拿到 '2026' 会按原版规则 + * 补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */ + function onBlur() { + var cur = el.value.trim(); + if (!cur) return; + var d = parseDateStrict(cur); + if (!d) return; + el.value = formatOut(d); + } + el.addEventListener('blur', onBlur); + + function formatOut(d) { + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + + '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 + }); + attach(el, opts); + return inst; + } + + global.rondDatetime = { + DATE_FMT: DATE_FMT, + parseDate: parseDateStrict, + attach: attach, + attachPicker: attachPicker, + init: init, + pad2: pad2 + }; +})(window); \ No newline at end of file diff --git a/internal/web/static/js/datetime-input.test.js b/internal/web/static/js/datetime-input.test.js new file mode 100644 index 0000000..93873b2 --- /dev/null +++ b/internal/web/static/js/datetime-input.test.js @@ -0,0 +1,85 @@ +/* parseDateStrict 的单元测试(node 直接跑,无依赖)。 + * 用法:node internal/web/static/js/datetime-input.test.js + * 这里只测解析,不碰 DOM —— 解析是整个修复的核心,边界必须钉死。 + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const src = fs.readFileSync(path.join(__dirname, 'datetime-input.js'), 'utf8'); +const sandbox = { window: {} }; +sandbox.window.document = undefined; +vm.createContext(sandbox); +vm.runInContext(src, sandbox); +const api = sandbox.window.rondDatetime; + +let pass = 0, fail = 0; +function eq(got, want, label) { + const g = JSON.stringify(got), w = JSON.stringify(want); + if (g === w) { pass++; console.log(' ok ' + label); } + else { fail++; console.log(' FAIL ' + label + '\n got ' + g + '\n want ' + w); } +} +function parse(s) { + const d = api.parseDate(s); + if (d === false || d === null || d === undefined) return null; + if (isNaN(d.getTime())) return 'INVALID_DATE'; + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + 'T' + String(d.getHours()).padStart(2, '0') + + ':' + String(d.getMinutes()).padStart(2, '0'); +} + +console.log('=== 必须接受的写法 ==='); +eq(parse('2026'), '2026-01-01T00:00', '2026(只给年)'); +eq(parse('2026/6/6'), '2026-06-06T00:00', '2026/6/6 斜杠'); +eq(parse('2026-6-6'), '2026-06-06T00:00', '2026-6-6 单数字月日'); +eq(parse('2026-06-06'), '2026-06-06T00:00', '2026-06-06 完整'); +eq(parse('20260606'), '2026-06-06T00:00', '20260606 八位纯数字'); +eq(parse('2026-06-06T09:30'),'2026-06-06T09:30', '标准格式'); +eq(parse('2026/6/6 09:30'), '2026-06-06T09:30', '斜杠+空格+时间'); +eq(parse('2026-06-06 09:30'),'2026-06-06T09:30', '横杠+空格+时间'); +eq(parse('2026-6-6 9:30'), '2026-06-06T09:30', '时间也用单数字'); +eq(parse('202606'), '2026-06-01T00:00', '202606 六位(年月)'); +eq(parse('2026-06'), '2026-06-01T00:00', '2026-06 年月'); +eq(parse(' 2026/6/6 '), '2026-06-06T00:00', '前后空白'); +eq(parse('2026.6.6'), '2026-06-06T00:00', '点号分隔'); +eq(parse('20260606T0930'), '2026-06-06T09:30', '十二位纯数字'); +eq(parse('202606060930'), '2026-06-06T09:30', '十二位纯数字无 T'); + +console.log('\n=== 必须拒绝的脏输入(flatpickr 原版会静默错解) ==='); +eq(parse('202606'), '2026-06-01T00:00', '202606 按位取仍是合法年月(歧义但唯一解)'); +eq(parse('2026060630'), null, '2026060630 十位:日=30 但月推断不了 → 拒'); +eq(parse('2026/13/6'), null, '月 13 非法'); +eq(parse('2026/2/30'), null, '2 月 30 日不存在'); +eq(parse('2026/4/31'), null, '4 月 31 日不存在'); +eq(parse('2026/00/6'), null, '月 0'); +eq(parse('2026/6/0'), null, '日 0'); +eq(parse('2026666'), null, '2026666 七位:原版解成 2031-06 → 必须拒'); +eq(parse('20260606456'), null, '超长脏串'); +eq(parse('abc'), null, '非数字'); +eq(parse(''), null, '空串'); +eq(parse(' '), null, '纯空白'); +eq(parse('2026-6-6x'), null, '尾部残留字符'); +eq(parse('2026年6月6日'), null, '中文写法(服务端不收)'); +eq(parse('99'), null, '两位数(补录也只可能是四位年)'); +eq(parse('2026-6-6T25:00'), null, '小时 25 非法'); +eq(parse('2026-6-6T09:60'), null, '分钟 60 非法'); +eq(parse('2026/6/6/9'), null, '多一段'); + +console.log('\n=== round-trip:解析结果必须能原样喂回 formatDate ==='); +(function () { + const cases = ['2026-06-06T09:30', '2026-01-01T00:00', '2026-12-31T23:55']; + let bad = []; + for (const c of cases) { + const d = api.parseDate(c); + if (!d) { bad.push(c + ' 解析失败'); continue; } + const out = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + + String(d.getDate()).padStart(2,'0') + 'T' + String(d.getHours()).padStart(2,'0') + + ':' + String(d.getMinutes()).padStart(2,'0'); + if (out !== c) bad.push(c + ' → ' + out); + } + eq(bad, [], 'round-trip 一致'); +})(); + +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/static/js/prompt-box.js b/internal/web/static/js/prompt-box.js new file mode 100644 index 0000000..4e269ac --- /dev/null +++ b/internal/web/static/js/prompt-box.js @@ -0,0 +1,146 @@ +/* rondPrompt:替代 window.prompt 的轻量输入弹层。 + * + * 为什么必须换掉 prompt():它会把提示文本与默认值写进地址栏 + * (Chrome 拼成 `当前页?新的活动名称=吃饭` 这类 URL 并留进历史), + * 刷新即丢、分享出去还带着原文。confirm / alert 不污染地址栏,只有 prompt 有这毛病。 + * + * 能力对齐原生 prompt:返回 Promise,回车确认、Esc 取消、点遮罩取消。 + * 额外做了两件 prompt 给不了的:只读模式下直接把内容放进一个可全选的框(复制链接用), + * 以及读值时可要求「非空」(改名这种必填的场景直接少一层判空)。 + * + * 降级:浏览器不支持 时,弹层挂到页面顶部一条,同样不碰地址栏。 + */ +(function (global) { + 'use strict'; + + function supportsDialog() { + var d = document.createElement('dialog'); + // 无头 / 老浏览器里 showModal 可能不存在,只看 typeof + return typeof d.showModal === 'function'; + } + + function buildFallback(title, value, opts) { + var box = document.createElement('div'); + box.className = 'prompt-fallback'; + var lab = document.createElement('label'); + lab.textContent = title; + lab.style.fontSize = '13px'; + var input = document.createElement('input'); + input.type = 'text'; + input.value = value || ''; + var ok = document.createElement('button'); + ok.type = 'button'; + ok.className = 'btn primary'; + ok.textContent = '确定'; + var cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'btn'; + cancel.textContent = '取消'; + var done = false; + function finish(v) { + if (done) return; + done = true; + document.removeEventListener('keydown', onKey, true); + box.remove(); + resolve(v); + } + function onKey(e) { + if (e.key === 'Escape') { e.preventDefault(); finish(null); } + } + ok.addEventListener('click', function () { finish(input.value); }); + cancel.addEventListener('click', function () { finish(null); }); + document.addEventListener('keydown', onKey, true); + lab.appendChild(document.createElement('br')); + lab.appendChild(input); + var acts = document.createElement('div'); + acts.style.cssText = 'display:flex;gap:8px'; + acts.appendChild(cancel); + acts.appendChild(ok); + lab.appendChild(acts); + box.appendChild(lab); + document.body.appendChild(box); + input.focus(); + input.select(); + var resolve; + var p = new Promise(function (r) { resolve = r; }); + return p; + } + + /** + * 弹一个输入框。 + * @param {string} title 标题(原文) + * @param {string} [value] 初始值 + * @param {object} [opts] { readonly: 只读展示(复制链接), required: 取消时忽略空值 } + * @returns {Promise} + */ + function ask(title, value, opts) { + opts = opts || {}; + if (!supportsDialog()) return buildFallback(title, value, opts); + + return new Promise(function (resolve) { + var dlg = document.createElement('dialog'); + dlg.className = 'prompt-box'; + + var body = document.createElement('div'); + body.className = 'pb-body'; + var h = document.createElement('div'); + h.className = 'pb-title'; + h.textContent = title; + var input = document.createElement('input'); + input.className = 'pb-input'; + input.type = 'text'; + input.value = value == null ? '' : String(value); + if (opts.readonly) input.readOnly = true; + body.appendChild(h); + body.appendChild(input); + + var acts = document.createElement('div'); + acts.className = 'pb-acts'; + var cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'btn'; + cancel.textContent = '取消'; + var ok = document.createElement('button'); + ok.type = 'button'; + ok.className = 'btn primary'; + ok.textContent = '确定'; + acts.appendChild(cancel); + acts.appendChild(ok); + dlg.appendChild(body); + dlg.appendChild(acts); + + var settled = false; + function finish(v) { + if (settled) return; + settled = true; + dlg.remove(); + resolve(v); + } + function submit() { + var v = input.value; + // required:空值视作取消(改名这种必填的场景,少一层判空) + if (opts.required && !v.trim()) return; + finish(opts.readonly ? v : v); + } + cancel.addEventListener('click', function () { finish(null); }); + ok.addEventListener('click', submit); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + }); + // 点遮罩(dialog 元素本身)= 取消。inner 区域点击要挡住,否则点标题也算取消。 + dlg.addEventListener('click', function (e) { + if (e.target === dlg) finish(null); + }); + // Esc 触发 cancel 事件( 原生行为),统一在这收口 + dlg.addEventListener('cancel', function (e) { e.preventDefault(); finish(null); }); + + document.body.appendChild(dlg); + dlg.showModal(); + input.focus(); + // 只读场景(复制链接)全选好让用户直接 Ctrl+C;可编辑场景选中原值便于改写 + input.select(); + }); + } + + global.rondPrompt = { ask: ask }; +})(window); \ No newline at end of file diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index 137b076..acc02a6 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -804,14 +804,7 @@ if (window.flatpickr) { if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); document.querySelectorAll('input[type=datetime-local]').forEach(function (el) { - flatpickr(el, { - enableTime: true, - time_24hr: true, - allowInput: true, - // 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度 - minuteIncrement: 5, - dateFormat: 'Y-m-dTH:i' - }); + window.rondDatetime.attachPicker(el); }); } } catch (e) { console.error('flatpickr 初始化失败', e); } @@ -1177,10 +1170,11 @@ var actSel = document.getElementById('activity-select'); var actRename = document.getElementById('activity-rename'); if (actSel && actRename) { - actRename.addEventListener('click', function () { + actRename.addEventListener('click', async function () { var opt = actSel.options[actSel.selectedIndex]; if (!actSel.value || !opt) { alert('请先在下拉里选择要改名的活动'); return; } - var name = window.prompt('新的活动名称', opt.textContent); + // 不用 window.prompt:它会把「新的活动名称=xxx」拼进地址栏并留进历史记录 + var name = await window.rondPrompt.ask('新的活动名称', opt.textContent, { required: true }); if (!name || !name.trim()) return; activityPost('/admin/edit/activity/update', 'id=' + encodeURIComponent(actSel.value) + '&name=' + encodeURIComponent(name.trim()), diff --git a/internal/web/templates/admin/photos.html b/internal/web/templates/admin/photos.html index 3b256e4..d238fc8 100644 --- a/internal/web/templates/admin/photos.html +++ b/internal/web/templates/admin/photos.html @@ -508,8 +508,7 @@ if (!window.flatpickr) return; if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) { - if (el._flatpickr) return; - flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' }); + window.rondDatetime.attachPicker(el); }); } catch (e) { console.error('flatpickr 初始化失败', e); } } diff --git a/internal/web/templates/admin/settings.html b/internal/web/templates/admin/settings.html index b584ee6..b5a51e0 100644 --- a/internal/web/templates/admin/settings.html +++ b/internal/web/templates/admin/settings.html @@ -142,6 +142,26 @@ +
+

统计脚本

+
第三方访问统计 / 追踪脚本。默认关闭,打开后只注入公开页面,后台与登录页不挂
+ + +
+
填一个 https://… 地址会包成 <script src=…>; + 需要初始化代码的服务(Umami、百度统计等)可以把整段 <script> 贴进来。
+
整段 <script> 需要闭合标签配对(多一个 </script> 会被判为粘贴错误); + src 只接受 https://、http:// 或以 / 开头的地址, + 超过 4096 字符会被截断。填了但没勾开关不会生效。
+
conf/app.ini 的 track_script 会作为这里的默认值—— + 它也需要开关打开才注入。
+
+
+
取消 diff --git a/internal/web/templates/admin/topics.html b/internal/web/templates/admin/topics.html index 32cc88c..f9058ae 100644 --- a/internal/web/templates/admin/topics.html +++ b/internal/web/templates/admin/topics.html @@ -96,7 +96,7 @@ if (!window.flatpickr) return; if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh); document.querySelectorAll('input[type=datetime-local]').forEach(function (el) { - flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' }); + window.rondDatetime.attachPicker(el); }); } catch (e) { console.error('flatpickr 初始化失败', e); } }); diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 6fa0de0..822c392 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -225,7 +225,13 @@ {{if .Flatpickr}} +{{/* 时间框的解析器与输入引导。必须在 flatpickr 之后、页面内联脚本之前: + 它不自己调 flatpickr,只提供 parseDate 与 attach,由各页 whenReady 调 attachPicker。 */}} + {{end}} +{{/* 输入弹层:替代 window.prompt(它会把提示文本与默认值写进地址栏)。 + 全站都可能用到(报告页复制链接、后台活动改名),所以无条件引入。 */}} + {{/* 第三方统计脚本:只在公开页面挂,后台/登录页不挂(免得把自己的操作也统计进去) */}} {{if and .TrackScript (ne .Nav "admin")}} {{.TrackScript}} diff --git a/internal/web/templates/report.html b/internal/web/templates/report.html index c38fd77..c6be96d 100644 --- a/internal/web/templates/report.html +++ b/internal/web/templates/report.html @@ -123,19 +123,22 @@ (function () { var share = document.getElementById('btn-share'); var pdf = document.getElementById('btn-pdf'); - if (share) share.addEventListener('click', function () { + if (share) share.addEventListener('click', async function () { var url = location.href; if (navigator.share) { navigator.share({ title: document.title, url: url }).catch(function () {}); return; } + // 复制失败时不再用 window.prompt 兜底(它会把整条链接写进地址栏并留进历史), + // 改成只读弹层:内容照样能选中 Ctrl+C,地址栏保持干净。 + var fallback = function () { return window.rondPrompt.ask('复制下面的链接:', url, { readonly: true }); }; if (navigator.clipboard) { navigator.clipboard.writeText(url).then(function () { share.textContent = '已复制链接'; setTimeout(function () { share.textContent = '复制链接'; }, 1600); - }).catch(function () { window.prompt('复制下面的链接:', url); }); + }).catch(fallback); } else { - window.prompt('复制下面的链接:', url); + fallback(); } }); if (pdf) pdf.addEventListener('click', function () { window.print(); }); diff --git a/internal/web/track_tag_test.go b/internal/web/track_tag_test.go index f0e3a91..12ea7c2 100644 --- a/internal/web/track_tag_test.go +++ b/internal/web/track_tag_test.go @@ -32,3 +32,52 @@ func TestTrackTag(t *testing.T) { t.Error("不以 http 开头但含尖括号以外的内容应原样输出") } } + +// TestCleanTrackScript 盯住「贴进来的是统计脚本」这道关口。 +// +// 这段内容会被原样注入**每一个公开页面**,因此必须挡住三类输入: +// 提前闭合 (页面布局散架)、javascript: 伪协议、超长误粘。 +// 判据写的是「危险输入一律清空」,所以这里逐条钉住放行与拒绝的边界。 +func TestCleanTrackScript(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"空串", "", ""}, + {"纯空白", " \n\t ", ""}, + {"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js"}, + {"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js"}, + {"http 也放行", "http://a/b.js", "http://a/b.js"}, + {"整段 script 原样保留(百度统计这类)", + ``, + ``}, + + // ↓ 必须拒的 + {"提前闭合 script", `abc`, ""}, + {"大写闭合标签也挡", `abc`, ""}, + {"大写 SRC 伪协议", ``, ""}, + {"data: 伪协议", ``, ``}, + {"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096)}, + } + for _, c := range cases { + if got := cleanTrackScript(c.in); got != c.want { + t.Errorf("%s:cleanTrackScript(%q) = %q,期望 %q", c.name, c.in, got, c.want) + } + } + // 定义性不变量:任何输入都不许产出「含 `, `ABC`, ``, + } + for _, in := range dangerous { + if got := cleanTrackScript(in); strings.Contains(strings.ToLower(got), " %q", in, got) + } + } + if len(cleanTrackScript(strings.Repeat("a", 9000))) > 4096 { + t.Error("超长内容没有被截断到 4096") + } +}