Files
liuzhuohua b7ca9c89e8 feat(i18n/layout): 全站中英双语 + 四轮排版修复收官 + 修 boot_id 重启检测
v1.0.10 两件大事:

1. 界面中英双语(英文界面无中文残留)
   - 新增 web/i18n.js(约 4200 条词条),零依赖零构建
   - 后端下发中文数据走 bt4() 查表翻译;独立模块(upstream/netdetail/
     realtime/update)纳入翻译并注册切语言重绘钩子
   - 后端「状态/聚合」接口只回稳定枚举+原始数值,不下发中文文案
   - 顺带修一个真实 XSS:顶层 const 不挂 window,独立脚本 esc 静默退化成零转义

2. 修掉「文字竖着排/错位」(多分辨率反复暴露)
   - 单元格 min-content 只有 1 字宽 → word-break:keep-all + 表头 nowrap
   - 依赖卡片正文被标签/按钮挤成 27px → .dep-body{flex:1 1 200px} + flex-wrap
   - 裸 flex:1(下限为0)给正文 → 一律改 flex:1 1 <px>
   - .dep-item 横向容器直接塞块级子项 → 退回块级
   - 关键陷阱:auto-fill 布局屏越宽列数越多单卡越窄,窄屏反而测不出来,
     真机审计推进到「逐视图点按钮 + 1920/2560 宽视口 + 中英双语」
   - 守卫 t-layout.py 扩到 35 条,12 处反向验证全红

3. 修真缺陷:_link_since() 重启检测被误改成 if False:(注释还写着「已修」),
   uptime 归零后相减得负数 → 恢复比对 boot_id

附带:新增 25 个检查器(t-*.py)、排版审计工具链(shot-*/ocr-*/live-*)、
版本号兜底值同步到 1.0.10、.gitignore 收纳 i18n/排版排查的一次性脚本。
2026-10-10 04:50:32 +08:00

