筛选在列表→详情→编辑之间保留,修复编辑页翻页丢筛选,收拾移动端触控与字号
- 新增 ?back= 贯穿链路:列表页(地点库 / 时间线 / 统计 / 专题地图 / 主页)生成详情链接时 带上当前完整地址,详情页的「返回」「编辑」与编辑页的返回按钮据此回到原来那份筛选结果。 只放行站内路径(挡 //host、伪协议、控制字符),参数编码走 template.URL 避免二次转义。 - 修 EditData.PageLink 把 & 手工写成 & 又被模板转义一次的 bug(&)—— 参数名会变成 amp;vpage,表现为「带筛选时翻页没反应、筛选还丢了」(既有问题)。 - 编辑页三个筛选表单与三个「重置」带上 back(重置改走 EditData.EditLink,各清各的, 顺带修掉搜索词含 & 或空格时重置链接被拆坏)。 - 详情页「在地图中查看」的地点名改走 QueryEscape(名字含 & / # 会拆坏地址)。 - 窄屏修正:把被基础规则压掉的 .chip / .btn / .check 覆盖挪到最后; checkbox 20px、select 44px、file/color 字号 16px(iOS 聚焦不再自放大整页)、 .tag 12px、导航 logo 触摸区 44×44。 - 新增回归 internal/web/pagelink_test.go(翻页链接、返回文案、地图链接)。
This commit is contained in:
+156
-5
@@ -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 是表单里的一项选项(活动 / 标签 / 交通 / 地点)。
|
||||
|
||||
Reference in New Issue
Block a user