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

170 lines
7.4 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 -*-
"""模块健康总览(#5)的静态守卫。
守的是「三张表必须对齐」这件事 —— 后端 read:status 只回**稳定枚举**,
所有展示文案都在前端 i18n 里;一旦后端加了新模块/新状态码而前端没补词条,
英文界面就会露出 key 原文(如 st.k.xxx),而**没有**这条守卫的话,
t-en-render 也发现不了(它只看「有没有中文字符」,key 原文是 ASCII)。
"""
import io
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASS = FAIL = 0
def chk(name, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
print(' ok %s' % name)
else:
FAIL += 1
print(' FAIL %s %s' % (name, ('→ ' + str(extra)) if extra else ''))
def rd(rel):
return io.open(os.path.join(ROOT, rel), encoding='utf-8').read()
HELPER = rd('backend/drouter-helper.py')
WEBPY = rd('backend/drouter-web.py')
APP = rd('web/app.js')
I18N = rd('web/i18n.js')
FIX = rd('_real_fixtures.js')
def st_block(name):
"""取 i18n.js 里 st: { <name>: { ... } } 这一层的文本。
⚠️ 必须按子块取,不能用整文件正则:`system` 既是 k 组的模块名,
也是 h 组的提示语键。整文件搜索时删掉 k.system,h.system 会顶上,
判据照样变绿(实测踩过)。
"""
i = I18N.find('\n st: {\n')
if i < 0:
return ''
j = I18N.find('\n %s: {\n' % name, i)
if j < 0:
return ''
k = I18N.find('\n },\n', j + 1)
return I18N[j:k] if k > 0 else ''
ST_K = st_block('k')
ST_H = st_block('h')
ST_C = st_block('c')
ST_M = st_block('m')
# ---------- 1. 后端 ----------
m = re.search(r'\ndef read_status\(_\):\n([\s\S]*?)\n\ndef ', HELPER)
chk('helper 里有 read_status', bool(m))
chk('i18n 里 st.k / st.h / st.c / st.m 四个子块都取到了',
all(len(x) > 200 for x in (ST_K, ST_H, ST_C, ST_M)),
'len=%s' % [len(x) for x in (ST_K, ST_H, ST_C, ST_M)])
body = m.group(1) if m else ''
chk("ACTIONS 注册了 read:status", "'read:status': read_status," in HELPER)
chk("web 路由注册了 /api/status", "'/api/status': 'read:status'," in WEBPY)
# 去掉注释与 docstring 后再查中文:read_status 只该回枚举与数值
stripped = re.sub(r'""".*?"""', '', body, flags=re.S)
stripped = re.sub(r'#[^\n]*', '', stripped)
# log(...) 是**内部日志行**(进系统日志,不下发给前端),中文是允许的;
# 不排掉的话这条判据会被「健康总览 X 判定失败」这种日志串误伤。
stripped = re.sub(r"log\((?:[^()]|\([^()]*\))*\)", '', stripped)
cjk = re.findall(r'[\u4e00-\u9fff]', stripped)
chk('read_status 不下发任何中文文案', not cjk, ''.join(cjk[:20]))
# 轮询安全:必须有结果缓存 + TTL
chk('有结果缓存(_STATUS_CACHE)', '_STATUS_CACHE' in HELPER)
tm = re.search(r'_STATUS_TTL\s*=\s*(\d+)', HELPER)
chk('_STATUS_TTL 是正数', bool(tm) and int(tm.group(1)) > 0,
tm.group(1) if tm else 'missing')
# ⚠️ read_metrics 内含 ping 测 RTT,实测 1.2s —— 轮询接口里绝不能出现它
chk('read_status 不调 read_metrics(含 1.2s 的 ping 探测)',
'read_metrics(' not in stripped)
chk('资源采集走零 fork 的 _st_resources', '_st_resources()' in stripped)
# ---------- 2. 模块键 → i18n st.k.* / st.h.* ----------
keys = re.findall(r"_run\('([a-z0-9_]+)'", body)
chk('read_status 至少覆盖 15 个模块', len(keys) >= 15, '实际 %d' % len(keys))
miss_k = [k for k in keys if (" %s:" % k) not in I18N or
("st.k." + k) not in APP]
bad_k = []
for k in keys:
if not re.search(r"\n %s: *\{ zh: '[^']*', en: '[^']*' \}"
% re.escape(k), ST_K):
bad_k.append(k)
chk('每个模块键都有 st.k.<键> 词条', not bad_k, str(bad_k))
# st.h.<键>:提示语。允许缺失(前端会留空),但至少覆盖 80%
hits = [k for k in keys
if re.search(r"\n %s: *\{ zh: '[^']*', en: '[^']*' \}" % re.escape(k), ST_H)]
chk('模块提示语 st.h.* 覆盖 >= 80%%', len(hits) >= len(keys) * 0.8,
'%d/%d' % (len(hits), len(keys)))
# ---------- 3. 状态码 → i18n st.c.* ----------
codes = set()
codes |= set(re.findall(r"return '[a-z_]+', '([a-z_]+)'", body))
# ⚠️ 这个形状里**第一个**字符串是等级、第二个才是状态码:
# lv, code = 'warn', 'noaddr' → 码是 noaddr 而不是 warn。
codes |= set(re.findall(r"lv, code = '[a-z_]+', '([a-z_]+)'", body))
codes |= set(re.findall(r"it\['lv'\], it\['c'\] = '[a-z_]+', '([a-z_]+)'", body))
# ⚠️ 不要再加 `\['lv'\] = '(...)'` 或 `return \('(...)' if ..., '...'\)` 这类
# 取**第一个**字符串的模式 —— 第一个永远是等级,会把 'warn'/'err'
# 当成状态码,然后误报「缺 st.c.warn 词条」(实测踩过)。
codes |= set(re.findall(r"return \('[a-z_]+' if [^\n]*? else '[a-z_]+'\), "
r"\('([a-z_]+)' if [^\n]*? else '[a-z_]+'\)", body))
codes |= set(re.findall(r"\['c'\] = '([a-z_]+)'", body))
# _svc_lv 的返回
codes |= {'running', 'starting', 'failed', 'stopped', 'disabled', 'unknown'}
chk('识别到 >= 12 个状态码', len(codes) >= 12, '实际 %d' % len(codes))
miss_c = sorted(c for c in codes
if not re.search(r"\n %s: *\{ zh: '[^']*', en: '[^']*' \}"
% re.escape(c), ST_C))
chk('每个状态码都有 st.c.<码> 词条', not miss_c, str(miss_c))
# ---------- 4. 指标名:真机 fixture 的键必须都能展示 ----------
mf = re.search(r'const REAL_STATUS = (\{[\s\S]*?\n\});', FIX)
chk('fixture 里有 REAL_STATUS', bool(mf))
if mf:
data = json.loads(mf.group(1))
real_keys = set()
for it in data.get('items') or []:
real_keys |= set((it.get('m') or {}).keys())
chk('真机 payload 至少 20 个指标键', len(real_keys) >= 20, '实际 %d' % len(real_keys))
stm = re.search(r'const ST_METRICS = \[([\s\S]*?)\n\];', APP)
chk('app.js 有 ST_METRICS 表', bool(stm))
listed = set(re.findall(r"\['([a-z0-9_]+)',", stm.group(1))) if stm else set()
hidden = sorted(real_keys - listed)
chk('真机所有指标键都在 ST_METRICS 里(不会静默不显示)', not hidden, str(hidden))
miss_m = sorted(k for k in listed
if not re.search(r"\n %s: *\{ zh: '[^']*', en: '[^']*' \}"
% re.escape(k), ST_M))
chk('ST_METRICS 每项都有 st.m.<名> 词条', not miss_m, str(miss_m))
# ---------- 5. 前端接线 ----------
chk('VIEWS 注册了 health', re.search(r'\n health: viewHealth,', APP) is not None)
chk('导航里有健康总览入口', "{ k: 'health', n: T('st.nav')" in APP)
chk('HEALTH_TIMER 进 stopPageTimers(切页要停轮询)',
'DK_TIMER, HEALTH_TIMER]' in APP and 'DK_TIMER = HEALTH_TIMER = null' in APP)
chk('顶栏状态灯挂进 boot()', 'initHealthLight();' in APP)
chk('本页状态灯容器存在(index.html)', 'id="page-health"' in rd('web/index.html'))
chk('本页灯在 go() 里同步', 'paintPageLight(); // 本页模块状态灯' in APP)
# 单位不能写中文(会原样漏进英文界面)
unit_cjk = re.findall(r"\['[a-z0-9_]+', ' ([^']*[\u4e00-\u9fff][^']*)'\]", APP)
chk('ST_METRICS 的单位不含中文', not unit_cjk, str(unit_cjk))
print('\n==== t-status: %d 条判据, %d 失败 ====' % (PASS + FAIL, FAIL))
if FAIL:
print('STATUS_GUARD_FAIL')
sys.exit(1)
print('STATUS_GUARD_OK')