191 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""排版 / 多端适配回归。
背景(2026-10-09):中英文全量翻译后,**英文标签普遍比中文长**,把几处
「裸 1fr 网格轨道」撑破了 —— CSS 里 `1fr` 的隐含最小宽度是 `auto`
(等价 `minmax(auto,1fr)`),轨道不能缩到内容 min-content 以下,
于是手机端整个内容区横向滚动(真机几何实测:NFS 页 #view 溢出 13px,
罪魁是 `grid-template-columns:1fr 1fr` 里长标签 "Local machine LAN Address")。
判据:
① 网格轨道禁止裸 `1fr` / `repeat(N,1fr)`,必须 `minmax(0,1fr)`
② `.kv` 的标签必须可收缩(flex 收缩因子非 0),否则长标签撑破父级
③ 表格横向滚动容器 `.tw` 仍在(check-mobile 也守着,这里做交叉确认)
"""
import io
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CSS = os.path.join(ROOT, 'web', 'app.css')
APP = os.path.join(ROOT, 'web', 'app.js')
FAILS = []
N = 0
def chk(name, cond, detail=''):
global N
N += 1
if cond:
print(' ok ' + name)
else:
FAILS.append('%s %s' % (name, detail))
print(' FAIL %s %s' % (name, detail))
css = io.open(CSS, encoding='utf-8').read()
app = io.open(APP, encoding='utf-8').read()
# ---- ① 网格轨道里的裸 1fr ----
# 只看 `grid-template-columns:` 后面的值;允许 minmax(0,1fr)。
bad_tracks = []
for src_name, src in (('app.css', css), ('app.js', app)):
for m in re.finditer(r'grid-template-columns:([^;"\n}]*)', src):
val = m.group(1)
# 去掉 minmax(...) 之后再找裸 1fr
stripped = re.sub(r'minmax\([^)]*\)', '', val)
# 任何 fr 轨道(1fr / 1.6fr / repeat(N,1fr))都必须包 minmax,
# 否则隐含 min-width:auto 会被长英文标签撑破。
if re.search(r'(^|[\s,])\d*\.?\d+fr(\s|$|,|;)', stripped) \
or re.search(r'repeat\(\s*\d+\s*,\s*\d*\.?\d+fr\s*\)', stripped):
bad_tracks.append('%s: %s' % (src_name, val.strip()[:70]))
chk('网格轨道没有裸 1fr(必须 minmax(0,1fr))', not bad_tracks,
' | '.join(bad_tracks[:4]))
# ---- ② .kv 标签**不能**收缩,靠整行换行解决窄容器 ----
# 这条判据的方向改过(2026-10-09 第二轮)。原判据要求「标签可收缩」,
# 修的是「长英文标签撑破父级 → 整页横向滚动」;但那个修法过头了:
# flex:0 1 auto + min-width:0 会把标签压成**一字一行** ——
# 真机几何实测 390px 下「Boot Mode」被压成 9px 宽 / 9 行,就是「竖着的字」。
# 正解是 flex:0 0 auto(不收缩)+ .kv 自身 flex-wrap:wrap(放不下就换行)。
_kv = re.search(r'\.kv\{([^}]*)\}', css)
chk('.kv 存在', _kv is not None)
if _kv:
chk('.kv 允许换行(flex-wrap:wrap)',
'flex-wrap:wrap' in _kv.group(1).replace(' ', ''), _kv.group(1)[:90])
_kvb = re.search(r'\.kv b\{([^}]*)\}', css)
chk('.kv b 存在', _kvb is not None)
if _kvb:
_b = _kvb.group(1).replace(' ', '')
_f = re.search(r'flex:([0-9.]+)([0-9.]+)', _b)
chk('.kv b 不收缩(flex-shrink:0,避免被压成竖排)',
bool(_f) and float(_f.group(2)) == 0, _b[:90])
chk('.kv b 有 max-width:100%(比整行还长时在空格处折行)',
'max-width:100%' in _b, _b[:90])
# ---- ②b 卡片标题不得靠 padding-left 缩进 ----
# padding-left 只挪文字不挪盒子,标题会和下面的正文/表格左边缘错开,
# 一屏卡片读起来就是「标题歪了」。竖条要挂在卡片内边距里(left:-12px)。
chk('.card>h3 不带 padding-left(标题与正文左对齐)',
re.search(r'\.card>h3\{[^}]*padding-left', css) is None)
chk('.card>h3::before 竖条挂在卡片内边距(left 为负)',
re.search(r'\.card>h3::before\{[^}]*left:-', css) is not None)
# ---- ②c 禁止 overflow-wrap:anywhere ----
# anywhere 会把元素的 min-content 算成 1 个字符,flex 就能把它压成细柱 → 竖排。
# break-word 不影响 min-content,是正确选择。
chk('CSS 里没有 overflow-wrap:anywhere(会把 min-content 变成 1 字符)',
'overflow-wrap:anywhere' not in css.replace(' ', ''))
# ---- ②d 多列日志行必须可换行 ----
# .lv 46 + .ts 130 + .mo 76 + gap 30 = 282px 是固定的;.ms 若写 flex:1,
# 窄屏下只剩 28px —— 实测一条日志被折成 24 行、每行 3 个字(「竖着的字」)。
_lg = re.search(r'\.logline\{([^}]*)\}', css)
chk('.logline 存在', _lg is not None)
if _lg:
chk('.logline 允许换行(flex-wrap:wrap)',
'flex-wrap:wrap' in _lg.group(1).replace(' ', ''), _lg.group(1)[:90])
_lms = re.search(r'\.logline \.ms\{([^}]*)\}', css)
chk('.logline .ms 存在', _lms is not None)
if _lms:
_m = _lms.group(1).replace(' ', '')
chk('.logline .ms 用 flex-basis 而不是裸 flex:1(放不下会整行换行)',
'flex:11' in _m or 'flex-basis' in _m, _m[:90])
chk('.logline .ms 不用 word-break:break-all(按词换行更好读)',
'word-break:break-all' not in _m, _m[:90])
# ---- ②e 窄屏必须压过内联固定宽度 ----
# 视图里有几十处 style="flex:0 0 110px",内联优先级高于样式表;
# 不 !important 的话手机上输入框仍是固定宽度左对齐,右边留一大片空白。
# ⚠️ 不要把它限定在「第一个 @media (max-width:760px) 块」里 ——
# app.css 有好几个 760px 断点块,.row>* 那条在「手机/窄屏适配」那一块。
chk('窄屏 .row>* 用 !important 压过内联固定宽度',
re.search(r'\.row>\*\{[^}]*flex:1 1 100% !important', css) is not None)
# ---- ②f 单元格 / 徽标 / 表单标签不得被压成竖排(2026-10-09 第三轮) ----
# 前两轮都只盯「卡片里的 .kv / .logline」,漏掉了**表格**:默认 CJK 可以在
# 任意两个字之间断行,于是单元格的 min-content 只有 1 个字宽,自动表格布局
# 会把这一列一路压到 ~25px。真机几何实测(_dev/live-text-audit.sh,800px):
# · 单元格里的徽标「虚拟磁盘」被压成 25px / 4 行(一列一个字)
# · 表头「修改时间」32px / 4 行
# · 表单标签「其它域名(逗号分隔)」34px / 5 行
# 统一用 word-break:keep-all 给一个「词」宽度的下限;表头再 nowrap 兜一层。
_td = re.search(r'\nth,td\{([^}]*)\}', css)
chk('th,td 规则存在', _td is not None)
if _td:
_t = _td.group(1).replace(' ', '')
chk('th,td 用 word-break:keep-all(否则表格列被压成一列一个字)',
'word-break:keep-all' in _t, _t[:110])
_th = re.search(r'\nth\{([^}]*)\}', css)
chk('th 规则存在', _th is not None)
if _th:
chk('th 用 white-space:nowrap(表头永不折成竖排)',
'white-space:nowrap' in _th.group(1).replace(' ', ''), _th.group(1)[:110])
_tag = re.search(r'\n\.tag\{([^}]*)\}', css)
chk('.tag 规则存在', _tag is not None)
if _tag:
_tg = _tag.group(1).replace(' ', '')
chk('.tag 用 word-break:keep-all(徽标不被压成一列一个字)',
'word-break:keep-all' in _tg, _tg[:110])
chk('.tag 不用 white-space:nowrap(英文长徽标会顶出内容区)',
'white-space:nowrap' not in _tg, _tg[:110])
chk('.row>label 用 word-break:keep-all(表单标签不被压成竖排)',
re.search(r'\.row>label\{[^}]*word-break:keep-all', css) is not None)
chk('#al-rules>.dep-item 是块级(.dep-item 是横向 flex,直接塞块会把 .row 压成 26px)',
re.search(r'#al-rules>\.dep-item\{[^}]*display:block', css) is not None)
chk('.dk-trow 单元格可断行(长设备名不撑破内容区)',
re.search(r'\.dk-trow>\*\{[^}]*min-width:0', css) is not None)
chk('.ul-trow 单元格可断行(窄屏两列下长 MAC/IPv6 不撑破内容区)',
re.search(r'\.ul-trow>\*\{[^}]*min-width:0', css) is not None)
# ---- ②g 依赖卡片:正文不能被标签/按钮挤成细柱(2026-10-10 第四轮) ----
# `.dep-grid{repeat(auto-fill,minmax(300px,1fr))}` → **屏越宽列数越多、卡片越窄**,
# 1920 下每张只有 ~307px。卡片里还有分组标签 + 必需/可选标签 + 「安装」按钮,
# 一行塞不下;`.dep-body{flex:1}`(= flex:1 1 0%,下限 0)就被压到 **27px**,
# 描述文字折成 23 行、每行 5 个字 —— 真机上就是「字是竖着的」。
# 而且窄屏(1 列、卡片 560px)反而看不出来,所以必须在 1920/2560 下量。
_di = re.search(r'\n\.dep-item\{([^}]*)\}', css)
chk('.dep-item 规则存在', _di is not None)
if _di:
chk('.dep-item 允许换行(flex-wrap:wrap,否则正文被标签挤成细柱)',
'flex-wrap:wrap' in _di.group(1).replace(' ', ''), _di.group(1)[:110])
_db = re.search(r'\.dep-item \.dep-body\{([^}]*)\}', css)
chk('.dep-item .dep-body 规则存在', _db is not None)
if _db:
_d = _db.group(1).replace(' ', '')
chk('.dep-item .dep-body 的 flex-basis 有像素下限(不能是裸 flex:1)',
re.search(r'flex:11\d+px', _d) is not None, _d[:110])
chk('app.js 里没有内联 style="flex:1" 覆盖 .dep-body(内联优先级更高)',
'class="dep-body" style="flex:1"' not in app)
# .switch 同理:inline-flex 的「拨杆 + 名称 + 说明」,名称会被说明挤成细柱
# (真机 390px:「不主动获取(without-acquire)」66px / 3 行)。
_sw = re.search(r'\n\.switch\{([^}]*)\}', css)
chk('.switch 规则存在', _sw is not None)
if _sw:
chk('.switch 允许换行(flex-wrap:wrap)',
'flex-wrap:wrap' in _sw.group(1).replace(' ', ''), _sw.group(1)[:110])
chk('.switch b 不收缩(flex:0 0 auto)',
re.search(r'\.switch b\{[^}]*flex:0 0 auto', css) is not None)
# ---- ③ 横向滚动容器 ----
chk('.tw 横向滚动容器仍在', re.search(r'\.tw\{[^}]*overflow-x:auto', css) is not None)
print('\n==== t-layout: %d 条判据, %d 失败 ====' % (N, len(FAILS)))
if FAILS:
for f in FAILS:
print(' - ' + f)
sys.exit(1)
print('LAYOUT_OK')