宽表在窄屏真的不横滑了:堆叠判据退回媒体查询,后台三张表补上

真机反馈「手机上还是要左右滑」,两个根因:

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 里」。
This commit is contained in:
tamakyi
2026-09-19 02:04:03 +08:00
parent 7236c7a9d5
commit 9e01923244
9 changed files with 309 additions and 36 deletions
+38 -12
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; }
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
@@ -1008,16 +1021,19 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
.filters-card.collapsed > :not(.panel-fold) { display: none; }
}
/* ---------- 窄屏:要横滑的宽表改成一屏一行的「字段 + 值」 ----------
地点库(8 列)、统计、专题、后台这几张表在手机上要横滑 300+px 才看得全,滑着还对不上表头。
窄屏改成每行一块、每格「字段名 + 值」竖排:不用横滑,也不用记「第几列是什么」。
/* ---------- 放不下宽表时:一屏一行的「字段 + 值」 ----------
地点库(8 列)、统计、专题、后台这几张表在窄处要横滑 300+px 才看得全,滑着还对不上表头。
放不下时改成每行一块、每格「字段名 + 值」竖排:不用横滑,也不用记「第几列是什么」。
字段名取 td 的 data-label,每行的首格留空当小标题(如地点名),操作格给「操作」。
表格结构与脚本都没动(只是隐藏 thead),链接 / 表单 / 按钮照旧;断点跟其它移动端适配一致
(620px),桌面与平板仍是原来的表格,一行不差。
注:统计页那几张表在宽屏也被放在 g2 的窄卡片里(视口 1440 时卡片只有 494px),仍然要横滑
100+px —— 但那属于桌面版式,真要治得单说;这里先只管移动端(按容器宽度判断会让这些卡片
在宽屏也竖排,实测把 /stats 撑高 1300px,得不偿失)。 */
@media (max-width: 620px) {
表格结构与脚本都没动(只是隐藏 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,
@@ -1036,11 +1052,21 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
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;
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; }
}
+15
View File
@@ -38,6 +38,8 @@ func TestStackedTablesHaveLabels(t *testing.T) {
{"/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})
@@ -47,6 +49,8 @@ func TestStackedTablesHaveLabels(t *testing.T) {
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 {
@@ -67,6 +71,14 @@ func TestStackedTablesHaveLabels(t *testing.T) {
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
@@ -79,6 +91,9 @@ func TestStackedTablesHaveLabels(t *testing.T) {
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)
+18 -18
View File
@@ -148,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" data-visit-count="{{.VisitCount}}" 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>
@@ -272,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">
@@ -370,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>
+1 -1
View File
@@ -127,7 +127,7 @@
<span class="muted">未启用</span>
{{end}}
</td>
<td data-label="操作" 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>
+1 -1
View File
@@ -66,7 +66,7 @@
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
</form>
</td>
<td data-label="操作" 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}}」?地点数据不受影响。')">
+14
View File
@@ -22,6 +22,20 @@
</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"}}
+1 -1
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>
+3 -3
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>
@@ -120,7 +120,7 @@
</div>
</div>
<div class="grid g2">
<div class="grid">
<div class="card">
<h2>星期分布</h2>
<div class="card-sub">一周中的到访规律</div>
@@ -159,7 +159,7 @@
</div>
</div>
<div class="grid g2">
<div class="grid">
{{if .Tags}}
<div class="card">
<h2>标签统计</h2>
+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
}