diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index e3d4776..36833b1 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -96,6 +96,10 @@ func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) { p.RangeLabel = rangeLabel(p.From, p.To) p.FilterBase = r.URL.Path p.Presets = buildPresets(p.From, p.To) + // 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用), + // BackQS 是把自己挂给别人的(列表页生成详情链接用)。 + p.Back = backFromRequest(r) + p.BackQS = backQS(r) return p, nil } diff --git a/internal/web/pagelink_test.go b/internal/web/pagelink_test.go new file mode 100644 index 0000000..e60ae4b --- /dev/null +++ b/internal/web/pagelink_test.go @@ -0,0 +1,87 @@ +package web + +import ( + "html/template" + "strings" + "testing" +) + +// 数据编辑页的翻页链接必须「带条件、不双重转义」。这两件事都踩过坑: +// +// - 等号被 URL 上下文转义成 %3d:`mt%3d4` 在服务端解出来是「键为 mt=4」的参数, +// 筛选条件翻页时静默失效 —— 所以 PageLink 返回 template.URL; +// - & 被手工写成 &:template.URL 在 href 里仍会过一次 urlNormalizer,它自己会做 +// HTML 属性转义,手工写一遍的结果是 `&`,浏览器解出来是「字面的 &」, +// 参数名变成 `amp;vpage`,表现为「带筛选时翻页没反应、筛选还丢了」(实测确认过)。 +func TestEditPageLink(t *testing.T) { + d := EditData{Page: Page{Back: template.URL("/places?city=河池市")}} + d.Query = "家" + d.VisitQuery = "咖啡" + got := string(d.PageLink("vpage", 2)) + + if strings.Contains(got, "&") { + t.Errorf("含 & 会被二次转义成 &:%s", got) + } + for _, want := range []string{"q=%E5%AE%B6", "vq=", "back=%2Fplaces", "vpage=2"} { + if !strings.Contains(got, want) { + t.Errorf("翻页链接丢了 %q:%s", want, got) + } + } + // 键值之间的等号必须是裸的:写成 %3d 时服务端解出来是「键为 vpage=2」的参数, + // 翻页静默失效。上面逐项断言 "vpage=2" 就是在守这条。 + // (back 的值里出现 %3D 是正常的 —— 那是内层地址自己的编码,必须保持。) + if !strings.HasPrefix(got, "/admin/edit?") { + t.Errorf("链接开头不对:%s", got) + } + // 分隔符必须是裸的 &(交给模板那层做属性转义) + if strings.Count(got, "&") != 3 { + t.Errorf("参数分隔符数量不对(期望 3 个裸 &):%s", got) + } + + // 没有 back 时不该凭空多出参数 + bare := string(EditData{}.PageLink("page", 3)) + if bare != "/admin/edit?page=3" { + t.Errorf("无筛选时链接不对:%s", bare) + } +} + +// 「在地图中查看」把地点名当查询参数拼进地址,名字里的 & / # / 空格都必须编码: +// 直接内插的话 & 会把参数拆断、# 会把后面整段当 fragment 吃掉。 +func TestMapLink(t *testing.T) { + got := string(Page{}.MapLink("A&B #咖啡 100%")) + want := "/map?q=A%26B+%23%E5%92%96%E5%95%A1+100%25" + if got != want { + t.Errorf("MapLink = %q,期望 %q", got, want) + } + if got := string(Page{}.MapLink(" 家 ")); got != "/map?q=%E5%AE%B6" { + t.Errorf("首尾空白应去掉:%q", got) + } +} + +// 返回按钮的文案要从 back 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串, +// 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上, +// 按钮退化成光秃秃的「返回」。 +func TestBackLabel(t *testing.T) { + cases := []struct{ back, want string }{ + {"/places?city=河池市&sort=dwell", "返回地点库"}, + {"/places/2141", "返回地点库"}, + {"/timeline?from=2026-01-01", "返回时间线"}, + {"/map?q=家", "返回地图"}, + {"/stats", "返回数据统计"}, + {"/topics/3", "返回专题"}, + {"/admin/edit?q=家", "返回数据编辑"}, + {"/admin", "返回后台"}, + {"/", "返回首页"}, + {"/something-else", "返回"}, + {"", ""}, + } + for _, c := range cases { + p := Page{} + if c.back != "" { + p.Back = template.URL(c.back) + } + if got := p.BackLabel(); got != c.want { + t.Errorf("BackLabel(%q) = %q,期望 %q", c.back, got, c.want) + } + } +} diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index 05dd7a0..2f3066d 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -128,9 +128,8 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; } @media (max-width: 620px) { .nav-links a, .btn, .btn.sm, .chip { min-height: 44px; } .nav-links a { display: inline-flex; align-items: center; padding: 8px 9px; } - .chip { padding: 9px 12px; } - .btn, .btn.sm { padding: 10px 14px; } - .check input { width: 20px; height: 20px; } + /* 内边距与控件尺寸的窄屏覆盖在文件末尾(那几组选择器的基础规则在下面, + 写在这里会被它们盖掉);这里只放基础规则里没有的属性。 */ /* iOS 聚焦字号小于 16px 的控件时会自动放大整页——用户点完输入框页面突然变大, 还要手动缩回去。窄屏统一提到 16px 规避(桌面端不受影响,保持紧凑)。 */ :root { --field-fs: 16px; } @@ -968,3 +967,30 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten /* 照片导入的进度/结果提示:脚本直接往元素里写文本,空的时候自己收起 */ #parse-status { margin: 12px 0 0; } #parse-status:empty { display: none; } + +/* ---------- 窄屏触控与控件字号(必须放在最后) ---------- + 这几组选择器(.chip / .btn / .check input)的基础规则写在文件中部, + 而 CSS 同特异性下靠后的赢 —— 把它们写在前面那份「窄屏触控」块里是被盖掉的 + (实测 .check input 的 20px 一直没生效,量出来还是 15px)。所以统一放这里。 + 量法与判据见技能 rond-web-verify 的 audit/mobile.js:触控目标 ≥44px、 + 输入框字号 ≥16px(否则 iOS 聚焦时会自放大整页)。 */ +@media (max-width: 620px) { + .chip { padding: 9px 12px; } + .btn, .btn.sm { padding: 10px 14px; } + /* 勾选框:原来 15px,手指很难点中(尤其后台设置页一屏几十个开关)。 + 有效目标仍是外面那圈 label,但控件本身大一点更好瞄。 */ + .check input, input[type=checkbox], input[type=radio] { width: 20px; height: 20px; } + /* 下拉框 39px 差 5px 到 44px,补齐 */ + select { min-height: 44px; } + /* 表格里的控件保持紧凑:那张表列多、行密,抬到 44px 会让整张表膨胀; + .photo-table input[type=checkbox]{width:auto} 是既有约定,别覆盖。 */ + table.tb select, .photo-table select { min-height: 0; } + /* file / color 这两种被基础规则排除在外(不套文本框外观),也就没吃到 + --field-fs,窄屏仍是 13.3px ⇒ iOS 一聚焦就放大。单独提上来。 */ + input[type=file], input[type=color] { font-size: 16px; } + /* 11.5px 的标签在手机上偏小(/stats 一页 29 处、/ 15 处) */ + .tag { font-size: 12px; } + /* 导航左上角那个 logo 是回首页的入口,原来只有 25×29:给它撑到 44 高, + 横向也补到 44(.brand .t 在窄屏被隐藏,加内边距就够了)。 */ + .brand { min-width: 44px; min-height: 44px; justify-content: center; } +} diff --git a/internal/web/static/js/rond-map.js b/internal/web/static/js/rond-map.js index b6969b3..79b89e2 100644 --- a/internal/web/static/js/rond-map.js +++ b/internal/web/static/js/rond-map.js @@ -10,6 +10,11 @@ var PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323; var DATA_CRS = 'gcj02'; + // 地图弹窗里「查看详情」要带回的返回地址(?back=):地图页有自己的筛选,点进详情再回来时 + // 要回到那份筛选,而不是裸地点库。值是服务端编码好的参数值,直接拼,别再转义一次。 + var backQS = ''; + function backSuffix() { return backQS ? '?back=' + backQS : ''; } + // 交通方式图例:轨迹开启时告诉用户颜色含义 function modeLegendControl(modes) { var Ctl = L.Control.extend({ @@ -341,7 +346,7 @@ // 备注只读直出,省得多点一次详情页 if (p.note) parts.push('
' + esc(p.note) + '
'); if (p.t) parts.push('
最近一次 ' + esc(p.t) + '
'); - parts.push('
查看详情 →
'); + parts.push('
查看详情 →
'); wrap.innerHTML = parts.join(''); if (vote && p.sp) { // 无论如何都挂上,交给 paintVote 决定显不显示; @@ -530,6 +535,7 @@ preview: function (opts) { var points = opts.points || []; var vote = opts.vote === true; + backQS = opts.back || ''; // 同一个容器画第二次(照片导入页反查完画一次、点「重新反查」又画一次): // Leaflet 不允许对已经有地图的容器再 L.map(),会抛 // "Map container is already initialized."——那个错会被调用方的 catch 接住, @@ -624,6 +630,7 @@ // 底图版权(© 高德地图 / © OpenStreetMap)必须留着——那是上游的使用条款要求。 L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(map); var points = opts.points || []; + backQS = opts.back || ''; // 窄屏筛选面板占满宽度,控件改挂右下角(该尺寸下图例已隐藏) var corner = window.matchMedia('(max-width: 620px)').matches ? 'bottomright' : 'topright'; L.control.zoom({ position: corner }).addTo(map); diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index 7a42a0b..2be30a6 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -6,6 +6,8 @@
手动补录没带手机时的记录点(新增地点 / 到访 / 行程 / 天气),改完在下方一键导出为 .rondbackup
+ {{/* 从筛选过的列表点「编辑」进来的:给一个回得去的出口(筛选条件还在那个地址里) */}} + {{if .Back}}← {{.BackLabel}}{{end}} 返回后台 导出 rondbackup @@ -122,6 +124,8 @@

地点列表

+ {{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}} + {{if .Back}}{{end}} {{if .VisitQuery}}{{end}} -
重置
+
重置
共 {{num .Total}} 个地点,按到访数排序{{if .SuspectCount}};其中 {{num .SuspectCount}} 个的显示坐标疑似就是 WGS-84(当初没做偏移,底图上会偏 500 米左右),行内已标注{{end}}。「坐标来源」是建点时留下的记录:照片导入会把当时选的坐标系一起记下来,未留档的是 2026-09 之前的行,只能靠原图或行内标注判断。
{{if .Places}} @@ -243,8 +247,9 @@

到访记录

+ {{if .Back}}{{end}} -
重置
+
重置
共 {{num .VisitTotal}} 条{{if .VisitQuery}}(匹配「{{.VisitQuery}}」){{end}},按时间倒序。可修改时间 / 类型 / 备注,或删除。
{{if .Visits}} @@ -321,6 +326,7 @@

行程记录

+ {{if .Back}}{{end}} {{if .Query}}{{end}} {{if .VisitQuery}}{{end}}
- ← 返回地点库 - 在地图中查看 - {{if .IsAdmin}}编辑{{end}} + {{/* 有 back 就按它回去:从筛选过的地点库/时间线/地图点进来时,这里要回到**那份筛选结果** */}} + ← {{if .BackLabel}}{{.BackLabel}}{{else}}返回地点库{{end}} + 在地图中查看 + {{if .IsAdmin}}编辑{{end}} {{if .Images}}{{end}}
diff --git a/internal/web/templates/places.html b/internal/web/templates/places.html index 788fa94..253de8f 100644 --- a/internal/web/templates/places.html +++ b/internal/web/templates/places.html @@ -51,7 +51,7 @@ {{range .Places}} - {{if .Name}}{{.Name}}{{else}}未命名地点{{end}} + {{if .Name}}{{.Name}}{{else}}未命名地点{{end}} {{if .IsHome}}家{{end}} {{if .IsWork}}工作{{end}} {{if eq .Verdict 1}}👍 推荐{{else if eq .Verdict -1}}👎 踩雷{{end}} diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html index fc8ca70..c620807 100644 --- a/internal/web/templates/stats.html +++ b/internal/web/templates/stats.html @@ -31,7 +31,7 @@
👍 推荐的地点
{{if .Recommended}} {{else}}
还没有标记
{{end}}
@@ -39,7 +39,7 @@
👎 踩雷的地点
{{if .Avoided}}
- {{range .Avoided}}{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}} · {{.City}}{{end}}{{end}} + {{range .Avoided}}{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}} · {{.City}}{{end}}{{end}}
{{else}}
还没有标记
{{end}}
diff --git a/internal/web/templates/timeline.html b/internal/web/templates/timeline.html index da22c18..45a5487 100644 --- a/internal/web/templates/timeline.html +++ b/internal/web/templates/timeline.html @@ -32,7 +32,7 @@
- {{if .PlaceID}}{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}{{else}}未命名地点{{end}} + {{if .PlaceID}}{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}{{else}}未命名地点{{end}} {{if .ActivityName}}{{.ActivityName}}{{end}} {{range .Tags}}{{.}}{{end}}
diff --git a/internal/web/templates/topic.html b/internal/web/templates/topic.html index 573877c..919ad66 100644 --- a/internal/web/templates/topic.html +++ b/internal/web/templates/topic.html @@ -81,7 +81,8 @@ points: {{.PointsJSON}}, fitCircle: {{.FitCircleJSON}}, fitBBox: {{.FitBBoxJSON}}, - showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}} + showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}}, + back: '{{.BackQS}}' }); } diff --git a/internal/web/view.go b/internal/web/view.go index 0336bf8..a2b9d19 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -76,6 +76,15 @@ type Page struct { // HasTopics 表示该数据集有专题,导航里才露出「专题」入口。 HasTopics bool + + // Back 是「回到哪里」(来自 ?back=):列表页把自己的地址挂给详情页,详情页的「返回」 + // 和「编辑」再把它带下去 —— 这样从筛选结果点进去、编辑完还能回到**同一份筛选**, + // 而不是回到裸列表。只接受站内路径,空表示没有来源。 + Back template.URL + + // BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值, + // 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。 + BackQS template.URL } // IsAdmin 报告当前访问者是否已登录后台。 @@ -133,8 +142,116 @@ func (p Page) filterPairs() []string { return kv } -// Link 在保留当前筛选条件的前提下生成链接。 -// Link 以当前筛选参数为底生成链接。extra 里的键会**覆盖**同名项,而不是追加—— +// backQS 把当前请求的地址编码成 ?back= 的值。 +// +// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页), +// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。 +// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。 +func backQS(r *http.Request) template.URL { + q := r.URL.Query() + q.Del("back") + // ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次 + q.Del("ok") + q.Del("err") + u := r.URL.Path + if e := q.Encode(); e != "" { + u += "?" + e + } + return template.URL(url.QueryEscape(u)) +} + +// backFromRequest 取出 ?back= 并只放行**站内路径**。 +// +// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以这里必须挡死 +// 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 —— +// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。 +func backFromRequest(r *http.Request) template.URL { + b := strings.TrimSpace(r.URL.Query().Get("back")) + if b == "" || !strings.HasPrefix(b, "/") || strings.HasPrefix(b, "//") { + return "" + } + for i := 0; i < len(b); i++ { + // 控制字符(含 %00、%0b 这类)与能跳出属性的符号一律不收: + // 浏览器会把 NUL 之类的字符悄悄吃掉,留着就是给「同一串地址在不同环节解析成不同东西」留口子 + if c := b[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' { + return "" + } + } + return template.URL(b) +} + +// BackRaw 是 Back 的纯字符串形式,给**非 href** 的场景用(表单的 hidden 字段): +// 那种位置由模板按普通属性做 HTML 转义就够了,用 template.URL 反而会被当成 URL 再处理一遍。 +func (p Page) BackRaw() string { return string(p.Back) } + +// BackLabel 给「返回」按钮起个名字:从哪儿来的就写哪儿。 +// 先剥掉查询串与 fragment,再取「第一个路径段」—— +// 否则 `/places?city=X` 这种「带筛选、没有下级路径」的地址整串都是第一段, +// 匹配不上任何前缀,按钮就退化成光秃秃的「返回」(这个坑踩过一次)。 +func (p Page) BackLabel() string { + b := string(p.Back) + if b == "" { + return "" + } + if i := strings.IndexAny(b, "?#"); i >= 0 { + b = b[:i] + } + seg := strings.SplitN(strings.TrimPrefix(b, "/"), "/", 2)[0] + switch seg { + case "": + return "返回首页" + case "places": + return "返回地点库" + case "timeline": + return "返回时间线" + case "map": + return "返回地图" + case "stats": + return "返回数据统计" + case "topics": + return "返回专题" + case "admin": + if strings.HasPrefix(b, "/admin/edit") { + return "返回数据编辑" + } + return "返回后台" + } + return "返回" +} + +// PlaceLink 指向某个地点的详情页,并把「返回到当前这份筛选结果」一起带上。 +func (p Page) PlaceLink(id int64) template.URL { + u := "/places/" + strconv.FormatInt(id, 10) + if p.BackQS != "" { + u += "?back=" + string(p.BackQS) + } + return template.URL(u) +} + +// EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。 +// 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。 +func (p Page) EditPlaceLink(srcPK int) template.URL { + back := p.Back + if back == "" { + back = p.BackQS + } + u := "/admin/edit?open=" + strconv.Itoa(srcPK) + if back != "" { + u += "&back=" + url.QueryEscape(string(back)) + } + return template.URL(u) +} + +// MapLink 是「在地图中查看这个地点」:按名字筛。 +// +// 名字必须 QueryEscape —— 以前是直接内插进 href 的,名字里带 & 会把参数拆断、 +// 带 # 会把后面整段吃掉(浏览器当 fragment)。真机数据里现在没有这种名字, +// 但用户随时能自己起一个。 +func (p Page) MapLink(name string) template.URL { + return template.URL("/map?q=" + url.QueryEscape(strings.TrimSpace(name))) +} + +// Link 在保留当前筛选条件的前提下生成链接。extra 里的键会**覆盖**同名项,而不是追加—— // 否则会拼出 ?sort=dwell&sort=name 这种重复参数,而取参数只认第一个, // 表现为「点了另一个排序没反应」。extra 里值为空的键会被丢掉, // 这样「回到默认排序」只要传 sort=""(buildURL 会跳过空值)。 @@ -643,8 +760,37 @@ func (f MovementFilter) set(v url.Values) { // // 返回 template.URL 是必须的:href 里再经 html/template 的 URL 转义会把 "=" 编成 // %3d,`mt%3d4` 在服务端解出来是「键为 mt=4」的参数,筛选条件会在翻页时静默失效。 -// & 手工写成实体,交给浏览器的解析结果仍是 &。 +// +// 这里**不能**再手工把 & 写成 &:template.URL 在 href 里仍会过一次 urlNormalizer, +// 它会替我们做 HTML 属性转义。手工写一遍的结果是 `&amp;`,浏览器解出来是「字面的 +// &」,参数名变成 `amp;vpage` —— 表现就是「带筛选时翻页没反应、筛选还丢了」。 func (d EditData) PageLink(param string, n int) template.URL { + v := d.editFilterValues() + v.Set(param, strconv.Itoa(n)) + // back 也要跟着翻页走,否则在数据编辑页翻一页,「返回地点库」就没了 + d.putBack(v) + return template.URL("/admin/edit?" + v.Encode()) +} + +// EditLink 是数据编辑页自己的一个链接:保留三个列表当前的全部筛选与 back, +// clear 里的键会被去掉 —— 用于「重置某个列表的筛选」。 +// +// 以前这些「重置」是在模板里手拼 `?vq={{.VisitQuery}}` 的:一来把 back 丢了, +// 二来搜索词里带 & 或空格就会把地址拆坏。统一在这里生成。 +func (d EditData) EditLink(clear ...string) template.URL { + v := d.editFilterValues() + for _, k := range clear { + v.Del(k) + } + d.putBack(v) + if e := v.Encode(); e != "" { + return template.URL("/admin/edit?" + e) + } + return template.URL("/admin/edit") +} + +// editFilterValues 是数据编辑页三个列表的当前筛选条件(不含页码、不含 back)。 +func (d EditData) editFilterValues() url.Values { v := url.Values{} if d.Query != "" { v.Set("q", d.Query) @@ -659,8 +805,13 @@ func (d EditData) PageLink(param string, n int) template.URL { v.Set("vq", d.VisitQuery) } d.MovementFilter.set(v) - v.Set(param, strconv.Itoa(n)) - return template.URL(strings.ReplaceAll("/admin/edit?"+v.Encode(), "&", "&")) + return v +} + +func (d EditData) putBack(v url.Values) { + if d.Back != "" { + v.Set("back", string(d.Back)) + } } // EditOption 是表单里的一项选项(活动 / 标签 / 交通 / 地点)。