diff --git a/internal/web/frontend_fixes_test.go b/internal/web/frontend_fixes_test.go new file mode 100644 index 0000000..94ff565 --- /dev/null +++ b/internal/web/frontend_fixes_test.go @@ -0,0 +1,200 @@ +package web + +import ( + "os" + "strings" + "testing" +) + +// ---------- F5:地图脚本里的 localStorage 访问必须走兜底 ---------- +// +// 隐私模式 / 禁用 Cookie 的浏览器里 localStorage 读写都会抛异常,裸调用会把整段脚本打断 +// (地图白屏、图层不出来,且控制台之外看不出来)。统一走 lsGet/lsSet。 +func TestMapLocalStorageGoesThroughGuard(t *testing.T) { + b, err := staticFS.ReadFile("static/js/rond-map.js") + if err != nil { + t.Fatalf("读取 rond-map.js 失败: %v", err) + } + src := string(b) + if !strings.Contains(src, "function lsGet(k)") || !strings.Contains(src, "function lsSet(k, v)") { + t.Fatal("rond-map.js 里找不到 lsGet/lsSet 兜底(写法变了,这条守卫失效)") + } + if n := strings.Count(src, "window.localStorage."); n != 2 { + t.Errorf("裸 localStorage 访问应只剩 lsGet/lsSet 里那 2 处,实际 %d 处"+ + "(其余一律要走 lsGet/lsSet,否则隐私模式下整段脚本会抛异常)", n) + } +} + +// ---------- F6:配置未就绪时的第二次 preview 不能丢点 ---------- +func TestMapPreviewQueuesPendingPoints(t *testing.T) { + b, err := staticFS.ReadFile("static/js/rond-map.js") + if err != nil { + t.Fatalf("读取 rond-map.js 失败: %v", err) + } + src := string(b) + body := funcBody(src, "preview: function (opts)") + if body == "" { + t.Fatal("在 rond-map.js 里找不到 preview(写法变了,这条守卫失效)") + } + queue := strings.Index(body, "previewQueue[opts.id] = points;") + live := strings.Index(body, "if (live) {") + if queue < 0 || live < 0 || queue > live { + t.Error("preview:必须在 `if (live)` 之前就把本次的点记进 previewQueue," + + "否则配置未就绪时第二次 preview 的点会被静默丢掉") + } + if !strings.Contains(body, "if (previewQueue[opts.id]) points = previewQueue[opts.id];") { + t.Error("preview:配置回调里没把 previewQueue 排队的点取回来画") + } + if strings.Contains(src, "previewPending") { + t.Error("rond-map.js 里不该再有 previewPending(已由 previewQueue 取代)") + } +} + +// ---------- F8:EXIF 全 0 时间不能变成 1899-11-30 ---------- +func TestExifRejectsOutOfRangeDate(t *testing.T) { + b, err := staticFS.ReadFile("static/js/photo-exif.js") + if err != nil { + t.Fatalf("读取 photo-exif.js 失败: %v", err) + } + src := string(b) + guard := strings.Index(src, "Y >= 1900") + mk := strings.Index(src, "res.shot = new Date(Y, Mo - 1, D, H, Mi, S);") + flag := strings.Index(src, "res.hasTime = true;") + if guard < 0 || mk < 0 || flag < 0 || guard > mk || guard > flag { + t.Error("photo-exif.js:拍摄时间必须先过范围校验再赋给 res.shot / hasTime" + + "(否则「0000:00:00」会被 Date 归一成 1899-11-30,被当成到达时间写进库)") + } +} + +// ---------- F9:findExifItemID 读 count 前必须卡住宽度 ---------- +func TestExifItemIDChecksBoundsBeforeRead(t *testing.T) { + b, err := staticFS.ReadFile("static/js/photo-exif.js") + if err != nil { + t.Fatalf("读取 photo-exif.js 失败: %v", err) + } + body := funcBody(string(b), "function findExifItemID(") + if body == "" { + t.Fatal("在 photo-exif.js 里找不到 findExifItemID(写法变了,这条守卫失效)") + } + bounds := strings.Index(body, "p + 2 > u8.length") + read := strings.Index(body, "dv.getUint16(p)") + if bounds < 0 || read < 0 || bounds > read { + t.Error("findExifItemID:读 count 之前必须按 ver 卡住宽度(p+2 / p+4)——" + + "末尾恰好相等(start+4 == length)时旧守卫会放行,越界会抛 RangeError") + } +} + +// ---------- F16:数据编辑页每个筛选表单都要带上全部筛选条件 ---------- +// +// 三个表单各自只放自己那几个控件,其余条件靠隐藏域带着;漏一个,用户一提交 +// 就会把别的列表的筛选悄悄清掉(原来「到访」表单只带 vq+back)。 +func TestEditSearchFormsCarryAllFilters(t *testing.T) { + b, err := tmplFS.ReadFile("templates/admin/edit.html") + if err != nil { + t.Fatalf("读取 edit.html 失败: %v", err) + } + src := string(b) + const open = `
") + if j < 0 { + t.Fatalf("第 %d 个筛选表单没有收尾", n) + } + body := rest[:j] + for _, p := range params { + if !strings.Contains(body, p) { + t.Errorf("第 %d 个筛选表单少了 %s:一提交就会把别的列表的筛选清掉", n, p) + } + } + rest = rest[j:] + } + if n < 3 { + t.Fatalf("只找到 %d 个 /admin/edit 筛选表单,预期至少 3 个(写法变了,这条守卫失效)", n) + } +} + +// ---------- F17:`safe` 这个现成的转义出口不该留着 ---------- +func TestNoSafeEscapeFunc(t *testing.T) { + b, err := os.ReadFile(pkgFile(t, "view.go")) + if err != nil { + t.Fatalf("读取 view.go 失败: %v", err) + } + if strings.Contains(string(b), `"safe":`) { + t.Error("view.go 里不该再有 `safe` 模板函数(模板一处都没用,却是个现成的 HTML 注入出口)") + } +} + +// ---------- F18:纯图标按钮要有 aria-label,图片要有 alt ---------- +func TestPlaceImageControlsAreLabelled(t *testing.T) { + b, err := tmplFS.ReadFile("templates/place.html") + if err != nil { + t.Fatalf("读取 place.html 失败: %v", err) + } + src := string(b) + if !strings.Contains(src, `aria-label="删除这张图片"`) { + t.Error("place.html 模板渲染的删除按钮(×)缺 aria-label") + } + if !strings.Contains(src, `setAttribute('aria-label', '删除这张图片')`) { + t.Error("place.html 用 JS 新建的删除按钮(×)缺 aria-label") + } + if strings.Contains(src, `img.alt = ''`) { + t.Error("place.html 新建缩略图时 alt 不能留空") + } +} + +// ---------- F14:窄屏导航链接的内边距别再写死一处(会被后面的触控块盖掉) ---------- +func TestNavLinksNarrowRuleHasNoDeadPadding(t *testing.T) { + b, err := os.ReadFile(pkgFile(t, "static/css/app.css")) + if err != nil { + t.Fatalf("读取 app.css 失败: %v", err) + } + src := string(b) + if !strings.Contains(src, ".nav-links a { font-size: 13px; }") { + t.Fatal("找不到窄屏的 `.nav-links a { font-size: 13px; }`(写法变了,这条守卫失效)") + } + if strings.Contains(src, ".nav-links a { padding:") { + t.Error("窄屏 .nav-links a 又写了 padding:同特异性的内边距由后面「窄屏触控」那块给," + + "写在这里会被盖掉(死代码)") + } +} + +// ---------- F10:补全页的 sessionStorage 读写都要包在 try 里 ---------- +// +// 隐私模式下 sessionStorage 本身就抛,脏数据又会让 JSON.parse 抛;任何一处漏了 try, +// 整段脚本就从那一行断掉——下拉候选、草稿恢复全部哑掉,而且控制台之外看不出来。 +func TestFillupSessionStorageIsGuarded(t *testing.T) { + b, err := tmplFS.ReadFile("templates/admin/fillup.html") + if err != nil { + t.Fatalf("读取 fillup.html 失败: %v", err) + } + src := string(b) + needles := []string{"JSON.parse(sessionStorage", "sessionStorage.setItem"} + total := 0 + for _, n := range needles { + rest := src + for { + i := strings.Index(rest, n) + if i < 0 { + break + } + total++ + if open := strings.LastIndex(rest[:i], "try {"); open < 0 || i-open > 160 { + t.Errorf("fillup.html:`%s` 前面没有紧挨着的 try {(隐私模式 / 脏数据会中断整段脚本)", n) + } + rest = rest[i+len(n):] + } + } + if total == 0 { + t.Fatal("fillup.html 里找不到 sessionStorage 读写(写法变了,这条守卫失效)") + } +} diff --git a/internal/web/map_async_cancel_test.go b/internal/web/map_async_cancel_test.go new file mode 100644 index 0000000..8986ab4 --- /dev/null +++ b/internal/web/map_async_cancel_test.go @@ -0,0 +1,89 @@ +package web + +import ( + "io/fs" + "strings" + "testing" +) + +// funcBody 从 marker 起按大括号配平取出一段函数体(含 marker)。 +// 这几段代码里的字符串字面量都不含大括号,直接数括号是安全的。 +func funcBody(src, marker string) string { + i := strings.Index(src, marker) + if i < 0 { + return "" + } + j := strings.Index(src[i:], "{") + if j < 0 { + return "" + } + depth := 0 + for k := i + j; k < len(src); k++ { + switch src[k] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return src[i : k+1] + } + } + } + return "" +} + +// TestMapLayersDropStaleResponses 守住「开关关着图层还在画」这个竞态。 +// +// 勾选 → 请求发出 → 用户立刻取消 → 请求回包。回包若直接 addTo(map),图层就画上去了。 +// 修法是给每次调用发一个序号,回包时对不上就作废;关键是**序号必须在 `if (!on) return;` +// 之前就递增**,否则取消时压根不会作废在途的那次请求。 +// +// 判据放在源码文本上:这是「两个异步分支的相对顺序」,用 CDP 造这个时序既慢又脆。 +func TestMapLayersDropStaleResponses(t *testing.T) { + js, err := staticFS.ReadFile("static/js/rond-map.js") + if err != nil { + t.Fatalf("读取 rond-map.js 失败: %v", err) + } + src := string(js) + + for _, c := range []struct{ setter, seq, draw string }{ + {"window.rondMap.setGpx = function (on)", "gpxSeq", "addTo(map)"}, + {"window.rondMap.setHeat = function (on)", "heatSeq", "addTo(map)"}, + {"window.rondMap.setCityAgg = function (on)", "citySeq", "addTo(map)"}, + } { + body := funcBody(src, c.setter) + if body == "" { + t.Fatalf("在 rond-map.js 里找不到 %s 的函数体(写法变了,这条守卫就失效了)", c.setter) + } + inc := strings.Index(body, "++"+c.seq) + off := strings.Index(body, "if (!on) return;") + guard := strings.Index(body, "seq !== "+c.seq) + draw := strings.Index(body, c.draw) + if inc < 0 || off < 0 || inc > off { + t.Errorf("%s:必须在 `if (!on) return;` 之前先递增序号(否则取消时作废不了在途回包)", c.setter) + } + if guard < 0 || draw < 0 || guard > draw { + t.Errorf("%s:回包在 %s 之前没有做 `seq !== %s` 作废检查", c.setter, c.draw, c.seq) + } + } + + // 行程轨迹开关在模板里(map.html),症状一样:取消勾选后旧回包把轨迹画回来。 + html, err := fs.ReadFile(tmplFS, "templates/map.html") + if err != nil { + t.Fatalf("读取 map.html 失败: %v", err) + } + body := funcBody(string(html), "function refreshTrack()") + if body == "" { + t.Fatal("在 map.html 里找不到 refreshTrack(写法变了,这条守卫就失效了)") + } + inc := strings.Index(body, "++trackSeq") + off := strings.Index(body, "if (!trackOn)") + guard := strings.Index(body, "seq !== trackSeq") + draw := strings.Index(body, "setTrack(d.segs") + if inc < 0 || off < 0 || inc > off { + t.Error("refreshTrack:必须在 `if (!trackOn)` 之前先递增序号") + } + if guard < 0 || draw < 0 || guard > draw { + t.Error("refreshTrack:回包在 setTrack(d.segs…) 之前没有做 `seq !== trackSeq` 作废检查") + } +} diff --git a/internal/web/map_escape_test.go b/internal/web/map_escape_test.go new file mode 100644 index 0000000..8c0046d --- /dev/null +++ b/internal/web/map_escape_test.go @@ -0,0 +1,57 @@ +package web + +import ( + "strings" + "testing" +) + +// mapHTMLSinks 是「可编辑字段被裸拼进 HTML」的具体写法。这些值来自地点名、交通方式名、 +// 城市名、GPX 轨迹名——站长自己可编辑,但访客页也会一起渲染。 +// +// Leaflet 的 bindTooltip / bindPopup 把字符串参数按 **HTML** 解析,innerHTML 同理, +// 所以少一层 esc() 就是一次注入。同文件里的 popup() 早就用 esc() 了, +// 这四处漏掉纯属不一致(2026-09-21 审计 P1-4)。 +var mapHTMLSinks = []string{ + "bindTooltip(p.n", // 地点名 + "bindTooltip((s.m", // 交通方式名(轨迹 tooltip) + "bindTooltip(t.name", // GPX 轨迹名 + "'' + c.city", // 城市名(城市气泡) + "+ m.name +", // 交通方式名(图例) + "+ (m.color ||", // 交通方式的颜色值,进了 style 属性 +} + +// TestMapTooltipsEscapeEditableFields 守住地图 JS 的 HTML 出口: +// 1. 已知的裸拼接写法一个都不许出现; +// 2. 每一个 bindTooltip / bindPopup 的调用行要么走 esc(),要么传的是 function(自己建 DOM)。 +// +// 判据放在源码文本上而不是浏览器里,是因为这四处的注入后果很轻(站长可编辑的数据偏「自伤」), +// 但渲染错乱是实打实的——用 CDP 起浏览器只为了证明一句 esc() 太贵。 +func TestMapTooltipsEscapeEditableFields(t *testing.T) { + raw, err := staticFS.ReadFile("static/js/rond-map.js") + if err != nil { + t.Fatalf("读取 rond-map.js 失败: %v", err) + } + src := string(raw) + + for _, bad := range mapHTMLSinks { + if strings.Contains(src, bad) { + t.Errorf("rond-map.js 里出现未转义的 HTML 拼接 %q:该字段可编辑,须过 esc()", bad) + } + } + + checked := 0 + for i, line := range strings.Split(src, "\n") { + if !strings.Contains(line, ".bindTooltip(") && !strings.Contains(line, ".bindPopup(") { + continue + } + checked++ + if !strings.Contains(line, "esc(") && !strings.Contains(line, "function") { + t.Errorf("rond-map.js:%d 的 tooltip/popup 参数既没过 esc() 也不是 function,"+ + "Leaflet 会按 HTML 渲染:%s", i+1, strings.TrimSpace(line)) + } + } + if checked == 0 { + t.Fatal("一个 bindTooltip/bindPopup 都没扫到:写法变了,这条守卫就失效了") + } + t.Logf("扫了 %d 处 tooltip/popup", checked) +} diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index a35235f..64ce704 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -27,6 +27,12 @@ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; /* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */ --nav-h: 54px; + /* 地图页浮层的层级:Leaflet 自己的 z-index 封顶在 1000(.leaflet-top/.leaflet-bottom, + 见 vendor/leaflet/leaflet.css),而 #map 那一层没有建立层叠上下文 —— + 站点的浮层排不到 1000 之上,就会被左上角的缩放按钮、右下的比例尺压住。 + 分两档是为了保住原有次序:加载状态条要压在所有浮层之上。 */ + --z-map-overlay: 1100; + --z-map-status: 1150; /* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */ --safe-t: env(safe-area-inset-top, 0px); --safe-r: env(safe-area-inset-right, 0px); @@ -76,7 +82,6 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; } /* ---------- 布局 ---------- */ .wrap { max-width: 1120px; margin: 0 auto; padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); } -.wrap-wide { max-width: 100%; padding: 0; } .page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .page-head h1 { font-size: 21px; } .page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; } @@ -124,7 +129,8 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; } -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); } - .nav-links a { padding: 5px 7px; font-size: 13px; } + /* 内边距由下面「窄屏触控」那块给(同特异性、写在后面才生效);这里只留字号。 */ + .nav-links a { font-size: 13px; } .nav-right .who { display: none; } .nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; } } @@ -159,7 +165,6 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; } 网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。 flex 会让最后一行自己撑满,任何个数都不会有空格。 */ .kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); } -@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; } /* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */ .kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; } @@ -282,7 +287,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); box-sha .btn.danger { color: #b42318; border-color: #f0cecb; } .btn.danger:hover { background: var(--err-bg); } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } -.spacer { flex: 1; } /* ---------- 表格 ---------- */ table.tb { width: 100%; border-collapse: collapse; font-size: 13px; } @@ -317,7 +321,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent- inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l); } .map-panel { - position: absolute; top: 12px; left: 12px; z-index: 400; width: 286px; max-height: calc(100% - 24px); + position: absolute; top: 12px; left: 12px; z-index: var(--z-map-overlay); width: 286px; max-height: calc(100% - 24px); overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); } @@ -354,16 +358,12 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent- .adv-fold > h3 { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; } .map-legend { /* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */ - position: absolute; bottom: 26px; right: 12px; z-index: 400; background: var(--glass-strong); + position: absolute; bottom: 26px; right: 12px; z-index: var(--z-map-overlay); background: var(--glass-strong); border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px; max-height: 40%; overflow-y: auto; box-shadow: var(--shadow); } .map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); } .map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; } -.maplink { display: block; padding: 8px 0; border-bottom: 1px solid var(--line-soft); } -.maplink b { font-weight: 500; } -.maplink span { color: var(--text-3); font-size: 12px; display: block; } -.dot-marker { border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.16); } /* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签—— 不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */ .fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; } @@ -380,7 +380,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent- /* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */ .to-top { - position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: 900; + position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: var(--z-map-status); width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line); background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1; cursor: pointer; box-shadow: var(--shadow); @@ -427,7 +427,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent- 和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */ .timeline-bar { position: absolute; left: 50%; transform: translateX(-50%); - bottom: 12px; z-index: 650; + bottom: 12px; z-index: var(--z-map-overlay); display: flex; align-items: center; gap: 10px; max-width: min(560px, calc(100% - 24px)); padding: 7px 12px; border-radius: 10px; @@ -689,7 +689,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; } .timeline-day { margin-bottom: 20px; } .timeline-day .dh { - position: sticky; top: 54px; background: var(--bg); z-index: 5; padding: 6px 0 7px; + position: sticky; top: var(--nav-h); background: var(--bg); z-index: 5; padding: 6px 0 7px; font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline; } .timeline-day .dh .muted { font-size: 12px; } @@ -857,11 +857,6 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text- .geo-name { display: block; font-size: 13.5px; } .geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; } -/* 「同时创建到访」折叠区 */ -.opt-visit { margin: 0 0 12px; } -.opt-visit > summary { cursor: pointer; color: var(--text-3); font-size: 12.5px; margin-bottom: 8px; } -.opt-visit .row { margin-bottom: 8px; } - /* ---------- 专题 ---------- */ /* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器, 单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */ @@ -871,8 +866,6 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text- .topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); } .topic-head { display: flex; align-items: center; gap: 8px; } .topic-head h2 { margin: 0; } -.topic-card .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); } -@media (max-width: 620px) { .topic-card .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */ .map-fallback { diff --git a/internal/web/static/js/photo-exif.js b/internal/web/static/js/photo-exif.js index 69cc1ad..3561f6e 100644 --- a/internal/web/static/js/photo-exif.js +++ b/internal/web/static/js/photo-exif.js @@ -113,8 +113,14 @@ if (sub[0x9003]) { var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim()); if (m) { - res.shot = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); - res.hasTime = true; + // 全 0 的「0000:00:00 00:00:00」会被 Date 归一成 1899-11-30,然后被当成拍摄时间 + // 写进到访时间;字段超范围一律当「没有时间」,别把垃圾时间带进库。 + var Y = +m[1], Mo = +m[2], D = +m[3], H = +m[4], Mi = +m[5], S = +m[6]; + if (Y >= 1900 && Y <= 2200 && Mo >= 1 && Mo <= 12 && D >= 1 && D <= 31 && + H <= 23 && Mi <= 59 && S <= 59) { + res.shot = new Date(Y, Mo - 1, D, H, Mi, S); + res.hasTime = true; + } } } } @@ -171,6 +177,9 @@ if (start + 4 > u8.length) return 0; var ver = u8[start]; var p = start + 4; + // 末尾恰好相等时(start+4 == length)上面那条守卫会放行,紧接着的 getUint16/32 + // 就越界抛 RangeError(用户看到一句英文的 DataView 报错)。读之前把宽度也卡住。 + if (ver === 0 ? p + 2 > u8.length : p + 4 > u8.length) return 0; var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p); p += ver === 0 ? 2 : 4; for (var i = 0; i < count; i++) { diff --git a/internal/web/static/js/place-image.js b/internal/web/static/js/place-image.js index c5ca442..55b66f7 100644 --- a/internal/web/static/js/place-image.js +++ b/internal/web/static/js/place-image.js @@ -96,7 +96,12 @@ function post(url, form) { return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' }) - .then(function (r) { return r.json(); }); + .then(function (r) { + // 服务端出错时回的是 HTML,直接 r.json() 会抛一句英文 SyntaxError; + // 先判状态码,调用方才能拿到人话错误。 + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }); } // upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}> diff --git a/internal/web/static/js/rond-map.js b/internal/web/static/js/rond-map.js index 79b89e2..648adaf 100644 --- a/internal/web/static/js/rond-map.js +++ b/internal/web/static/js/rond-map.js @@ -26,8 +26,8 @@ modes.forEach(function (m) { var row = L.DomUtil.create('div'); row.innerHTML = '' + - m.name + ' ' + m.count + ''; + 'background:' + esc(m.color || '#30b0c7') + ';margin-right:6px">' + + esc(m.name) + ' ' + esc(m.count) + ''; box.appendChild(row); }); L.DomEvent.disableClickPropagation(box); @@ -89,7 +89,7 @@ L.DomEvent.on(slider, 'input', function () { var v = parseInt(slider.value, 10) / 100; layer.setOpacity(v); - localStorage.setItem('rond.fogOpacity', String(v)); + lsSet('rond.fogOpacity', String(v)); }); return box; } @@ -155,6 +155,7 @@ }; var maps = {}; + var previewQueue = {}; // id -> 最近一次 preview 请求的点集(配置就绪后补画) var cfgCache = null; function register(id, map) { @@ -211,20 +212,27 @@ */ var BASE_PICK = 'rond.base'; + /* localStorage 在隐私模式 / 禁用 Cookie 的浏览器里**读写都会抛异常**, + 裸调用会把整段脚本打断(地图白屏、图层不出来)。统一走这两个兜底,失败当没有。 */ + function lsGet(k) { + try { return window.localStorage.getItem(k); } catch (e) { return null; } + } + function lsSet(k, v) { + try { window.localStorage.setItem(k, v); } catch (e) { /* 隐私模式,忽略 */ } + } + function pickBase(cfg) { var byKey = {}; cfg.base.forEach(function (b) { byKey[b.key] = b; }); - try { - var saved = window.localStorage.getItem(BASE_PICK); - if (saved && byKey[saved]) return byKey[saved]; - } catch (e) { /* 隐私模式禁用了 localStorage,忽略即可 */ } + var saved = lsGet(BASE_PICK); + if (saved && byKey[saved]) return byKey[saved]; if (cfg.default && byKey[cfg.default]) return byKey[cfg.default]; return cfg.base[0]; } function rememberBase(key) { if (!key) return; - try { window.localStorage.setItem(BASE_PICK, key); } catch (e) { /* 同上 */ } + lsSet(BASE_PICK, key); } function esc(s) { @@ -379,7 +387,7 @@ // maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行 m.bindPopup(function () { return popup(p, vote); }, { closeButton: true, maxWidth: 360, minWidth: 240 }); - m.bindTooltip(p.n || '未命名', { direction: 'top', offset: [0, -4] }); + m.bindTooltip(esc(p.n || '未命名'), { direction: 'top', offset: [0, -4] }); m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」 return m; } @@ -543,12 +551,18 @@ // 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」: // 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。 // 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。 + // 记下这一轮要画的点:配置就绪后以「最新一次请求」为准。 + // 原来只在 _state 已建好时才处理;配置还没回来时 _state 里还没有条目, + // 第二次 preview 的点会被静默丢掉——地图上少一批点,且完全不报错。 + previewQueue[opts.id] = points; var live = maps[opts.id]; if (live) { live.invalidateSize(); var prev = window.rondMap._state[opts.id]; - if (prev && prev.setPoints) prev.setPoints(points); - else if (prev) prev.previewPending = points; // 配置还没回来,等 setPoints 建好再补画 + if (prev && prev.setPoints) { + delete previewQueue[opts.id]; + prev.setPoints(points); + } return; } var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false }); @@ -557,6 +571,9 @@ if (vote) loadVotes(); loadConfig().then(function (cfg) { var def = pickBase(cfg); + // 配置期间又来了一次 preview 的话,以那一次的点为准 + if (previewQueue[opts.id]) points = previewQueue[opts.id]; + delete previewQueue[opts.id]; tileLayer(def).addTo(map); var byId = {}; var group = markers(points, def.crs, false, vote, byId); @@ -587,12 +604,6 @@ applyFit(); }; window.rondMap._state[opts.id] = st; - // 配置就绪前有别的调用挂上来的点,现在补画一次 - if (st.previewPending) { - var queued = st.previewPending; - st.previewPending = null; - st.setPoints(queued); - } }); }, @@ -701,7 +712,7 @@ var overlayDefs = {}; if (fogLayer) { overlayDefs['世界迷雾'] = fogLayer; - var savedFog = parseFloat(localStorage.getItem('rond.fogOpacity')); + var savedFog = parseFloat(lsGet('rond.fogOpacity')); if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog); var fogCtl = addFogOpacityControl(map, fogLayer, corner); // 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了; @@ -710,9 +721,9 @@ var on = map.hasLayer(fogLayer); var box = fogCtl.getContainer(); if (box) box.style.display = on ? '' : 'none'; - localStorage.setItem('rond.fogOff', on ? '0' : '1'); + lsSet('rond.fogOff', on ? '0' : '1'); }; - if (localStorage.getItem('rond.fogOff') === '1') syncFog(); + if (lsGet('rond.fogOff') === '1') syncFog(); else fogLayer.addTo(map); map.on('overlayadd overlayremove', function (e) { if (e.name === '世界迷雾') syncFog(); @@ -764,12 +775,15 @@ // 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影 var trackLayer = null, trackSegs = null, heatLayer = null; var cityLayer = null, gpxLayer = null, modeLegend = null; + // 图层开关与异步回包的竞态:勾选后立刻取消时请求还在飞,回包若直接 addTo(map), + // 开关明明关着图层却画了上去(切底图重取也走同一条路)。递增序号作废过期回包。 + var gpxSeq = 0, heatSeq = 0, citySeq = 0; function buildTrack(segs, crs) { var g = L.layerGroup(); segs.forEach(function (s) { var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs); L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 }) - .bindTooltip((s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true }) + .bindTooltip(esc(s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true }) .addTo(g); }); return g; @@ -802,9 +816,11 @@ // 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑 window.rondMap.setCityAgg = function (on) { + var seq = ++citySeq; if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; } if (!on) return; fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) { + if (seq !== citySeq) return; // 期间又切过开关/底图,这次回包作废 var cities = d.cities || []; if (!cities.length) return; var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; })); @@ -815,10 +831,10 @@ L.circleMarker(pos, { radius: r, color: '#2f7d6e', weight: 1.5, fillColor: '#2f7d6e', fillOpacity: 0.18 - }).bindPopup('' + c.city + '' + - (c.province ? ' · ' + c.province : '') + + }).bindPopup('' + esc(c.city) + '' + + (c.province ? ' · ' + esc(c.province) : '') + '
到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' + - (c.first ? '
首次到达 ' + c.first : '')).addTo(cityLayer); + (c.first ? '
首次到达 ' + esc(c.first) : '')).addTo(cityLayer); }); cityLayer.addTo(map); }); @@ -826,24 +842,28 @@ // GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好 window.rondMap.setGpx = function (on) { + var seq = ++gpxSeq; if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; } if (!on) return; ensureGpx(st.crs, function (tracks) { + if (seq !== gpxSeq) return; // 期间又切过开关/底图,这次回包作废 gpxLayer = L.layerGroup(); tracks.forEach(function (t) { if (!t.points || t.points.length < 2) return; L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 }) - .bindTooltip(t.name + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true }) + .bindTooltip(esc(t.name) + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true }) .addTo(gpxLayer); }); gpxLayer.addTo(map); }); }; window.rondMap.setHeat = function (on) { + var seq = ++heatSeq; if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } if (!on) return; // heat 库只在第一次开热力图时才下载,平时不进首屏 ensureHeat(function () { + if (seq !== heatSeq) return; // 期间又切过开关/底图,这次回包作废 if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map); }); }; diff --git a/internal/web/tb_stack_test.go b/internal/web/tb_stack_test.go index 733bc68..429c84f 100644 --- a/internal/web/tb_stack_test.go +++ b/internal/web/tb_stack_test.go @@ -40,6 +40,8 @@ func TestStackedTablesHaveLabels(t *testing.T) { {"/admin/topics", f.srv.topicsAdminPage, true}, // 数据编辑页的三张表默认折叠在卡片里(探针量到的是 display:none),最容易漏标 {"/admin/edit", f.srv.editPage, true}, + // 遮罩表原来既没 tb-stack 也没 .tw,两条守卫都看不见(数据库里没有遮罩时这页也不渲染表) + {"/admin/fog/mask", f.srv.fogMaskPage, true}, } if topicID > 0 { targets = append(targets, target{"/topics/" + strconv.FormatInt(topicID, 10), f.srv.topicPage, false}) diff --git a/internal/web/tb_static_test.go b/internal/web/tb_static_test.go new file mode 100644 index 0000000..d0344b8 --- /dev/null +++ b/internal/web/tb_static_test.go @@ -0,0 +1,135 @@ +package web + +import ( + "io/fs" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// tbStackExempt 是明确不参与窄屏堆叠的表格类名,必须写清理由。 +var tbStackExempt = map[string]string{ + "photo-table": "照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路", +} + +// TestAllTBTablesAreStackable 静态扫全部模板:只要用了 `tb` 这个宽表基类,就必须带 `tb-stack`。 +// +// 这是对 wide_table_layout_test 的补充——那条是**渲染后**按 DOM 栈判的,两种表能躲过去: +// ① 运行期的数据为空(比如库里没有遮罩区),那张表压根没渲染; +// ② 模板忘了写 .tw 包裹,判据里的「在 .tw 里」就不成立。 +// /admin/fog/mask 的遮罩表两条都占了,2026-09-21 审计才发现。 +func TestAllTBTablesAreStackable(t *testing.T) { + tableRe := regexp.MustCompile(`]*class="([^"]*)"`) + + scanned, tables := 0, 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 + } + scanned++ + for _, m := range tableRe.FindAllStringSubmatch(string(raw), -1) { + classes := strings.Fields(m[1]) + if !containsStr(classes, "tb") { + continue + } + tables++ + if containsStr(classes, "tb-stack") { + continue + } + exempt := false + for _, c := range classes { + if _, ok := tbStackExempt[c]; ok { + exempt = true + } + } + if exempt { + continue + } + t.Errorf("%s:用了 tb 却没带 tb-stack(class=%q)——窄屏没有任何堆叠规则可命中,只能横滑", + path, m[1]) + } + return nil + }) + if err != nil { + t.Fatalf("遍历模板失败: %v", err) + } + if scanned == 0 || tables == 0 { + t.Fatalf("没扫到模板/表格(模板目录或类名写法变了?):模板 %d 个、tb 表 %d 张", scanned, tables) + } + t.Logf("扫了 %d 个模板里的 %d 张 tb 表", scanned, tables) +} + +// TestTemplatesHaveBalancedRows 静态钉住模板里 `` 与 `` 数量相等。 +// +// 浏览器会自己把漏掉的 `` 补上,页面看着完全正常——但 tb_stack_test 的行列校验 +// 是按 `…` 配对的,一个没闭合的 `` 会让**那一行整行不被检查** +// (2026-09-21 审计在 report.html 的「最南」行上发现,之前一直静默跳过)。 +// +// 先剥掉 的内容(含标签本身)。 +func stripScriptBlocks(s string) string { + var b strings.Builder + for { + i := strings.Index(s, "") + if j < 0 { + return b.String() + } + end := strings.Index(s[i+j:], "") + if end < 0 { + return b.String() + } + s = s[i+j+end+len(""):] + } +} + +func containsStr(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index 40a03aa..d8fa326 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -205,6 +205,13 @@ {{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}} {{if .Back}}{{end}} + {{/* 三个表单各自只带自己那几个条件,其余条件必须原样带上: + 否则搜一下就把别的列表的筛选悄悄清掉了。 */}} + {{if .VisitQuery}}{{end}} + {{if .MovementFilter.Transport}}{{end}} + {{if .MovementFilter.PlaceID}}{{end}} + {{if .MovementFilter.From}}{{end}} + {{if .MovementFilter.To}}{{end}}