筛选在列表→详情→编辑之间保留,修复编辑页翻页丢筛选,收拾移动端触控与字号
- 新增 ?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:
@@ -96,6 +96,10 @@ func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) {
|
|||||||
p.RangeLabel = rangeLabel(p.From, p.To)
|
p.RangeLabel = rangeLabel(p.From, p.To)
|
||||||
p.FilterBase = r.URL.Path
|
p.FilterBase = r.URL.Path
|
||||||
p.Presets = buildPresets(p.From, p.To)
|
p.Presets = buildPresets(p.From, p.To)
|
||||||
|
// 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用),
|
||||||
|
// BackQS 是把自己挂给别人的(列表页生成详情链接用)。
|
||||||
|
p.Back = backFromRequest(r)
|
||||||
|
p.BackQS = backQS(r)
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,9 +128,8 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
|
|||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.nav-links a, .btn, .btn.sm, .chip { min-height: 44px; }
|
.nav-links a, .btn, .btn.sm, .chip { min-height: 44px; }
|
||||||
.nav-links a { display: inline-flex; align-items: center; padding: 8px 9px; }
|
.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 的控件时会自动放大整页——用户点完输入框页面突然变大,
|
/* iOS 聚焦字号小于 16px 的控件时会自动放大整页——用户点完输入框页面突然变大,
|
||||||
还要手动缩回去。窄屏统一提到 16px 规避(桌面端不受影响,保持紧凑)。 */
|
还要手动缩回去。窄屏统一提到 16px 规避(桌面端不受影响,保持紧凑)。 */
|
||||||
:root { --field-fs: 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 { margin: 12px 0 0; }
|
||||||
#parse-status:empty { display: none; }
|
#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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
var PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323;
|
var PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323;
|
||||||
var DATA_CRS = 'gcj02';
|
var DATA_CRS = 'gcj02';
|
||||||
|
|
||||||
|
// 地图弹窗里「查看详情」要带回的返回地址(?back=):地图页有自己的筛选,点进详情再回来时
|
||||||
|
// 要回到那份筛选,而不是裸地点库。值是服务端编码好的参数值,直接拼,别再转义一次。
|
||||||
|
var backQS = '';
|
||||||
|
function backSuffix() { return backQS ? '?back=' + backQS : ''; }
|
||||||
|
|
||||||
// 交通方式图例:轨迹开启时告诉用户颜色含义
|
// 交通方式图例:轨迹开启时告诉用户颜色含义
|
||||||
function modeLegendControl(modes) {
|
function modeLegendControl(modes) {
|
||||||
var Ctl = L.Control.extend({
|
var Ctl = L.Control.extend({
|
||||||
@@ -341,7 +346,7 @@
|
|||||||
// 备注只读直出,省得多点一次详情页
|
// 备注只读直出,省得多点一次详情页
|
||||||
if (p.note) parts.push('<div class="mp-note">' + esc(p.note) + '</div>');
|
if (p.note) parts.push('<div class="mp-note">' + esc(p.note) + '</div>');
|
||||||
if (p.t) parts.push('<div class="mp-m">最近一次 ' + esc(p.t) + '</div>');
|
if (p.t) parts.push('<div class="mp-m">最近一次 ' + esc(p.t) + '</div>');
|
||||||
parts.push('<div class="mp-foot"><a href="/places/' + p.id + '">查看详情 →</a></div>');
|
parts.push('<div class="mp-foot"><a href="/places/' + p.id + backSuffix() + '">查看详情 →</a></div>');
|
||||||
wrap.innerHTML = parts.join('');
|
wrap.innerHTML = parts.join('');
|
||||||
if (vote && p.sp) {
|
if (vote && p.sp) {
|
||||||
// 无论如何都挂上,交给 paintVote 决定显不显示;
|
// 无论如何都挂上,交给 paintVote 决定显不显示;
|
||||||
@@ -530,6 +535,7 @@
|
|||||||
preview: function (opts) {
|
preview: function (opts) {
|
||||||
var points = opts.points || [];
|
var points = opts.points || [];
|
||||||
var vote = opts.vote === true;
|
var vote = opts.vote === true;
|
||||||
|
backQS = opts.back || '';
|
||||||
// 同一个容器画第二次(照片导入页反查完画一次、点「重新反查」又画一次):
|
// 同一个容器画第二次(照片导入页反查完画一次、点「重新反查」又画一次):
|
||||||
// Leaflet 不允许对已经有地图的容器再 L.map(),会抛
|
// Leaflet 不允许对已经有地图的容器再 L.map(),会抛
|
||||||
// "Map container is already initialized."——那个错会被调用方的 catch 接住,
|
// "Map container is already initialized."——那个错会被调用方的 catch 接住,
|
||||||
@@ -624,6 +630,7 @@
|
|||||||
// 底图版权(© 高德地图 / © OpenStreetMap)必须留着——那是上游的使用条款要求。
|
// 底图版权(© 高德地图 / © OpenStreetMap)必须留着——那是上游的使用条款要求。
|
||||||
L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(map);
|
L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(map);
|
||||||
var points = opts.points || [];
|
var points = opts.points || [];
|
||||||
|
backQS = opts.back || '';
|
||||||
// 窄屏筛选面板占满宽度,控件改挂右下角(该尺寸下图例已隐藏)
|
// 窄屏筛选面板占满宽度,控件改挂右下角(该尺寸下图例已隐藏)
|
||||||
var corner = window.matchMedia('(max-width: 620px)').matches ? 'bottomright' : 'topright';
|
var corner = window.matchMedia('(max-width: 620px)').matches ? 'bottomright' : 'topright';
|
||||||
L.control.zoom({ position: corner }).addTo(map);
|
L.control.zoom({ position: corner }).addTo(map);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<div class="sub">手动补录没带手机时的记录点(新增地点 / 到访 / 行程 / 天气),改完在下方一键导出为 <span class="mono">.rondbackup</span></div>
|
<div class="sub">手动补录没带手机时的记录点(新增地点 / 到访 / 行程 / 天气),改完在下方一键导出为 <span class="mono">.rondbackup</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
{{/* 从筛选过的列表点「编辑」进来的:给一个回得去的出口(筛选条件还在那个地址里) */}}
|
||||||
|
{{if .Back}}<a class="btn" href="{{.Back}}">← {{.BackLabel}}</a>{{end}}
|
||||||
<button class="btn" type="button" id="fold-all">全部折叠</button>
|
<button class="btn" type="button" id="fold-all">全部折叠</button>
|
||||||
<a class="btn" href="/admin">返回后台</a>
|
<a class="btn" href="/admin">返回后台</a>
|
||||||
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup</a>
|
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup</a>
|
||||||
@@ -122,6 +124,8 @@
|
|||||||
<div class="card" data-card="places">
|
<div class="card" data-card="places">
|
||||||
<h2>地点列表</h2>
|
<h2>地点列表</h2>
|
||||||
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
|
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
|
||||||
|
{{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}}
|
||||||
|
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{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:1 1 200px"><span>搜索</span><input type="text" name="q" value="{{.Query}}" placeholder="名称 / 城市 / 区县"></label>
|
||||||
<label class="f" style="flex:0 1 170px"><span>坐标来源</span>
|
<label class="f" style="flex:0 1 170px"><span>坐标来源</span>
|
||||||
<select name="src">
|
<select name="src">
|
||||||
@@ -137,7 +141,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
|
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
|
||||||
<div class="act"><button class="btn" type="submit">筛选</button><a class="btn" href="/admin/edit{{if .VisitQuery}}?vq={{.VisitQuery}}{{end}}">重置</a></div>
|
<div class="act"><button class="btn" type="submit">筛选</button><a class="btn" href="{{.EditLink "q" "src" "sus"}}">重置</a></div>
|
||||||
</form>
|
</form>
|
||||||
<div class="card-sub">共 {{num .Total}} 个地点,按到访数排序{{if .SuspectCount}};其中 <b>{{num .SuspectCount}}</b> 个的显示坐标疑似就是 WGS-84(当初没做偏移,底图上会偏 500 米左右),行内已标注{{end}}。「坐标来源」是建点时留下的记录:照片导入会把当时选的坐标系一起记下来,<b>未留档</b>的是 2026-09 之前的行,只能靠原图或行内标注判断。</div>
|
<div class="card-sub">共 {{num .Total}} 个地点,按到访数排序{{if .SuspectCount}};其中 <b>{{num .SuspectCount}}</b> 个的显示坐标疑似就是 WGS-84(当初没做偏移,底图上会偏 500 米左右),行内已标注{{end}}。「坐标来源」是建点时留下的记录:照片导入会把当时选的坐标系一起记下来,<b>未留档</b>的是 2026-09 之前的行,只能靠原图或行内标注判断。</div>
|
||||||
{{if .Places}}
|
{{if .Places}}
|
||||||
@@ -243,8 +247,9 @@
|
|||||||
<div class="card" data-card="visits">
|
<div class="card" data-card="visits">
|
||||||
<h2>到访记录</h2>
|
<h2>到访记录</h2>
|
||||||
<form method="get" action="/admin/edit" class="search-row" style="margin-top:6px">
|
<form method="get" action="/admin/edit" class="search-row" style="margin-top:6px">
|
||||||
|
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
|
||||||
<label class="f"><span>搜索</span><input type="text" name="vq" value="{{.VisitQuery}}" placeholder="地点 / 城市 / 备注 / 活动 / 日期 / #编号"></label>
|
<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="/admin/edit{{if .Query}}?q={{.Query}}{{end}}">重置</a></div>
|
<div class="act"><button class="btn" type="submit">搜索</button><a class="btn" href="{{.EditLink "vq"}}">重置</a></div>
|
||||||
</form>
|
</form>
|
||||||
<div class="card-sub">共 {{num .VisitTotal}} 条{{if .VisitQuery}}(匹配「{{.VisitQuery}}」){{end}},按时间倒序。可修改时间 / 类型 / 备注,或删除。</div>
|
<div class="card-sub">共 {{num .VisitTotal}} 条{{if .VisitQuery}}(匹配「{{.VisitQuery}}」){{end}},按时间倒序。可修改时间 / 类型 / 备注,或删除。</div>
|
||||||
{{if .Visits}}
|
{{if .Visits}}
|
||||||
@@ -321,6 +326,7 @@
|
|||||||
<div class="card" data-card="movements">
|
<div class="card" data-card="movements">
|
||||||
<h2>行程记录</h2>
|
<h2>行程记录</h2>
|
||||||
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
|
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
|
||||||
|
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
|
||||||
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
|
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
|
||||||
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
|
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
|
||||||
<label class="f" style="flex:0 1 150px"><span>交通方式</span>
|
<label class="f" style="flex:0 1 150px"><span>交通方式</span>
|
||||||
@@ -340,7 +346,7 @@
|
|||||||
<label class="f" style="flex:0 1 150px"><span>到</span><input type="date" name="mto" value="{{.MovementFilter.To}}"></label>
|
<label class="f" style="flex:0 1 150px"><span>到</span><input type="date" name="mto" value="{{.MovementFilter.To}}"></label>
|
||||||
<div class="act">
|
<div class="act">
|
||||||
<button class="btn" type="submit">筛选</button>
|
<button class="btn" type="submit">筛选</button>
|
||||||
<a class="btn" href="/admin/edit{{if .Query}}?q={{.Query}}{{end}}">重置</a>
|
<a class="btn" href="{{.EditLink "mt" "mp" "mfrom" "mto"}}">重置</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="card-sub">共 {{num .MovementTotal}} 段行程{{if or .MovementFilter.Transport .MovementFilter.PlaceID .MovementFilter.From .MovementFilter.To}}(已筛选){{end}},按开始时间倒序。可改起止、方式与时间区间,或删除。</div>
|
<div class="card-sub">共 {{num .MovementTotal}} 段行程{{if or .MovementFilter.Transport .MovementFilter.PlaceID .MovementFilter.From .MovementFilter.To}}(已筛选){{end}},按开始时间倒序。可改起止、方式与时间区间,或删除。</div>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="ic" style="background:{{colorof .Color}}22">{{iconor .Icon "📍"}}</div>
|
<div class="ic" style="background:{{colorof .Color}}22">{{iconor .Icon "📍"}}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="t">{{if .PlaceID}}<a href="/places/{{.PlaceID}}">{{.PlaceName}}</a>{{else}}未命名地点{{end}}</div>
|
<div class="t">{{if .PlaceID}}<a href="{{$.PlaceLink .PlaceID}}">{{.PlaceName}}</a>{{else}}未命名地点{{end}}</div>
|
||||||
<div class="m">{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}<span class="tag">{{$t}}</span>{{end}}{{end}}</div>
|
<div class="m">{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}<span class="tag">{{$t}}</span>{{end}}{{end}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="r">{{dur .DurationMin}}<br><span class="muted">{{since .Arrival}}</span></div>
|
<div class="r">{{dur .DurationMin}}<br><span class="muted">{{since .Arrival}}</span></div>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
window.addEventListener('load', function () {
|
window.addEventListener('load', function () {
|
||||||
// 首屏不带地点数据(全量内联会让 HTML 膨胀到 ~58KB),
|
// 首屏不带地点数据(全量内联会让 HTML 膨胀到 ~58KB),
|
||||||
// 页面先出壳,地点走 /api/points 并行拉取后再填充。
|
// 页面先出壳,地点走 /api/points 并行拉取后再填充。
|
||||||
window.__map = window.rondMap.full({ id: 'map', points: [] });
|
window.__map = window.rondMap.full({ id: 'map', points: [], back: '{{.BackQS}}' });
|
||||||
|
|
||||||
var form = document.getElementById('filter-form');
|
var form = document.getElementById('filter-form');
|
||||||
var ptCount = document.getElementById('pt-count');
|
var ptCount = document.getElementById('pt-count');
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="btn" href="/places">← 返回地点库</a>
|
{{/* 有 back 就按它回去:从筛选过的地点库/时间线/地图点进来时,这里要回到**那份筛选结果** */}}
|
||||||
<a class="btn" href="/map?q={{.Place.Name}}">在地图中查看</a>
|
<a class="btn" href="{{if .Back}}{{.Back}}{{else}}/places{{end}}">← {{if .BackLabel}}{{.BackLabel}}{{else}}返回地点库{{end}}</a>
|
||||||
{{if .IsAdmin}}<a class="btn" href="/admin/edit?open={{.Place.SrcPK}}" title="在数据编辑页里直接打开这个地点的编辑框">编辑</a>{{end}}
|
<a class="btn" href="{{.MapLink .Place.Name}}">在地图中查看</a>
|
||||||
|
{{if .IsAdmin}}<a class="btn" href="{{.EditPlaceLink .Place.SrcPK}}" title="在数据编辑页里直接打开这个地点的编辑框">编辑</a>{{end}}
|
||||||
{{if .Images}}<button class="btn" type="button" id="imgs-open">图片 <span class="muted">{{len .Images}}</span></button>{{end}}
|
{{if .Images}}<button class="btn" type="button" id="imgs-open">图片 <span class="muted">{{len .Images}}</span></button>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{{range .Places}}
|
{{range .Places}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="/places/{{.ID}}">{{if .Name}}{{.Name}}{{else}}未命名地点{{end}}</a>
|
<a href="{{$.PlaceLink .ID}}">{{if .Name}}{{.Name}}{{else}}未命名地点{{end}}</a>
|
||||||
{{if .IsHome}}<span class="tag">家</span>{{end}}
|
{{if .IsHome}}<span class="tag">家</span>{{end}}
|
||||||
{{if .IsWork}}<span class="tag">工作</span>{{end}}
|
{{if .IsWork}}<span class="tag">工作</span>{{end}}
|
||||||
{{if eq .Verdict 1}}<span class="tag vote-up">👍 推荐</span>{{else if eq .Verdict -1}}<span class="tag vote-down">👎 踩雷</span>{{end}}
|
{{if eq .Verdict 1}}<span class="tag vote-up">👍 推荐</span>{{else if eq .Verdict -1}}<span class="tag vote-down">👎 踩雷</span>{{end}}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 推荐的地点</div>
|
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 推荐的地点</div>
|
||||||
{{if .Recommended}}
|
{{if .Recommended}}
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
{{range .Recommended}}<a class="chip" href="/places/{{.ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
{{range .Recommended}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
|
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 踩雷的地点</div>
|
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 踩雷的地点</div>
|
||||||
{{if .Avoided}}
|
{{if .Avoided}}
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
{{range .Avoided}}<a class="chip" href="/places/{{.ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
{{range .Avoided}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
|
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<div class="ln"><i style="background:{{colorof .Color}}"></i></div>
|
<div class="ln"><i style="background:{{colorof .Color}}"></i></div>
|
||||||
<div class="bd">
|
<div class="bd">
|
||||||
<div>
|
<div>
|
||||||
{{if .PlaceID}}<a href="/places/{{.PlaceID}}">{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}</a>{{else}}未命名地点{{end}}
|
{{if .PlaceID}}<a href="{{$.PlaceLink .PlaceID}}">{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}</a>{{else}}未命名地点{{end}}
|
||||||
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:11.5px"><span class="dot" style="background:{{colorof .Color}}"></span>{{.ActivityName}}</span>{{end}}
|
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:11.5px"><span class="dot" style="background:{{colorof .Color}}"></span>{{.ActivityName}}</span>{{end}}
|
||||||
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
|
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
points: {{.PointsJSON}},
|
points: {{.PointsJSON}},
|
||||||
fitCircle: {{.FitCircleJSON}},
|
fitCircle: {{.FitCircleJSON}},
|
||||||
fitBBox: {{.FitBBoxJSON}},
|
fitBBox: {{.FitBBoxJSON}},
|
||||||
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}}
|
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}},
|
||||||
|
back: '{{.BackQS}}'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+156
-5
@@ -76,6 +76,15 @@ type Page struct {
|
|||||||
|
|
||||||
// HasTopics 表示该数据集有专题,导航里才露出「专题」入口。
|
// HasTopics 表示该数据集有专题,导航里才露出「专题」入口。
|
||||||
HasTopics bool
|
HasTopics bool
|
||||||
|
|
||||||
|
// Back 是「回到哪里」(来自 ?back=):列表页把自己的地址挂给详情页,详情页的「返回」
|
||||||
|
// 和「编辑」再把它带下去 —— 这样从筛选结果点进去、编辑完还能回到**同一份筛选**,
|
||||||
|
// 而不是回到裸列表。只接受站内路径,空表示没有来源。
|
||||||
|
Back template.URL
|
||||||
|
|
||||||
|
// BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值,
|
||||||
|
// 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。
|
||||||
|
BackQS template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAdmin 报告当前访问者是否已登录后台。
|
// IsAdmin 报告当前访问者是否已登录后台。
|
||||||
@@ -133,8 +142,116 @@ func (p Page) filterPairs() []string {
|
|||||||
return kv
|
return kv
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link 在保留当前筛选条件的前提下生成链接。
|
// backQS 把当前请求的地址编码成 ?back= 的值。
|
||||||
// Link 以当前筛选参数为底生成链接。extra 里的键会**覆盖**同名项,而不是追加——
|
//
|
||||||
|
// 去掉 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 这种重复参数,而取参数只认第一个,
|
// 否则会拼出 ?sort=dwell&sort=name 这种重复参数,而取参数只认第一个,
|
||||||
// 表现为「点了另一个排序没反应」。extra 里值为空的键会被丢掉,
|
// 表现为「点了另一个排序没反应」。extra 里值为空的键会被丢掉,
|
||||||
// 这样「回到默认排序」只要传 sort=""(buildURL 会跳过空值)。
|
// 这样「回到默认排序」只要传 sort=""(buildURL 会跳过空值)。
|
||||||
@@ -643,8 +760,37 @@ func (f MovementFilter) set(v url.Values) {
|
|||||||
//
|
//
|
||||||
// 返回 template.URL 是必须的:href 里再经 html/template 的 URL 转义会把 "=" 编成
|
// 返回 template.URL 是必须的:href 里再经 html/template 的 URL 转义会把 "=" 编成
|
||||||
// %3d,`mt%3d4` 在服务端解出来是「键为 mt=4」的参数,筛选条件会在翻页时静默失效。
|
// %3d,`mt%3d4` 在服务端解出来是「键为 mt=4」的参数,筛选条件会在翻页时静默失效。
|
||||||
// & 手工写成实体,交给浏览器的解析结果仍是 &。
|
//
|
||||||
|
// 这里**不能**再手工把 & 写成 &:template.URL 在 href 里仍会过一次 urlNormalizer,
|
||||||
|
// 它会替我们做 HTML 属性转义。手工写一遍的结果是 `&amp;`,浏览器解出来是「字面的
|
||||||
|
// &」,参数名变成 `amp;vpage` —— 表现就是「带筛选时翻页没反应、筛选还丢了」。
|
||||||
func (d EditData) PageLink(param string, n int) template.URL {
|
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{}
|
v := url.Values{}
|
||||||
if d.Query != "" {
|
if d.Query != "" {
|
||||||
v.Set("q", 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)
|
v.Set("vq", d.VisitQuery)
|
||||||
}
|
}
|
||||||
d.MovementFilter.set(v)
|
d.MovementFilter.set(v)
|
||||||
v.Set(param, strconv.Itoa(n))
|
return v
|
||||||
return template.URL(strings.ReplaceAll("/admin/edit?"+v.Encode(), "&", "&"))
|
}
|
||||||
|
|
||||||
|
func (d EditData) putBack(v url.Values) {
|
||||||
|
if d.Back != "" {
|
||||||
|
v.Set("back", string(d.Back))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditOption 是表单里的一项选项(活动 / 标签 / 交通 / 地点)。
|
// EditOption 是表单里的一项选项(活动 / 标签 / 交通 / 地点)。
|
||||||
|
|||||||
Reference in New Issue
Block a user