Compare commits
4
Commits
96e76318ab
...
9e01923244
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e01923244 | ||
|
|
7236c7a9d5 | ||
|
|
e8cb94230a | ||
|
|
86e420bd4e |
@@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFiltersFoldHook 盯住「地点库 / 时间线 / 统计三页共用筛选卡在窄屏默认收起」的挂载点。
|
||||||
|
//
|
||||||
|
// 折叠是纯前端的事:CSS 认 `.filters-card.collapsed`,脚本认 `#filters-fold`,两边都靠
|
||||||
|
// 模板里的类名/ID 挂钩子。谁哪天把表单的 class 换了、或者把这个按钮删了,页面照样能渲染、
|
||||||
|
// 桌面端也看不出问题 —— 只有手机会退化成「永远展开」,所以在这里钉住。
|
||||||
|
//
|
||||||
|
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run FiltersFold -v
|
||||||
|
func TestFiltersFoldHook(t *testing.T) {
|
||||||
|
f := newMovementFixture(t)
|
||||||
|
for _, path := range []string{"/places", "/timeline", "/stats"} {
|
||||||
|
var h http.HandlerFunc
|
||||||
|
switch path {
|
||||||
|
case "/places":
|
||||||
|
h = f.srv.placesPage
|
||||||
|
case "/timeline":
|
||||||
|
h = f.srv.timelinePage
|
||||||
|
case "/stats":
|
||||||
|
h = f.srv.statsPage
|
||||||
|
}
|
||||||
|
// 访客身份:这三页公开,折叠对访客同样生效
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, httptest.NewRequest("GET", path, nil).WithContext(context.Background()))
|
||||||
|
body := w.Body.String()
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("%s 渲染失败: code=%d", path, w.Code)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`class="card filters-card"`, // CSS 的挂载点
|
||||||
|
`id="filters-fold"`, // 折叠条
|
||||||
|
`id="filters-fold-caret"`, // 箭头(脚本要改它的字)
|
||||||
|
`id="filters-form"`, // 脚本靠它找表单;收起时表单控件仍在里面
|
||||||
|
`rond-filters-fold`, // 记收起状态的本地键:翻了页不该又弹开
|
||||||
|
`name="q"`, // 收起只是 CSS 隐藏,筛选控件得照旧提交得出去
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("%s 里找不到 %q:窄屏筛选折叠的挂载点断了", path, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,7 +154,11 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
|
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
|
||||||
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
|
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
|
||||||
((r.raw->>'ZLATITUDE'))::float8, ((r.raw->>'ZLONGITUDE'))::float8,
|
((r.raw->>'ZLATITUDE'))::float8, ((r.raw->>'ZLONGITUDE'))::float8,
|
||||||
COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon
|
COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon,
|
||||||
|
COALESCE((SELECT string_agg(x.n || ' ' || x.c, ' · ' ORDER BY x.c DESC, x.n) FROM (
|
||||||
|
SELECT COALESCE(a.name,'(无)') AS n, count(*) AS c FROM visits v
|
||||||
|
LEFT JOIN activities a ON a.id=v.activity_id
|
||||||
|
WHERE v.place_id=p.id GROUP BY 1) x), '')
|
||||||
FROM places p`+placeRawJoin+` WHERE %s ORDER BY p.visit_count DESC, p.id LIMIT $%d OFFSET $%d`,
|
FROM places p`+placeRawJoin+` WHERE %s ORDER BY p.visit_count DESC, p.id LIMIT $%d OFFSET $%d`,
|
||||||
where, len(args)+1, len(args)+2),
|
where, len(args)+1, len(args)+2),
|
||||||
append(args, editPerPage, (d.PageNo-1)*editPerPage)...)
|
append(args, editPerPage, (d.PageNo-1)*editPerPage)...)
|
||||||
@@ -166,7 +170,8 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
var e EditPlace
|
var e EditPlace
|
||||||
if err := lrows.Scan(&e.ID, &e.SrcPK, &e.Name, &e.Category, &e.Province, &e.City,
|
if err := lrows.Scan(&e.ID, &e.SrcPK, &e.Name, &e.Category, &e.Province, &e.City,
|
||||||
&e.District, &e.Thoroughfare, &e.Lat, &e.Lon, &e.VisitCount, &e.SuspectWGS,
|
&e.District, &e.Thoroughfare, &e.Lat, &e.Lon, &e.VisitCount, &e.SuspectWGS,
|
||||||
&e.OrigLat, &e.OrigLon, &e.CoordSource, &e.CoordSys, &e.SrcLat, &e.SrcLon); err != nil {
|
&e.OrigLat, &e.OrigLon, &e.CoordSource, &e.CoordSys, &e.SrcLat, &e.SrcLon,
|
||||||
|
&e.ActivityText); err != nil {
|
||||||
lrows.Close()
|
lrows.Close()
|
||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -437,6 +442,10 @@ func pickCategory(r *http.Request) string {
|
|||||||
// categoryCustomValue 是类别下拉里「自定义…」这一项的提交值。
|
// categoryCustomValue 是类别下拉里「自定义…」这一项的提交值。
|
||||||
const categoryCustomValue = "__custom__"
|
const categoryCustomValue = "__custom__"
|
||||||
|
|
||||||
|
// activityNewValue 是活动下拉里「+ 新增活动…」这一项的提交值。前端有提交守卫,
|
||||||
|
// 服务端再拦一道:哨兵当 id 解析不出整数,会静默变成「无活动」。
|
||||||
|
const activityNewValue = "__new__"
|
||||||
|
|
||||||
// poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。
|
// poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。
|
||||||
// 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。
|
// 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。
|
||||||
var poiCategoryText = map[string]string{
|
var poiCategoryText = map[string]string{
|
||||||
@@ -648,6 +657,10 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 活动可选:历史数据里本就存在无活动的到访
|
// 活动可选:历史数据里本就存在无活动的到访
|
||||||
|
if strings.TrimSpace(r.PostFormValue("activity_id")) == activityNewValue {
|
||||||
|
redirectEdit(w, r, "err", "活动选了「+ 新增活动…」但还没点「添加」")
|
||||||
|
return
|
||||||
|
}
|
||||||
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
|
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
|
||||||
tags := r.PostForm["tags"]
|
tags := r.PostForm["tags"]
|
||||||
|
|
||||||
@@ -778,16 +791,65 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
city: strings.TrimSpace(r.PostFormValue("city")),
|
city: strings.TrimSpace(r.PostFormValue("city")),
|
||||||
district: strings.TrimSpace(r.PostFormValue("district")),
|
district: strings.TrimSpace(r.PostFormValue("district")),
|
||||||
}
|
}
|
||||||
if _, err := s.updatePlaceRow(ctx, ds.ID, src, form, placeExtraFromForm(r), oldLat, oldLon); err != nil {
|
|
||||||
|
// 「活动」是把该地点名下**全部到访**一起换类型(地点自己没有活动列,活动挂在到访上)。
|
||||||
|
// 这一项不会被清空——空值表示「(不修改)」,行内表单每次都会提交它。
|
||||||
|
var actID any
|
||||||
|
var actName string
|
||||||
|
var actHome, actWork bool
|
||||||
|
switch raw := strings.TrimSpace(r.PostFormValue("activity_id")); raw {
|
||||||
|
case "":
|
||||||
|
case activityNewValue:
|
||||||
|
redirectEdit(w, r, "err", "活动选了「+ 新增活动…」但还没点「添加」")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
id, home, work := s.resolveActivity(ctx, ds.ID, raw)
|
||||||
|
if id == nil {
|
||||||
|
redirectEdit(w, r, "err", "所选活动不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actID, actHome, actWork = id, home, work
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(name,'') FROM activities WHERE id=$1`,
|
||||||
|
id).Scan(&actName); err != nil {
|
||||||
|
actName = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地点行与它名下到访的活动一起落库:只改完地点就失败,会留下「提示说改了、其实没改」的状态
|
||||||
|
var actVisits int64
|
||||||
|
if err := s.withTx(ctx, func(ex execer) error {
|
||||||
|
if _, err := s.updatePlaceRow(ctx, ex, ds.ID, src, form, placeExtraFromForm(r), oldLat, oldLon); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if actID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
|
||||||
|
WHERE dataset_id=$1 AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`,
|
||||||
|
ds.ID, src, actID, actHome, actWork)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actVisits, _ = res.RowsAffected()
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 {
|
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
|
||||||
redirectEdit(w, r, "ok", fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)",
|
if actID != nil {
|
||||||
lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon))))
|
s.refreshDatasetAgg(ctx, ds.ID)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
redirectEdit(w, r, "ok", "地点已更新")
|
|
||||||
|
msg := "地点已更新"
|
||||||
|
if moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 {
|
||||||
|
msg = fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)",
|
||||||
|
lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon)))
|
||||||
|
}
|
||||||
|
if actID != nil {
|
||||||
|
msg += fmt.Sprintf(";该地点 %d 条到访的活动已改为「%s」", actVisits, actName)
|
||||||
|
}
|
||||||
|
redirectEdit(w, r, "ok", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// placeForm 是地点行编辑里必填的那几个字段。
|
// placeForm 是地点行编辑里必填的那几个字段。
|
||||||
@@ -858,7 +920,7 @@ func placeExtraFromForm(r *http.Request) placeExtra {
|
|||||||
// 没带、但坐标确实动过,就认为提交上来的是显示坐标本身(等价于 gcj02)。
|
// 没带、但坐标确实动过,就认为提交上来的是显示坐标本身(等价于 gcj02)。
|
||||||
// 坐标没动则一个字都不碰,否则每次改个名字都会把「导入时的原始坐标」冲掉,
|
// 坐标没动则一个字都不碰,否则每次改个名字都会把「导入时的原始坐标」冲掉,
|
||||||
// 行内那个「比导入时移动了 N 米」的对照就没了。
|
// 行内那个「比导入时移动了 N 米」的对照就没了。
|
||||||
func (s *Server) updatePlaceRow(ctx context.Context, datasetID int64, src int, f placeForm,
|
func (s *Server) updatePlaceRow(ctx context.Context, ex execer, datasetID int64, src int, f placeForm,
|
||||||
e placeExtra, oldLat, oldLon float64) (int64, error) {
|
e placeExtra, oldLat, oldLon float64) (int64, error) {
|
||||||
set := []string{
|
set := []string{
|
||||||
"name=COALESCE(NULLIF($3,''), name)", "poi_category=NULLIF($4,'')",
|
"name=COALESCE(NULLIF($3,''), name)", "poi_category=NULLIF($4,'')",
|
||||||
@@ -902,7 +964,7 @@ func (s *Server) updatePlaceRow(ctx context.Context, datasetID int64, src int, f
|
|||||||
add("src_lat=$%d", *e.srcLat)
|
add("src_lat=$%d", *e.srcLat)
|
||||||
add("src_lon=$%d", *e.srcLon)
|
add("src_lon=$%d", *e.srcLon)
|
||||||
}
|
}
|
||||||
res, err := s.db.ExecContext(ctx, `UPDATE places SET `+strings.Join(set, ", ")+
|
res, err := ex.ExecContext(ctx, `UPDATE places SET `+strings.Join(set, ", ")+
|
||||||
` WHERE dataset_id=$1 AND src_pk=$2`, args...)
|
` WHERE dataset_id=$1 AND src_pk=$2`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -545,6 +545,10 @@ func TestMovementCleanExport(t *testing.T) {
|
|||||||
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
|
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
|
||||||
}
|
}
|
||||||
f := newMovementFixture(t)
|
f := newMovementFixture(t)
|
||||||
|
// 目录不存在时直接 WriteFile 只会报「找不到路径」,看着像导出坏了——顺手建出来
|
||||||
|
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
|
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"rond-with-you/internal/rond"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPlaceUpdateBatchActivity 盯住「地点行内编辑里的活动」这条路:地点自己没有活动列
|
||||||
|
// (活动挂在到访上),所以那个下拉是「把该地点名下全部到访一起换类型」。
|
||||||
|
// 写错的两种后果都很安静:空值被当成「改成无活动」→ 历史到访的类型被清掉;
|
||||||
|
// 不校验归属 → 能把别的数据集的活动塞进来。
|
||||||
|
//
|
||||||
|
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run BatchActivity -v
|
||||||
|
func TestPlaceUpdateBatchActivity(t *testing.T) {
|
||||||
|
f := newMovementFixture(t)
|
||||||
|
ctx, pg, dsID := f.ctx, f.pg, f.dsID
|
||||||
|
|
||||||
|
// 挑到访最多的那个地点:批量的效果才看得出来
|
||||||
|
var pk int
|
||||||
|
var placeID int64
|
||||||
|
var n int
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT p.id, p.src_pk, count(v.id) AS c FROM places p
|
||||||
|
JOIN visits v ON v.place_id = p.id
|
||||||
|
WHERE p.dataset_id=$1 GROUP BY p.id, p.src_pk ORDER BY c DESC, p.src_pk LIMIT 1`,
|
||||||
|
dsID).Scan(&placeID, &pk, &n); err != nil {
|
||||||
|
t.Fatalf("找不到带有访次的地点: %v", err)
|
||||||
|
}
|
||||||
|
if n < 2 {
|
||||||
|
t.Fatalf("这个数据集里没有多于一条到访的地点(最多 %d 条),验不出「批量」", n)
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
var lat, lon float64
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
|
||||||
|
FROM places WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&name, &lat, &lon); err != nil {
|
||||||
|
t.Fatalf("读地点失败: %v", err)
|
||||||
|
}
|
||||||
|
// 挑一个该地点还没用过的活动,否则「改了」和「没改」分不出来
|
||||||
|
var actID int64
|
||||||
|
var actName string
|
||||||
|
var actHome, actWork bool
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work FROM activities a
|
||||||
|
WHERE a.dataset_id=$1 AND NOT EXISTS (SELECT 1 FROM visits v
|
||||||
|
WHERE v.place_id=$2 AND v.activity_id=a.id)
|
||||||
|
ORDER BY a.id LIMIT 1`, dsID, placeID).Scan(&actID, &actName, &actHome, &actWork); err != nil {
|
||||||
|
t.Fatalf("找不到没被这个地点用过的活动: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postRaw := func(activity string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{
|
||||||
|
"src_pk": {fmt.Sprint(pk)}, "name": {name},
|
||||||
|
"lat": {fmt.Sprintf("%.6f", lat)}, "lon": {fmt.Sprintf("%.6f", lon)},
|
||||||
|
"province": {""}, "city": {""}, "district": {""},
|
||||||
|
}
|
||||||
|
if activity != "" {
|
||||||
|
form.Set("activity_id", activity)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.srv.editPlaceUpdate(rec, req)
|
||||||
|
if rec.Code >= 500 {
|
||||||
|
t.Fatalf("处理失败: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
// is_home / is_work 是从活动上抄到到访的冗余标记,换活动时要跟着一起走
|
||||||
|
matched := func() int {
|
||||||
|
t.Helper()
|
||||||
|
var c int
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
|
||||||
|
WHERE dataset_id=$1 AND place_id=$2 AND activity_id=$3 AND is_home=$4 AND is_work=$5`,
|
||||||
|
dsID, placeID, actID, actHome, actWork).Scan(&c); err != nil {
|
||||||
|
t.Fatalf("统计失败: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
var othersBefore int
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
|
||||||
|
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersBefore); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 选中一个活动:该地点名下全部到访一起换过去
|
||||||
|
rec := postRaw(fmt.Sprint(actID))
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
if msg, _ := url.QueryUnescape(loc); !strings.Contains(msg, "ok=") || !strings.Contains(msg, "条到访") {
|
||||||
|
t.Fatalf("提示里要说清改了几条到访,实际 %q", loc)
|
||||||
|
}
|
||||||
|
if got := matched(); got != n {
|
||||||
|
t.Fatalf("该地点有 %d 条到访,只有 %d 条换成了「%s」(含 is_home/is_work)", n, got, actName)
|
||||||
|
}
|
||||||
|
var othersAfter int
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
|
||||||
|
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersAfter); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if othersAfter != othersBefore {
|
||||||
|
t.Fatalf("改一个地点的活动,别的地点的到访也变了:%d → %d", othersBefore, othersAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 「(不修改)」:行内表单每次都会提交这一项,空值必须一个到访都不动
|
||||||
|
postRaw("")
|
||||||
|
if got := matched(); got != n {
|
||||||
|
t.Fatalf("没选活动却动了到访:只剩 %d/%d 条还是「%s」", got, n, actName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 哨兵值「+ 新增活动…」:前端有守卫,服务端也要拦(当 id 会解析成「无活动」)
|
||||||
|
for _, raw := range []string{"__new__", "999999"} {
|
||||||
|
rec = postRaw(raw)
|
||||||
|
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
|
||||||
|
t.Fatalf("提交 activity_id=%q 应当被拒绝,实际 %q", raw, l)
|
||||||
|
}
|
||||||
|
if got := matched(); got != n {
|
||||||
|
t.Fatalf("提交 activity_id=%q 之后到访被改了:只剩 %d/%d 条还是「%s」", raw, got, n, actName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3b) 别的数据集的活动:越权也得拒(resolveActivity 带 dataset_id 条件就是为了这个)
|
||||||
|
var foreign int64
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT id FROM activities WHERE dataset_id<>$1 ORDER BY id LIMIT 1`,
|
||||||
|
dsID).Scan(&foreign); err == nil {
|
||||||
|
rec = postRaw(fmt.Sprint(foreign))
|
||||||
|
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
|
||||||
|
t.Fatalf("提交别的数据集的活动 #%d 应当被拒绝,实际 %q", foreign, l)
|
||||||
|
}
|
||||||
|
if got := matched(); got != n {
|
||||||
|
t.Fatalf("越权提交之后到访被改了:只剩 %d/%d 条还是「%s」", got, n, actName)
|
||||||
|
}
|
||||||
|
} else if err != sql.ErrNoRows {
|
||||||
|
t.Fatalf("查别的数据集的活动失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 模板:行内表单必须带上活动下拉 + 新建框,且下拉里三样齐全
|
||||||
|
// (JS 靠 .activity-id-inline / .visit-act-new / .visit-act-add 三个类名找同伴元素)
|
||||||
|
req := httptest.NewRequest("GET", "/admin/edit", nil)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.srv.editPage(w, req)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("编辑页渲染失败: code=%d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`action="/admin/edit/place/update"`, `select name="activity_id" class="activity-id-inline"`,
|
||||||
|
`class="f visit-act-new"`, `class="btn visit-act-add"`, `(不修改)`, `+ 新增活动…`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("编辑页里找不到 %q:模板与 JS 的契约断了", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 这一行的提示要落在它自己身上:地点按到访数倒序排,246 条的那个在第 1 页
|
||||||
|
i := strings.Index(body, fmt.Sprintf(`id="place-row-%d"`, pk))
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("编辑页第 1 页找不到 #%d 这一行", pk)
|
||||||
|
}
|
||||||
|
row := body[i:]
|
||||||
|
if j := strings.Index(row, `<tr id="place-row-`); j > 0 {
|
||||||
|
row = row[:j]
|
||||||
|
}
|
||||||
|
if !strings.Contains(row, "该地点到访当前类型:") ||
|
||||||
|
!strings.Contains(row, actName+" "+fmt.Sprint(n)) {
|
||||||
|
t.Fatalf("这一行的「当前类型」提示不对(期望 %s %d):%s", actName, n, row[:min(len(row), 320)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 导出链路:站点改的是 visits.activity_id,导出的包里对应到访必须换成新活动的 **src_pk**
|
||||||
|
// —— 站点内部 id 与 rond 编号是两套号,写错就会「网页上改了、rond 里还是旧活动」。
|
||||||
|
var actSrc int
|
||||||
|
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM activities WHERE id=$1`, actID).Scan(&actSrc); err != nil {
|
||||||
|
t.Fatalf("读活动编号失败: %v", err)
|
||||||
|
}
|
||||||
|
visitRows, err := pg.QueryContext(ctx, `SELECT src_pk FROM visits WHERE dataset_id=$1 AND place_id=$2`, dsID, placeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读到访编号失败: %v", err)
|
||||||
|
}
|
||||||
|
var pks []int
|
||||||
|
for visitRows.Next() {
|
||||||
|
var v int
|
||||||
|
if err := visitRows.Scan(&v); err != nil {
|
||||||
|
visitRows.Close()
|
||||||
|
t.Fatalf("扫到访编号失败: %v", err)
|
||||||
|
}
|
||||||
|
pks = append(pks, v)
|
||||||
|
}
|
||||||
|
visitRows.Close()
|
||||||
|
|
||||||
|
blob := f.export(t)
|
||||||
|
out := filepath.Join(t.TempDir(), "activity-export.rondbackup")
|
||||||
|
if err := os.WriteFile(out, blob, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
edb, err := rond.Open(out, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开导出包失败: %v", err)
|
||||||
|
}
|
||||||
|
defer edb.Close()
|
||||||
|
ph := make([]string, len(pks))
|
||||||
|
args := make([]any, 0, len(pks)+1)
|
||||||
|
for i, v := range pks {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
args = append(args, actSrc)
|
||||||
|
var inPackage, converted int
|
||||||
|
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
|
||||||
|
strings.Join(ph, ",") + `)`
|
||||||
|
if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
|
||||||
|
t.Fatalf("读导出包失败: %v", err)
|
||||||
|
}
|
||||||
|
if inPackage != len(pks) {
|
||||||
|
t.Fatalf("导出包里只有 %d/%d 条到访", inPackage, len(pks))
|
||||||
|
}
|
||||||
|
if converted != len(pks) {
|
||||||
|
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,13 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
|
|||||||
.brand .t { display: none; }
|
.brand .t { display: none; }
|
||||||
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
|
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
|
||||||
.nav-links::-webkit-scrollbar { display: none; }
|
.nav-links::-webkit-scrollbar { display: none; }
|
||||||
|
/* 还能往右滑的时候右缘渐隐一下(由 nav 后面那几行脚本按 scrollLeft 加/去这个类)。
|
||||||
|
用 mask 而不是盖一层渐变伪元素:伪元素会把链接文字一起遮住,mask 是让内容本身渐隐。
|
||||||
|
不支持 mask 的浏览器只是少个提示,滑动照旧。 */
|
||||||
|
.nav-links.overflow-right {
|
||||||
|
-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 { padding: 5px 7px; font-size: 13px; }
|
||||||
.nav-right .who { display: none; }
|
.nav-right .who { display: none; }
|
||||||
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
|
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
|
||||||
@@ -284,6 +291,10 @@ table.tb th {
|
|||||||
padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
|
padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
|
||||||
}
|
}
|
||||||
table.tb td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); }
|
table.tb td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); }
|
||||||
|
/* 操作列(桌面要横着一排按钮,不折行)。原来是写在模板上的 inline style,而 white-space 会
|
||||||
|
继承进行内编辑表单,堆叠态下「已选:…重选」那一行就顶破了卡片——inline 又压不过后面的覆盖,
|
||||||
|
所以挪成类,放不下宽表时在文件末尾统一放开。 */
|
||||||
|
table.tb td.ops { white-space: nowrap; }
|
||||||
table.tb tr:hover td { background: var(--row-hover); }
|
table.tb tr:hover td { background: var(--row-hover); }
|
||||||
table.tb td.num, table.tb th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
table.tb td.num, table.tb th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.tw { overflow-x: auto; }
|
.tw { overflow-x: auto; }
|
||||||
@@ -807,7 +818,9 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
|
|||||||
.inline-edit summary { list-style: none; display: inline-block; }
|
.inline-edit summary { list-style: none; display: inline-block; }
|
||||||
.inline-edit summary::-webkit-details-marker { display: none; }
|
.inline-edit summary::-webkit-details-marker { display: none; }
|
||||||
.inline-edit[open] { display: block; }
|
.inline-edit[open] { display: block; }
|
||||||
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
/* 行内编辑表单的宽度:桌面靠 --efw 撑到够填(各表不同,见模板里的 inline 变量),
|
||||||
|
放不下宽表时下面会放开这个下限——不然 320px 的表单会把 294px 的卡片顶破。 */
|
||||||
|
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: 10px; padding: 12px; min-width: var(--efw, 280px); }
|
||||||
.inline-edit form .f { margin-bottom: 8px; }
|
.inline-edit form .f { margin-bottom: 8px; }
|
||||||
|
|
||||||
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
|
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
|
||||||
@@ -993,4 +1006,67 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
|
|||||||
/* 导航左上角那个 logo 是回首页的入口,原来只有 25×29:给它撑到 44 高,
|
/* 导航左上角那个 logo 是回首页的入口,原来只有 25×29:给它撑到 44 高,
|
||||||
横向也补到 44(.brand .t 在窄屏被隐藏,加内边距就够了)。 */
|
横向也补到 44(.brand .t 在窄屏被隐藏,加内边距就够了)。 */
|
||||||
.brand { min-width: 44px; min-height: 44px; justify-content: center; }
|
.brand { min-width: 44px; min-height: 44px; justify-content: center; }
|
||||||
|
|
||||||
|
/* ---------- 地点库 / 时间线 / 统计三页共用的筛选卡:窄屏默认收成一条 ----------
|
||||||
|
与地图页那块 .panel-fold 同形——手机上它一上来就占掉大半屏(预设 + 已选 + 类型 + 六个
|
||||||
|
输入框),内容反而看不见。展开时折叠条用负外边距贴到卡片上沿(圆角照着卡片补,
|
||||||
|
卡片没有 overflow:hidden,否则折叠条就粘不住了);收起时卡片内边距归零、只留那一条。
|
||||||
|
top 取 --nav-h:导航是 sticky 的,写 0 会被压在它下面。
|
||||||
|
这些规则整条在媒体查询里 ⇒ 桌面端 .collapsed 不生效,等于没影响。 */
|
||||||
|
.filters-card > .panel-fold {
|
||||||
|
margin: -18px -18px 14px; border-radius: var(--radius) var(--radius) 0 0; top: var(--nav-h);
|
||||||
|
}
|
||||||
|
.filters-card.collapsed { padding: 0; }
|
||||||
|
.filters-card.collapsed > .panel-fold { margin: 0; border-radius: var(--radius); }
|
||||||
|
.filters-card.collapsed > :not(.panel-fold) { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 放不下宽表时:一屏一行的「字段 + 值」 ----------
|
||||||
|
地点库(8 列)、统计、专题、后台这几张表在窄处要横滑 300+px 才看得全,滑着还对不上表头。
|
||||||
|
放不下时改成每行一块、每格「字段名 + 值」竖排:不用横滑,也不用记「第几列是什么」。
|
||||||
|
字段名取 td 的 data-label,每行的首格留空当小标题(如地点名),操作格给「操作」。
|
||||||
|
表格结构与脚本都没动(只是隐藏 thead),链接 / 表单 / 按钮照旧。
|
||||||
|
阈值 800 是从「卡片能不能放下表格」换算来的:卡片内容宽 ≈ 视口 − 93(页面 + 卡片的内边距),
|
||||||
|
这几张表的 min-width 最宽是 700 ⇒ 视口 793 以上才放得下,取 800 留几像素余量。
|
||||||
|
**必须用 @media,别改成 @container**:容器查询能按「卡片自己有多宽」判得更准,可它要
|
||||||
|
iOS 16 / Chrome 105 起才支持——旧手机整块规则直接失效,表格完全不堆叠、退化成横滑
|
||||||
|
328~388px(2026-09-19 真机上就是这么炸的,比改之前还糟)。现在宽表卡片已被要求不放 g2
|
||||||
|
(见 wide_table_layout_test.go),卡片宽度只由视口决定,两种判据在这些页面上等价,
|
||||||
|
那就用兼容性好的那个。若页面/卡片的内边距变了,这里的 800 要跟着重算。 */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.tw > table.tb.tb-stack { min-width: 0; }
|
||||||
|
.tw > table.tb.tb-stack thead { display: none; }
|
||||||
|
.tw > table.tb.tb-stack,
|
||||||
|
.tw > table.tb.tb-stack > tbody,
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr,
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr > td { display: block; width: auto; }
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr {
|
||||||
|
padding: 9px 11px; border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr:last-child { border-bottom: 0; }
|
||||||
|
/* 值用悬挂缩进:标签绝对定位在左边,值换行时对齐到值那一栏,而不是缩到标签下面 */
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr > td {
|
||||||
|
position: relative; padding: 2px 0 2px 5.6em; border-bottom: 0;
|
||||||
|
}
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr > td::before {
|
||||||
|
content: attr(data-label); position: absolute; left: 0; top: 2px;
|
||||||
|
width: 5em; color: var(--text-3); font-size: 12px;
|
||||||
|
}
|
||||||
|
/* 首格是这一行的主角(地点名 / 天气 / 方式…):不缩进、字号抬一档当小标题;
|
||||||
|
操作格也是这一类(表头为空、整行铺开)。它们里面的内容是长名称或编辑表单,
|
||||||
|
而桌面模板给操作格挂了 nowrap(那一列要横向排按钮)——white-space 会继承到表单里,
|
||||||
|
于是「已选:#403 2026-09-13 19:53 · 某某地」这一行在窄处不换行、把卡片顶出 44px。
|
||||||
|
堆叠态每格本来就是一整行,nowrap 没有意义,放开。 */
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr > td:not([data-label]) {
|
||||||
|
padding-left: 0; margin-bottom: 2px; font-size: 14px; white-space: normal;
|
||||||
|
}
|
||||||
|
/* 操作格(有的带「操作」标签、有的表头本来就是空的)在堆叠态整行铺开,
|
||||||
|
桌面那条 nowrap 会让格里的编辑表单不换行、把卡片顶出几十像素 */
|
||||||
|
.tw > table.tb.tb-stack > tbody > tr > td.ops { white-space: normal; }
|
||||||
|
/* 数字列不必再右对齐(现在已经是「标签 + 值」两栏) */
|
||||||
|
.tw > table.tb.tb-stack td.num { text-align: left; }
|
||||||
|
/* 行内编辑表单的桌面宽度下限在这里放开:320px 的表单加上悬挂缩进会顶破卡片,
|
||||||
|
而表单字段本来就是 flex 自适应的(见 .row / label.f),窄处自然铺满即可。 */
|
||||||
|
.tw > table.tb.tb-stack .inline-edit form { min-width: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStackedTablesHaveLabels 守住「窄屏宽表变一屏一行」这条改动的核心不变量:
|
||||||
|
// 每张打了 tb-stack 的表,窄屏会把表头藏起来、改成「字段名 + 值」竖排,字段名只能取自
|
||||||
|
// td 的 data-label。少写一个标签,那一格在手机上就是没有名字的裸值——而桌面端一切正常,
|
||||||
|
// 很难发现(这轮就漏过两次:一张表的中间几行、后台两张表的「状态 / 对访客 / 操作」)。
|
||||||
|
// 顺带钉住每行格子数 == 表头列数,否则说明模板结构歪了。
|
||||||
|
//
|
||||||
|
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run StackedTables -v
|
||||||
|
func TestStackedTablesHaveLabels(t *testing.T) {
|
||||||
|
f := newMovementFixture(t)
|
||||||
|
|
||||||
|
var topicID int64
|
||||||
|
if err := f.pg.QueryRowContext(f.ctx, `SELECT id FROM topics WHERE dataset_id=$1 ORDER BY id LIMIT 1`,
|
||||||
|
f.dsID).Scan(&topicID); err != nil {
|
||||||
|
topicID = 0 // 这个数据集没有专题,就少查一页
|
||||||
|
}
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
path string
|
||||||
|
h http.HandlerFunc
|
||||||
|
admin bool
|
||||||
|
}
|
||||||
|
targets := []target{
|
||||||
|
{"/places", f.srv.placesPage, false},
|
||||||
|
{"/stats", f.srv.statsPage, false},
|
||||||
|
{"/", f.srv.home, false},
|
||||||
|
{"/report", f.srv.reportPage, false},
|
||||||
|
{"/admin", f.srv.adminHome, true},
|
||||||
|
{"/admin/topics", f.srv.topicsAdminPage, true},
|
||||||
|
// 数据编辑页的三张表默认折叠在卡片里(探针量到的是 display:none),最容易漏标
|
||||||
|
{"/admin/edit", f.srv.editPage, true},
|
||||||
|
}
|
||||||
|
if topicID > 0 {
|
||||||
|
targets = append(targets, target{"/topics/" + strconv.FormatInt(topicID, 10), f.srv.topicPage, false})
|
||||||
|
}
|
||||||
|
|
||||||
|
tableRe := regexp.MustCompile(`(?s)<table[^>]*class="[^"]*tb-stack[^"]*"[^>]*>.*?</table>`)
|
||||||
|
rowRe := regexp.MustCompile(`(?s)<tr[^>]*>(.*?)</tr>`)
|
||||||
|
tdRe := regexp.MustCompile(`(?s)<td([^>]*)>`)
|
||||||
|
thRe := regexp.MustCompile(`<th[\s>]`)
|
||||||
|
thTextRe := regexp.MustCompile(`(?s)<th[^>]*>(.*?)</th>`)
|
||||||
|
tagStripRe := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
for _, tg := range targets {
|
||||||
|
ctx := context.Background()
|
||||||
|
if tg.admin {
|
||||||
|
ctx = f.ctx // 夹具的 ctx 里已经注入登录态
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tg.h(w, httptest.NewRequest("GET", tg.path, nil).WithContext(ctx))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Logf("%s 渲染 code=%d,跳过", tg.path, w.Code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for ti, tb := range tableRe.FindAllString(w.Body.String(), -1) {
|
||||||
|
total++
|
||||||
|
head := ""
|
||||||
|
if i, j := strings.Index(tb, "<thead>"), strings.Index(tb, "</thead>"); i >= 0 && j > i {
|
||||||
|
head = tb[i:j]
|
||||||
|
}
|
||||||
|
cols := len(thRe.FindAllString(head, -1))
|
||||||
|
// 表头为空的列(如三张表末尾那个只放「编辑」的操作格)不需要名字:窄屏堆叠时
|
||||||
|
// 它整行铺开,硬加个标签反而把按钮/表单挤窄。判据跟着表头走,两边自然一致。
|
||||||
|
blank := make([]bool, cols)
|
||||||
|
if ths := thTextRe.FindAllStringSubmatch(head, -1); len(ths) == cols {
|
||||||
|
for i, th := range ths {
|
||||||
|
blank[i] = strings.TrimSpace(tagStripRe.ReplaceAllString(th[1], "")) == ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ri, row := range rowRe.FindAllString(tb, -1) {
|
||||||
|
if !strings.Contains(row, "<td") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cells := tdRe.FindAllStringSubmatch(row, -1)
|
||||||
|
if cols > 0 && len(cells) != cols {
|
||||||
|
t.Errorf("%s 第 %d 张表第 %d 行有 %d 格,表头 %d 列", tg.path, ti+1, ri+1, len(cells), cols)
|
||||||
|
}
|
||||||
|
for ci, c := range cells {
|
||||||
|
if ci == 0 {
|
||||||
|
continue // 首格是这一行的主角,故意不带标签
|
||||||
|
}
|
||||||
|
if ci < len(blank) && blank[ci] {
|
||||||
|
continue // 表头本来就是空的
|
||||||
|
}
|
||||||
|
if !strings.Contains(c[1], "data-label=") {
|
||||||
|
t.Errorf("%s 第 %d 张表第 %d 行第 %d 格没有 data-label(窄屏会变成没有名字的裸值)",
|
||||||
|
tg.path, ti+1, ri+1, ci+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Fatal("一页 tb-stack 表都没找到:模板里的类名是不是被改掉了")
|
||||||
|
}
|
||||||
|
t.Logf("检查了 %d 张 tb-stack 表", total)
|
||||||
|
}
|
||||||
@@ -98,15 +98,17 @@
|
|||||||
<a href="javascript:void(0)" id="activity-delete" class="danger">删除</a>
|
<a href="javascript:void(0)" id="activity-delete" class="danger">删除</a>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<select name="activity_id" id="activity-select">
|
{{/* 与照片导入页、到访行内编辑同一套:下拉里带「+ 新增活动…」,选中后就地展开新建框 */}}
|
||||||
|
<select name="activity_id" id="activity-select" class="activity-id-inline">
|
||||||
<option value="">未指定</option>
|
<option value="">未指定</option>
|
||||||
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
|
<option value="__new__">+ 新增活动…</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="f" style="flex:1 1 170px"><span>新建活动</span>
|
<label class="f visit-act-new" style="flex:1 1 170px;display:none"><span>新建活动</span>
|
||||||
<div class="input-btn">
|
<div class="input-btn">
|
||||||
<input type="text" id="new-activity-name" placeholder="名称">
|
<input type="text" class="visit-act-name" placeholder="名称">
|
||||||
<button type="button" class="btn" id="activity-add">添加</button>
|
<button type="button" class="btn visit-act-add">添加</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="f" style="flex:1 1 140px"><span>备注</span><input type="text" name="remark" placeholder="可选"></label>
|
<label class="f" style="flex:1 1 140px"><span>备注</span><input type="text" name="remark" placeholder="可选"></label>
|
||||||
@@ -146,22 +148,22 @@
|
|||||||
<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}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>名称</th><th>城市</th><th>坐标</th><th class="num">到访</th><th></th></tr></thead>
|
<thead><tr><th>名称</th><th>城市</th><th>坐标</th><th class="num">到访</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Places}}
|
{{range .Places}}
|
||||||
<tr id="place-row-{{.SrcPK}}"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} class="located"{{end}}>
|
<tr id="place-row-{{.SrcPK}}"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} class="located"{{end}}>
|
||||||
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}} <span class="muted mono">#{{.SrcPK}}</span></td>
|
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}} <span class="muted mono">#{{.SrcPK}}</span></td>
|
||||||
<td class="muted">{{.City}}{{if .District}} · {{.District}}{{end}}{{if .Thoroughfare}} · {{.Thoroughfare}}{{end}}</td>
|
<td class="muted" data-label="城市">{{.City}}{{if .District}} · {{.District}}{{end}}{{if .Thoroughfare}} · {{.Thoroughfare}}{{end}}</td>
|
||||||
<td class="muted mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}
|
<td class="muted mono" data-label="坐标">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}
|
||||||
<br><span class="tag{{if not .CoordSource}} warn{{end}}" title="{{if .CoordSource}}建点时留下的坐标来源记录{{else}}这行建于 2026-09 之前,没有坐标来源留档;坐标是否做过偏移只能靠原图 EXIF 或行内标注判断{{end}}">{{.SourceTag}}</span>
|
<br><span class="tag{{if not .CoordSource}} warn{{end}}" title="{{if .CoordSource}}建点时留下的坐标来源记录{{else}}这行建于 2026-09 之前,没有坐标来源留档;坐标是否做过偏移只能靠原图 EXIF 或行内标注判断{{end}}">{{.SourceTag}}</span>
|
||||||
{{if .SuspectWGS}}<span class="tag warn" title="导入留档里的原始 GPS 与显示坐标完全相同,说明当初没做 GCJ-02 偏移。在 GCJ-02 底图上会整体偏 500 米左右——用行内「编辑」→ 坐标系换算修。">疑似 WGS-84</span>{{end}}
|
{{if .SuspectWGS}}<span class="tag warn" title="导入留档里的原始 GPS 与显示坐标完全相同,说明当初没做 GCJ-02 偏移。在 GCJ-02 底图上会整体偏 500 米左右——用行内「编辑」→ 坐标系换算修。">疑似 WGS-84</span>{{end}}
|
||||||
{{if .MovedM}}<br><span class="muted">比导入时移动了 {{num .MovedM}} 米</span>{{end}}</td>
|
{{if .MovedM}}<br><span class="muted">比导入时移动了 {{num .MovedM}} 米</span>{{end}}</td>
|
||||||
<td class="num">{{num .VisitCount}}</td>
|
<td class="num" data-label="到访">{{num .VisitCount}}</td>
|
||||||
<td style="white-space:nowrap">
|
<td class="ops">
|
||||||
<details class="inline-edit"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} open{{end}}>
|
<details class="inline-edit"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} open{{end}}>
|
||||||
<summary class="btn sm">编辑</summary>
|
<summary class="btn sm">编辑</summary>
|
||||||
<form method="post" action="/admin/edit/place/update" style="margin-top:8px;min-width:280px">
|
<form method="post" action="/admin/edit/place/update" data-visit-count="{{.VisitCount}}" style="margin-top:8px;--efw:280px">
|
||||||
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
||||||
<label class="f"><span>名称<b class="req">*</b></span><input type="text" name="name" value="{{.Name}}" required></label>
|
<label class="f"><span>名称<b class="req">*</b></span><input type="text" name="name" value="{{.Name}}" required></label>
|
||||||
<label class="f"><span>类别</span>
|
<label class="f"><span>类别</span>
|
||||||
@@ -173,6 +175,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="f cat-custom" style="display:none"><span>自定义类别</span><input type="text" name="category_custom" placeholder="如 公园"></label>
|
<label class="f cat-custom" style="display:none"><span>自定义类别</span><input type="text" name="category_custom" placeholder="如 公园"></label>
|
||||||
|
{{/* 改的是该地点名下**全部到访**的类型,所以默认「(不修改)」、
|
||||||
|
不预选当前值:否则每次改个坐标保存,都会把历史到访重写一遍 */}}
|
||||||
|
<label class="f"><span>活动<span class="muted">(改该地点全部到访)</span></span>
|
||||||
|
<select name="activity_id" class="activity-id-inline">
|
||||||
|
<option value="">(不修改)</option>
|
||||||
|
{{range $.Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
|
<option value="__new__">+ 新增活动…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="f visit-act-new" style="display:none"><span>新建活动</span>
|
||||||
|
<div class="input-btn">
|
||||||
|
<input type="text" class="visit-act-name" placeholder="名称">
|
||||||
|
<button type="button" class="btn visit-act-add">添加</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{{if .ActivityText}}<div class="card-sub" style="margin:0 0 8px">该地点到访当前类型:<span class="mono">{{.ActivityText}}</span></div>{{end}}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="f" style="flex:1 1 120px"><span>纬度<b class="req">*</b></span><input type="text" name="lat" value="{{printf "%.6f" .Lat}}" required></label>
|
<label class="f" style="flex:1 1 120px"><span>纬度<b class="req">*</b></span><input type="text" name="lat" value="{{printf "%.6f" .Lat}}" required></label>
|
||||||
<label class="f" style="flex:1 1 120px"><span>经度<b class="req">*</b></span><input type="text" name="lon" value="{{printf "%.6f" .Lon}}" required></label>
|
<label class="f" style="flex:1 1 120px"><span>经度<b class="req">*</b></span><input type="text" name="lon" value="{{printf "%.6f" .Lon}}" required></label>
|
||||||
@@ -254,25 +272,25 @@
|
|||||||
<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}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>时间</th><th>地点</th><th>类型</th><th>备注</th><th></th></tr></thead>
|
<thead><tr><th>时间</th><th>地点</th><th>类型</th><th>备注</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Visits}}
|
{{range .Visits}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="muted mono">{{.ArrivalStr}}{{if .DepartureStr}}<br>→ {{.DepartureStr}}{{else}}<br><span class="muted">(未离开)</span>{{end}}</td>
|
<td class="muted mono">{{.ArrivalStr}}{{if .DepartureStr}}<br>→ {{.DepartureStr}}{{else}}<br><span class="muted">(未离开)</span>{{end}}</td>
|
||||||
<td><span class="muted mono">#{{.SrcPK}}</span> {{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">—</span>{{end}}{{if .City}} <span class="muted">· {{.City}}</span>{{end}}</td>
|
<td data-label="地点"><span class="muted mono">#{{.SrcPK}}</span> {{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">—</span>{{end}}{{if .City}} <span class="muted">· {{.City}}</span>{{end}}</td>
|
||||||
<td class="muted">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td>
|
<td class="muted" data-label="类型">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td>
|
||||||
<td class="muted">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
|
<td class="muted" data-label="备注">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
|
||||||
<form method="post" action="/admin/edit/visit/bookmark" style="display:inline">
|
<form method="post" action="/admin/edit/visit/bookmark" style="display:inline">
|
||||||
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
||||||
<button class="star{{if .Bookmarked}} on{{end}}" type="submit"
|
<button class="star{{if .Bookmarked}} on{{end}}" type="submit"
|
||||||
title="{{if .Bookmarked}}取消收藏{{else}}标记为收藏{{end}}">{{if .Bookmarked}}★{{else}}☆{{end}}</button>
|
title="{{if .Bookmarked}}取消收藏{{else}}标记为收藏{{end}}">{{if .Bookmarked}}★{{else}}☆{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space:nowrap">
|
<td class="ops">
|
||||||
<details class="inline-edit">
|
<details class="inline-edit">
|
||||||
<summary class="btn sm">编辑</summary>
|
<summary class="btn sm">编辑</summary>
|
||||||
<form method="post" action="/admin/edit/visit/update" style="margin-top:8px;min-width:300px">
|
<form method="post" action="/admin/edit/visit/update" style="margin-top:8px;--efw:300px">
|
||||||
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
||||||
<label class="f"><span>类型</span>
|
<label class="f"><span>类型</span>
|
||||||
<select name="activity_id" class="activity-id-inline">
|
<select name="activity_id" class="activity-id-inline">
|
||||||
@@ -352,20 +370,20 @@
|
|||||||
<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>
|
||||||
{{if .Movements}}
|
{{if .Movements}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>时间</th><th>路线</th><th>方式</th><th class="num">里程</th><th></th></tr></thead>
|
<thead><tr><th>时间</th><th>路线</th><th>方式</th><th class="num">里程</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Movements}}
|
{{range .Movements}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="muted mono">{{if .Span}}{{.Span}}{{else}}(无时间){{end}}
|
<td class="muted mono">{{if .Span}}{{.Span}}{{else}}(无时间){{end}}
|
||||||
{{if .DurationMin}}<br><span class="muted">{{dur .DurationMin}}</span>{{end}}</td>
|
{{if .DurationMin}}<br><span class="muted">{{dur .DurationMin}}</span>{{end}}</td>
|
||||||
<td class="mv-route" title="{{.FromLabel}}{{if .FromCity}} · {{.FromCity}}{{end}} → {{.ToLabel}}{{if .ToCity}} · {{.ToCity}}{{end}}">{{.FromLabel}}<span class="mv-city">{{if .FromCity}} · {{.FromCity}}{{end}}</span><br><span class="muted">→ {{.ToLabel}}<span class="mv-city">{{if .ToCity}} · {{.ToCity}}{{end}}</span></span></td>
|
<td class="mv-route" data-label="路线" title="{{.FromLabel}}{{if .FromCity}} · {{.FromCity}}{{end}} → {{.ToLabel}}{{if .ToCity}} · {{.ToCity}}{{end}}">{{.FromLabel}}<span class="mv-city">{{if .FromCity}} · {{.FromCity}}{{end}}</span><br><span class="muted">→ {{.ToLabel}}<span class="mv-city">{{if .ToCity}} · {{.ToCity}}{{end}}</span></span></td>
|
||||||
<td class="muted">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{colorof .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
|
<td class="muted" data-label="方式">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{colorof .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
|
||||||
<td class="num">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
|
<td class="num" data-label="里程">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
|
||||||
<td style="white-space:nowrap">
|
<td class="ops">
|
||||||
<details class="inline-edit">
|
<details class="inline-edit">
|
||||||
<summary class="btn sm">编辑</summary>
|
<summary class="btn sm">编辑</summary>
|
||||||
<form method="post" action="/admin/edit/movement/update" style="margin-top:8px;min-width:320px">
|
<form method="post" action="/admin/edit/movement/update" style="margin-top:8px;--efw:320px">
|
||||||
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="f" style="flex:1 1 210px"><span>起始到访<b class="req">*</b></span>
|
<label class="f" style="flex:1 1 210px"><span>起始到访<b class="req">*</b></span>
|
||||||
@@ -939,23 +957,11 @@
|
|||||||
}).catch(function (e) { alert('创建活动失败:' + e); });
|
}).catch(function (e) { alert('创建活动失败:' + e); });
|
||||||
}
|
}
|
||||||
|
|
||||||
var actAdd = document.getElementById('activity-add');
|
// 顶部「新建活动」不再单独接事件:它和到访/地点行内编辑共用下面那套
|
||||||
if (actAdd) {
|
// `visit-act-*` 委派处理器(同一个表单里只有一个新建框,重复监听会建出两条)。
|
||||||
var newActInput = document.getElementById('new-activity-name');
|
|
||||||
var addActivityTop = function () {
|
|
||||||
var sel = document.getElementById('activity-select');
|
|
||||||
createActivity(newActInput.value, function (id) {
|
|
||||||
if (sel) sel.value = id;
|
|
||||||
newActInput.value = '';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
actAdd.addEventListener('click', addActivityTop);
|
|
||||||
newActInput.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Enter') { e.preventDefault(); addActivityTop(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 到访行内编辑里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
|
// ---- 下拉里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
|
||||||
|
// 顶部新建地点卡、到访行内编辑、地点行内编辑三处共用(靠 form 里的类名找同伴元素)
|
||||||
function inlineActParts(el) {
|
function inlineActParts(el) {
|
||||||
var form = el.closest ? el.closest('form') : null;
|
var form = el.closest ? el.closest('form') : null;
|
||||||
if (!form) return null;
|
if (!form) return null;
|
||||||
@@ -993,16 +999,23 @@
|
|||||||
inlineActCreate(t);
|
inlineActCreate(t);
|
||||||
});
|
});
|
||||||
// 提交守卫:还停在「+ 新增活动…」上就拦下来。否则 activity_id 会带着 "__new__" 提交,
|
// 提交守卫:还停在「+ 新增活动…」上就拦下来。否则 activity_id 会带着 "__new__" 提交,
|
||||||
// resolveActivity 解析不出整数就当成「无」,这条到访的类型会被静默清掉。
|
// resolveActivity 解析不出整数就当成「无」,那条记录的类型会被静默清掉。
|
||||||
document.addEventListener('submit', function (e) {
|
document.addEventListener('submit', function (e) {
|
||||||
var f = e.target;
|
var f = e.target;
|
||||||
if (!f || !f.querySelector) return;
|
if (!f || !f.querySelector) return;
|
||||||
var sel = f.querySelector('select.activity-id-inline');
|
var sel = f.querySelector('select.activity-id-inline');
|
||||||
if (sel && sel.value === '__new__') {
|
if (sel && sel.value === '__new__') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
alert('类型选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或改回「(无)」。');
|
alert('活动选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或把这一项改回默认值。');
|
||||||
sel.focus();
|
sel.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 地点行内编辑的「活动」是批量改该地点**全部**到访,动的是历史记录,落库前再问一次
|
||||||
|
if (!f.getAttribute || f.getAttribute('action') !== '/admin/edit/place/update') return;
|
||||||
|
if (!sel || !sel.value) return;
|
||||||
|
var n = f.getAttribute('data-visit-count') || '';
|
||||||
|
if (!window.confirm('把该地点' + (n ? '全部 ' + n + ' 条' : '全部') + '到访的活动都改成「' +
|
||||||
|
sel.options[sel.selectedIndex].textContent + '」?原有记录会被一起改掉。')) e.preventDefault();
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
// ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ----------
|
// ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ----------
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
<div class="card-sub">同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它</div>
|
<div class="card-sub">同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它</div>
|
||||||
{{if .Datasets}}
|
{{if .Datasets}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>文件</th><th>导入时间</th><th class="num">到访</th><th class="num">地点</th><th>数据跨度</th><th>状态</th><th></th></tr>
|
<tr><th>文件</th><th>导入时间</th><th class="num">到访</th><th class="num">地点</th><th>数据跨度</th><th>状态</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -116,18 +116,18 @@
|
|||||||
{{range .Datasets}}
|
{{range .Datasets}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="mono">{{trunc .Name 34}}</span></td>
|
<td><span class="mono">{{trunc .Name 34}}</span></td>
|
||||||
<td class="muted">{{dt .UploadedAt}}</td>
|
<td class="muted" data-label="导入时间">{{dt .UploadedAt}}</td>
|
||||||
<td class="num">{{num .Visits}}</td>
|
<td class="num" data-label="到访">{{num .Visits}}</td>
|
||||||
<td class="num">{{num .Places}}</td>
|
<td class="num" data-label="地点">{{num .Places}}</td>
|
||||||
<td class="muted">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
|
<td class="muted" data-label="数据跨度">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
|
||||||
<td>
|
<td data-label="状态">
|
||||||
{{if and $.Dataset (eq $.Dataset.ID .ID)}}
|
{{if and $.Dataset (eq $.Dataset.ID .ID)}}
|
||||||
<span class="tag">展示中</span>
|
<span class="tag">展示中</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="muted">未启用</span>
|
<span class="muted">未启用</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space:nowrap">
|
<td data-label="操作" class="ops">
|
||||||
{{if not (and $.Dataset (eq $.Dataset.ID .ID))}}
|
{{if not (and $.Dataset (eq $.Dataset.ID .ID))}}
|
||||||
<form method="post" action="/admin/datasets/{{.ID}}/activate" style="display:inline">
|
<form method="post" action="/admin/datasets/{{.ID}}/activate" style="display:inline">
|
||||||
<button class="btn sm" type="submit">启用</button>
|
<button class="btn sm" type="submit">启用</button>
|
||||||
@@ -152,15 +152,15 @@
|
|||||||
<h2>数据说明</h2>
|
<h2>数据说明</h2>
|
||||||
<div class="card-sub">从备份中读取并转换的字段</div>
|
<div class="card-sub">从备份中读取并转换的字段</div>
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>来源表</th><th>内容</th><th>用途</th></tr></thead>
|
<thead><tr><th>来源表</th><th>内容</th><th>用途</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td class="mono">ZVISIT</td><td>到访记录(到达 / 离开时间、类型、地点、备注、标签)</td><td>时间线、统计、地图</td></tr>
|
<tr><td class="mono">ZVISIT</td><td data-label="内容">到访记录(到达 / 离开时间、类型、地点、备注、标签)</td><td data-label="用途">时间线、统计、地图</td></tr>
|
||||||
<tr><td class="mono">ZLOCATION</td><td>地点(名称、经纬度、城市、省份、POI 分类)</td><td>地图打点、地点库</td></tr>
|
<tr><td class="mono">ZLOCATION</td><td data-label="内容">地点(名称、经纬度、城市、省份、POI 分类)</td><td data-label="用途">地图打点、地点库</td></tr>
|
||||||
<tr><td class="mono">ZACTIVITY</td><td>自定义类型(颜色、图标、是否家 / 工作)</td><td>类型分布</td></tr>
|
<tr><td class="mono">ZACTIVITY</td><td data-label="内容">自定义类型(颜色、图标、是否家 / 工作)</td><td data-label="用途">类型分布</td></tr>
|
||||||
<tr><td class="mono">ZMOVEMENT</td><td>位移段(交通方式、起止时间)</td><td>出行方式统计</td></tr>
|
<tr><td class="mono">ZMOVEMENT</td><td data-label="内容">位移段(交通方式、起止时间)</td><td data-label="用途">出行方式统计</td></tr>
|
||||||
<tr><td class="mono">ZTAG</td><td>自定义标签及分组</td><td>标签筛选</td></tr>
|
<tr><td class="mono">ZTAG</td><td data-label="内容">自定义标签及分组</td><td data-label="用途">标签筛选</td></tr>
|
||||||
<tr><td class="mono">ZHOURLYWEATHER</td><td>逐小时天气(气温、天气现象、湿度)</td><td>天气足迹</td></tr>
|
<tr><td class="mono">ZHOURLYWEATHER</td><td data-label="内容">逐小时天气(气温、天气现象、湿度)</td><td data-label="用途">天气足迹</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,23 +50,23 @@
|
|||||||
<div class="card-sub">共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览</div>
|
<div class="card-sub">共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览</div>
|
||||||
{{if .Topics}}
|
{{if .Topics}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>标题</th><th>时间范围</th><th class="num">地点</th><th class="num">到访</th><th>区域</th><th>对访客</th><th></th></tr></thead>
|
<thead><tr><th>标题</th><th>时间范围</th><th class="num">地点</th><th class="num">到访</th><th>区域</th><th>对访客</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Topics}}
|
{{range .Topics}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/topics/{{.ID}}" target="_blank">{{.Title}}</a>{{if .Subtitle}} <span class="muted">· {{.Subtitle}}</span>{{end}}</td>
|
<td><a href="/topics/{{.ID}}" target="_blank">{{.Title}}</a>{{if .Subtitle}} <span class="muted">· {{.Subtitle}}</span>{{end}}</td>
|
||||||
<td class="muted mono">{{.RangeLabel}}</td>
|
<td class="muted mono" data-label="时间范围">{{.RangeLabel}}</td>
|
||||||
<td class="num">{{num .PlaceCount}}</td>
|
<td class="num" data-label="地点">{{num .PlaceCount}}</td>
|
||||||
<td class="num">{{num .VisitCount}}</td>
|
<td class="num" data-label="到访">{{num .VisitCount}}</td>
|
||||||
<td class="muted">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
|
<td class="muted" data-label="区域">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
|
||||||
<td>
|
<td data-label="对访客">
|
||||||
<form method="post" action="/admin/topics/toggle" style="display:inline">
|
<form method="post" action="/admin/topics/toggle" style="display:inline">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
|
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space:nowrap">
|
<td data-label="操作" class="ops">
|
||||||
<a class="btn sm" href="/admin/topics?edit={{.ID}}">编辑</a>
|
<a class="btn sm" href="/admin/topics?edit={{.ID}}">编辑</a>
|
||||||
<form method="post" action="/admin/topics/delete" style="display:inline"
|
<form method="post" action="/admin/topics/delete" style="display:inline"
|
||||||
onsubmit="return confirm('删除专题「{{.Title}}」?地点数据不受影响。')">
|
onsubmit="return confirm('删除专题「{{.Title}}」?地点数据不受影响。')">
|
||||||
|
|||||||
@@ -132,15 +132,15 @@
|
|||||||
<h2>天气足迹</h2>
|
<h2>天气足迹</h2>
|
||||||
<div class="card-sub">记录期间经历过的天气</div>
|
<div class="card-sub">记录期间经历过的天气</div>
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>天气</th><th class="num">记录小时数</th><th class="num">平均气温</th><th class="num">范围</th></tr></thead>
|
<thead><tr><th>天气</th><th class="num">记录小时数</th><th class="num">平均气温</th><th class="num">范围</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Weather}}
|
{{range .Weather}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{condition .Condition}}</td>
|
<td>{{condition .Condition}}</td>
|
||||||
<td class="num">{{num .Count}}</td>
|
<td class="num" data-label="记录小时数">{{num .Count}}</td>
|
||||||
<td class="num">{{f1 .AvgTemp}} ℃</td>
|
<td class="num" data-label="平均气温">{{f1 .AvgTemp}} ℃</td>
|
||||||
<td class="num">{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃</td>
|
<td class="num" data-label="范围">{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -22,10 +22,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<script>
|
||||||
|
/* 手机上导航装不下这几项,可以横向滑动(滚动条已隐藏)——右侧加一层渐隐,提示「还能往右滑」;
|
||||||
|
滑到末端就收掉,免得让人以为后面还有。桌面端没有横向溢出,这段等于不做事。 */
|
||||||
|
(function () {
|
||||||
|
var el = document.querySelector('.nav-links');
|
||||||
|
if (!el) return;
|
||||||
|
var update = function () {
|
||||||
|
el.classList.toggle('overflow-right', el.scrollWidth - el.clientWidth - el.scrollLeft > 2);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
el.addEventListener('scroll', update, { passive: true });
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "filters"}}
|
{{define "filters"}}
|
||||||
<form class="card" method="get" action="{{.FilterBase}}" id="filters-form">
|
<form class="card filters-card" method="get" action="{{.FilterBase}}" id="filters-form">
|
||||||
|
{{/* 窄屏折叠开关,与地图页筛选面板同一套(.panel-fold):桌面端它 display:none、表单照旧常开;
|
||||||
|
手机上默认收成这一条,只留「筛选 + 当前范围 + 箭头」。收起只是 CSS 隐藏,
|
||||||
|
表单里的控件都还在,筛选条件照旧随提交带上(act/hw/sort 那几个 hidden 也在)。 */}}
|
||||||
|
<button class="panel-fold" id="filters-fold" type="button" aria-controls="filters-form" aria-expanded="true">
|
||||||
|
<span>筛选</span>
|
||||||
|
<span class="pf-n">{{.RangeLabel}}{{if .ActiveChips}} · {{len .ActiveChips}} 个条件{{end}}</span>
|
||||||
|
<span class="pf-c" id="filters-fold-caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
<div class="chips" style="margin-bottom:12px">
|
<div class="chips" style="margin-bottom:12px">
|
||||||
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
|
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
|
||||||
<span class="chip" style="background:transparent;color:var(--text-3)">{{.RangeLabel}}</span>
|
<span class="chip" style="background:transparent;color:var(--text-3)">{{.RangeLabel}}</span>
|
||||||
@@ -104,6 +126,37 @@
|
|||||||
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
|
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
|
||||||
{{if .Sort}}<input type="hidden" name="sort" value="{{.Sort}}">{{end}}
|
{{if .Sort}}<input type="hidden" name="sort" value="{{.Sort}}">{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
{{/* 窄屏折叠:脚本必须紧跟这个表单 —— 放到 body 末尾时,手机上会先按「全部展开」画一帧
|
||||||
|
再收起,看着就是闪一下整张大卡(地图页那块面板也是贴在面板后面)。收起态记在本地,
|
||||||
|
翻页/换页之后不该又自己弹开。 */}}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById('filters-form');
|
||||||
|
var btn = document.getElementById('filters-fold');
|
||||||
|
if (!form || !btn) return;
|
||||||
|
var caret = document.getElementById('filters-fold-caret');
|
||||||
|
var KEY = 'rond-filters-fold';
|
||||||
|
var desk = window.matchMedia('(min-width: 621px)');
|
||||||
|
var saved = null;
|
||||||
|
try { saved = localStorage.getItem(KEY); } catch (e) { /* 隐私模式禁用了,忽略 */ }
|
||||||
|
|
||||||
|
function apply(collapsed) {
|
||||||
|
form.classList.toggle('collapsed', collapsed);
|
||||||
|
btn.setAttribute('aria-expanded', String(!collapsed));
|
||||||
|
btn.title = collapsed ? '展开筛选' : '收起筛选';
|
||||||
|
if (caret) caret.textContent = collapsed ? '▸' : '▾';
|
||||||
|
}
|
||||||
|
// 没记过状态时窄屏默认收起。桌面端这条 class 不生效(显示规则整条在窄屏媒体查询里),
|
||||||
|
// 所以同一个浏览器在手机上收起过、回桌面照样看得见筛选。
|
||||||
|
apply(saved === null ? !desk.matches : saved === '1');
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var collapsed = !form.classList.contains('collapsed');
|
||||||
|
apply(collapsed);
|
||||||
|
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* 同上 */ }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "emptystate"}}
|
{{define "emptystate"}}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
{{if .Places}}
|
{{if .Places}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb places-table">
|
<table class="tb places-table tb-stack">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>地点</th><th>城市</th><th>主要类型</th><th>图片</th>
|
<th>地点</th><th>城市</th><th>主要类型</th><th>图片</th>
|
||||||
@@ -57,15 +57,15 @@
|
|||||||
{{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}}
|
||||||
{{if or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
|
{{if or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">{{if .City}}{{.City}}{{else}}—{{end}}</td>
|
<td class="muted" data-label="城市">{{if .City}}{{.City}}{{else}}—{{end}}</td>
|
||||||
<td>
|
<td data-label="主要类型">
|
||||||
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{colorof .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
|
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{colorof .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{if .HasImage}}<span title="有图片">📷</span>{{else}}<span class="muted" title="还没有上传图片">—</span>{{end}}</td>
|
<td data-label="图片">{{if .HasImage}}<span title="有图片">📷</span>{{else}}<span class="muted" title="还没有上传图片">—</span>{{end}}</td>
|
||||||
<td class="num">{{num .VisitCount}}</td>
|
<td class="num" data-label="到访">{{num .VisitCount}}</td>
|
||||||
<td class="num">{{dur .DwellMinutes}}</td>
|
<td class="num" data-label="累计停留">{{dur .DwellMinutes}}</td>
|
||||||
<td class="muted">{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}</td>
|
<td class="muted" data-label="首次">{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}</td>
|
||||||
<td class="muted">{{if .LastAt}}{{d .LastAt}}{{end}}</td>
|
<td class="muted" data-label="最近">{{if .LastAt}}{{d .LastAt}}{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="grid g2">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>去得最多的城市</h2>
|
<h2>去得最多的城市</h2>
|
||||||
<div class="card-sub">按到访次数</div>
|
<div class="card-sub">按到访次数</div>
|
||||||
@@ -64,11 +64,11 @@
|
|||||||
<div class="card-sub">这一年到过纬度最高与最低的地点</div>
|
<div class="card-sub">这一年到过纬度最高与最低的地点</div>
|
||||||
{{if .North}}
|
{{if .North}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
|
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>最北</td><td>{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num">{{f1 .North.Lat}}°N</td></tr>
|
<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>{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num">{{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>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,14 +81,14 @@
|
|||||||
<h2>在路上</h2>
|
<h2>在路上</h2>
|
||||||
<div class="card-sub">这一年 {{num .Overview.Movements}} 段行程,共 {{km .Overview.DistanceKm}} km</div>
|
<div class="card-sub">这一年 {{num .Overview.Movements}} 段行程,共 {{km .Overview.DistanceKm}} km</div>
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th></tr></thead>
|
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Transports}}
|
{{range .Transports}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
||||||
<td class="num">{{num .Count}}</td>
|
<td class="num" data-label="次数">{{num .Count}}</td>
|
||||||
<td class="num">{{km .Distance}} km</td>
|
<td class="num" data-label="里程">{{km .Distance}} km</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid g2">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>城市排行</h2>
|
<h2>城市排行</h2>
|
||||||
<div class="card-sub">到访次数</div>
|
<div class="card-sub">到访次数</div>
|
||||||
@@ -103,15 +103,15 @@
|
|||||||
<h2>省级行政区</h2>
|
<h2>省级行政区</h2>
|
||||||
<div class="card-sub">覆盖情况</div>
|
<div class="card-sub">覆盖情况</div>
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>省份</th><th class="num">城市</th><th class="num">地点</th><th class="num">到访</th></tr></thead>
|
<thead><tr><th>省份</th><th class="num">城市</th><th class="num">地点</th><th class="num">到访</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .ProvinceStats}}
|
{{range .ProvinceStats}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Province}}</td>
|
<td>{{.Province}}</td>
|
||||||
<td class="num">{{num .Cities}}</td>
|
<td class="num" data-label="城市">{{num .Cities}}</td>
|
||||||
<td class="num">{{num .Places}}</td>
|
<td class="num" data-label="地点">{{num .Places}}</td>
|
||||||
<td class="num">{{num .Count}}</td>
|
<td class="num" data-label="到访">{{num .Count}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid g2">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>星期分布</h2>
|
<h2>星期分布</h2>
|
||||||
<div class="card-sub">一周中的到访规律</div>
|
<div class="card-sub">一周中的到访规律</div>
|
||||||
@@ -141,15 +141,15 @@
|
|||||||
<div class="card-sub">次数与估算里程</div>
|
<div class="card-sub">次数与估算里程</div>
|
||||||
{{if .Transports}}
|
{{if .Transports}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th><th class="num">在途时间</th></tr></thead>
|
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th><th class="num">在途时间</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Transports}}
|
{{range .Transports}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
|
||||||
<td class="num">{{num .Count}}</td>
|
<td class="num" data-label="次数">{{num .Count}}</td>
|
||||||
<td class="num">{{km .Distance}} km</td>
|
<td class="num" data-label="里程">{{km .Distance}} km</td>
|
||||||
<td class="num">{{dur .DwellMin}}</td>
|
<td class="num" data-label="在途时间">{{dur .DwellMin}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid g2">
|
<div class="grid">
|
||||||
{{if .Tags}}
|
{{if .Tags}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>标签统计</h2>
|
<h2>标签统计</h2>
|
||||||
@@ -181,14 +181,14 @@
|
|||||||
<h2>天气与气温</h2>
|
<h2>天气与气温</h2>
|
||||||
<div class="card-sub">记录期间经历的小时级天气</div>
|
<div class="card-sub">记录期间经历的小时级天气</div>
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>天气</th><th class="num">小时数</th><th class="num">平均气温</th></tr></thead>
|
<thead><tr><th>天气</th><th class="num">小时数</th><th class="num">平均气温</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Weather}}
|
{{range .Weather}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{condition .Condition}}</td>
|
<td>{{condition .Condition}}</td>
|
||||||
<td class="num">{{num .Count}}</td>
|
<td class="num" data-label="小时数">{{num .Count}}</td>
|
||||||
<td class="num">{{f1 .AvgTemp}} ℃</td>
|
<td class="num" data-label="平均气温">{{f1 .AvgTemp}} ℃</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -42,17 +42,17 @@
|
|||||||
<div class="card-sub">共 {{num .Topic.PlaceCount}} 个,按到访次数排序</div>
|
<div class="card-sub">共 {{num .Topic.PlaceCount}} 个,按到访次数排序</div>
|
||||||
{{if .Points}}
|
{{if .Points}}
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>地点</th><th>城市</th><th>类型</th><th class="num">到访</th><th class="num">停留</th><th>最近一次</th></tr></thead>
|
<thead><tr><th>地点</th><th>城市</th><th>类型</th><th class="num">到访</th><th class="num">停留</th><th>最近一次</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Points}}
|
{{range .Points}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
|
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
|
||||||
<td class="muted">{{.City}}</td>
|
<td class="muted" data-label="城市">{{.City}}</td>
|
||||||
<td class="muted">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
|
<td class="muted" data-label="类型">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
|
||||||
<td class="num">{{num .Count}}</td>
|
<td class="num" data-label="到访">{{num .Count}}</td>
|
||||||
<td class="num muted">{{dur .Dwell}}</td>
|
<td class="num muted" data-label="停留">{{dur .Dwell}}</td>
|
||||||
<td class="muted mono">{{.LastAt}}</td>
|
<td class="muted mono" data-label="最近一次">{{.LastAt}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -852,6 +852,9 @@ type EditPlace struct {
|
|||||||
SrcLat, SrcLon sql.NullFloat64
|
SrcLat, SrcLon sql.NullFloat64
|
||||||
// CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上)
|
// CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上)
|
||||||
CategoryOptions []EditOption
|
CategoryOptions []EditOption
|
||||||
|
// ActivityText 是该地点名下到访当前的活动构成(「商场 8 · 餐饮 3」这种)。
|
||||||
|
// 行内编辑里的「活动」是把该地点全部到访一起改,所以得先让人看清现在是什么。
|
||||||
|
ActivityText string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。
|
// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWideTableCardsAreFullWidth 守住「宽表卡片必须整行」这条不变量。
|
||||||
|
//
|
||||||
|
// 这批表的 min-width 是 640 / 700,只要卡片被塞进 g2 的半宽列(视口 1440 时卡片只有 494px),
|
||||||
|
// 桌面端就得横滑 100+px 才看得全(/stats 三张、/report「南北两端」原来就是这样)。卡片能不能
|
||||||
|
// 放下只由它自己的宽度决定,所以宽表卡片一律不放进栅格——将来想给页面加 g2 排版时,这条会拦住。
|
||||||
|
//
|
||||||
|
// 同时钉住 CSS 侧的两个挂载点:.tw 得是容器、容器查询阈值得在(否则窄下来不会竖排,又会横滑)。
|
||||||
|
//
|
||||||
|
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run WideTableCards -v
|
||||||
|
func TestWideTableCardsAreFullWidth(t *testing.T) {
|
||||||
|
f := newMovementFixture(t)
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
path string
|
||||||
|
h http.HandlerFunc
|
||||||
|
admin bool
|
||||||
|
}
|
||||||
|
tables := 0
|
||||||
|
for _, tg := range []target{
|
||||||
|
{"/stats", f.srv.statsPage, false},
|
||||||
|
{"/report", f.srv.reportPage, false},
|
||||||
|
{"/places", f.srv.placesPage, false},
|
||||||
|
{"/", f.srv.home, false},
|
||||||
|
{"/admin", f.srv.adminHome, true},
|
||||||
|
{"/admin/topics", f.srv.topicsAdminPage, true},
|
||||||
|
// 数据编辑页的三张表原来漏了 tb-stack:它们默认折叠在卡片里,探针量到的是 display:none,
|
||||||
|
// 「窄屏会不会横滑」这件事在默认态根本看不出来(站长 2026-09-19 就是这么反馈上来的)。
|
||||||
|
{"/admin/edit", f.srv.editPage, true},
|
||||||
|
{"/admin/photos", f.srv.adminPhotosPage, true},
|
||||||
|
} {
|
||||||
|
ctx := context.Background()
|
||||||
|
if tg.admin {
|
||||||
|
ctx = f.ctx
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tg.h(w, httptest.NewRequest("GET", tg.path, nil).WithContext(ctx))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Logf("%s 渲染 code=%d,跳过", tg.path, w.Code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tables += checkWideTablesOutsideGrid(t, tg.path, w.Body.String())
|
||||||
|
}
|
||||||
|
if tables == 0 {
|
||||||
|
t.Fatal("一页 tb-stack 表都没渲染出来:模板里的类名是不是被改掉了")
|
||||||
|
}
|
||||||
|
|
||||||
|
css, err := fs.ReadFile(staticFS, "static/css/app.css")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读不到 app.css: %v", err)
|
||||||
|
}
|
||||||
|
// 堆叠规则必须包在 @media 里,而且阈值要够宽:
|
||||||
|
// 卡片内容宽 ≈ 视口 − 93(页面 + 卡片的内边距),最宽的表 min-width 700 ⇒ 至少 792 才放得下。
|
||||||
|
// 这条断言挡的是两个真实踩过的坑:① 阈值 620 时视口 621~793 的卡片两头不靠、照样横滑;
|
||||||
|
// ② 换成 @container 后旧手机(iOS<16 / Chrome<105)整块规则失效,表格退化成横滑 388px。
|
||||||
|
clean := stripCSSComments(string(css))
|
||||||
|
blocks := cssMediaBlocks(clean)
|
||||||
|
found := 0
|
||||||
|
for _, loc := range regexp.MustCompile(`tb-stack`).FindAllStringIndex(clean, -1) {
|
||||||
|
found++
|
||||||
|
blk, ok := innermostMedia(blocks, loc[0])
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("app.css 里有 tb-stack 规则不在任何 @media 里:旧手机的容器查询/自适应会失效(附近:%s)",
|
||||||
|
snippetAt(clean, loc[0]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if blk.maxW < 792 {
|
||||||
|
t.Errorf("tb-stack 规则所在的 @media 阈值只有 %dpx:视口 %d~792 的卡片放不下表格,又会横滑",
|
||||||
|
blk.maxW, blk.maxW+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == 0 {
|
||||||
|
t.Error("app.css 里一条 tb-stack 堆叠规则都没有:宽表在窄屏不会竖排")
|
||||||
|
}
|
||||||
|
t.Logf("检查了 %d 张 tb-stack 表、%d 条堆叠规则", tables, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripCSSComments 去掉 /* */ 注释:注释里出现的 @media / @container 字样会把解析带偏。
|
||||||
|
func stripCSSComments(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' {
|
||||||
|
j := strings.Index(s[i+2:], "*/")
|
||||||
|
if j < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 2 + j + 2
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type cssMediaBlock struct{ start, end, maxW int }
|
||||||
|
|
||||||
|
// cssMediaBlocks 找出所有 @media 块的区间和它的 max-width 阈值(没有就记 -1)。
|
||||||
|
func cssMediaBlocks(css string) []cssMediaBlock {
|
||||||
|
var out []cssMediaBlock
|
||||||
|
headRe := regexp.MustCompile(`@media([^{]*)\{`)
|
||||||
|
widthRe := regexp.MustCompile(`max-width:\s*(\d+)px`)
|
||||||
|
for _, m := range headRe.FindAllStringSubmatchIndex(css, -1) {
|
||||||
|
maxW := -1
|
||||||
|
if w := widthRe.FindStringSubmatch(css[m[2]:m[3]]); w != nil {
|
||||||
|
maxW, _ = strconv.Atoi(w[1])
|
||||||
|
}
|
||||||
|
depth, end := 0, -1
|
||||||
|
for i := m[1] - 1; i < len(css); i++ {
|
||||||
|
switch css[i] {
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
end = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end >= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end > 0 {
|
||||||
|
out = append(out, cssMediaBlock{m[0], end, maxW})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func innermostMedia(blocks []cssMediaBlock, pos int) (cssMediaBlock, bool) {
|
||||||
|
best := cssMediaBlock{}
|
||||||
|
ok := false
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.start < pos && pos < b.end && (!ok || b.start > best.start) {
|
||||||
|
best, ok = b, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func snippetAt(s string, pos int) string {
|
||||||
|
lo, hi := pos-20, pos+30
|
||||||
|
if lo < 0 {
|
||||||
|
lo = 0
|
||||||
|
}
|
||||||
|
if hi > len(s) {
|
||||||
|
hi = len(s)
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(s[lo:hi], "\n", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWideTablesOutsideGrid 用 div 栈还原每个表格的祖先,确认两件事:
|
||||||
|
// ① 每张 tb-stack 表都不在任何 g2 网格里(半宽卡片会横滑);
|
||||||
|
// ② 凡是被 .tw 包住的表格都得带 tb-stack —— 否则窄屏没有任何堆叠规则可命中,照样横滑。
|
||||||
|
// 只靠「最近一个 class=\"grid g2\" 出现在前面」会被已闭合的网格误伤,所以按标签顺序配对。
|
||||||
|
// 返回这一页检查了几张堆叠表(数据为空时某些页面本来就没有表)。
|
||||||
|
func checkWideTablesOutsideGrid(t *testing.T, path, body string) int {
|
||||||
|
t.Helper()
|
||||||
|
tagRe := regexp.MustCompile(`(?s)<div\b[^>]*>|</div>|<table\b[^>]*>`)
|
||||||
|
classRe := regexp.MustCompile(`class="([^"]*)"`)
|
||||||
|
|
||||||
|
type frame struct{ g2, tw bool }
|
||||||
|
var stack []frame
|
||||||
|
seen := 0
|
||||||
|
for _, tag := range tagRe.FindAllString(body, -1) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(tag, "</div"):
|
||||||
|
if len(stack) > 0 {
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tag, "<div"):
|
||||||
|
var f frame
|
||||||
|
if m := classRe.FindStringSubmatch(tag); m != nil {
|
||||||
|
for _, c := range strings.Fields(m[1]) {
|
||||||
|
switch c {
|
||||||
|
case "g2":
|
||||||
|
f.g2 = true
|
||||||
|
case "tw":
|
||||||
|
f.tw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack = append(stack, f)
|
||||||
|
case strings.HasPrefix(tag, "<table"):
|
||||||
|
inTW, inG2 := false, false
|
||||||
|
for _, f := range stack {
|
||||||
|
inTW = inTW || f.tw
|
||||||
|
inG2 = inG2 || f.g2
|
||||||
|
}
|
||||||
|
if !strings.Contains(tag, "tb-stack") {
|
||||||
|
// 照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路,暂不纳入堆叠。
|
||||||
|
if inTW && !strings.Contains(tag, "photo-table") {
|
||||||
|
t.Errorf("%s:.tw 里的表格没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑)", path)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen++
|
||||||
|
// 只有标了 tb-stack 的才算「宽表」;普通的窄表放进 g2 没问题,不该报。
|
||||||
|
if inG2 {
|
||||||
|
t.Errorf("%s:一张宽表被放在 g2 网格里(卡片只剩半宽,桌面端要横滑)", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user