Files
tamakyi ab7ccd22ce 前端改造:设计令牌 + 卡片层级 + 图表可读性 + 类型色收口 + 窄屏导航
先出方案(frontend-plan-2026-10-09.md)再动手,四批按顺序落地。方法上先存基线、再用同一份
探针在改动前后逐值 diff —— 这批里有 6 处问题只有「量」才看得见,看截图全是正常的。

A 设计令牌(app.css 为主)
- 字阶收敛到 6 档 --fs-xs/sm/md/lg/xl/2xl = 11/13/15/21/26/34px。原来 app.css 有 19 种字号、
  模板里另有 54 处内联字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶。
- 两个「字外档」不是漏的:--fs-control: 16px(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页,
  属功能约束);--glyph-*(✕/箭头/空态图标按控件尺寸定,硬塞进字阶只会把按钮撑坏)。
- 字重收敛到 400/500/600(删掉 650 与 700)。
- 圆角 4 档 --r-*、阴影 3 档 --sh-1/2/3(暗色整套覆盖)、间距 --sp-1..5、
  卡片内边距单列 --card-pad(与折叠卡片标题行的负 margin 绑在一起,改一个即改两处)。
- 门禁 TestNoBareFontSizePx:静态扫 app.css 与全部模板,出现 font-size: <数字>px 即红。

B 卡片分主次
- .card--hero / .card(默认)/ .card--quiet 三档。quiet 走「页面底色 + 细边 + 无阴影」——
  第一版拿 --bg-2 当底色,实测它与页面底 #f6f7f8 只差 4/255,看不出边界、像破了个洞。
- 首屏 KPI 从「4×2 等重白盒」改成 .kpi-lead(主数 + 一行上下文)+ 5 个常规指标,8 个数字一个没少。

C 图表说人话
- 月度分布:柱底加基线;峰值柱不透明拉满、柱顶标次数(.pv),副标题里不再重复写「最高 N 次」。
- 一天中的分布:把「0/6/12/18 四个数字」换成 6 小时一段的标尺(深夜/上午/下午/晚上),
  用同一套 24 列网格,段边界与柱子严格对齐。
- 首页月度图同样加峰值锚点(给 HomeData 补 MaxMonthly,与统计页同口径 maxOf)。
- 空态没动:查下来已经统一(8 页共用 emptystate、局部走 .empty)。

D1 类型色语义化(结论与计划不同,按实测改了)
- 先算对比度:白底上 yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57,
  6 个色连图形对象 3:1 都到不了;暗底只有 indigo 2.90 不够。
- 所以**没有**把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是文字色,只会更难读。
  这也解释了全站为什么一直只用 8px 小圆点做身份标记。浅色那 6 个色一个没动:
  它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 实际做的:色值收口成 --cat-* / --cat-*-soft(26 个变量);模板 colorof → csscolor / csssoft(19 处);
  暗色只提亮 indigo(2.90 → 3.55:1);colorOf 成了死代码,删掉。
- ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 过滤器不认识 var(),普通 string 会被静默
  换成 ZgotmplZ —— 页面上所有圆点当场变透明,而且不报错、模板测试照过。挂了它等于关掉那道过滤,
  所以数据侧色值先过严格校验(只有 # + 6 位 hex 放行),否则 #"><img… 能逃出 style 属性。
  守卫 TestCSSColorSurvivesTemplateSanitizer 把两个方向都钉住(做过变异验证)。

D2 窄屏导航
- 改前 ≤620px 靠横向滚动兜底:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴后面的按钮,
  渐隐提示实际看不出来。现在链接本体抽成 {{define "navitems"}} 渲染两遍:桌面 .nav-links 平铺、
  窄屏 .nav-menu(原生 <details>,不靠 JS 也能开合);JS 只补点外面 / Esc / 点链接后收起。
- ⚠️ 顶栏 --z-nav 抬到 1200:菜单是 .nav 的子元素、自己抬不动层叠上下文,不抬就会被地图页的
  --z-map-overlay(1100) 盖住。已在 /map 上用 elementFromPoint 实测。
- 守卫 TestNarrowNavCollapsesToMenu 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。

途中发现并一并修掉的(都只有量 DOM 才看得见)
- Leaflet 的 .leaflet-container 字号是 0.75rem、跟着根字号漂:根 14 → 13px 后地图署名/比例尺
  掉到 9.8px(低于本站 11px 下限)、弹窗正文也跟着缩。已按自己的字阶钉死。
- h1..h4 原来只设了字重与 margin、没设字号,靠浏览器默认的 2em/1.5em/1.17em —— 同样跟着根字号漂:
  /stats 那两个 h2 从 21px 变成 19.5px,比同页卡片标题大一圈。已显式给出字号。
- <b>/<strong> 浏览器默认 700,比全站最高层级(标题 600)还重,已钉成 600。

回归
- ./scripts/verify.sh:PASS=348 FAIL=0 SKIP=0
- internal/fog 加 -race:PASS=22 FAIL=0 SKIP=0
- JS 单测 34/34
- 移动端 320 / 390 溢出全 0(改前 /admin 在 320 还溢 34px);桌面 1440 与改前一致(−15 是滚动条宽)
- 全站 7 个页面都没有低于 11px 的文字

说明:A/B 与 C/D 本想拆两笔提交,但 app.css / layout.html / view.go 里两批改动交织,按文件拆不开、
按 hunk 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
2026-10-09 17:01:08 +08:00

167 lines
7.1 KiB
HTML

{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>换基线</h1>
<div class="sub">
「底座」是导出 rondbackup 时铺数据的底稿:导出只覆盖我们托管的那几张实体表,
<strong>其余内容(Core Data 元数据、索引、ZRAWVISIT 原始采样点…)全部从底座原样带出来</strong>。
所以底座必须是手机导出的包;换底座<strong>不动站点里的任何数据</strong>,只换底稿。
</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if not .Dataset}}
<div class="card">
<div class="empty"><div class="big">📦</div>还没有导入任何数据集,先上传一份备份再说。</div>
</div>
{{else}}
<div class="card">
<h2>当前底座</h2>
<div class="card-sub">数据集「{{trunc .Dataset.Name 40}}」(#{{.Dataset.ID}})的导出底稿</div>
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>项目</th><th>值</th></tr></thead>
<tbody>
<tr>
<td>状态</td>
<td data-label="状态">
{{if .Status.Exists}}
<span class="tag">已就绪</span>
<span class="muted">{{size .Status.Size}} · {{dtt .Status.ModTime}}</span>
{{else}}
<span class="tag">文件缺失</span>
<span class="muted">导出时会按记档自动重建;也可以点下面的「重建」。</span>
{{end}}
</td>
</tr>
<tr>
<td>来源</td>
<td data-label="来源">
{{if .Status.SHA}}
<span class="mono">{{trunc .Status.Name 40}}</span>
<span class="muted">· {{dtt .Status.At}}</span>
{{if .FromOriginal}}<span class="tag">与数据集原始包同一份</span>{{end}}
{{else}}
<span>数据集最初上传的那份包</span>
<span class="mono muted">{{trunc .OrigName 40}}</span>
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
{{if .Status.Exists}}
<div class="card-sub" style="margin-top:14px">底座里有什么(换底座前后可以对着看)</div>
{{if .CountsErr}}
<div class="alert err">{{.CountsErr}}</div>
{{else}}
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>表</th><th>内容</th><th class="num">行数</th></tr></thead>
<tbody>
{{range .Counts}}
<tr>
<td><span class="mono">{{.Table}}</span></td>
<td data-label="内容">{{.Label}}</td>
<td class="num" data-label="行数">{{num .N}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<div class="row" style="margin-top:12px">
<form method="post" action="/admin/baseline/rebuild">
<button class="btn" type="submit">按记档重建底座</button>
</form>
</div>
{{end}}
</div>
{{if .PendingSHA}}
<div class="card">
<div class="alert err">
刚上传的 <span class="mono">{{trunc .PendingName 40}}</span> 与当前数据集只对得上
<strong>{{num .PendingMatched}}/{{num .PendingWith}}</strong> 条带 UUID 的到访(正常应当有九成以上)。
它可能不是同一批数据的手机包——换下去会让导出包整个错位:站点每一行都在新底座里
认不出身份,于是全部按「新行」插到最大编号之后,真机上就是「这些到访全变成新的、老记录还在」。
</div>
<div class="row">
<form method="post" action="/admin/baseline/confirm"
onsubmit="return confirm('确认这份包确实是同一批数据的手机包?换错会让导出的备份在真机上错位。')">
<input type="hidden" name="sha" value="{{.PendingSHA}}">
<input type="hidden" name="name" value="{{.PendingName}}">
<button class="btn danger" type="submit">我确认,强制替换</button>
</form>
<a class="btn" href="/admin/baseline">放弃</a>
</div>
</div>
{{end}}
<div class="card">
<h2>换成另一份包当底座</h2>
<div class="card-sub">
在 rond App 里<strong>重新导出</strong>一份备份,传到这里。换完之后:
到访数、地点、备注、主题都不变;导出 rondbackup 立刻以新底座为准。
判据有两条——这份包得跟当前数据集是同一批数据(按到访 UUID 认),而且得真的能导出成功。
换之前旧底座会留档,随时可以回滚。
</div>
<div class="alert">
<strong>注意</strong>:导出的行集合以<strong>站点数据</strong>为准(另加少量「导入时进不了站点、
靠原始留档补回」的行),所以这份包里如果有站点还没导入过的到访(手机上新记录的那些),
它们<strong>不会</strong>进导出包。想让它们进包,先用后台的「分析差异」把这份备份合并进来,再来换底座。
</div>
<form method="post" action="/admin/baseline" enctype="multipart/form-data">
<div class="row" style="align-items:center;flex-wrap:wrap">
<input type="file" name="backup" accept=".rondbackup,.zip" required>
<button class="btn primary" type="submit">上传并换底座</button>
</div>
<label class="muted" style="display:block;margin-top:10px;font-size:var(--fs-sm)">
<input type="checkbox" name="force" value="1">
即使与当前数据集对不上也强制替换(重合率偏低时会再让你确认一次)
</label>
<div class="card-sub" style="margin-top:8px">单份上限 {{size .MaxSize}}。</div>
</form>
</div>
<div class="card">
<h2>旧底座留档</h2>
<div class="card-sub">每次替换前都会把旧底座改名留一份(最近 3 份)。换错了就回滚。</div>
{{if .Status.Backups}}
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>留档</th><th>时间</th><th class="num">大小</th></tr></thead>
<tbody>
{{range .Status.Backups}}
<tr>
<td><span class="mono">{{trunc .Name 48}}</span></td>
<td class="muted" data-label="时间">{{dt .At}}</td>
<td class="num" data-label="大小">{{size .Size}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="row" style="margin-top:12px">
<form method="post" action="/admin/baseline/rollback"
onsubmit="return confirm('回滚到最近一份留档的旧底座?当前这份也会被留档,可以再滚回来。')">
<button class="btn" type="submit">回滚到最近一份</button>
</form>
</div>
<div class="card-sub" style="margin-top:8px">
回滚后底座的「来源」记档会清空(表示它来自数据集最初那份包),导出照常可用。
</div>
{{else}}
<div class="empty"><div class="big">🗂️</div>还没有留档——这个数据集还没换过底座。</div>
{{end}}
</div>
{{end}}
</div>
{{end}}