审计修复:前端(模板 / CSS / JS 与无障碍)
- 地图脚本:localStorage 读写走 lsGet/lsSet 兜底(隐私模式下裸访问会打断整段脚本); preview() 在 /api/map-config 还没回来时不丢点(previewQueue 在 if (live) 之前排队); 异步回包一律发序号作废,取消后回来的包不再画 - photo-exif.js:EXIF 时间全 0 不再变成 1899-11-30 写进到访时间;读 count 前先按版本卡宽度 - 数据编辑页:三个筛选表单都把其余条件用隐藏域原样带上,提交不再把别的列表的筛选清掉 - 补全页:sessionStorage 的读写一律包 try(脏数据/隐私模式都会抛) - 窄屏:删掉被后面同特异性规则盖掉的死内边距;宽表堆叠类名成对、判据扩到所有 tb 表 - 无障碍:图片删除按钮补 aria-label,访客画廊图片补 alt - report.html 补上漏掉的 </tr>,fog-mask 的宽表带 tb-stack 本批不碰数据库与导出路径:往返包 823/323/8/30/791/557/2246 全等、 坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。
This commit is contained in:
@@ -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 = `<form method="get" action="/admin/edit"`
|
||||
params := []string{`name="q"`, `name="src"`, `name="sus"`, `name="vq"`,
|
||||
`name="mt"`, `name="mp"`, `name="mfrom"`, `name="mto"`}
|
||||
n := 0
|
||||
rest := src
|
||||
for {
|
||||
i := strings.Index(rest, open)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
n++
|
||||
rest = rest[i:]
|
||||
j := strings.Index(rest, "</form>")
|
||||
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 读写(写法变了,这条守卫失效)")
|
||||
}
|
||||
}
|
||||
@@ -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` 作废检查")
|
||||
}
|
||||
}
|
||||
@@ -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 轨迹名
|
||||
"'<b>' + 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
modes.forEach(function (m) {
|
||||
var row = L.DomUtil.create('div');
|
||||
row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' +
|
||||
'background:' + (m.color || '#30b0c7') + ';margin-right:6px"></span>' +
|
||||
m.name + ' <span style="color:#8a9199">' + m.count + '</span>';
|
||||
'background:' + esc(m.color || '#30b0c7') + ';margin-right:6px"></span>' +
|
||||
esc(m.name) + ' <span style="color:#8a9199">' + esc(m.count) + '</span>';
|
||||
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('<b>' + c.city + '</b>' +
|
||||
(c.province ? ' · ' + c.province : '') +
|
||||
}).bindPopup('<b>' + esc(c.city) + '</b>' +
|
||||
(c.province ? ' · ' + esc(c.province) : '') +
|
||||
'<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' +
|
||||
(c.first ? '<br>首次到达 ' + c.first : '')).addTo(cityLayer);
|
||||
(c.first ? '<br>首次到达 ' + 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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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(`<table\b[^>]*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 静态钉住模板里 `<tr>` 与 `</tr>` 数量相等。
|
||||
//
|
||||
// 浏览器会自己把漏掉的 `</tr>` 补上,页面看着完全正常——但 tb_stack_test 的行列校验
|
||||
// 是按 `<tr>…</tr>` 配对的,一个没闭合的 `<tr>` 会让**那一行整行不被检查**
|
||||
// (2026-09-21 审计在 report.html 的「最南」行上发现,之前一直静默跳过)。
|
||||
//
|
||||
// 先剥掉 <script> 块:照片表的行是脚本拼字符串生成的,那边的 `<tr>` 不算数。
|
||||
func TestTemplatesHaveBalancedRows(t *testing.T) {
|
||||
checked := 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
|
||||
}
|
||||
body := stripScriptBlocks(string(raw))
|
||||
open := len(regexp.MustCompile(`<tr\b`).FindAllString(body, -1))
|
||||
close := strings.Count(body, "</tr>")
|
||||
if open == 0 {
|
||||
return nil
|
||||
}
|
||||
checked++
|
||||
if open != close {
|
||||
t.Errorf("%s:<tr> 有 %d 个、</tr> 只有 %d 个 —— 缺口那一行会被 tb_stack_test 静默跳过",
|
||||
path, open, close)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历模板失败: %v", err)
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("一个含表格行的模板都没扫到:模板目录变了?")
|
||||
}
|
||||
t.Logf("检查了 %d 个含表格行的模板", checked)
|
||||
}
|
||||
|
||||
// stripScriptBlocks 去掉所有 <script>…</script> 的内容(含标签本身)。
|
||||
func stripScriptBlocks(s string) string {
|
||||
var b strings.Builder
|
||||
for {
|
||||
i := strings.Index(s, "<script")
|
||||
if i < 0 {
|
||||
b.WriteString(s)
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(s[:i])
|
||||
j := strings.Index(s[i:], ">")
|
||||
if j < 0 {
|
||||
return b.String()
|
||||
}
|
||||
end := strings.Index(s[i+j:], "</script>")
|
||||
if end < 0 {
|
||||
return b.String()
|
||||
}
|
||||
s = s[i+j+end+len("</script>"):]
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -205,6 +205,13 @@
|
||||
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
|
||||
{{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}}
|
||||
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
|
||||
{{/* 三个表单各自只带自己那几个条件,其余条件必须原样带上:
|
||||
否则搜一下就把别的列表的筛选悄悄清掉了。 */}}
|
||||
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
|
||||
{{if .MovementFilter.Transport}}<input type="hidden" name="mt" value="{{.MovementFilter.Transport}}">{{end}}
|
||||
{{if .MovementFilter.PlaceID}}<input type="hidden" name="mp" value="{{.MovementFilter.PlaceID}}">{{end}}
|
||||
{{if .MovementFilter.From}}<input type="hidden" name="mfrom" value="{{.MovementFilter.From}}">{{end}}
|
||||
{{if .MovementFilter.To}}<input type="hidden" name="mto" value="{{.MovementFilter.To}}">{{end}}
|
||||
<label class="f" style="flex:1 1 200px"><span>搜索</span><input type="text" name="q" value="{{.Query}}" placeholder="名称 / 城市 / 区县"></label>
|
||||
<label class="f" style="flex:0 1 170px"><span>坐标来源</span>
|
||||
<select name="src">
|
||||
@@ -343,6 +350,15 @@
|
||||
<h2>到访记录</h2>
|
||||
<form method="get" action="/admin/edit" class="search-row" style="margin-top:6px">
|
||||
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
|
||||
{{/* 只搜到访不能把别的列表的筛选清掉(原来只带 vq+back,一搜 q/src/sus
|
||||
和行程的 mt/mp/mfrom/mto 全丢了)。 */}}
|
||||
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
|
||||
{{if .SourceFilter}}<input type="hidden" name="src" value="{{.SourceFilter}}">{{end}}
|
||||
{{if .SuspectOnly}}<input type="hidden" name="sus" value="1">{{end}}
|
||||
{{if .MovementFilter.Transport}}<input type="hidden" name="mt" value="{{.MovementFilter.Transport}}">{{end}}
|
||||
{{if .MovementFilter.PlaceID}}<input type="hidden" name="mp" value="{{.MovementFilter.PlaceID}}">{{end}}
|
||||
{{if .MovementFilter.From}}<input type="hidden" name="mfrom" value="{{.MovementFilter.From}}">{{end}}
|
||||
{{if .MovementFilter.To}}<input type="hidden" name="mto" value="{{.MovementFilter.To}}">{{end}}
|
||||
<label class="f"><span>搜索</span><input type="text" name="vq" value="{{.VisitQuery}}" placeholder="地点 / 城市 / 备注 / 活动 / 日期 / #编号"></label>
|
||||
<div class="act"><button class="btn" type="submit">搜索</button><a class="btn" href="{{.EditLink "vq"}}">重置</a></div>
|
||||
</form>
|
||||
@@ -424,6 +440,8 @@
|
||||
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
|
||||
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
|
||||
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
|
||||
{{if .SourceFilter}}<input type="hidden" name="src" value="{{.SourceFilter}}">{{end}}
|
||||
{{if .SuspectOnly}}<input type="hidden" name="sus" value="1">{{end}}
|
||||
<label class="f" style="flex:0 1 150px"><span>交通方式</span>
|
||||
<select name="mt">
|
||||
<option value="">全部</option>
|
||||
|
||||
@@ -40,14 +40,15 @@
|
||||
<div class="card">
|
||||
<h2>已有遮罩区({{len .Masks}})</h2>
|
||||
{{if .Masks}}
|
||||
<table class="tb">
|
||||
<div class="tw">
|
||||
<table class="tb tb-stack">
|
||||
<thead><tr><th>名称</th><th>圆心(纬度, 经度)</th><th>半径</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Masks}}
|
||||
<tr>
|
||||
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
|
||||
<td class="mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
|
||||
<td>{{printf "%.0f" .RadiusM}} 米</td>
|
||||
<td class="mono" data-label="圆心">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
|
||||
<td data-label="半径">{{printf "%.0f" .RadiusM}} 米</td>
|
||||
<td>
|
||||
<button class="btn sm" type="button" data-edit
|
||||
data-id="{{.ID}}" data-name="{{.Name}}"
|
||||
@@ -62,6 +63,7 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="muted">还没有遮罩区。在地图上点选圆心、设置半径后保存即可。</div>
|
||||
{{end}}
|
||||
|
||||
@@ -520,7 +520,11 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ coord: coordSel(), points: coords })
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
}).then(function (r) {
|
||||
// 500 时回的是 HTML,直接 r.json() 会抛 SyntaxError(英文),先判状态码
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (d) {
|
||||
if (d.error) throw new Error(d.error);
|
||||
return d.items || [];
|
||||
});
|
||||
@@ -671,29 +675,24 @@
|
||||
|
||||
// ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ----
|
||||
var modal = document.getElementById('point-modal');
|
||||
var modalInited = false;
|
||||
function openPointMap(lat, lon, name, addr) {
|
||||
document.getElementById('point-modal-title').textContent = name || '点位';
|
||||
document.getElementById('point-modal-meta').textContent =
|
||||
(addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6);
|
||||
modal.style.display = 'flex';
|
||||
var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }];
|
||||
if (!modalInited) {
|
||||
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
|
||||
window.rondMap.preview({ id: 'point-modal-map', points: pts });
|
||||
modalInited = true;
|
||||
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
|
||||
var pin = function () {
|
||||
var st = window.rondMap._state['point-modal-map'];
|
||||
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
|
||||
};
|
||||
setTimeout(pin, 400);
|
||||
setTimeout(pin, 1200);
|
||||
} else {
|
||||
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
|
||||
// 统一交给 preview:它自己区分「首次建图」与「复用换点」,配置还没回来时也会把点排队补画。
|
||||
// 原来这里同步置 modalInited=true,第二次打开时地图实例其实还没建好(_state 里没有条目),
|
||||
// 于是走 else 分支拿不到 st,该画的点被静默丢掉。
|
||||
window.rondMap.preview({ id: 'point-modal-map', points: pts });
|
||||
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
|
||||
var pin = function () {
|
||||
var st = window.rondMap._state['point-modal-map'];
|
||||
if (st && st.setPoints) st.setPoints(pts);
|
||||
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
|
||||
}
|
||||
};
|
||||
setTimeout(pin, 400);
|
||||
setTimeout(pin, 1200);
|
||||
}
|
||||
function closePointMap() { modal.style.display = 'none'; }
|
||||
document.getElementById('point-modal-close').addEventListener('click', closePointMap);
|
||||
|
||||
@@ -212,7 +212,9 @@
|
||||
{{if ne .Nav "map"}}
|
||||
<footer class="ft">
|
||||
{{.Settings.SiteTitle}}{{if .Settings.SiteSubtitle}} · {{.Settings.SiteSubtitle}}{{end}}
|
||||
{{if .Dataset}} · 数据截至 {{d .Dataset.Last}}{{end}}
|
||||
{{/* 到访被删光的数据集 last_visit_at 是 NULL,而 d 收的是 time.Time —— 少判这一层就会
|
||||
nil 解引用、页脚之后整段不渲染(客户端还拿到 200)。 */}}
|
||||
{{if .Dataset}}{{if .Dataset.Last}} · 数据截至 {{d .Dataset.Last}}{{end}}{{end}}
|
||||
</footer>
|
||||
{{end}}
|
||||
{{if .HasMap}}
|
||||
|
||||
@@ -384,13 +384,21 @@
|
||||
});
|
||||
|
||||
var trackOn = false;
|
||||
// 与图层开关同理:勾选后马上取消时请求还在飞,回包会把轨迹画回来;
|
||||
// 序号顺带解决「连点筛选 / 切底图」时旧回包盖掉新回包。
|
||||
var trackSeq = 0;
|
||||
function refreshTrack() {
|
||||
var seq = ++trackSeq;
|
||||
if (!trackOn) { window.rondMap.setTrack(null, null); return; }
|
||||
var q = qs();
|
||||
loadJSON('/api/track' + (q ? '?' + q : ''))
|
||||
.then(function (d) { window.rondMap.setTrack(d.segs || [], d.modes || []); })
|
||||
.then(function (d) {
|
||||
if (seq !== trackSeq) return;
|
||||
window.rondMap.setTrack(d.segs || [], d.modes || []);
|
||||
})
|
||||
// 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态
|
||||
.catch(function (err) {
|
||||
if (seq !== trackSeq) return;
|
||||
trackOn = false;
|
||||
document.getElementById('track-toggle').checked = false;
|
||||
window.rondMap.setTrack(null, null);
|
||||
|
||||
@@ -70,12 +70,12 @@
|
||||
当前存储:<b>{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}}</b>
|
||||
,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。
|
||||
</div>
|
||||
<div id="imgs-admin" data-src="{{.Place.SrcPK}}">
|
||||
<div id="imgs-admin" data-src="{{.Place.SrcPK}}" data-name="{{.Place.Name}}">
|
||||
<div class="imgs-grid" id="imgs-grid">
|
||||
{{range .Images}}
|
||||
<figure class="imgs-cell" data-id="{{.ID}}">
|
||||
<img src="{{.URL}}" alt="" loading="lazy">
|
||||
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张">×</button>
|
||||
<img src="{{.URL}}" alt="{{if $.Place.Name}}{{$.Place.Name}} 的图片{{else}}地点图片{{end}}" loading="lazy">
|
||||
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张" aria-label="删除这张图片">×</button>
|
||||
</figure>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@
|
||||
<button class="imgs-nav imgs-prev" type="button" id="imgs-prev" aria-label="上一张" hidden>‹</button>
|
||||
<button class="imgs-nav imgs-next" type="button" id="imgs-next" aria-label="下一张" hidden>›</button>
|
||||
<div class="imgs-stage" id="imgs-stage">
|
||||
<img id="imgs-img" alt="">
|
||||
<img id="imgs-img" alt="{{if .Place.Name}}{{.Place.Name}} 的图片{{else}}地点图片{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -386,9 +386,11 @@
|
||||
fig.className = 'imgs-cell';
|
||||
fig.setAttribute('data-id', id);
|
||||
var img = document.createElement('img');
|
||||
img.src = url; img.loading = 'lazy'; img.alt = '';
|
||||
img.src = url; img.loading = 'lazy';
|
||||
img.alt = (admin && admin.dataset.name ? admin.dataset.name + ' 的图片' : '地点图片');
|
||||
var del = document.createElement('button');
|
||||
del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张';
|
||||
del.setAttribute('aria-label', '删除这张图片');
|
||||
del.setAttribute('data-id', id);
|
||||
fig.appendChild(img); fig.appendChild(del);
|
||||
return fig;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>最北</td><td data-label="地点">{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num" data-label="纬度">{{f1 .North.Lat}}°N</td></tr>
|
||||
{{if .South}}<tr><td>最南</td><td data-label="地点">{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num" data-label="纬度">{{f1 .South.Lat}}°N</td>{{end}}
|
||||
{{if .South}}<tr><td>最南</td><td data-label="地点">{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num" data-label="纬度">{{f1 .South.Lat}}°N</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPageTemplatesParse 把每个页面模板都解析一遍。
|
||||
@@ -17,3 +20,48 @@ func TestPageTemplatesParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLayoutFooterToleratesMissingLastVisit 钉住页脚在「数据集一条到访都没有」时也渲染得完。
|
||||
//
|
||||
// last_visit_at 可以为 NULL(refreshDatasetAgg 用 LEFT JOIN 求 max,零到访就写 NULL),
|
||||
// 而 d 的形参是 time.Time、实参是 *time.Time:非 nil 时模板会自动解一层指针,**nil 时报
|
||||
// "dereference of nil pointer" 并中断渲染**。render() 只把错误记进日志,客户端拿到的是
|
||||
// HTTP 200 加一个「页脚之后整段不存在」的页面——滚动恢复、地图脚本引入全丢,
|
||||
// 服务端一点动静都没有。所以这里断言「有收尾的 </html>」,而不只是断言没报错。
|
||||
func TestLayoutFooterToleratesMissingLastVisit(t *testing.T) {
|
||||
base, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(tmplFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatalf("解析 layout.html: %v", err)
|
||||
}
|
||||
tpl, err := base.Parse(`{{define "content"}}正文{{end}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("补 content 块: %v", err)
|
||||
}
|
||||
last := time.Date(2026, 9, 21, 12, 0, 0, 0, time.Local)
|
||||
cases := []struct {
|
||||
name string
|
||||
ds *Dataset
|
||||
want string
|
||||
}{
|
||||
{"没有数据集", nil, ""},
|
||||
{"数据集里一条到访都没有", &Dataset{Name: "空数据集"}, ""},
|
||||
{"正常", &Dataset{Name: "有数据", Last: &last}, "数据截至 2026-09-21"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.ExecuteTemplate(&buf, "layout", Page{Nav: "home", Title: "测", Dataset: c.ds}); err != nil {
|
||||
t.Fatalf("%s: 渲染失败: %v", c.name, err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "</html>") {
|
||||
t.Errorf("%s: 页面被截断(没有收尾的 </html>)", c.name)
|
||||
}
|
||||
if c.want == "" {
|
||||
if strings.Contains(got, "数据截至") {
|
||||
t.Errorf("%s: 没有可信的截止时间时不该渲染「数据截至」", c.name)
|
||||
}
|
||||
} else if !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: 页脚应含 %q", c.name, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +202,17 @@ func checkWideTablesOutsideGrid(t *testing.T, path, body string) int {
|
||||
}
|
||||
if !strings.Contains(tag, "tb-stack") {
|
||||
// 照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路,暂不纳入堆叠。
|
||||
if inTW && !strings.Contains(tag, "photo-table") {
|
||||
t.Errorf("%s:.tw 里的表格没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑)", path)
|
||||
if strings.Contains(tag, "photo-table") {
|
||||
continue
|
||||
}
|
||||
// 判据从「在 .tw 里」扩成「只要用了 tb 这个宽表基类就得堆叠」:
|
||||
// 漏了 .tw 包裹的宽表(/admin/fog/mask 的遮罩表就是这样)以前两条守卫都看不见。
|
||||
usesTB := false
|
||||
if m := classRe.FindStringSubmatch(tag); m != nil {
|
||||
usesTB = strings.Contains(" "+m[1]+" ", " tb ")
|
||||
}
|
||||
if inTW || usesTB {
|
||||
t.Errorf("%s:宽表没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑):%s", path, tag)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user