Compare commits

..
4 Commits
Author SHA1 Message Date
tamakyi 9e01923244 宽表在窄屏真的不横滑了:堆叠判据退回媒体查询,后台三张表补上
真机反馈「手机上还是要左右滑」,两个根因:

1. 上一版把堆叠判据写成了 @container。它按卡片自己的宽度判断更准,但要
   iOS 16 / Chrome 105 才支持——旧手机把整块规则当不认识的东西丢掉,表格
   完全不堆叠、退化成横滑 328~388px,比改之前还糟。改回 @media,阈值从
   620 抬到 800:卡片内容宽 ≈ 视口 − 93,最宽的表 min-width 700,视口 793
   以上才放得下,620 那档会让 621~792 两头不靠。

2. /admin/edit 的地点/到访/行程三张表压根没加 tb-stack。它们默认折叠在
   data-card 里,探针在默认态量到的是 display:none,前两轮审计都没扫到。

顺带修掉三处被这次改动顶出来的问题:
- 宽表卡片不放 g2:/stats /report 有几张表被塞进半宽卡片,视口 1440 时
  卡片只有 494px,桌面端照样要横滑 100+px。改成整行(只动含表的那些网格,
  「推荐 & 踩雷」「类型分布 + 一天中的分布」这类没有表的仍保持两列)。
- 操作格的 inline white-space:nowrap 会继承进行内编辑表单,堆叠态下
  「已选:#403 …」那一行不换行、把卡片顶破。挪成 .ops 类,窄屏放开。
- 行内编辑表单的 min-width 改成 --efw 变量(inline 的 min-width 压不过
  CSS 覆盖),堆叠态置 0 让它自然铺满。

导航装不下时右缘渐隐一下,提示还能往右滑;滑到末端自动收掉。用 mask 而不是
盖一层渐变伪元素——伪元素会把链接文字一起遮住。

回归:tb_stack_test.go 补 /admin/edit(12 张表),表头为空的列(如末尾操作格)
不再要求 data-label;新增 wide_table_layout_test.go 守住「宽表卡片必须整行」+
「凡被 .tw 包住的表必须带 tb-stack」+「堆叠规则必须在 max-width≥792 的 @media 里」。
2026-09-19 02:04:03 +08:00
tamakyi 7236c7a9d5 窄屏宽表不再横向滚动:改成一屏一行的「字段名 + 值」
移动端有 7 张表要横滑 300+px 才看得全,滑着还对不上表头(地点库 8 列、统计 3 张、专题详情、
首页天气、后台数据集表与数据说明、专题管理、报告页 2 张)。给这些表加 tb-stack:
窄屏隐藏 thead、每行一屏一块、每格「字段名 + 值」竖排,值多行时用悬挂缩进。

- 字段名取自 td 的 data-label(首格是这行主角,故意不带标签;操作格给「操作」)——
  这样改列顺序也不会串标签,比把名字写进 CSS 的 nth-child 稳。
- 表格结构、链接、表单、按钮一律没动(只是隐藏 thead + 改 display),桌面与平板仍是原表。
- 断点只用 @media (max-width:620px):试过容器查询(能顺手治掉「宽屏里 g2 窄卡片也要横滑」),
  但实测会把 /stats 三个卡片撑高 1300px,太臃肿,不划算。

实测:390/620 十张表需横滑全部为 0、每行每格都有标签;621/700/1440 的横滑量与改动前一致
(桌面零变化);128 个链接/按钮滚动到可见后逐个点测均可点中,无裁切;全站审计 390 站长/访客 +
320 + 1440 溢出全 0、0 JS 报错,小触控与小字数与改动前逐页一致(320 下露外元素 /places 277→2、
/stats 114→12、/admin 46→4,剩下的都是有意的横滑容器)。

新增回归 internal/web/tb_stack_test.go:渲染 7 个页面、逐行核对「每个非首格都有 data-label」
且「格子数 == 表头列数」(变异验证过:去掉一个标签立刻 FAIL)。
2026-09-18 22:24:12 +08:00
tamakyi e8cb94230a 地点库 / 时间线 / 统计:窄屏筛选卡默认收成一条(与地图页筛选面板同形)
手机上这三页共用的筛选卡一上来就占掉大半屏(时间预设 + 已选条件 + 类型 chips + 六个输入框,
实测 945px),内容反而看不见。按地图页那套 .panel-fold 收成一条:

