先出方案(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 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
259 lines
10 KiB
Go
259 lines
10 KiB
Go
package web
|
||
|
||
import (
|
||
"io/fs"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ---------- F5:地图脚本里的 localStorage 访问必须走兜底 ----------
|
||
//
|
||
// 隐私模式 / 禁用 Cookie 的浏览器里 localStorage 读写都会抛异常,裸调用会把整段脚本打断
|
||
// (地图白屏、图层不出来,且控制台之外看不出来)。统一走 lsGet/lsSet。
|
||
func TestMapLocalStorageGoesThroughGuard(t *testing.T) {
|
||
b, err := staticFS.ReadFile("static/js/rond-map.js")
|
||
if err != nil {
|
||
t.Fatalf("读取 rond-map.js 失败: %v", err)
|
||
}
|
||
src := string(b)
|
||
if !strings.Contains(src, "function lsGet(k)") || !strings.Contains(src, "function lsSet(k, v)") {
|
||
t.Fatal("rond-map.js 里找不到 lsGet/lsSet 兜底(写法变了,这条守卫失效)")
|
||
}
|
||
if n := strings.Count(src, "window.localStorage."); n != 2 {
|
||
t.Errorf("裸 localStorage 访问应只剩 lsGet/lsSet 里那 2 处,实际 %d 处"+
|
||
"(其余一律要走 lsGet/lsSet,否则隐私模式下整段脚本会抛异常)", n)
|
||
}
|
||
}
|
||
|
||
// ---------- F6:配置未就绪时的第二次 preview 不能丢点 ----------
|
||
func TestMapPreviewQueuesPendingPoints(t *testing.T) {
|
||
b, err := staticFS.ReadFile("static/js/rond-map.js")
|
||
if err != nil {
|
||
t.Fatalf("读取 rond-map.js 失败: %v", err)
|
||
}
|
||
src := string(b)
|
||
body := funcBody(src, "preview: function (opts)")
|
||
if body == "" {
|
||
t.Fatal("在 rond-map.js 里找不到 preview(写法变了,这条守卫失效)")
|
||
}
|
||
queue := strings.Index(body, "previewQueue[opts.id] = points;")
|
||
live := strings.Index(body, "if (live) {")
|
||
if queue < 0 || live < 0 || queue > live {
|
||
t.Error("preview:必须在 `if (live)` 之前就把本次的点记进 previewQueue," +
|
||
"否则配置未就绪时第二次 preview 的点会被静默丢掉")
|
||
}
|
||
if !strings.Contains(body, "if (previewQueue[opts.id]) points = previewQueue[opts.id];") {
|
||
t.Error("preview:配置回调里没把 previewQueue 排队的点取回来画")
|
||
}
|
||
if strings.Contains(src, "previewPending") {
|
||
t.Error("rond-map.js 里不该再有 previewPending(已由 previewQueue 取代)")
|
||
}
|
||
}
|
||
|
||
// ---------- F8:EXIF 全 0 时间不能变成 1899-11-30 ----------
|
||
func TestExifRejectsOutOfRangeDate(t *testing.T) {
|
||
b, err := staticFS.ReadFile("static/js/photo-exif.js")
|
||
if err != nil {
|
||
t.Fatalf("读取 photo-exif.js 失败: %v", err)
|
||
}
|
||
src := string(b)
|
||
guard := strings.Index(src, "Y >= 1900")
|
||
mk := strings.Index(src, "res.shot = new Date(Y, Mo - 1, D, H, Mi, S);")
|
||
flag := strings.Index(src, "res.hasTime = true;")
|
||
if guard < 0 || mk < 0 || flag < 0 || guard > mk || guard > flag {
|
||
t.Error("photo-exif.js:拍摄时间必须先过范围校验再赋给 res.shot / hasTime" +
|
||
"(否则「0000:00:00」会被 Date 归一成 1899-11-30,被当成到达时间写进库)")
|
||
}
|
||
}
|
||
|
||
// ---------- F9:findExifItemID 读 count 前必须卡住宽度 ----------
|
||
func TestExifItemIDChecksBoundsBeforeRead(t *testing.T) {
|
||
b, err := staticFS.ReadFile("static/js/photo-exif.js")
|
||
if err != nil {
|
||
t.Fatalf("读取 photo-exif.js 失败: %v", err)
|
||
}
|
||
body := funcBody(string(b), "function findExifItemID(")
|
||
if body == "" {
|
||
t.Fatal("在 photo-exif.js 里找不到 findExifItemID(写法变了,这条守卫失效)")
|
||
}
|
||
bounds := strings.Index(body, "p + 2 > u8.length")
|
||
read := strings.Index(body, "dv.getUint16(p)")
|
||
if bounds < 0 || read < 0 || bounds > read {
|
||
t.Error("findExifItemID:读 count 之前必须按 ver 卡住宽度(p+2 / p+4)——" +
|
||
"末尾恰好相等(start+4 == length)时旧守卫会放行,越界会抛 RangeError")
|
||
}
|
||
}
|
||
|
||
// ---------- F16:数据编辑页每个筛选表单都要带上全部筛选条件 ----------
|
||
//
|
||
// 三个表单各自只放自己那几个控件,其余条件靠隐藏域带着;漏一个,用户一提交
|
||
// 就会把别的列表的筛选悄悄清掉(原来「到访」表单只带 vq+back)。
|
||
func TestEditSearchFormsCarryAllFilters(t *testing.T) {
|
||
b, err := tmplFS.ReadFile("templates/admin/edit.html")
|
||
if err != nil {
|
||
t.Fatalf("读取 edit.html 失败: %v", err)
|
||
}
|
||
src := string(b)
|
||
const open = `<form method="get" action="/admin/edit"`
|
||
params := []string{`name="q"`, `name="src"`, `name="sus"`, `name="vq"`,
|
||
`name="mt"`, `name="mp"`, `name="mfrom"`, `name="mto"`}
|
||
n := 0
|
||
rest := src
|
||
for {
|
||
i := strings.Index(rest, open)
|
||
if i < 0 {
|
||
break
|
||
}
|
||
n++
|
||
rest = rest[i:]
|
||
j := strings.Index(rest, "</form>")
|
||
if j < 0 {
|
||
t.Fatalf("第 %d 个筛选表单没有收尾", n)
|
||
}
|
||
body := rest[:j]
|
||
for _, p := range params {
|
||
if !strings.Contains(body, p) {
|
||
t.Errorf("第 %d 个筛选表单少了 %s:一提交就会把别的列表的筛选清掉", n, p)
|
||
}
|
||
}
|
||
rest = rest[j:]
|
||
}
|
||
if n < 3 {
|
||
t.Fatalf("只找到 %d 个 /admin/edit 筛选表单,预期至少 3 个(写法变了,这条守卫失效)", n)
|
||
}
|
||
}
|
||
|
||
// ---------- F17:`safe` 这个现成的转义出口不该留着 ----------
|
||
func TestNoSafeEscapeFunc(t *testing.T) {
|
||
b, err := os.ReadFile(pkgFile(t, "view.go"))
|
||
if err != nil {
|
||
t.Fatalf("读取 view.go 失败: %v", err)
|
||
}
|
||
if strings.Contains(string(b), `"safe":`) {
|
||
t.Error("view.go 里不该再有 `safe` 模板函数(模板一处都没用,却是个现成的 HTML 注入出口)")
|
||
}
|
||
}
|
||
|
||
// ---------- F18:纯图标按钮要有 aria-label,图片要有 alt ----------
|
||
func TestPlaceImageControlsAreLabelled(t *testing.T) {
|
||
b, err := tmplFS.ReadFile("templates/place.html")
|
||
if err != nil {
|
||
t.Fatalf("读取 place.html 失败: %v", err)
|
||
}
|
||
src := string(b)
|
||
if !strings.Contains(src, `aria-label="删除这张图片"`) {
|
||
t.Error("place.html 模板渲染的删除按钮(×)缺 aria-label")
|
||
}
|
||
if !strings.Contains(src, `setAttribute('aria-label', '删除这张图片')`) {
|
||
t.Error("place.html 用 JS 新建的删除按钮(×)缺 aria-label")
|
||
}
|
||
if strings.Contains(src, `img.alt = ''`) {
|
||
t.Error("place.html 新建缩略图时 alt 不能留空")
|
||
}
|
||
}
|
||
|
||
// ---------- 窄屏导航:链接行收进菜单(原来是横向滚动,最右边那个字会被切掉) ----------
|
||
//
|
||
// 620px 以下链接行一定放不下(实测:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴
|
||
// 后面的按钮,而渐隐提示实际上看不出来)。现在整行收进 .nav-menu 这个原生 <details>。
|
||
//
|
||
// 这条守卫钉的是**成对关系**:CSS 的两半(桌面藏菜单 / 窄屏藏链接行)与模板里的两个类
|
||
// 缺任何一个,窄屏就会退回老样子、或者宽屏凭空多出一个菜单按钮。
|
||
func TestNarrowNavCollapsesToMenu(t *testing.T) {
|
||
css, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
|
||
if err != nil {
|
||
t.Fatalf("读取 app.css 失败: %v", err)
|
||
}
|
||
src := string(css)
|
||
for _, want := range []string{
|
||
".nav-menu { display: none; }", // 桌面:菜单按钮不出现
|
||
".nav-links { display: none; }", // 窄屏:链接行不出现
|
||
".nav-menu { display: block; flex: 1; }", // 窄屏:菜单按钮取而代之
|
||
} {
|
||
if !strings.Contains(src, want) {
|
||
t.Errorf("app.css 缺少 %q —— 「桌面藏菜单 / 窄屏藏链接行」这两半必须成对,"+
|
||
"否则窄屏会退回「横向滚动 + 最右边的字被切掉」", want)
|
||
}
|
||
}
|
||
|
||
b, err := tmplFS.ReadFile("templates/layout.html")
|
||
if err != nil {
|
||
t.Fatalf("读取 layout.html 失败: %v", err)
|
||
}
|
||
nav := string(b)
|
||
if !strings.Contains(nav, `class="nav-links"`) || !strings.Contains(nav, `class="nav-menu"`) {
|
||
t.Error("layout.html 里 .nav-links 与 .nav-menu 必须都在:CSS 的两半分别作用在这两个元素上")
|
||
}
|
||
if n := strings.Count(nav, `{{template "navitems" .}}`); n != 2 {
|
||
t.Errorf("导航链接应当渲染两遍(宽屏平铺 + 窄屏菜单),实际 %d 处", n)
|
||
}
|
||
}
|
||
|
||
// ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ----------
|
||
//
|
||
// 改前 app.css 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 /
|
||
// 17 / 18 / 21px 十几档字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶;
|
||
// 模板里另有 54 处内联字号。现在:文本走 --fs-*,表单控件走 --fs-control(窄屏必须 ≥16px,
|
||
// 否则 iOS 聚焦会放大整页),字形走 --glyph-*(22px 的 ✕ 塞进字阶只会把按钮撑坏)。
|
||
func TestNoBareFontSizePx(t *testing.T) {
|
||
re := regexp.MustCompile(`font-size:\s*[^;\n]*[0-9]+(\.[0-9]+)?px`)
|
||
check := func(name string, b []byte) {
|
||
for _, m := range re.FindAllString(string(b), -1) {
|
||
t.Errorf("%s 里还有裸字号 %q —— 一律走 --fs-* / --fs-control / --glyph-* 令牌", name, m)
|
||
}
|
||
}
|
||
|
||
b, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
|
||
if err != nil {
|
||
t.Fatalf("读取 app.css 失败: %v", err)
|
||
}
|
||
check("app.css", b)
|
||
|
||
for _, dir := range []string{"templates/*.html", "templates/admin/*.html"} {
|
||
names, err := fs.Glob(tmplFS, dir)
|
||
if err != nil {
|
||
t.Fatalf("列举 %s 失败: %v", dir, err)
|
||
}
|
||
for _, name := range names {
|
||
body, err := tmplFS.ReadFile(name)
|
||
if err != nil {
|
||
t.Fatalf("读取 %s 失败: %v", name, err)
|
||
}
|
||
check(name, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- F10:补全页的 sessionStorage 读写都要包在 try 里 ----------
|
||
//
|
||
// 隐私模式下 sessionStorage 本身就抛,脏数据又会让 JSON.parse 抛;任何一处漏了 try,
|
||
// 整段脚本就从那一行断掉——下拉候选、草稿恢复全部哑掉,而且控制台之外看不出来。
|
||
func TestFillupSessionStorageIsGuarded(t *testing.T) {
|
||
b, err := tmplFS.ReadFile("templates/admin/fillup.html")
|
||
if err != nil {
|
||
t.Fatalf("读取 fillup.html 失败: %v", err)
|
||
}
|
||
src := string(b)
|
||
needles := []string{"JSON.parse(sessionStorage", "sessionStorage.setItem"}
|
||
total := 0
|
||
for _, n := range needles {
|
||
rest := src
|
||
for {
|
||
i := strings.Index(rest, n)
|
||
if i < 0 {
|
||
break
|
||
}
|
||
total++
|
||
if open := strings.LastIndex(rest[:i], "try {"); open < 0 || i-open > 160 {
|
||
t.Errorf("fillup.html:`%s` 前面没有紧挨着的 try {(隐私模式 / 脏数据会中断整段脚本)", n)
|
||
}
|
||
rest = rest[i+len(n):]
|
||
}
|
||
}
|
||
if total == 0 {
|
||
t.Fatal("fillup.html 里找不到 sessionStorage 读写(写法变了,这条守卫失效)")
|
||
}
|
||
}
|