宽表在窄屏真的不横滑了:堆叠判据退回媒体查询,后台三张表补上
真机反馈「手机上还是要左右滑」,两个根因: 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:
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}」?地点数据不受影响。')">
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid g2">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>去得最多的城市</h2>
|
||||
<div class="card-sub">按到访次数</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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