- 折叠条显示「筛选 + 当前时间范围〔+ N 个条件〕」,展开/收起箭头都能点;
- 状态记在 localStorage('rond-filters-fold'),翻页/换页不会又自己弹开;
- 收起只是 CSS 隐藏,表单控件照旧随提交带上(act/hw/sort 那几个 hidden 也在);
- 整组显示规则都在窄屏媒体查询里 ⇒ 桌面端 .collapsed 不生效、折叠条也 display:none,零影响;
- 折叠脚本紧跟表单(放 body 末尾会先按展开画一帧再收起,手机上就是闪一下整张大卡)。

实测:390 三页站长/访客都默认收起、条高 44、内容隐藏、滚动时粘在导航下方(top=50);
筛选卡 945px → 46px;1440 无变化;320/350 摘要不溢出;全站审计 320/390/1440 溢出全 0、
0 JS 报错、小触控只减不增(A/B 31/181/82 vs 35/185/86);关键词回车提交未受影响
(折叠条是 type="button",默认按钮仍是「查询」)。

新增回归 internal/web/filters_fold_test.go(三页的挂载点 + 筛选控件仍在,做了变异验证)。
2026-09-18 21:52:29 +08:00
tamakyi 86e420bd4e 新建地点的活动下拉可即时新增,地点行内编辑支持批量改该地点全部到访的活动
站长反馈「已有地点编辑和新建里只有类别没有活动能选」。实测澄清:新建地点卡一直有活动下拉,
只是形态与类别/照片页不一致(旁边常显一个新建框、下拉里没有「+ 新增活动…」);真正完全没有
活动字段的是地点列表行的「编辑」。

- 新建地点卡:活动下拉补「+ 新增活动…」哨兵,旁边的新建框改成选中哨兵才展开;
  删掉原来那对 #activity-add/#new-activity-name 监听——三处活动下拉现在共用同一套
  `visit-act-*` 委派处理器,重复监听会建出两条活动。
- 地点行内编辑:加「活动(改该地点全部到访)」下拉,默认「(不修改)」不预选当前值
  (否则每次改坐标保存都会重写历史到访的类型),并标出当前类型构成;选了真实活动时
  提交前 confirm 一次。
- 服务端(places 没有活动列,活动挂在到访上):editPlaceUpdate 解析 activity_id,空值=不修改,
  与地点行同一事务内 UPDATE visits 的 activity_id/is_home/is_work,flash 报改了几条;
  哨兵值/不存在的 id/别的数据集的活动一律拒。导出无需改动(ZVISIT.ZACTIVITY_ 本就是管理列)。
- 回归 internal/web/place_activity_test.go:批量改(含别的地点不受影响)、空值不修改、越权被拒、
  模板契约、以及导出包里那批到访的 ZACTIVITY_ 必须换成新活动的 rond 编号。
