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

307 lines
16 KiB
JavaScript
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.
// 离线视图渲染回归:用最小 DOM 桩 + 假 fetch,把所有视图函数都跑一遍,
// 检查是否抛异常、是否渲染出内容。不依赖真实路由器。
// 用法:node devtools/check-render.js web/app.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// 以脚本自身位置定位,避免依赖运行时的当前目录(否则换个目录跑就 ENOENT)。
const DEFAULT_APP = path.join(__dirname, '..', 'web', 'app.js');
const file = process.argv[2] || DEFAULT_APP;
const code = fs.readFileSync(file, 'utf8');
function mkEl(id) {
const el = {
id, _html: '', textContent: '', value: '', disabled: false, checked: false,
className: '', tagName: 'DIV', dataset: {}, style: {}, children: [],
classList: {
add(c) { el.className += ' ' + c; },
remove(c) { el.className = el.className.replace(c, ''); },
toggle() { return false; }, contains() { return false; },
},
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); },
addEventListener() {}, removeEventListener() {},
appendChild(c) { el.children.push(c); },
removeChild() {},
querySelector() { return mkEl('sub'); },
querySelectorAll() { return []; },
get parentElement() { return mkEl('parent'); },
get firstChild() { return mkEl('first'); },
insertBefore() {}, replaceChild() {}, cloneNode() { return mkEl('clone'); },
focus() {}, select() {}, scrollIntoView() {}, click() {},
onclick: null, onchange: null, oninput: null, onkeydown: null,
getContext() { return {}; },
};
return el;
}
const ELS = new Map();
function getEl(sel) {
const k = String(sel).replace(/^#/, '').split(/[\s>]/)[0];
if (!ELS.has(k)) ELS.set(k, mkEl(k));
return ELS.get(k);
}
const document = {
getElementById: id => getEl(id),
querySelector: getEl,
querySelectorAll: () => [],
createElement: t => mkEl('new:' + t),
body: mkEl('body'),
addEventListener() {},
};
// 假 fetch:所有 GET 返回一个宽松的 ok 响应
const CALLED = [];
async function fetchStub(url, opts) {
CALLED.push({ url, method: (opts && opts.method) || 'GET' });
let data = { ok: true, data: {}, msg_cn: 'ok' };
// 各接口按需返回形状合理的假数据,避免视图因字段类型不符而抛异常
if (/\/api\/ifaces/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/iface\/meta/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/iface_method/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/ddns/.test(url)) data = { ok: true, data: {
cfg: { provider: 'custom', domain: '', ipv4: true, ipv6: false },
public: { combo: 'both', v4: '1.2.3.4', v6: '2408::1', note: '' },
// note 形状与真后端 read_ddns 一致(title/conclusion/advice + en_* 孪生),
// viewDdns 里 enOrZh(note.en_advice, note.advice).map(...) 需要它是数组。
note: { level: 'ok', text: '',
title: '双栈公网', en_title: 'Dual-stack public IP',
conclusion: 'v4 与 v6 都有公网能力', en_conclusion: 'Both v4 and v6 have public reachability',
advice: ['建议同时启用 A 与 AAAA 记录'], en_advice: ['Enable both A and AAAA records'] },
providers: [{ v: 'custom', name: '自定义', region: 'other', fields: [],
n: '自定义(URL 模板)', desc: '适用于任何提供 HTTP 更新的服务商', doc: 'manual' }],
records: [], last_result: '' } };
else if (/\/api\/ulog\/daemon/.test(url)) data = { ok: true, data: { units: { 'drouter-logd.timer': { active: 'active', enabled: 'enabled' } }, next: 'in 42s', last: { ts: '2026-09-28T22:00:00', msg_cn: '已归档 12 条日志', code: 'ULOG_ARCHIVE_OK' } } };
else if (/\/api\/ulog\/daemon/.test(url)) data = { ok: true, data: {
units: { 'drouter-logd.timer': { active: 'active', enabled: 'enabled' } },
next: 'in 42s',
last: { ts: '2026-09-28T22:00:00', msg_cn: '已归档 12 条日志', code: 'ULOG_ARCHIVE_OK' } } };
else if (/\/api\/ulog\/conf/.test(url)) data = { ok: true, data: {
conf: { enabled: true, archive: true, keep_days: 7, keep_rows: 200000,
min_level: 'debug', sources: { fw: true, conntrack: true, flow: true,
wan: true, ddns: true, app: true, system: true } },
caps: { conntrack: true, journalctl: true },
conntrack: { enabled: true, count: 120, max: 65536 }, fw_log: true,
sources: [{ v: 'fw', n: '防火墙', desc: '' }, { v: 'conntrack', n: '连接跟踪', desc: '' },
{ v: 'flow', n: '当前连接', desc: '' }, { v: 'wan', n: 'WAN 接入', desc: '' },
{ v: 'ddns', n: '动态域名', desc: '' }, { v: 'app', n: '应用日志', desc: '' },
{ v: 'system', n: '系统日志', desc: '' }],
levels: [{ v: 'err', n: '错误' }, { v: 'warn', n: '警告' }, { v: 'info', n: '信息' }],
archive: { path: '/var/log/drouter/ulog.jsonl', size: 20480, rows: 128 } } };
else if (/\/api\/ulog\/flow/.test(url)) data = { ok: true, data: {
rows: [{ ts: '', src: 'flow', level: 'info', action: 'ORIG', proto: 'TCP',
saddr: '192.168.7.100', sport: '51234', daddr: '1.1.1.1', dport: '443',
msg_cn: 'TCP 连接', extra: { state: 'ESTABLISHED', state_cn: '已建立',
bytes: 20480, reply: false } }],
stat: { total: 1, tcp: 1, udp: 0, icmp: 0, other: 0 },
cap: { count: 120, max: 65536 }, err: '', note: '快照' } };
else if (/\/api\/ulog/.test(url)) data = { ok: true, data: {
items: [{ ts: '2026-09-28T10:00:00', src: 'fw', level: 'warn', level_cn: '警告',
action: 'DROP', proto: 'TCP', saddr: '203.0.113.9', sport: '54321',
daddr: '192.168.7.3', dport: '22', iface: 'ens19', msg_cn: '丢弃 SSH',
raw: 'DROUTER-FW DROP ...' }],
stat: { total: 1, by_src: { fw: 1 }, by_level: { warn: 1 }, by_action: { DROP: 1 },
by_proto: { TCP: 1 }, top_src_ip: [{ v: '203.0.113.9', n: 1 }],
top_dst_port: [{ v: '22', n: 1 }] },
meta: { sources_used: ['fw'], collected: 1, matched: 1, shown: 1, errors: {} },
conf: { enabled: true, archive: true, keep_days: 7, keep_rows: 200000,
min_level: 'debug', sources: { fw: true } },
sources: [{ v: 'fw', n: '防火墙', desc: '' }],
levels: [{ v: 'err', n: '错误' }, { v: 'warn', n: '警告' }, { v: 'info', n: '信息' }],
archive: { path: '/var/log/drouter/ulog.jsonl', size: 20480, rows: 128 },
note: '统一日志' } };
else if (/\/api\/fflow/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/metrics/.test(url)) data = { ok: true, data: { cpu: 3, mem: 21, disk: 12, up: 0, down: 0 } };
else if (/\/api\/dash|sysinfo/.test(url)) data = { ok: true, data: {} };
else if (/\/api\/users/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/leases/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/routes/.test(url)) data = { ok: true, data: [] };
// 主题之家(#12):list / vars / get 三种 op
const TM_VARS_ALL = ['--bg', '--bg2', '--panel', '--panel2', '--line', '--line2',
'--txt', '--txt2', '--txt3', '--pri', '--pri-d', '--pri-l', '--ok', '--ok-l',
'--warn', '--warn-l', '--err', '--err-l', '--info', '--info-l',
'--r', '--sh', '--grad', '--side', '--side-c'];
function mkTheme(id, name, builtin, dark, pri, bg) {
const sw = [pri, bg, '#ffffff', '#161f30'];
return { id, name, description: name + ' 的说明', author: builtin ? 'Drouter 内置' : 'tester',
version: '1.0', dark, builtin, vars_count: 25, has_css: false, swatch: sw,
vars: { '--pri': pri, '--bg': bg } };
}
if (/\/api\/theme/.test(url)) {
const op = /op=([a-z_]+)/.exec(url);
const which = op ? op[1] : 'list';
if (which === 'vars') {
const defaults = {};
TM_VARS_ALL.forEach(k => { defaults[k] = k.startsWith('--side') ? '236px' : '#1f6feb'; });
data = { ok: true, data: {
vars: TM_VARS_ALL.map(k => ({ name: k, cat: 'base', cat_cn: '基础配色', cn: k })),
cats: [{ k: 'base', n: '基础配色' }, { k: 'color', n: '主色与状态色' },
{ k: 'shape', n: '圆角 / 阴影 / 渐变' }, { k: 'layout', n: '布局尺寸' }],
defaults } };
} else if (which === 'get') {
data = { ok: true, data: { id: 'teal', name: '青竹', description: 'd', author: 'x',
version: '1.0', dark: false, css: '', css_text: ':root{--pri:#0d9488}',
vars: { '--pri': '#0d9488', '--bg': '#f2fbf9' } } };
} else {
data = { ok: true, data: {
themes: [
mkTheme('default', 'Drouter 经典蓝', true, false, '#1f6feb', '#eef2f9'),
mkTheme('teal', '青竹', true, false, '#0d9488', '#f2fbf9'),
mkTheme('violet', '紫罗兰', true, false, '#7c3aed', '#f6f4fe'),
mkTheme('dark-green', '暗夜墨绿', true, true, '#0f766e', '#111827'),
mkTheme('warm', '暖阳', true, false, '#f97316', '#fff8f2'),
mkTheme('dark-rose', '暗夜绯红', true, true, '#e11d48', '#150d14'),
mkTheme('my-theme', '我的自定义主题', false, false, '#3366cc', '#f7f9fc'),
],
active: 'my-theme', active_name: '我的自定义主题',
vars_total: 25, dir: '/etc/drouter/themes',
css_path: '/etc/drouter/generated/theme.css' } };
}
}
else if (/\/api\/services/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/snapshot\/list/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/config/.test(url)) data = { ok: true, data: {} };
if (/\/api\/docker/.test(url)) {
data = { ok: true, data: {
pkgs: { docker: { installed: true, pkgs: ['docker.io'] },
compose: { installed: true }, install_cmd: 'apt-get install -y docker.io' },
info: { version: '27.5.1', containers: '2', running: '1', images: '5',
driver: 'overlay2', root: '/var/lib/docker' },
services: { docker: 'active', containerd: 'active' },
containers: [
{ id: 'abc123def456', name: 'web', image: 'nginx:alpine', state: 'running',
status: 'Up 3 hours', ports: '0.0.0.0:8080->80/tcp', created: '2 hours ago',
size: '12MB', stat: { cpu: '0.42%', mem: '24.1MiB / 1GiB', mem_perc: '2.35%',
net: '1.2MB / 340kB', block: '0B / 0B', pids: '5' } },
{ id: 'ffffeeee1111', name: 'db', image: 'postgres:16', state: 'exited',
status: 'Exited (0) 1 hour ago', ports: '5432/tcp', created: '1 day ago',
size: '90MB', stat: {} },
],
images: [{ repo: 'nginx', tag: 'alpine', id: 'a1b2c3d4e5f6', size: '48MB',
created: '2026-09-20 10:00:00 +0800 CST' }],
networks: [{ name: 'bridge', driver: 'bridge', scope: 'local' }],
volumes: [{ name: 'pgdata', driver: 'local', mountpoint: '/var/lib/docker/volumes/pgdata' }],
stacks: [{ name: 'my-app', dir: '/etc/drouter/docker/stacks/my-app',
file: 'docker-compose.yml', mtime: '2026-09-27 12:00:00', size: 320 }],
compose_cmd: 'docker compose', stack_dir: '/etc/drouter/docker/stacks',
note: '测试数据' }, msg_cn: 'ok' };
} else if (/\/api\/convert/.test(url)) {
data = { ok: true, data: { ok: true, yaml: 'services:\n web:\n image: nginx\n',
service: 'web', image: 'nginx', warnings: [], msg_cn: '解析成功' } };
}
return { status: 200, ok: true, json: async () => data };
}
const sandbox = {
document, fetch: fetchStub, console,
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {},
requestAnimationFrame: () => 0, alert() {}, confirm: () => false,
location: { reload() {}, href: '' },
navigator: { userAgent: 'node' }, window: {},
TextEncoder, TextDecoder, URL, Blob: function () {}, FormData: function () {},
};
// 1.0.10 起 app.js 里的文案走 t()(i18n.js 提供)。离线检查器不加载
// i18n.js(它要 DOM + localStorage),所以这里给一个**恒等兜底**:
// 保证渲染检查能跑完、且不会因为缺 t() 而把「概览页抛异常」误报成
// 真 bug。⚠️ 必须是兜底而不是「加载真的 i18n.js」——
// 后者会让检查器依赖字典,而字典天天在改,改一个词就可能连带
// 让 check-render 失败,掩盖真正的渲染问题。
sandbox.t = k => k;
sandbox.i18n = {
t: k => k, setLang() {}, getLang: () => 'zh-CN', toggle() {},
onChange() {}, applyDom() {}, dict: {}, register() {}, rerenderAll() {},
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
const ctx = vm.createContext(sandbox);
try {
vm.runInContext(code, ctx, { filename: file });
} catch (e) {
console.error('脚本加载失败:' + e.message);
process.exit(1);
}
// `const` 声明不会挂到 context 对象上,需用表达式取出
let VIEWS;
try {
VIEWS = vm.runInContext('VIEWS', ctx);
} catch (e) {
VIEWS = null;
}
if (!VIEWS || typeof VIEWS !== 'object') {
console.error('未找到 VIEWS 导出(脚本需在顶层用 const VIEWS = {...})');
process.exit(1);
}
// 带内部标签页的视图:{ state: S 上的字段名, tabs: [...], render: '渲染函数名' }
const TABBED = {
theme: { state: 'themeTab', tabs: ['gallery', 'design', 'import'], render: 'tmRender' },
};
(async () => {
const names = Object.keys(VIEWS);
let bad = 0, empty = 0;
for (const n of names) {
try {
const fn = VIEWS[n];
if (typeof fn !== 'function') { console.log(` ✗ ${n}:不是函数`); bad++; continue; }
await fn();
const viewEl = getEl('#view');
const html = viewEl.innerHTML || '';
if (process.env.DUMP_VIEW === n) {
console.log('--- ' + n + ' 渲染 HTML(前 2500 字符)---');
console.log(html.slice(0, 2500));
console.log('--- 结束 ---');
}
if (html.length < 40) {
console.log(` ⚠ ${n}:渲染内容过少(${html.length} 字符)`);
empty++;
}
// 带标签页的视图:逐个标签渲染一遍,否则只有默认标签会被真正执行。
// 主题之家(#12)的三个标签页各自 $("#tm-pane").innerHTML = ...,逻辑完全不同。
if (TABBED[n]) {
for (const tab of TABBED[n].tabs) {
try {
vm.runInContext(`S.${TABBED[n].state} = ${JSON.stringify(tab)}`, ctx);
await vm.runInContext(TABBED[n].render + '()', ctx);
const h2 = getEl('#tm-pane').innerHTML || '';
if (h2.length < 40) {
console.log(` ⚠ ${n}/${tab}:标签内容过少(${h2.length} 字符)`);
empty++;
} else if (process.env.DUMP_VIEW === n) {
console.log('--- ' + n + '/' + tab + ' 前 1200 字符 ---');
console.log(h2.slice(0, 1200));
}
// 每个标签都应跑到;< 若某个标签 HTML 里出现未完成标记要报警
if (/undefined|\[object Object\]/.test(h2)) {
const hit = (/undefined/.test(h2) ? 'undefined' : '[object Object]');
console.log(` ⚠ ${n}/${tab}:输出含 ${hit}`);
empty++;
}
} catch (e) {
const st = String(e.stack || '').split('\n').slice(1, 4).join(' | ');
console.log(` ✗ ${n}/${tab}:抛异常 → ${e.message}\n ${st}`);
bad++;
}
}
// 复位到默认标签,避免影响后续视图
vm.runInContext(`S.${TABBED[n].state} = ${JSON.stringify(TABBED[n].tabs[0])}`, ctx);
}
} catch (e) {
const st = String(e.stack || '').split('\n').slice(1, 4).join(' | ');
console.log(` ✗ ${n}:抛异常 → ${e.message}\n ${st}`);
bad++;
}
}
console.log('='.repeat(60));
console.log(`视图总数 ${names.length}|异常 ${bad}|疑似空渲染 ${empty}`);
const dockerCalls = CALLED.filter(c => /\/api\/docker/.test(c.url)).length;
console.log(`docker 接口调用次数:${dockerCalls}`);
process.exit(bad ? 1 : 0);
})();