Files
drouter/_dev/t-theme-parity.py
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

305 lines
11 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
前后端校验一致性测试(#12 主题之家)
背景:为了「输入即时报错」的体验,前端 app.js 里镜像了一份取值校验与 CSS 校验
(tmCheckValue / tmCheckCss),后端 theme.py 里有权威实现(valid_css_value /
_check_extra_css)。两份实现一旦漂移,就会出现
「前端说没问题 → 点保存后端拒绝」或「前端拦住了正常值」这类体验问题。
本测试用同一份语料分别喂给两侧,逐条比对结论,
把「前端通过但后端拒绝」标为 ✗(用户会被后端打脸),
把「前端拒绝但后端通过」标为 ⚠(用户被误拦,功能不可用)。
运行:python3 router-build/_dev/t-theme-parity.py
"""
import io
import os
import re
import sys
import json
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
BACKEND = os.path.join(ROOT, 'backend')
sys.path.insert(0, BACKEND)
import theme # noqa: E402
NODE = os.environ.get('NODE_BIN') or 'node'
# ------------------------------------------------------------------ 语料
VALUES = [
# 颜色
'#fff', '#FFF', '#ffff', '#1f6feb', '#1f6febcc', '#12345678',
'#zzz', '#12345', '#gg', '#', '##fff',
'rgb(31,111,235)', 'rgb(0,0,0)', 'rgb(255,255,255)', 'rgb(256,0,0)', 'rgb(300,0,0)',
'rgba(31,111,235,.35)', 'rgba(0,0,0,0)', 'rgba(0,0,0,1)', 'rgba(1,2,3)',
'hsl(210,90%,55%)', 'hsl(0,0%,0%)', 'hsl(360,100%,100%)', 'hsl(361,50%,50%)',
'hsla(210,90%,55%,0.5)', 'hsl(210deg,90%,55%)', 'hsl(210,101%,50%)', 'hsl(-10,50%,50%)',
# 具名
'transparent', 'currentColor', 'white', 'steelblue', 'red', 'rebeccapurple', 'borange',
# 数值
'12px', '1rem', '2.5em', '50%', '0', '10pt', '3ch', '2ex',
# var()
'var(--pri)', 'var(--pri, #fff)', 'var(--bg)', 'var(--unk)', 'var(', 'var()',
# 渐变 / 阴影
'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)',
'radial-gradient(circle,#fff,#000)',
'0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
'inset 0 1px 0 #fff',
# 攻击串
'red;background:url(x)', '#fff}', '#fff{', 'url(http://evil.com/x.css)',
'expression(alert(1))', '@import url(x)', '#fff\\', '/* */ #fff', '#fff !important',
'#fff /* c */', '', ' ', 'none', 'auto', 'inherit', 'initial',
]
CSS_SAMPLES = [
'.card{border-width:2px}',
'.stat .val{font-size:22px}',
'/* 带注释 */ .card{color:red}',
'.card{color:#fff;background:var(--pri)}',
'h3{letter-spacing:.5px}',
'.a{color:red}.b{color:blue}',
'.card{box-shadow:0 1px 2px rgba(0,0,0,.1)}',
'.card{content:"x"}',
'.card{background:url(x.png)}',
'@import url("http://evil.com")',
'@media screen{.card{color:red}}',
'@font-face{src:url(x)}',
'.card{color:red',
'.card < > {color:red}',
'.card{a;b:c}',
'.card{background:expression(1)}',
'.card{background:javascript:alert(1)}',
'',
' ',
]
# ------------------------------------------------------------------ 前端求值
app_js = io.open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
def grab(fn_name):
"""从 app.js 抽取某个顶层函数的源码(大括号配平法)"""
i = app_js.index('function %s(' % fn_name)
j = app_js.index('{', i)
depth = 0
for k in range(j, len(app_js)):
if app_js[k] == '{':
depth += 1
elif app_js[k] == '}':
depth -= 1
if depth == 0:
return app_js[i:k + 1]
raise RuntimeError('未找到函数结尾:' + fn_name)
def grab_array(name):
"""抽取 `const NAME = [...];` 字面量(方括号配平)。
⚠️ 顶层常量改**惰性函数**之后(`const TM_VARS = () => ([...])`),
只认 `const NAME = [` 会直接抛 ValueError —— 不是代码错了,
是抽取器没跟上。两种写法都要支持。
"""
pat = 'const %s = [' % name
lazy = False
if pat in app_js:
i = app_js.index(pat)
else:
pat2 = 'const %s = () => ([' % name
if pat2 in app_js:
i = app_js.index(pat2)
lazy = True
else:
raise RuntimeError('未找到数组声明:%s(试过 %r 与 %r)' % (name, pat, pat2))
j = app_js.index('[', i)
depth = 0
for k in range(j, len(app_js)):
if app_js[k] == '[':
depth += 1
elif app_js[k] == ']':
depth -= 1
if depth == 0:
# 惰性写法是 `() => ([ ... ])` —— 方括号后面还有个 `)`,
# 只补 `;` 会拼成 `() => ([...];` → SyntaxError。
return app_js[i:k + 1] + (');' if lazy else ';')
raise RuntimeError('未找到数组结尾:' + name)
harness = ("""
'use strict';
// ⚠️ 必须提供 t() 恒等桩 —— 2026-10-05 i18n 补全后,TM_VARS / TM_NAMED
// 里的中文标签被包成 t('…'),而这份 harness 是把 app.js 的字面量
// 原样贴进 Node 求值的,不给桩就是 ReferenceError: t is not defined。
// 用恒等桩(t(k) => k)而不是查真字典:这份判据只验 CSS 校验逻辑,
// 关心的是**变量名**(x[0]),与显示文案无关。
const t = (k) => k;
// ⚠️ T 是同一个翻译函数的**大写别名**(app.js 顶部:t 会被形参遮蔽时用 T)。
// 只桩 t 不桩 T 的话,贴进来的函数里一调 T() 就 ReferenceError
// (2026-10-08:TM_VARS 改惰性后 tmCheckValue 里的 T 暴露了这个问题)。
const T = t;
const i18n = { getLang: () => 'zh-CN' };
// 直接用 app.js 自己的 TM_VARS / TM_NAMED 字面量(数组-of-数组形态),
// 避免因桩写错形态而误判:TM_VARS 元素的 x[0] 才是变量名。
@@TMVARSLIT@@
@@TMNAMEDLIT@@
@@TMCHECKVALUE@@
@@TMCHECKCSS@@
const out = {
values: VALUES.map(v => [v, tmCheckValue(v)]),
css: CSS_SAMPLES.map(c => [c, tmCheckCss(c)]),
unit: {},
};
for (const vr of ['--r', '--side', '--side-c']) {
for (const vl of ['12px', '12vw', '12vh', '2rem', '50%']) {
out.unit[vr + '|' + vl] = tmCheckValue(vl, vr);
}
}
console.log(JSON.stringify(out));
""".replace('@@TMVARSLIT@@', grab_array('TM_VARS'))
.replace('@@TMNAMEDLIT@@', grab_array('TM_NAMED'))
.replace('@@TMCHECKVALUE@@', grab('tmCheckValue'))
.replace('@@TMCHECKCSS@@', grab('tmCheckCss')))
values_json = json.dumps(VALUES, ensure_ascii=False)
css_json = json.dumps(CSS_SAMPLES, ensure_ascii=False)
tmp = os.path.join(tempfile.gettempdir(), 'tm-parity-%d.js' % os.getpid())
io.open(tmp, 'w', encoding='utf-8').write(
'const VALUES = %s;\nconst CSS_SAMPLES = %s;\n%s' % (values_json, css_json, harness))
import subprocess # noqa: E402
p = subprocess.run([NODE, tmp], capture_output=True, text=True, encoding='utf-8')
if p.returncode != 0:
print('前端求值失败:', p.stderr[:800])
sys.exit(1)
fe = json.loads(p.stdout)
os.unlink(tmp)
fe_vals = {k: v for k, v in fe['values']}
fe_css = {k: v for k, v in fe['css']}
# ------------------------------------------------------------------ 比对
pass_n = fail_n = warn_n = 0
lines = []
print('== 取值校验一致性(valid_css_value vs tmCheckValue)==')
for v in VALUES:
be_ok, be_why = theme.valid_css_value(v)
fe_why = fe_vals.get(v, '<前端未返回>')
fe_ok = (fe_why == '')
if be_ok == fe_ok:
pass_n += 1
continue
if not be_ok and fe_ok:
fail_n += 1
lines.append(' ✗ 前端放行但后端拒绝:%r\n 后端原因:%s' % (v, be_why))
else:
warn_n += 1
lines.append(' ⚠ 前端误拦但后端允许:%r' % v)
# 单位限制(只对 --r / --side / --side-c)
print('== 单位限制一致性(--r / --side / --side-c)==')
for var in ('--r', '--side', '--side-c'):
for val in ('12px', '12vw', '12vh', '2rem', '50%'):
be_ok, _ = theme.valid_css_value(val, var)
# 前端同参数
fe_why = fe.get('unit', {}).get(var + '|' + val)
if fe_why is None:
continue
fe_ok = (fe_why == '')
if be_ok == fe_ok:
pass_n += 1
elif not be_ok and fe_ok:
fail_n += 1
lines.append(' ✗ %s=%r 前端放行但后端拒绝' % (var, val))
else:
warn_n += 1
lines.append(' ⚠ %s=%r 前端误拦但后端允许' % (var, val))
print('== 自定义 CSS 校验一致性(_check_extra_css vs tmCheckCss)==')
for c in CSS_SAMPLES:
be_bad, be_why = theme._check_extra_css(c)
fe_why = fe_css.get(c, '<前端未返回>')
fe_bad = (fe_why != '')
be_ok = not be_bad
fe_ok = not fe_bad
if be_ok == fe_ok:
pass_n += 1
continue
if not be_ok and fe_ok:
fail_n += 1
lines.append(' ✗ CSS 前端放行但后端拒绝:%r\n 后端原因:%s' % (c[:60], be_why))
else:
warn_n += 1
lines.append(' ⚠ CSS 前端误拦但后端允许:%r' % c[:60])
print('== 关键安全串:两侧都必须拒绝 ==')
MUST_REJECT = [
'red;background:url(x)', '#fff}', '#fff{', 'url(http://evil.com/x.css)',
'expression(alert(1))', '@import url(x)', '#fff\\', '/* */ #fff', '#fff !important',
'rgb(300,0,0)', 'hsl(361,50%,50%)', 'var(--unk)',
]
for v in MUST_REJECT:
be_ok, _ = theme.valid_css_value(v)
fe_ok = (fe_vals.get(v) == '')
ok_pair = (not be_ok) and (not fe_ok)
if ok_pair:
pass_n += 1
else:
fail_n += 1
lines.append(' ✗ 危险串 %r 未被两侧同时拒绝(后端ok=%s 前端ok=%s)'
% (v, be_ok, fe_ok))
MUST_REJECT_CSS = ['@import url("http://evil.com")', '.card{background:url(x.png)}',
'.card{background:expression(1)}', '@font-face{src:url(x)}']
for c in MUST_REJECT_CSS:
be_bad, _ = theme._check_extra_css(c)
fe_bad = (fe_css.get(c) != '')
if be_bad and fe_bad:
pass_n += 1
else:
fail_n += 1
lines.append(' ✗ 危险 CSS %r 未被两侧同时拒绝(后端bad=%s 前端bad=%s)'
% (c[:40], be_bad, fe_bad))
print('== 清单一致性:前端副本必须等于后端权威表 ==')
drift = 0
# TM_VARS(app.js)的元素 x[0] 应正好等于 THEME_VARS 的键
fe_vars_names = re.findall(r"\[\s*'(--[a-z0-9-]+)'\s*,", grab_array('TM_VARS'))
be_vars_names = list(theme.THEME_VARS.keys())
only_fe = sorted(set(fe_vars_names) - set(be_vars_names))
only_be = sorted(set(be_vars_names) - set(fe_vars_names))
if only_fe or only_be:
drift += 1
lines.append(' ✗ TM_VARS 与 THEME_VARS 不一致:前端多 %s |后端多 %s'
% (only_fe, only_be))
else:
pass_n += 1
# TM_NAMED 应正好等于 _NAMED
fe_named = re.findall(r"'([a-z]+)'", grab_array('TM_NAMED'))
only_fe = sorted(set(fe_named) - set(theme._NAMED))
only_be = sorted(set(theme._NAMED) - set(fe_named))
if only_fe or only_be:
drift += 1
lines.append(' ✗ TM_NAMED 与 _NAMED 不一致:前端多 %s |后端多 %s'
% (only_fe, only_be))
else:
pass_n += 1
fail_n += drift
print()
print('=' * 60)
print('一致 %d 项|不一致 %d 项(其中「前端误拦」%d 项)' % (pass_n, fail_n + warn_n, warn_n))
if lines:
print('-' * 60)
for l in lines[:60]:
print(l)
print('=' * 60)
sys.exit(1 if fail_n + warn_n else 0)