时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(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:
@@ -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():原生 <dialog> 居中弹层,
|
||||
// 带遮罩、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, "<script") {
|
||||
continue
|
||||
}
|
||||
if callRe.MatchString(stripComments(ln)) {
|
||||
hits++
|
||||
t.Errorf("%s 模板非脚本区还有 prompt 调用:%s\n"+
|
||||
"换成 window.rondPrompt.ask()。", path, strings.TrimSpace(ln))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历模板失败: %v", err)
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("一个内联 <script> 块都没扫到:模板目录或写法变了?")
|
||||
}
|
||||
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 也要在:<dialog> 的 ::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()
|
||||
}
|
||||
Reference in New Issue
Block a user