- 顺手:TestMovementCleanExport 建出 ROND_CLEAN_OUT 的父目录,别再把「目录不存在」报成导出失败。
2026-09-18 21:23:13 +08:00
17 changed files with 924 additions and 112 deletions
+50
View File
@@ -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)
}
}
}
}
+72 -10
View File
@@ -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),
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
((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`,
where, len(args)+1, len(args)+2),
append(args, editPerPage, (d.PageNo-1)*editPerPage)...)
@@ -166,7 +170,8 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
var e EditPlace
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.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()
s.serverError(w, r, err)
return
@@ -437,6 +442,10 @@ func pickCategory(r *http.Request) string {
// categoryCustomValue 是类别下拉里「自定义…」这一项的提交值。
const categoryCustomValue = "__custom__"
// activityNewValue 是活动下拉里「+ 新增活动…」这一项的提交值。前端有提交守卫,
// 服务端再拦一道:哨兵当 id 解析不出整数,会静默变成「无活动」。
const activityNewValue = "__new__"
// poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。
// 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。
var poiCategoryText = map[string]string{
@@ -648,6 +657,10 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
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"))
tags := r.PostForm["tags"]
@@ -778,16 +791,65 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
city: strings.TrimSpace(r.PostFormValue("city")),
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)
return
}
if moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 {
redirectEdit(w, r, "ok", fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)",
lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon))))
return
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
if actID != nil {
s.refreshDatasetAgg(ctx, ds.ID)
}
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 是地点行编辑里必填的那几个字段。
@@ -858,7 +920,7 @@ func placeExtraFromForm(r *http.Request) placeExtra {
// 没带、但坐标确实动过,就认为提交上来的是显示坐标本身(等价于 gcj02)。
// 坐标没动则一个字都不碰,否则每次改个名字都会把「导入时的原始坐标」冲掉,
// 行内那个「比导入时移动了 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) {
set := []string{
"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_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...)
if err != nil {
return 0, err
+4
View File
@@ -545,6 +545,10 @@ func TestMovementCleanExport(t *testing.T) {
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
}
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 {
t.Fatal(err)
}
+224
View File
@@ -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)
}
}
+77 -1
View File
@@ -117,6 +117,13 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
.brand .t { display: none; }
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
.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-right .who { display: none; }
.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;
}
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 td.num, table.tb th.num { text-align: right; font-variant-numeric: tabular-nums; }
.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::-webkit-details-marker { display: none; }
.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; }
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
@@ -993,4 +1006,67 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
/* 导航左上角那个 logo 是回首页的入口,原来只有 25×29:给它撑到 44 高,
横向也补到 44(.brand .t 在窄屏被隐藏,加内边距就够了)。 */
.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; }
}
+109
View File
@@ -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)
}
+53 -40
View File
@@ -98,15 +98,17 @@
<a href="javascript:void(0)" id="activity-delete" class="danger">删除</a>
</span>
</span>
<select name="activity_id" id="activity-select">
{{/* 与照片导入页、到访行内编辑同一套:下拉里带「+ 新增活动…」,选中后就地展开新建框 */}}
<select name="activity_id" id="activity-select" class="activity-id-inline">
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</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">
<input type="text" id="new-activity-name" placeholder="名称">
<button type="button" class="btn" id="activity-add">添加</button>
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</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>
{{if .Places}}
<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>
<tbody>
{{range .Places}}
<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 class="muted">{{.City}}{{if .District}} · {{.District}}{{end}}{{if .Thoroughfare}} · {{.Thoroughfare}}{{end}}</td>
<td class="muted mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}
<td class="muted" data-label="城市">{{.City}}{{if .District}} · {{.District}}{{end}}{{if .Thoroughfare}} · {{.Thoroughfare}}{{end}}</td>
<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>
{{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>
<td class="num">{{num .VisitCount}}</td>
<td style="white-space:nowrap">
<td class="num" data-label="到访">{{num .VisitCount}}</td>
<td class="ops">
<details class="inline-edit"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} open{{end}}>
<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}}">
<label class="f"><span>名称<b class="req">*</b></span><input type="text" name="name" value="{{.Name}}" required></label>
<label class="f"><span>类别</span>
@@ -173,6 +175,22 @@
</select>
</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">
<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>
@@ -254,25 +272,25 @@
<div class="card-sub">共 {{num .VisitTotal}} 条{{if .VisitQuery}}(匹配「{{.VisitQuery}}」){{end}},按时间倒序。可修改时间 / 类型 / 备注,或删除。</div>
{{if .Visits}}
<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>
<tbody>
{{range .Visits}}
<tr>
<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 class="muted">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td>
<td class="muted">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
<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" data-label="类型">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td>
<td class="muted" data-label="备注">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
<form method="post" action="/admin/edit/visit/bookmark" style="display:inline">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<button class="star{{if .Bookmarked}} on{{end}}" type="submit"
title="{{if .Bookmarked}}取消收藏{{else}}标记为收藏{{end}}">{{if .Bookmarked}}★{{else}}☆{{end}}</button>
</form>
</td>
<td style="white-space:nowrap">
<td class="ops">
<details class="inline-edit">
<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}}">
<label class="f"><span>类型</span>
<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>
{{if .Movements}}
<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>
<tbody>
{{range .Movements}}
<tr>
<td class="muted mono">{{if .Span}}{{.Span}}{{else}}(无时间){{end}}
{{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="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="num">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
<td style="white-space:nowrap">
<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" 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" data-label="里程">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
<td class="ops">
<details class="inline-edit">
<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}}">
<div class="row">
<label class="f" style="flex:1 1 210px"><span>起始到访<b class="req">*</b></span>
@@ -939,23 +957,11 @@
}).catch(function (e) { alert('创建活动失败:' + e); });
}
var actAdd = document.getElementById('activity-add');
if (actAdd) {
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(); }
});
}
// 顶部「新建活动」不再单独接事件:它和到访/地点行内编辑共用下面那套
// `visit-act-*` 委派处理器(同一个表单里只有一个新建框,重复监听会建出两条)。
// ---- 到访行内编辑里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
// ---- 下拉里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
// 顶部新建地点卡、到访行内编辑、地点行内编辑三处共用(靠 form 里的类名找同伴元素)
function inlineActParts(el) {
var form = el.closest ? el.closest('form') : null;
if (!form) return null;
@@ -993,16 +999,23 @@
inlineActCreate(t);
});
// 提交守卫:还停在「+ 新增活动…」上就拦下来。否则 activity_id 会带着 "__new__" 提交,
// resolveActivity 解析不出整数就当成「无」,这条到访的类型会被静默清掉。
// resolveActivity 解析不出整数就当成「无」,那条记录的类型会被静默清掉。
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || !f.querySelector) return;
var sel = f.querySelector('select.activity-id-inline');
if (sel && sel.value === '__new__') {
e.preventDefault();
alert('类型选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或改回「(无)」。');
alert('活动选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或把这一项改回默认值。');
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);
// ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ----------
+14 -14
View File
@@ -108,7 +108,7 @@
<div class="card-sub">同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它</div>
{{if .Datasets}}
<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>
@@ -116,18 +116,18 @@
{{range .Datasets}}
<tr>
<td><span class="mono">{{trunc .Name 34}}</span></td>
<td class="muted">{{dt .UploadedAt}}</td>
<td class="num">{{num .Visits}}</td>
<td class="num">{{num .Places}}</td>
<td class="muted">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
<td>
<td class="muted" data-label="导入时间">{{dt .UploadedAt}}</td>
<td class="num" data-label="到访">{{num .Visits}}</td>
<td class="num" data-label="地点">{{num .Places}}</td>
<td class="muted" data-label="数据跨度">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
<td data-label="状态">
{{if and $.Dataset (eq $.Dataset.ID .ID)}}
<span class="tag">展示中</span>
{{else}}
<span class="muted">未启用</span>
{{end}}
</td>
<td style="white-space:nowrap">
<td data-label="操作" class="ops">
{{if not (and $.Dataset (eq $.Dataset.ID .ID))}}
<form method="post" action="/admin/datasets/{{.ID}}/activate" style="display:inline">
<button class="btn sm" type="submit">启用</button>
@@ -152,15 +152,15 @@
<h2>数据说明</h2>
<div class="card-sub">从备份中读取并转换的字段</div>
<div class="tw">
<table class="tb">
<table class="tb tb-stack">
<thead><tr><th>来源表</th><th>内容</th><th>用途</th></tr></thead>
<tbody>
<tr><td class="mono">ZVISIT</td><td>到访记录(到达 / 离开时间、类型、地点、备注、标签)</td><td>时间线、统计、地图</td></tr>
<tr><td class="mono">ZLOCATION</td><td>地点(名称、经纬度、城市、省份、POI 分类)</td><td>地图打点、地点库</td></tr>
<tr><td class="mono">ZACTIVITY</td><td>自定义类型(颜色、图标、是否家 / 工作)</td><td>类型分布</td></tr>
<tr><td class="mono">ZMOVEMENT</td><td>位移段(交通方式、起止时间)</td><td>出行方式统计</td></tr>
<tr><td class="mono">ZTAG</td><td>自定义标签及分组</td><td>标签筛选</td></tr>
<tr><td class="mono">ZHOURLYWEATHER</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 data-label="内容">地点(名称、经纬度、城市、省份、POI 分类)</td><td data-label="用途">地图打点、地点库</td></tr>
<tr><td class="mono">ZACTIVITY</td><td data-label="内容">自定义类型(颜色、图标、是否家 / 工作)</td><td data-label="用途">类型分布</td></tr>
<tr><td class="mono">ZMOVEMENT</td><td data-label="内容">位移段(交通方式、起止时间)</td><td data-label="用途">出行方式统计</td></tr>
<tr><td class="mono">ZTAG</td><td data-label="内容">自定义标签及分组</td><td data-label="用途">标签筛选</td></tr>
<tr><td class="mono">ZHOURLYWEATHER</td><td data-label="内容">逐小时天气(气温、天气现象、湿度)</td><td data-label="用途">天气足迹</td></tr>
</tbody>
</table>
</div>
+7 -7
View File
@@ -50,23 +50,23 @@
<div class="card-sub">共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览</div>
{{if .Topics}}
<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>
<tbody>
{{range .Topics}}
<tr>
<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="num">{{num .PlaceCount}}</td>
<td class="num">{{num .VisitCount}}</td>
<td class="muted">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
<td>
<td class="muted mono" data-label="时间范围">{{.RangeLabel}}</td>
<td class="num" data-label="地点">{{num .PlaceCount}}</td>
<td class="num" data-label="到访">{{num .VisitCount}}</td>
<td class="muted" data-label="区域">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
<td data-label="对访客">
<form method="post" action="/admin/topics/toggle" style="display:inline">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
</form>
</td>
<td style="white-space:nowrap">
<td data-label="操作" class="ops">
<a class="btn sm" href="/admin/topics?edit={{.ID}}">编辑</a>
<form method="post" action="/admin/topics/delete" style="display:inline"
onsubmit="return confirm('删除专题「{{.Title}}」?地点数据不受影响。')">
+4 -4
View File
@@ -132,15 +132,15 @@
<h2>天气足迹</h2>
<div class="card-sub">记录期间经历过的天气</div>
<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>
<tbody>
{{range .Weather}}
<tr>
<td>{{condition .Condition}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{f1 .AvgTemp}} ℃</td>
<td class="num">{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃</td>
<td class="num" data-label="记录小时数">{{num .Count}}</td>
<td class="num" data-label="平均气温">{{f1 .AvgTemp}} ℃</td>
<td class="num" data-label="范围">{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃</td>
</tr>
{{end}}
</tbody>
+54 -1
View File
@@ -22,10 +22,32 @@
</div>
</div>
</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}}
{{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">
{{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>
@@ -104,6 +126,37 @@
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
{{if .Sort}}<input type="hidden" name="sort" value="{{.Sort}}">{{end}}
</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}}
{{define "emptystate"}}
+8 -8
View File
@@ -39,7 +39,7 @@
{{if .Places}}
<div class="tw">
<table class="tb places-table">
<table class="tb places-table tb-stack">
<thead>
<tr>
<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 or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
</td>
<td class="muted">{{if .City}}{{.City}}{{else}}—{{end}}</td>
<td>
<td class="muted" data-label="城市">{{if .City}}{{.City}}{{else}}—{{end}}</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}}
</td>
<td>{{if .HasImage}}<span title="有图片">📷</span>{{else}}<span class="muted" title="还没有上传图片">—</span>{{end}}</td>
<td class="num">{{num .VisitCount}}</td>
<td class="num">{{dur .DwellMinutes}}</td>
<td class="muted">{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}</td>
<td class="muted">{{if .LastAt}}{{d .LastAt}}{{end}}</td>
<td data-label="图片">{{if .HasImage}}<span title="有图片">📷</span>{{else}}<span class="muted" title="还没有上传图片">—</span>{{end}}</td>
<td class="num" data-label="到访">{{num .VisitCount}}</td>
<td class="num" data-label="累计停留">{{dur .DwellMinutes}}</td>
<td class="muted" data-label="首次">{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}</td>
<td class="muted" data-label="最近">{{if .LastAt}}{{d .LastAt}}{{end}}</td>
</tr>
{{end}}
</tbody>
+7 -7
View File
@@ -42,7 +42,7 @@
</div>
{{end}}
<div class="grid g2">
<div class="grid">
<div class="card">
<h2>去得最多的城市</h2>
<div class="card-sub">按到访次数</div>
@@ -64,11 +64,11 @@
<div class="card-sub">这一年到过纬度最高与最低的地点</div>
{{if .North}}
<div class="tw">
<table class="tb">
<table class="tb tb-stack">
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
<tbody>
<tr><td>最北</td><td>{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num">{{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}}
<tr><td>最北</td><td data-label="地点">{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num" data-label="纬度">{{f1 .North.Lat}}°N</td></tr>
{{if .South}}<tr><td>最南</td><td data-label="地点">{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num" data-label="纬度">{{f1 .South.Lat}}°N</td>{{end}}
</tbody>
</table>
</div>
@@ -81,14 +81,14 @@
<h2>在路上</h2>
<div class="card-sub">这一年 {{num .Overview.Movements}} 段行程,共 {{km .Overview.DistanceKm}} km</div>
<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>
<tbody>
{{range .Transports}}
<tr>
<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">{{km .Distance}} km</td>
<td class="num" data-label="次数">{{num .Count}}</td>
<td class="num" data-label="里程">{{km .Distance}} km</td>
</tr>
{{end}}
</tbody>
+14 -14
View File
@@ -84,7 +84,7 @@
</div>
</div>
<div class="grid g2">
<div class="grid">
<div class="card">
<h2>城市排行</h2>
<div class="card-sub">到访次数</div>
@@ -103,15 +103,15 @@
<h2>省级行政区</h2>
<div class="card-sub">覆盖情况</div>
<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>
<tbody>
{{range .ProvinceStats}}
<tr>
<td>{{.Province}}</td>
<td class="num">{{num .Cities}}</td>
<td class="num">{{num .Places}}</td>
<td class="num">{{num .Count}}</td>
<td class="num" data-label="城市">{{num .Cities}}</td>
<td class="num" data-label="地点">{{num .Places}}</td>
<td class="num" data-label="到访">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
@@ -120,7 +120,7 @@
</div>
</div>
<div class="grid g2">
<div class="grid">
<div class="card">
<h2>星期分布</h2>
<div class="card-sub">一周中的到访规律</div>
@@ -141,15 +141,15 @@
<div class="card-sub">次数与估算里程</div>
{{if .Transports}}
<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>
<tbody>
{{range .Transports}}
<tr>
<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">{{km .Distance}} km</td>
<td class="num">{{dur .DwellMin}}</td>
<td class="num" data-label="次数">{{num .Count}}</td>
<td class="num" data-label="里程">{{km .Distance}} km</td>
<td class="num" data-label="在途时间">{{dur .DwellMin}}</td>
</tr>
{{end}}
</tbody>
@@ -159,7 +159,7 @@
</div>
</div>
<div class="grid g2">
<div class="grid">
{{if .Tags}}
<div class="card">
<h2>标签统计</h2>
@@ -181,14 +181,14 @@
<h2>天气与气温</h2>
<div class="card-sub">记录期间经历的小时级天气</div>
<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>
<tbody>
{{range .Weather}}
<tr>
<td>{{condition .Condition}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{f1 .AvgTemp}} ℃</td>
<td class="num" data-label="小时数">{{num .Count}}</td>
<td class="num" data-label="平均气温">{{f1 .AvgTemp}} ℃</td>
</tr>
{{end}}
</tbody>
+6 -6
View File
@@ -42,17 +42,17 @@
<div class="card-sub">共 {{num .Topic.PlaceCount}} 个,按到访次数排序</div>
{{if .Points}}
<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>
<tbody>
{{range .Points}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="muted">{{.City}}</td>
<td class="muted">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
<td class="num">{{num .Count}}</td>
<td class="num muted">{{dur .Dwell}}</td>
<td class="muted mono">{{.LastAt}}</td>
<td class="muted" data-label="城市">{{.City}}</td>
<td class="muted" data-label="类型">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
<td class="num" data-label="到访">{{num .Count}}</td>
<td class="num muted" data-label="停留">{{dur .Dwell}}</td>
<td class="muted mono" data-label="最近一次">{{.LastAt}}</td>
</tr>
{{end}}
</tbody>
+3
View File
@@ -852,6 +852,9 @@ type EditPlace struct {
SrcLat, SrcLon sql.NullFloat64
// CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上)
CategoryOptions []EditOption
// ActivityText 是该地点名下到访当前的活动构成(「商场 8 · 餐饮 3」这种)。
// 行内编辑里的「活动」是把该地点全部到访一起改,所以得先让人看清现在是什么。
ActivityText string
}
// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。
+218
View File
@@ -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
}