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/排版排查的一次性脚本。
1099 lines
68 KiB
JavaScript
1099 lines
68 KiB
JavaScript
// 离线视图渲染回归:用最小 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() {
|
||
// canvas 2d 桩:realtime.js 用 canvas 画实时折线图。
|
||
// 早先这里返回 {},一调 setTransform 就 TypeError,把整个检查器带崩
|
||
// (2026-10-09 接独立模块时踩到)。
|
||
return {
|
||
setTransform() {}, clearRect() {}, beginPath() {}, closePath() {},
|
||
moveTo() {}, lineTo() {}, stroke() {}, fill() {}, fillRect() {},
|
||
fillText() {}, strokeRect() {}, save() {}, restore() {}, translate() {},
|
||
scale() {}, arc() {}, measureText(t) { return { width: String(t == null ? '' : t).length * 7 }; },
|
||
fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textAlign: '',
|
||
textBaseline: '', globalAlpha: 1,
|
||
};
|
||
},
|
||
};
|
||
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);
|
||
}
|
||
// 新建元素也必须登记进 ELS:否则「createElement → innerHTML → appendChild」这条
|
||
// 渲染路径对扫描器完全不可见(第二类结构性盲区,和动态子块那次同源)。
|
||
let EL_SEQ = 0;
|
||
function mkTracked(tag) {
|
||
const e = mkEl('new:' + tag + ':' + (++EL_SEQ));
|
||
ELS.set(e.id, e);
|
||
return e;
|
||
}
|
||
|
||
const document = {
|
||
getElementById: id => getEl(id),
|
||
querySelector: getEl,
|
||
querySelectorAll: () => [],
|
||
createElement: t => mkTracked(t),
|
||
body: mkEl('body'),
|
||
addEventListener() {},
|
||
};
|
||
|
||
/* 向导专用的 probe 数据桩。
|
||
* 字段名与真机 helper._wiz_probe() 一致(不能凭印象填),
|
||
* 中文值就是真机下发的那些——正是要被 i18n 翻掉的那些。
|
||
* 同时故意给满值(非空字符串),不然清单路径不会被走到。*/
|
||
const WIZ_STEPS_FIXTURE = [
|
||
{ k: 'wan', n: '第 1 步 · 接上外网', icon: '⇄', d: '告诉这台机器「从哪里接宽带」,并且确认它真的连上了。' },
|
||
{ k: 'lan', n: '第 2 步 · 给内网发地址', icon: '⌘', d: '让手机、电脑插上网线或连上 Wi-Fi 就能自动拿到 IP,不用手工设置。' },
|
||
{ k: 'dns', n: '第 3 步 · 能打开网址', icon: '⌖', d: '域名要翻译成 IP 才能访问。' },
|
||
{ k: 'v6', n: '第 4 步 · 要不要 IPv6', icon: '⁶', d: 'IPv6 是新一代地址。' },
|
||
];
|
||
const WIZ_WAN_MODES_FIXTURE = [
|
||
{ k: 'pppoe', n: 'PPPoE 拨号',
|
||
d: '需要宽带账号和密码,由本机发起拨号。',
|
||
when: '光猫已设为桥接,或者你打算让本机取代运营商光猫拨号。' },
|
||
{ k: 'dhcp', n: 'DHCP 自动获取(IPoE)', d: '网线插上就有地址,不需要账号密码。', when: '光猫已经在拨号。' },
|
||
{ k: 'static', n: '静态地址', d: '运营商给了固定的 IP、网关和 DNS,手工填进去。', when: '企业专线、固定 IP 宽带。' },
|
||
];
|
||
const WIZ_DNS_PRESETS_FIXTURE = [
|
||
{ k: 'ali', n: '阿里云 AliDNS', v4: '223.5.5.5,223.6.6.6', d: '国内解析最快、覆盖最广。' },
|
||
{ k: 'dnspod', n: '腾讯 DNSPod', v4: '119.29.29.29,182.254.116.116', d: '国内老牌。' },
|
||
{ k: 'isp', n: '跟随运营商下发', v4: '', d: '用宽带拨号时运营商给的 DNS。' },
|
||
];
|
||
const WIZ_POOL_HINT_FIXTURE = {
|
||
why: '内网设备的地址由这里开始分配。填错了轻则上不了网,重则和上游路由器打架。',
|
||
rules: [
|
||
'前三段必须和本机 LAN 地址一致,最后一段落在 2–254 之间。',
|
||
'起始–结束这段不要包含本机自己的地址(否则会和本机抢 IP)。',
|
||
'常见做法是让池子从 .100 开始,把 .2–.99 留给打印机、NAS 这类固定设备。',
|
||
],
|
||
};
|
||
|
||
function WIZ_PROBE_FIXTURE() {
|
||
return {
|
||
steps: WIZ_STEPS_FIXTURE,
|
||
wan: { mode: 'pppoe', mode_cn: 'PPPoE 拨号', iface: 'ens19', has_wan_iface: true,
|
||
has_account: false, default_route: '', has_default_route: false, online: false,
|
||
ping_note: '2 packets transmitted, 2 received, 0% packet loss, time 1001ms' },
|
||
lan: { iface: 'ens18', lan_ip: '192.168.7.3', dhcp_enabled: false,
|
||
pool_start: '192.168.7.100', pool_end: '192.168.7.200',
|
||
pool_netmask: '255.255.255.0', lease_time: 7200,
|
||
gateway: '192.168.7.3', dns_option: '192.168.7.3',
|
||
pool_problems: ['还没给 LAN 口配 IP 地址',
|
||
'地址池把本机自己的地址(%8%)也包含进去了'],
|
||
dnsmasq_active: false, ok: false },
|
||
dns: { mode: 'isp', mode_cn: '仅运营商下发', custom: '',
|
||
first: '', probe_ok: false,
|
||
probe_note: '选了「跟随运营商」,但当前还没拿到运营商下发的 DNS',
|
||
presets: WIZ_DNS_PRESETS_FIXTURE,
|
||
default_custom: '223.5.5.5,119.29.29.29', ok: false },
|
||
v6: { lan_iface: 'ens18', wan_iface: 'ens19', lan_has_global: false,
|
||
wan_has_global: false, forwarding: false, radvd_active: false,
|
||
radvd_installed: false, prefix: '', rdnss: '', advice: '建议开启', ok: false },
|
||
wan_modes: WIZ_WAN_MODES_FIXTURE,
|
||
pool_hint: WIZ_POOL_HINT_FIXTURE,
|
||
dns_presets: WIZ_DNS_PRESETS_FIXTURE,
|
||
internet_ok: false,
|
||
todo: ['第 1 步:外网还没通', '第 2 步:内网还没在发地址',
|
||
'第 3 步:DNS 还解析不出结果', '第 4 步:IPv6 还没启用(可选)'],
|
||
build_mode: false,
|
||
lan_ifaces: [{ name: 'ens18', mac: 'aa:bb:cc:dd:ee:01', state: 'UP', role: 'lan' }],
|
||
wan_ifaces: [{ name: 'ens19', mac: 'aa:bb:cc:dd:ee:02', state: 'UP', role: 'wan' }],
|
||
lease_default: 7200, dns_cache_default: 1000,
|
||
};
|
||
}
|
||
|
||
// 假 fetch:所有 GET 返回一个宽松的 ok 响应
|
||
/* 真机实测数据(_real_fixtures.js,由 _analyze_real.py 从 192.168.7.3 抓取生成)。
|
||
* 为什么必须用它:**不要照后端源码臆造字段** —— 我曾臆造过 sysinfo.temp.src /
|
||
* accel.hw_note 等真机上根本不存在的字段,判据看着全绿,真机实际一堆中文。
|
||
* 这里 require 进来当桩,字段名/中文文案/层级与真机完全一致。*/
|
||
const REAL = require('../_real_fixtures.js');
|
||
const REAL_F = {
|
||
qos: REAL.REAL_QOS, acl: REAL.REAL_ACL, storage: REAL.REAL_STORAGE,
|
||
opensoho: REAL.REAL_OPENSOHO, share: REAL.REAL_SHARE, docker: REAL.REAL_DOCKER,
|
||
deps: REAL.REAL_DEPS_CHECK, dpi: REAL.REAL_DPI, wol: REAL.REAL_WOL,
|
||
accel: REAL.REAL_ACCEL, sysinfo: REAL.REAL_SYSINFO, natlast: REAL.REAL_NAT_LAST,
|
||
pppmulti: REAL.REAL_PPPMULTI, vlan: REAL.REAL_VLAN, wanlog: REAL.REAL_WANLOG,
|
||
ulog: REAL.REAL_ULOG, audit: REAL.REAL_AUDIT,
|
||
depscheck: REAL.REAL_DEPS_CHECK, logs: REAL.REAL_LOGS,
|
||
apiex: REAL.REAL_API_EX, apipaths: REAL.REAL_API_PATHS,
|
||
fwlog: REAL.REAL_FWLOG,
|
||
upstream: REAL.REAL_UPSTREAM, netdetail: REAL.REAL_NETDETAIL,
|
||
metrics: REAL.REAL_METRICS,
|
||
updateCheck: REAL.REAL_UPDATE_CHECK, updateState: REAL.REAL_UPDATE_STATE,
|
||
updateCheckFail: REAL.REAL_UPDATE_CHECK_FAIL,
|
||
status: REAL.REAL_STATUS, statusBad: REAL.REAL_STATUS_BAD,
|
||
};
|
||
|
||
// ★ 真机实测数据(_real_api_more.json):目标机上直调 helper.run_action 抓取的
|
||
// 52 个只读接口真实 payload。为什么必须覆盖手写桩:桩里有一批**英文臆造值**
|
||
// (主题描述写死 'Theme preview'、主题变量 cn 写成变量名、/api/ifaces 给 []),
|
||
// 渲染出来的 HTML 是英文 —— 扫描器全绿,真机英文界面却全是中文。
|
||
// 假绿的根因就是桩不像真机,所以这里一律真机数据优先。
|
||
const MORE = require('../_real_api_more.json');
|
||
const URL2REAL = [
|
||
[/^\/api\/sysinfo$/, 'read:sysinfo'], [/^\/api\/metrics$/, 'read:metrics'],
|
||
[/^\/api\/upstream$/, 'read:upstream'],
|
||
[/^\/api\/services$/, 'read:services'], [/^\/api\/ifaces$/, 'read:ifaces'],
|
||
[/^\/api\/ipv6$/, 'read:ipv6'], [/^\/api\/routes$/, 'read:routes'],
|
||
[/^\/api\/netdetail$/, 'read:netdetail'], [/^\/api\/ntp$/, 'read:ntp'],
|
||
[/^\/api\/ddns$/, 'read:ddns'], [/^\/api\/acl$/, 'read:acl'],
|
||
[/^\/api\/share$/, 'read:share'], [/^\/api\/docker$/, 'read:docker'],
|
||
[/^\/api\/ulog\/conf$/, 'read:ulog_conf'], [/^\/api\/ulog\/flow$/, 'read:ulog_flow'],
|
||
[/^\/api\/ulog\/daemon$/, 'read:logd'], [/^\/api\/ulog$/, 'read:ulog'],
|
||
[/^\/api\/backupd$/, 'read:backupd'], [/^\/api\/backup$/, 'backup/status'],
|
||
[/^\/api\/alert$/, 'alert/status'], [/^\/api\/quotad$/, 'read:quotad'],
|
||
[/^\/api\/nat\/last$/, 'read:nat_last'], [/^\/api\/fw\/log$/, 'read:fw_log'],
|
||
[/^\/api\/users$/, 'read:users'], [/^\/api\/logs$/, 'read:logs'],
|
||
[/^\/api\/journal$/, 'read:journal'], [/^\/api\/leases$/, 'read:leases'],
|
||
[/^\/api\/nft$/, 'read:nft'], [/^\/api\/iface_method$/, 'read:iface_method'],
|
||
[/^\/api\/ppp\/log$/, 'read:ppp_log'], [/^\/api\/wan\/log$/, 'read:wan_log'],
|
||
[/^\/api\/cleanup$/, 'cleanup/status'], [/^\/api\/kern$/, 'kern/status'],
|
||
[/^\/api\/ca$/, 'ca/status'], [/^\/api\/dpi$/, 'dpi/get'],
|
||
[/^\/api\/qos$/, 'qos/get'], [/^\/api\/vlan$/, 'vlan/get'],
|
||
[/^\/api\/wol$/, 'wol/get'], [/^\/api\/accel$/, 'accel/get'],
|
||
[/^\/api\/pppoe-multi$/, 'pppoe_multi/get'],
|
||
[/^\/api\/opensoho$/, 'opensoho/status'], [/^\/api\/print$/, 'print/status'],
|
||
[/^\/api\/dcfg$/, 'dcfg/status'], [/^\/api\/storage$/, 'storage/get'],
|
||
[/^\/api\/deps\/check$/, 'depcheck/check'], [/^\/api\/snapshots$/, 'snapshot_list'],
|
||
// pubip 不覆盖:前端 op=check 的形状(verdict/evidence 带 en_* 字段)与
|
||
// probe_status 不同,现有桩已经是真机形状,换成 probe_status 会让页面渲染不到内容。
|
||
];
|
||
|
||
const CALLED = [];
|
||
async function fetchStub(url, opts) {
|
||
CALLED.push({ url, method: (opts && opts.method) || 'GET' });
|
||
let data = { ok: true, data: {}, msg_cn: 'ok' };
|
||
// 真机数据优先:命中 URL2REAL 就用真机 payload,否则沿用下面的手写桩
|
||
const _u = String(url).split('?')[0];
|
||
const _qs = String(url).split('?')[1] || '';
|
||
let _real = false;
|
||
if (_u === '/api/theme') {
|
||
const _op = (/op=([a-z_]+)/.exec(_qs) || [null, 'list'])[1];
|
||
const _k = 'read:theme/' + _op;
|
||
if (MORE[_k]) { data = MORE[_k]; _real = true; }
|
||
} else {
|
||
for (const _e of URL2REAL) {
|
||
if (_e[0].test(_u) && MORE[_e[1]]) { data = MORE[_e[1]]; _real = true; break; }
|
||
}
|
||
}
|
||
// ⚠ 命中真机数据必须**立刻返回**:下面那一长串手写桩 if/else 里有同 URL 的分支
|
||
// (尤其 /api/theme 的英文臆造主题),不早返回就会被它覆盖回去,扫描器继续假绿。
|
||
if (_real) return { status: 200, ok: true, json: async () => data };
|
||
// 各接口按需返回形状合理的假数据,避免视图因字段类型不符而抛异常
|
||
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: { level: 'ok', text: '' },
|
||
providers: [{ v: 'custom', name: '自定义', region: 'other', fields: [] }],
|
||
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: 'Dropped 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: 'Theme preview', author: builtin ? 'Drouter Built-in' : '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', 'My Custom Theme', false, false, '#3366cc', '#f7f9fc'),
|
||
],
|
||
active: 'my-theme', active_name: 'My Custom Theme',
|
||
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: {} };
|
||
else if (/\/api\/wizard/.test(url)) data = { ok: true, data: WIZ_PROBE_FIXTURE(), msg_cn: '体检完成' };
|
||
// ---- 真机实测数据(字段与中文全部来自 192.168.7.3 的真实响应)----
|
||
// 后端下发的中文全在这些字段里,判据必须能看到它们,否则「0 残留中文」是假绿。
|
||
else if (/\/api\/accel/.test(url)) data = { ok: true, data: REAL_F.accel };
|
||
else if (/\/api\/wol/.test(url)) data = { ok: true, data: REAL_F.wol };
|
||
else if (/\/api\/sysinfo|dash|sysinfo/.test(url)) data = { ok: true, data: REAL_F.sysinfo };
|
||
else if (/\/api\/vlan/.test(url)) data = { ok: true, data: REAL_F.vlan };
|
||
else if (/\/api\/wan\/log/.test(url)) data = { ok: true, data: REAL_F.wanlog };
|
||
else if (/\/api\/pppoe-multi/.test(url)) data = { ok: true, data: REAL_F.pppmulti };
|
||
else if (/\/api\/nat\/(last|check)/.test(url)) data = { ok: true, data: REAL_F.natlast };
|
||
else if (/\/api\/acl/.test(url)) data = { ok: true, data: REAL_F.acl };
|
||
else if (/\/api\/qos/.test(url)) data = { ok: true, data: REAL_F.qos };
|
||
else if (/\/api\/share/.test(url)) data = { ok: true, data: REAL_F.share };
|
||
else if (/\/api\/storage/.test(url)) data = { ok: true, data: REAL_F.storage };
|
||
else if (/\/api\/opensoho/.test(url)) data = { ok: true, data: REAL_F.opensoho };
|
||
else if (/\/api\/examples/.test(url)) data = { ok: true, data: REAL_F.apiex };
|
||
else if (/\/api\/openapi/.test(url)) data = { ok: true, data: REAL_F.apipaths };
|
||
else if (/\/api\/docker/.test(url)) data = { ok: true, data: Object.assign(
|
||
// 真机 /api/docker 带 note(helper L7772),旧桩漏了 → docker 页假绿
|
||
{ note: 'Docker 面板支持容器/镜像/网络/卷的日常运维,以及 Compose 项目管理。「命令转换」可以把任意 docker run 命令转成 docker-compose.yml。' },
|
||
REAL_F.docker || {}) };
|
||
else if (/\/api\/deps\/check/.test(url)) data = { ok: true, data: REAL_F.depscheck };
|
||
else if (/\/api\/dpi/.test(url)) data = { ok: true, data: REAL_F.dpi };
|
||
else if (/\/api\/ulog/.test(url)) data = { ok: true, data: REAL_F.ulog };
|
||
else if (/\/api\/journal/.test(url)) data = { ok: true, data: REAL_F.ulog };
|
||
else if (/\/api\/logs/.test(url)) data = { ok: true, data: REAL_F.logs };
|
||
else if (/\/api\/alert/.test(url)) data = { ok: true, data: {
|
||
conf: { enabled: true, interval_sec: 300, cooldown_min: 30, quiet_from: '', quiet_to: '',
|
||
rules: { wan_down: 90, disk_full: 85, temp_high: 80, mem_high: 92, load_high: 3,
|
||
loss_high: 30, backup_fail: 2, snapshot_fail: 3 },
|
||
channels: [] },
|
||
rules: [
|
||
{ key: 'wan_down', name: '\u5916\u7f51\u8fde\u63a5\u4e2d\u65ad', lv: 'critical', unit: '\u79d2', value: 90, default: 90, min: 30, max: 3600, step: 1, why: '\u8fde\u7eed\u8fd9\u4e48\u4e45\u62ff\u4e0d\u5230\u7f51\u5173\u56de\u5e94\u624d\u5224\u5b9a\u4e3a\u6389\u7ebf\u3002', firing: false },
|
||
{ key: 'disk_full', name: '\u78c1\u76d8\u5360\u7528\u8fc7\u9ad8', lv: 'warn', unit: '%', value: 85, default: 85, min: 50, max: 99, step: 1, why: '\u65e5\u5fd7\u3001\u5feb\u7167\u3001\u5907\u4efd\u90fd\u5728\u5f80\u6839\u5206\u533a\u5199\u3002', firing: false },
|
||
{ key: 'temp_high', name: 'CPU \u6e29\u5ea6\u8fc7\u9ad8', lv: 'warn', unit: '\u2103', value: 80, default: 80, min: 50, max: 110, step: 1, why: 'x86 \u5c0f\u4e3b\u673a\u6563\u70ed\u4e00\u822c\uff0c\u8d85\u8fc7 80\u2103\u5c31\u8be5\u68c0\u67e5\u98ce\u6247\u548c\u673a\u7bb1\u79ef\u7070\u3002', firing: false },
|
||
{ key: 'mem_high', name: '\u5185\u5b58\u5360\u7528\u8fc7\u9ad8', lv: 'warn', unit: '%', value: 92, default: 92, min: 60, max: 99, step: 1, why: '\u8fd9\u53f0\u673a\u5668\u53ea\u6709 4GB\uff0c\u53ef\u7528\u5185\u5b58\u8017\u5c3d\u65f6 dnsmasq \u4e0e Web \u9762\u677f\u4f1a\u5148\u6302\u3002', firing: false },
|
||
{ key: 'load_high', name: '\u8d1f\u8f7d\u6301\u7eed\u504f\u9ad8', lv: 'info', unit: '', value: 3, default: 3, min: 1, max: 32, step: 0.1, why: '2 \u6838\u673a\u5668\u4e0a\u8d1f\u8f7d\u957f\u671f\u8d85\u8fc7 3 \u8bf4\u660e\u6709\u4efb\u52a1\u5728\u5fd9\u3002', firing: false },
|
||
{ key: 'loss_high', name: '\u5230\u7f51\u5173\u4e22\u5305\u4e25\u91cd', lv: 'warn', unit: '%', value: 30, default: 30, min: 5, max: 100, step: 1, why: 'ping \u4e22\u5305\u9ad8\u901a\u5e38\u610f\u5473\u7740\u7f51\u7ebf\u3001\u7f51\u53e3\u534f\u5546\u6216 Wi-Fi \u4fe1\u53f7\u95ee\u9898\u3002', firing: false },
|
||
{ key: 'backup_fail', name: '\u81ea\u52a8\u5907\u4efd\u8fde\u7eed\u5931\u8d25', lv: 'warn', unit: '\u6b21', value: 2, default: 2, min: 1, max: 20, step: 1, why: '\u5907\u4efd\u5931\u8d25\u4e00\u6b21\u53ef\u80fd\u662f\u78c1\u76d8\u6b63\u597d\u6ee1\u4e86\uff0c\u8fde\u7eed\u5931\u8d25\u8bf4\u660e\u8bbe\u7f6e\u672c\u8eab\u6709\u95ee\u9898\u3002', firing: false },
|
||
{ key: 'snapshot_fail', name: '\u81ea\u52a8\u5feb\u7167\u8fde\u7eed\u5931\u8d25', lv: 'info', unit: '\u6b21', value: 3, default: 3, min: 1, max: 20, step: 1, why: '\u5feb\u7167\u662f\u4f60\u300c\u6539\u574f\u4e86\u80fd\u9000\u56de\u53bb\u300d\u7684\u5e95\u5ea7\u3002', firing: false },
|
||
],
|
||
channel_types: [], units: {}, next: '', last: {},
|
||
state: { ts: '', firing: {}, probes: {} },
|
||
quiet_now: false, enabled_ch: 0, history: []
|
||
} };
|
||
else if (/\/api\/upstream/.test(url)) data = { ok: true, data: REAL_F.upstream };
|
||
else if (/\/api\/netdetail/.test(url)) data = { ok: true, data: REAL_F.netdetail };
|
||
else if (/\/api\/status/.test(url)) data = { ok: true,
|
||
data: (sandbox.__stOverride || REAL_F.status) };
|
||
else if (/\/api\/metrics/.test(url)) data = { ok: true, data: REAL_F.metrics };
|
||
else if (/\/api\/update\/check/.test(url)) data = { ok: true,
|
||
data: (sandbox.__updCheckOverride || REAL_F.updateCheck) };
|
||
else if (/\/api\/update\/state/.test(url)) data = { ok: true, data: REAL_F.updateState };
|
||
else if (/\/api\/audit/.test(url)) data = { ok: true, data: REAL_F.audit };
|
||
else if (/\/api\/fw\/log|\/api\/fwlog/.test(url)) data = { ok: true, data: REAL_F.fwlog || {} };
|
||
else if (/\/api\/wizard/.test(url)) data = { ok: true, data: WIZ_PROBE_FIXTURE(), msg_cn: '体检完成' };
|
||
// ---- 下面这些桩按真机实际下发内容构造(含后端下发的中文)----
|
||
// ⚠️ 之前这些接口走默认 {ok:true,data:{}},于是后端下发的中文
|
||
// (NAT 类型名、WOL 说明、温度提示…)在判据里**根本没被渲染**,
|
||
// 「英文残留中文 0」是假绿(2026-10-07 真机实测 539 处)。
|
||
else if (/\/api\/nat\/(last|check)/.test(url)) data = { ok: true, data: {
|
||
nat: 'NAT2', title: 'NAT2 \u00b7 \u5730\u5740\u53d7\u9650\u9525\u5f62\uff08Restricted Cone\uff09',
|
||
desc: '\u516c\u7f51\u5730\u5740\u56fa\u5b9a\u3001\u6620\u5c04\u7aef\u53e3\u968f\u76ee\u6807\u53d8\u5316\u3002\u53ea\u8981\u4f60\u5148\u4e3b\u52a8\u8bbf\u95ee\u8fc7\u5bf9\u65b9\uff0c\u5bf9\u65b9\u5c31\u80fd\u56de\u8fde\u4f60\u3002\u65e5\u5e38\u4e0a\u7f51\u3001\u6e38\u620f\u8054\u673a\u57fa\u672c\u591f\u7528\u3002',
|
||
detail: { public_ip: '101.70.135.90', public_port: '41234',
|
||
same_ip: true, same_port: false, local_ip: '192.168.7.3',
|
||
probes: [{ server: 'stun.aliyun.com', ip: '101.70.135.90', port: '41234' },
|
||
{ server: 'stun.l.google.com', ip: '101.70.135.90', port: '56789' }] },
|
||
checked_at: '2026-09-29 13:14:26' }, msg_cn: '\u68c0\u6d4b\u5b8c\u6210' };
|
||
|
||
else if (/\/api\/accel/.test(url)) data = { ok: true, data: {
|
||
on: false, hw: false, offloaded: 0,
|
||
hw_note: '\u5f53\u524d\u7f51\u5361\u672a\u62a5\u544a\u652f\u6301\u786c\u4ef6\u5378\u8f7d\uff0c\u5c06\u4f7f\u7528\u8f6f\u4ef6\u5feb\u901f\u8def\u5f84\uff08\u5bf9\u5bb6\u7528\u573a\u666f\u5df2\u8db3\u591f\uff09\u3002',
|
||
scope: '\u5f53\u524d\u4f5c\u7528\u8303\u56f4\uff1aIPv4',
|
||
nft: '', v4: 'ip protocol { tcp, udp } ct state established,related flow add @f',
|
||
v6: 'ip6 nexthdr { tcp, udp, sctp, dccp, udplite } ct state established,related flow add @f',
|
||
impact: { note: '' } }, msg_cn: '\u6210\u529f' };
|
||
|
||
else if (/\/api\/wol/.test(url)) data = { ok: true, data: [
|
||
{ name: 'ens18', mac: 'aa:bb:cc:dd:ee:01', supports_wol: false,
|
||
note: '\u8be5\u7f51\u5361\u6216\u9a71\u52a8\u672a\u4e0a\u62a5 WOL \u80fd\u529b', enabled: false },
|
||
{ name: 'ens19', mac: 'aa:bb:cc:dd:ee:02', supports_wol: false,
|
||
note: '\u8be5\u7f51\u5361\u6216\u9a71\u52a8\u672a\u4e0a\u62a5 WOL \u80fd\u529b', enabled: false }] };
|
||
|
||
else if (/\/api\/sysinfo/.test(url)) data = { ok: true, data: {
|
||
hostname: 'debian-primaryrouter', uptime: 123456, virt: { name: 'QEMU / KVM \u865a\u62df\u673a' },
|
||
temp: { c: null, src: '\u672a\u8bfb\u53d6\u5230\u6e29\u5ea6\u4f20\u611f\u5668\uff0c\u4ec5\u663e\u793a\u8fdb\u7a0b\u6570' },
|
||
procs: 178,
|
||
virt_note: '\u5f53\u524d\u8fd0\u884c\u5728 QEMU / KVM \u865a\u62df\u673a \u4e2d\uff0c\u865a\u62df\u5316\u5c42\u9ed8\u8ba4\u4e0d\u4f1a\u628a\u5bbf\u4e3b\u673a CPU \u6e29\u5ea6\u4f20\u611f\u5668\u66b4\u9732\u7ed9\u5ba2\u6237\u673a\uff0c\u56e0\u6b64\u6e29\u5ea6\u65e0\u6cd5\u8bfb\u53d6\u3002' } };
|
||
|
||
else if (/\/api\/vlan/.test(url)) data = { ok: true, data: {
|
||
vlans: [], ifaces: [{ name: 'ens18', role: 'lan' }],
|
||
presets: [
|
||
{ name: '\u4e2d\u56fd\u7535\u4fe1 IPTV', vid: 85 },
|
||
{ name: '\u4e2d\u56fd\u8054\u901a IPTV', vid: 3961 },
|
||
{ name: '\u4e2d\u56fd\u79fb\u52a8 IPTV', vid: 3961 },
|
||
{ name: '\u901a\u7528 VLAN', vid: 100 }] } };
|
||
|
||
else if (/\/api\/wan\/log/.test(url)) data = { ok: true, data: {
|
||
mode: 'pppoe', mode_hint: '\u5149\u732b\u6865\u63a5 + \u7535\u8111/\u8def\u7531\u62e8\u53f7\uff0c\u9700\u8981\u5bbd\u5e26\u8d26\u53f7\u5bc6\u7801\uff08\u7535\u4fe1/\u8054\u901a/\u79fb\u52a8/\u5e7f\u7535\u5747\u5e38\u89c1\uff09',
|
||
iface: 'ens19', dial_state: '\u672a\u62e8\u53f7\uff08\u62e8\u53f7\u8fdb\u7a0b\u672a\u8fd0\u884c\uff09',
|
||
lines: [] } };
|
||
|
||
else if (/\/api\/pppoe-multi/.test(url)) data = { ok: true, data: {
|
||
sessions: [], presets: [],
|
||
notes: ['\u591a\u62e8\u80fd\u5426\u6210\u529f\u53d6\u51b3\u4e8e\u8fd0\u8425\u5546\u662f\u5426\u5141\u8bb8\u540c\u4e00\u8d26\u53f7\u5e76\u53d1\u62e8\u53f7\uff08\u90e8\u5206\u7701\u4efd\u9650\u5236\u4e3a 1 \u6761\uff09\u3002',
|
||
'\u5c1d\u8bd5 N \u4e2a\u4f1a\u8bdd\u524d\uff0c\u5efa\u8bae\u5148\u53ea\u7528 1 \u6761\u786e\u8ba4\u8d26\u53f7\u53ef\u6b63\u5e38\u62e8\u901a\u3002',
|
||
'\u82e5\u8fd0\u8425\u5546\u62d2\u7edd\u5e76\u53d1\uff0c\u4f1a\u8bdd\u4f1a\u53cd\u590d\u91cd\u62e8\u5e76\u5728\u8fd9\u9875\u663e\u793a\u4e3a\u300c\u672a\u8fde\u63a5\u300d\uff0c\u5c5e\u6b63\u5e38\u73b0\u8c61\u3002',
|
||
'\u591a\u62e8\u4f1a\u6539\u53d8\u51fa\u7f51\u8def\u7531\uff0c\u8bf7\u786e\u8ba4\u5df2\u586b\u597d\u8d26\u53f7\u5e76\u663e\u5f0f\u70b9\u51fb\u300c\u5e94\u7528\u5e76\u8fde\u63a5\u300d\u3002'] } };
|
||
|
||
else if (/\/api\/pubip/.test(url)) data = { ok: true, data: {
|
||
ip: '101.70.135.90', v6: '2408:8240:5416:ad61:acf0:c128:bf9e:44ac',
|
||
egress: 'ens18', gw: '192.168.7.2', v4_local: '192.168.7.3',
|
||
'class': 'public', combo: 'both', checked_at: '2026-10-07 22:53:09',
|
||
evidence_cached: false,
|
||
verdict: {
|
||
key: 'likely', level: 'warn',
|
||
title: '疑似公网(地址是公网段,入向未验证)',
|
||
conclusion: '出口地址属于公网段、多个外部服务回显也一致,但还没有实测过「外网能不能主动连进来」。',
|
||
advice: ['点下方「开始入向实测」', '没有外网主机时,可用手机蜂窝网络访问一次', '实测前不要急着配置端口转发'],
|
||
en_title: "Suspected public (address is in a public range, inbound not verified)",
|
||
en_conclusion: "The egress address falls in a public range and multiple external services echo the same address, but inbound has not been measured.",
|
||
en_advice: ["Click Start inbound test below", "Without an external host, use mobile cellular network", "Do not rush to configure port forwarding before the actual test"]
|
||
},
|
||
evidence: [
|
||
{ key: 'class', name: '出口地址段', en_name: 'Egress address range',
|
||
value: '101.70.135.90', en_value: '101.70.135.90', pass: true,
|
||
detail: '属于公网地址段,在互联网上可路由', en_detail: 'Belongs to a public address range, routable on the Internet' },
|
||
{ key: 'echo', name: '多源回显一致性', en_name: 'Multi-source echo consistency',
|
||
value: '101.70.135.90', en_value: '101.70.135.90', pass: true,
|
||
detail: '2 个独立服务都返回同一个地址,出口地址可信', en_detail: '2 independent services all returned the same address; the egress address is trustworthy' },
|
||
{ key: 'path', name: '首跳链路', en_name: 'First-hop link',
|
||
value: '192.168.7.2 \u2192 172.30.80.1', en_value: '192.168.7.2 \u2192 172.30.80.1', pass: false,
|
||
detail: '出网第一跳是私网地址(192.168.7.2),说明本机上面至少还有一层 NAT', en_detail: 'The first egress hop is a private address (192.168.7.2), meaning there is at least one more layer of NAT above this host' },
|
||
{ key: 'inbound', name: '入向可达性实测', en_name: 'Inbound reachability test',
|
||
value: '未测试', en_value: 'Not tested', pass: false, probed: false,
|
||
detail: '尚未做入向实测 —— 这是唯一能拍板「外网能不能主动连进来」的证据', en_detail: 'No inbound test has been run yet — this is the only evidence that can decide whether the Internet can actively connect in' }
|
||
],
|
||
msg_cn: '判定完成:疑似公网(地址是公网段,入向未验证)'
|
||
} };
|
||
|
||
else if (/\/api\/ddns/.test(url)) data = { ok: true, data: {
|
||
cfg: {
|
||
enabled: true, provider: 'custom', provider_cn: '自定义(URL 模板)',
|
||
provider_region: '通用', provider_desc: '填入服务商给出的更新 URL,支持占位符 {ip} {ipv6} {domain} {token} {user} {pass}',
|
||
provider_fields: ['domain', 'url4', 'url6', 'token', 'user', 'pass'],
|
||
provider_doc: '按服务商文档填写;适用于任何提供 HTTP 更新的服务商',
|
||
provider_supported: true, timer_active: true, timer_unit: 'drouter-ddns.timer',
|
||
domain: '', subdomain: '', ipv4: true, ipv6: false, ttl: 300, interval: 300,
|
||
last_update: '', last_ip4: '', last_ip6: '', last_msg_cn: ''
|
||
},
|
||
public: {
|
||
v4_local: '192.168.7.3', v4_has: true, v4_nat: false, v4_public: '101.70.135.90', v4_source: 'stun',
|
||
v6_local: '2408:8240:5416:ad61:acf0:c128:bf9e:44ac', v6_has: true, v6_public: '2408:8240:5416:ad61:acf0:c128:bf9e:44ac', v6_source: 'stun',
|
||
egress: 'ens18', gw: '192.168.7.2', checked_at: '2026-10-07 22:53:09', combo: 'both'
|
||
},
|
||
note: {
|
||
title: '双栈公网(IPv4 + IPv6 均有公网能力)', level: 'ok',
|
||
conclusion: '你的宽带同时具备公网 IPv4 与公网 IPv6,DDNS 可同时解析 A(IPv4)与 AAAA(IPv6)记录,对外访问兼容性最好。',
|
||
advice: ['建议同时启用 A 与 AAAA 记录,客户端会优先走 IPv6(延迟更低)', 'IPv6 地址通常是动态前缀(DHCPv6-PD),需开启前缀变化检测并重新下发', '公网 IPv4 若为动态,请把 TTL 设为 300 秒以内,加快解析生效'],
|
||
en_title: "Dual-stack public (both IPv4 + IPv6 have public capability)",
|
||
en_conclusion: "Your broadband has both public IPv4 and public IPv6. DDNS can resolve both A and AAAA records, giving the best compatibility for inbound access.",
|
||
en_advice: ["Enable both A and AAAA records; clients will prefer IPv6 (lower latency)", "IPv6 addresses usually have a dynamic prefix (DHCPv6-PD); enable prefix-change detection and re-publish", "If the public IPv4 is dynamic, set TTL to 300 seconds or less to speed up propagation"]
|
||
},
|
||
warn: '',
|
||
providers: [
|
||
{ v: 'custom', n: '自定义(URL 模板)', region: '通用',
|
||
desc: '填入服务商给出的更新 URL,支持占位符 {ip} {ipv6} {domain} {token} {user} {pass}',
|
||
doc: '按服务商文档填写;适用于任何提供 HTTP 更新的服务商', fields: ['domain', 'url4', 'url6', 'token', 'user', 'pass'] },
|
||
{ v: 'aliyun', n: '阿里云解析 DNS', region: '国内',
|
||
desc: 'AccessKey 签名(HMAC-SHA1)调用 Alidns OpenAPI,支持 IPv4/IPv6 双记录',
|
||
doc: 'https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-update-domain-record', fields: ['domain', 'subdomain', 'access_key_id', 'access_key_secret', 'ttl'] },
|
||
{ v: 'cloudflare', n: 'Cloudflare', region: '国际',
|
||
desc: 'API Token + Zone ID,支持 A / AAAA 记录,免费版即可',
|
||
doc: 'https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-update-dns-record', fields: ['domain', 'subdomain', 'api_token', 'zone_id', 'proxied'] }
|
||
],
|
||
catalog_note: '服务商接口依据 2026 年现行公开文档整理,接入方式如有调整请以官方文档为准。'
|
||
} };
|
||
|
||
else if (/\/api\/fwlog|flowlog_state/.test(url)) data = { ok: true, data: {
|
||
enabled: true, loaded: false, count: 0, dropped: 0, allowed: 0,
|
||
v4: 0, v6: 0,
|
||
empty_note: '\u5f53\u524d\u5185\u6838\u91cc\u6ca1\u6709 drouter \u7684\u9632\u706b\u5899\u89c4\u5219\uff08nft \u89c4\u5219\u96c6\u4e3a\u7a7a\uff09\uff0c\u8fd8\u6ca1\u6709\u4efb\u4f55\u5305\u7ecf\u8fc7\u672c\u673a\u7684\u9632\u706b\u5899\u94fe\uff0c\u6240\u4ee5\u8fd9\u91cc\u4e0d\u4f1a\u6709\u8bb0\u5f55\u3002\u8bf7\u5230\u300c\u9632\u706b\u5899 IPv4 / IPv6\u300d\u9875\u9762\u70b9\u4e00\u6b21\u300c\u5e94\u7528\u300d\uff0c\u89c4\u5219\u8f7d\u5165\u540e\u5373\u53ef\u5f00\u59cb\u8bb0\u5f55\u3002',
|
||
lines: [] } };
|
||
|
||
else if (/\/api\/acl/.test(url)) data = { ok: true, data: {
|
||
enabled: false, dev_groups: [], time_groups: [], rules: [],
|
||
dpi_installed: false, qos_enabled: false,
|
||
summary_note: '\u65f6\u95f4\u89c4\u5219\u6309\u5317\u4eac\u65f6\u95f4\uff08UTC+8\uff09\u6362\u7b97\u540e\u5199\u5165\u5185\u6838\uff1b\u8de8\u96f6\u70b9\u7684\u65f6\u95f4\u6bb5\u4f1a\u81ea\u52a8\u62c6\u6210\u4e24\u6bb5\u3002\u5e94\u7528\u8bc6\u522b\u4f9d\u8d56 DPI \u5e93\uff0c\u9650\u901f\u52a8\u4f5c\u4f9d\u8d56 QoS \u6a21\u5757\u3002' } };
|
||
|
||
else if (/\/api\/qos/.test(url)) data = { ok: true, data: {
|
||
enabled: false, mode: 'cake', cake_mode: 'diffserv3',
|
||
bw_down: '', bw_up: '',
|
||
qdisc: 'qdisc fq_codel 0: root refcnt 2 limit 10240p',
|
||
nft_table: false,
|
||
cake_hint: '\u6309 Diffserv \u5206\u4e3a\u4e09\u6863\uff1a\u8bed\u97f3\uff08CS7/CS6/EF/VA\uff0c\u5360 25% \u5e26\u5bbd\u4efd\u989d\uff0cCodel \u95f4\u9694\u7f29\u77ed\uff09\u3001\u5c3d\u529b\uff08\u666e\u901a\u6d41\u91cf\uff0c\u5360\u6ee1\u5e26\u5bbd\uff09\u3001\u6279\u91cf\uff08CS1/LE\uff0c\u4f4e\u4f18\u5148\u7ea7\uff0c\u53ea\u5206\u5230 6.25% \u5e26\u5bbd\u4efd\u989d\uff09\u3002',
|
||
impacts: [
|
||
{ k: 'flowtable', title: '\u8f6f\u52a0\u901f\u8ba9\u5df2\u5efa\u7acb\u8fde\u63a5\u7ed5\u8fc7 netfilter\uff0cDSCP \u6253\u6807\u4e0e mark \u5206\u7c7b\u4f1a\u5931\u6548\u3002' },
|
||
{ k: 'cpu', title: 'CAKE \u662f\u9010\u5305\u8c03\u5ea6\uff0c\u5e26\u5bbd\u8d8a\u9ad8 CPU \u5f00\u9500\u8d8a\u5927\u30021Gbps \u4ee5\u5185\u901a\u5e38\u65e0\u538b\u529b\u3002' },
|
||
{ k: 'htb', title: '\u9650\u901f\u5bf9\u4e00\u4e2a\u5185\u7f51 IP \u7684\u4e0a\u4e0b\u884c\u540c\u65f6\u751f\u6548\u3002' },
|
||
{ k: 'dpi', title: 'HTTPS / QUIC \u5df2\u52a0\u5bc6\uff0c\u4ec5\u9760\u7aef\u53e3\u4e0e DSCP \u65e0\u6cd5\u51c6\u786e\u533a\u5206\u3002' },
|
||
{ k: 'connlimit', title: '\u9650\u5236\u5355 IP \u5e76\u53d1\u8fde\u63a5\u6570\u80fd\u6291\u5236\u5f02\u5e38\u8bbe\u5907\u62d6\u57ae\u8def\u7531\u3002' }] } };
|
||
|
||
else if (/\/api\/share/.test(url)) data = { ok: true, data: {
|
||
smb: { on: false, installed: false, pkgs: ['samba', 'samba-common-bin'],
|
||
services: { smbd: 'inactive', nmbd: 'inactive' } },
|
||
nfs: { on: false, installed: false, pkgs: ['nfs-kernel-server'],
|
||
services: { 'nfs-server': 'inactive', rpcbind: 'inactive' } },
|
||
shares: [], exports: [], lan_ip: '192.168.7.3', include_written: false,
|
||
workgroup: 'WORKGROUP', desc: 'drouter \u6587\u4ef6\u5171\u4eab', iface: '',
|
||
threads: 8,
|
||
intro: 'SMB \u9762\u5411 Windows / macOS / Linux \u5168\u5e73\u53f0\uff0c\u517c\u5bb9\u6027\u6700\u597d\uff1bNFS \u66f4\u9002\u5408 Linux \u4e4b\u95f4\u4e92\u4f20\uff0c\u6027\u80fd\u66f4\u9ad8\u4f46 Windows \u9700\u8981\u989d\u5916\u5ba2\u6237\u7aef\u3002\u4e24\u8005\u53ef\u4ee5\u540c\u65f6\u5f00\u542f\uff0c\u5171\u4eab\u540c\u4e00\u4e2a\u76ee\u5f55\u3002',
|
||
win_hint: '\u6253\u5f00\u300c\u6b64\u7535\u8111\u300d\uff0c\u5728\u5730\u5740\u680f\u8f93\u5165 \\\\\\\\192.168.7.3 \u56de\u8f66',
|
||
mac_hint: '\u8bbf\u8fbe \u2192 \u9876\u90e8\u83dc\u5355\u300c\u524d\u5f80\u300d\u2192\u300c\u8fde\u63a5\u670d\u52a1\u5668\u300d(\\u2318K)',
|
||
linux_hint: 'SMB\uff1a\u5b89\u88c5 cifs-utils \u540e\u6302\u8f7d\uff1bNFS\uff1a\u5b89\u88c5 nfs-common \u540e\u6302\u8f7d\u3002',
|
||
templates: [] } };
|
||
|
||
else if (/\/api\/storage/.test(url)) data = { ok: true, data: {
|
||
devices: [], internal: [{ fstype: 'ext4', size: '40G' }, { fstype: 'xfs', size: '0' }],
|
||
fs_missing: ['xfs', 'f2fs'],
|
||
note: '\u5916\u63a5 U \u76d8 / \u79fb\u52a8\u786c\u76d8 / Type-C \u4e0e\u96f7\u7535\u786c\u76d8\u76d2\u4f1a\u663e\u793a\u5728\u4e0b\u65b9\u3002',
|
||
conn_note: '\u300c\u8fde\u63a5\u65b9\u5f0f\u300d\u663e\u793a\u7684\u662f\u603b\u7ebf\u7c7b\u578b\uff08USB / \u96f7\u7535 / NVMe / SATA\uff09\uff1bType-C \u662f\u63d2\u5934\u5f62\u6001\u3002',
|
||
empty: '\u672a\u68c0\u6d4b\u5230\u5916\u63a5\u8bbe\u5907\u3002\u63d2\u4e0a U \u76d8\u6216\u79fb\u52a8\u786c\u76d8\u540e\u70b9\u300c\u5237\u65b0\u300d\u3002' } };
|
||
|
||
else if (/\/api\/opensoho/.test(url)) data = { ok: true, data: {
|
||
installed: false, version: '', services: { state: 'inactive', enabled: 'disabled' },
|
||
listening: false, console: '',
|
||
note: '\u771f\u6b63\u7684 Wi-Fi\u3001VLAN\u3001PoE \u914d\u7f6e\u90fd\u5728 OpenSOHO \u81ea\u5df1\u7684\u63a7\u5236\u53f0\u91cc\u505a\u3002',
|
||
ap_hint: 'AP \u4fa7\u9700\u8981\u5728 OpenWRT \u4e0a\u88c5 openwisp-config \u5e76\u586b\u5165\u4e0b\u9762\u7684\u5171\u4eab\u5bc6\u94a5\u6765\u6ce8\u518c\u3002' } };
|
||
|
||
else if (/\/api\/examples|\/api\/openapi/.test(url)) data = { ok: true, data: {
|
||
groups: [{ k: 'common', n: '\u901a\u7528', items: [
|
||
{ m: 'GET', p: '/api/health', d: '\u5065\u5eb7\u68c0\u67e5\uff08\u65e0\u9700\u767b\u5f55\uff09' },
|
||
{ m: 'POST', p: '/api/login', d: '\u767b\u5165\u83b7\u53d6 Token' }] }],
|
||
examples: [{ k: 'curl', n: 'cURL\uff08\u6700\u901a\u7528\uff0cLinux/macOS/Windows \u5747\u53ef\uff09',
|
||
code: '# 1) \u767b\u5165\u62ff token\nTOKEN=$(curl -sk -X POST "$BASE/api/login")' }] } };
|
||
|
||
else if (/\/api\/docker/.test(url)) data = { ok: true, data: {
|
||
pkgs: { docker: { installed: false }, compose: { installed: false },
|
||
install_cmd: 'apt-get install -y docker.io' },
|
||
info: {}, services: { docker: 'inactive', containerd: 'inactive' },
|
||
containers: [], images: [], networks: [], volumes: [], stacks: [],
|
||
mirrors: [
|
||
{ n: '\u817e\u8baf\u4e91\u516c\u5171\u955c\u50cf\u6e90\uff0c\u957f\u671f\u7a33\u5b9a\uff0c\u65e0\u9700\u767b\u5f55\u3002' },
|
||
{ n: 'DaoCloud \u516c\u5171\u52a0\u901f\u8282\u70b9\uff0c\u56fd\u5185\u53ef\u7528\u6027\u8f83\u597d\u3002' },
|
||
{ n: '\u7f51\u6613\u7684\u8001\u724c\u955c\u50cf\u6e90\uff0c\u901f\u5ea6\u7a33\u5b9a\u4f46\u5076\u5c14\u540c\u6b65\u6ede\u540e\u3002' },
|
||
{ n: '\u6559\u80b2\u7f51\u4e0e\u7535\u4fe1\u94fe\u8def\u8868\u73b0\u597d\uff0c\u9ad8\u5cf0\u671f\u5076\u5c11\u9650\u901f\u3002' },
|
||
{ n: '\u5357\u5927\u5f00\u6e90\u955c\u50cf\u7ad9\uff0c\u6559\u80b2\u7f51\u7528\u6237\u4f18\u5148\u3002' },
|
||
{ n: '\u4e0a\u6d77\u4ea4\u5927\u955c\u50cf\u7ad9\uff0c\u534e\u4e1c\u5730\u533a\u8f83\u5feb\u3002' },
|
||
{ n: '\u963f\u91cc\u4e91\u4e0d\u518d\u63d0\u4f9b\u901a\u7528\u52a0\u901f\u5730\u5740\u3002' }] } };
|
||
|
||
else if (/\/api\/deps\/check/.test(url)) data = { ok: true, data: {
|
||
ok: false, missing: ['nfdpi', 'nginx'],
|
||
dpi_note: 'DPI \u9700\u8981\u72ec\u7acb\u8fdb\u7a0b\uff08nDPI Reader / Suricata\uff09\uff0c\u4f1a\u989d\u5916\u5360\u7528\u5185\u5b58\uff1b\u672c\u673a\u4e3a 4GB \u5185\u5b58\u7684\u865a\u62df\u673a\uff0c\u5efa\u8bae\u53ea\u88c5 nDPI \u4e00\u9879\u3002',
|
||
apt_note: 'Debian 13 \u4ed3\u5e93\u76f4\u63a5\u88c5\uff0c\u65e0\u9700\u7f16\u8bd1\u3002nDPI \u7248\u672c\u53ef\u80fd\u7565\u65e7\uff0c\u4f46\u591f\u5bb6\u7528\u3002',
|
||
dpi2_note: '\u89c4\u5219\u5e93\u9ed8\u8ba4\u4ece GitHub \u83b7\u53d6\uff0c\u56fd\u5185\u76f4\u8fde\u5e38\u5e38\u8d85\u65f6 \u2014\u2014 \u8bf7\u5728\u4e0b\u65b9\u9009\u62e9\u52a0\u901f\u524d\u7f00\u3002',
|
||
proxy_note: '\u8001\u724c\u516c\u5171\u52a0\u901f\uff0c\u7a33\u5b9a\u6027\u4e00\u822c\uff0c\u5076\u5c14\u9650\u6d41\u3002',
|
||
order_note: '\u6309\u300c\u5982\u4f55\u7701\u5fc3 \u2192 \u6700\u65b0 \u2192 \u6700\u5f3a\u300d\u987a\u5e8f\u9009\u4e00\u4e2a\u3002' } };
|
||
|
||
else if (/\/api\/dpi/.test(url)) data = { ok: true, data: {
|
||
installed: false, version: '', rules_ver: '',
|
||
proxy_note: '\u8001\u724c\u516c\u5171\u52a0\u901f\uff0c\u7a33\u5b9a\u6027\u4e00\u822c\uff0c\u5076\u5c14\u9650\u6d41\u3002',
|
||
speed_note: '\u6d4b\u901f\u4ece\u8def\u7531\u5668\u81ea\u8eab\u53d1\u8d77\uff0c\u53cd\u6620\u7684\u662f Docker \u771f\u5b9e\u94fe\u8def\u3002' } };
|
||
|
||
else if (/\/api\/ulog|journal/.test(url)) data = { ok: true, data: {
|
||
rows: [{ ts: '2026-10-07T23:03:14', level: 'info', module: 'ulogd',
|
||
msg_cn: '\u5df2\u5f52\u6863 801 \u6761\u65e5\u5fd7\uff08\u91c7\u96c6 801 \u6761\uff09', msg_en: 'Archived 801 log entries (collected 801)' },
|
||
{ ts: '2026-10-07T22:42:58', level: 'info', module: 'ulogd',
|
||
msg_cn: '\u5f52\u6863\u53bb\u91cd\u79fb\u9664 798 \u6761\u91cd\u590d\u8bb0\u5f55', msg_en: 'Removed 798 duplicate entries while archiving' },
|
||
{ ts: '2026-10-07T22:43:34', level: 'info', module: 'helpd',
|
||
msg_cn: '\u5e38\u9a7b\u6267\u884c\u5b88\u62a4\u5df2\u542f\u52a8\uff08socket=/run/drouter/helper.sock\uff0c\u52a8\u4f5c 89 \u4e2a\uff09',
|
||
msg_en: 'Resident exec daemon started (socket=/run/drouter/helper.sock, 89 actions)' }],
|
||
total: 300 } };
|
||
|
||
else if (/\/api\/audit/.test(url)) data = { ok: true, data: {
|
||
rows: [{ ts: '2026-10-07T22:47:05', user: 'admin', action: '\u767b\u5f55\u6210\u529f',
|
||
detail: '192.168.7.46', ok: true },
|
||
{ ts: '2026-10-07T22:33:00', user: 'admin', action: '\u767b\u5f55\u5931\u8d25',
|
||
detail: '192.168.7.8', ok: false },
|
||
{ ts: '2026-10-05T14:03:13', user: 'admin', action: '\u8bca\u65ad\u5de5\u5177',
|
||
detail: 'ping 223.5.5.5', ok: true }] } };
|
||
|
||
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: 'Test data' }, 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.window = sandbox;
|
||
sandbox.globalThis = sandbox;
|
||
|
||
const ctx = vm.createContext(sandbox);
|
||
|
||
/* —— 真 i18n(不是恒等兜底)—— */
|
||
// i18n.js 的 setLang() 会写 document.documentElement.lang,
|
||
// applyDom() 会遍历 document —— check-render 的桩里没有,先补上。
|
||
if (!document.documentElement) document.documentElement = {};
|
||
if (!document.head) document.head = mkEl('head');
|
||
const i18nSrc = fs.readFileSync(path.join(__dirname, '..', 'web', 'i18n.js'), 'utf8');
|
||
vm.runInContext(i18nSrc, sandbox);
|
||
{
|
||
const I = sandbox.window.i18n || sandbox.i18n;
|
||
if (!I) { console.error('i18n.js 没导出 window.i18n'); process.exit(1); }
|
||
// ⚠ 真机顺序是「先按界面语言加载 app.js,之后用户才切英文」。
|
||
// 顶层 const 里的 T('中文') **只在加载那一刻求值一次**,切语言不会重算
|
||
// —— 这正是顶栏日期/农历、IPv6 前缀策略等一批残留的真因。
|
||
// 所以这里先按 zh-CN 加载,加载完再切 en-US,完整复现真机路径。
|
||
// (PRELOAD_LANG=en-US 可退回旧行为,用于对比排查。)
|
||
I.setLang(process.env.PRELOAD_LANG || 'zh-CN', { force: true });
|
||
sandbox.t = I.t; // 其它 web 文件用的是 window.t
|
||
sandbox.i18n = I;
|
||
if (sandbox.localStorage && sandbox.localStorage.setItem) {
|
||
sandbox.localStorage.setItem('drouter_lang', process.env.PRELOAD_LANG || 'zh-CN');
|
||
}
|
||
}
|
||
|
||
try {
|
||
vm.runInContext(code, ctx, { filename: file });
|
||
} catch (e) {
|
||
console.error('脚本加载失败:' + e.message);
|
||
process.exit(1);
|
||
}
|
||
|
||
/* —— 独立模块(不在 VIEWS 顶层键里,此前是英文残留的盲区)——
|
||
upstream / netdetail / realtime / update 各自 IIFE + i18n.register,
|
||
必须**在切 en-US 之前**载入(复现真机顺序:先按 zh-CN 加载,用户再切语言)。*/
|
||
for (const _m of ['upstream.js', 'netdetail.js', 'realtime.js', 'update.js']) {
|
||
try {
|
||
vm.runInContext(
|
||
fs.readFileSync(path.join(__dirname, '..', 'web', _m), 'utf8'), ctx,
|
||
{ filename: _m });
|
||
} catch (e) {
|
||
console.error('模块加载失败 ' + _m + ':' + 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);
|
||
}
|
||
|
||
/* —— app.js 已按 zh-CN 加载完毕,现在切到英文(复现用户手动切语言)—— */
|
||
{
|
||
const I = sandbox.window.i18n || sandbox.i18n;
|
||
I.setLang('en-US', { force: true });
|
||
sandbox.t = I.t;
|
||
if (sandbox.localStorage && sandbox.localStorage.setItem) {
|
||
sandbox.localStorage.setItem('drouter_lang', 'en-US');
|
||
}
|
||
}
|
||
|
||
// 带内部标签页的视图:{ state: S 上的字段名, tabs: [...], render: '渲染函数名' }
|
||
// 向导每一步的正文写在 #wz-body(不在 #view),
|
||
// 而步骤条与进度受全局 WIZ_STEP 驱动。
|
||
// 只跑 viewWizard() 只能拿到第 1 步。
|
||
const WIZ_STEPS_IDS = { wizard: 4 };
|
||
|
||
// 点击后才有内容的视图:渲染后手动触发加载器,再抓 #view HTML。
|
||
const EXTRA_LOADERS = {
|
||
depcheck: ['runDepCheck()'],
|
||
api: ['loadApi()'],
|
||
log: ['loadLogs()'],
|
||
fw4: ['fwLogLoad()'],
|
||
fw6: ['fwLogLoad()'],
|
||
alert: ['alLoad(false)'],
|
||
};
|
||
|
||
const TABBED = {
|
||
theme: { state: 'themeTab', tabs: ['gallery', 'design', 'import'], render: 'tmRender' },
|
||
};
|
||
|
||
/* 只取**用户能看见**的中文。
|
||
* 剥掉三类不上屏的内容,否则误报:
|
||
* ① data-* 属性 —— 里面常放 JSON(如 VLAN 预设的 data-p='{"name":"中国电信 IPTV"}'),
|
||
* 那是给点击回调用的内部数据,按钮文字本身已经是英文;
|
||
* ② <style> / <script> 块;
|
||
* ③ HTML 注释。
|
||
* 2026-10-07:因为没剥 data-*,VLAN 页按钮文字已翻成英文却仍被判红。*/
|
||
function visibleHan(html) {
|
||
return String(html || '')
|
||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<!--[\s\S]*?-->/g, '')
|
||
.replace(/\sdata-[a-z-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||
.match(/[\u4e00-\u9fff]+/g);
|
||
}
|
||
|
||
(async () => {
|
||
const names = Object.keys(VIEWS);
|
||
let bad = 0, empty = 0, han = 0;
|
||
for (const n of names) {
|
||
try {
|
||
const fn = VIEWS[n];
|
||
if (typeof fn !== 'function') { console.log(` ✗ ${n}:不是函数`); bad++; continue; }
|
||
ELS.clear();
|
||
await fn();
|
||
for (const ex of (EXTRA_LOADERS[n] || [])) {
|
||
try { await vm.runInContext(ex, ctx); }
|
||
catch (e) { console.log(` ⚠ ${n} 加载器 ${ex} 异常:${e.message}`); }
|
||
}
|
||
await new Promise(r => setTimeout(r, 300));
|
||
const viewEl = getEl('#view');
|
||
const html = [...ELS.values()].map(e => (e._html || '') + '\n' + (e.textContent || '')).join('\n');
|
||
void viewEl;
|
||
// DUMP_ALL=1:把每个视图导成一张可比对的独立 HTML(挂真 app.css),
|
||
// 供真浏览器按 PC / 移动视口截图做排版检查(_dev/.layout/)。
|
||
if (process.env.DUMP_ALL) {
|
||
const dir = path.join(__dirname, '.layout');
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
|
||
// 真机上 app.js 的 MutationObserver 会给每个 <table> 套一层 .tw
|
||
// (overflow-x:auto)。预览页没有 JS,必须自己补上,否则
|
||
// 「表格溢出视口」全是假阳性。
|
||
const inner = (getEl('#view').innerHTML || '')
|
||
.replace(/<table[\s\S]*?<\/table>/g, (m) => '<div class="tw">' + m + '</div>');
|
||
// ⚠️ 预览必须带上**真外壳**(侧栏 + 顶栏)。只导 #view 的话,
|
||
// 任何出在菜单/标题栏的排版问题都测不到 —— 而「新增导航项显示成
|
||
// nav.n.health」「顶栏本页状态灯挤到标题」这类问题恰恰都在外壳上。
|
||
// 侧栏直接调真 renderNav() 生成,不手写假导航(假的测不出真问题)。
|
||
let navHtml = '';
|
||
try {
|
||
vm.runInContext('renderNav()', ctx);
|
||
navHtml = getEl('#nav')._html || getEl('#nav').innerHTML || '';
|
||
} catch (e) { navHtml = '<!-- renderNav 失败:' + e.message + ' -->'; }
|
||
const title = vm.runInContext(
|
||
"T('nav.t.' + " + JSON.stringify(n) + ") || ''", ctx);
|
||
const page = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
|
||
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||
+ '<link rel="stylesheet" href="../../web/app.css">'
|
||
+ '<link rel="stylesheet" href="../../web/update.css">'
|
||
+ '</head><body><div id="app">'
|
||
+ '<aside class="sidebar">'
|
||
+ '<a class="brand brand-home"><span class="brand-mark">◆</span>'
|
||
+ '<span class="brand-txt"><b>drouter</b><span>Debian 13 x86 router</span></span></a>'
|
||
+ '<div class="nav-search"><input placeholder="搜索菜单…" aria-label="search"></div>'
|
||
+ '<nav id="nav">' + navHtml + '</nav>'
|
||
+ '<div class="side-foot"><div class="dot-line"><span class="dot on"></span>'
|
||
+ '<span>后端在线</span></div>'
|
||
+ '<button class="side-logout"><span class="sl-txt">退出系统</span></button></div>'
|
||
+ '</aside>'
|
||
+ '<main>'
|
||
+ '<header class="topbar"><div class="tb-left">'
|
||
+ '<button class="icon-btn" aria-label="menu">≡</button>'
|
||
+ '<h2 id="page-title">' + title + '</h2>'
|
||
+ '<span id="page-health" class="page-health">'
|
||
+ '<span class="st-dot lv-ok"></span><span class="ph-txt">OK</span>'
|
||
+ '<button type="button" class="st-info" title="about">i</button></span>'
|
||
+ '</div><div class="tb-right">'
|
||
+ '<button class="icon-btn tb-health lv-ok"><span class="tb-h-dot"></span></button>'
|
||
+ '<button class="icon-btn" id="btn-lang"><span id="lang-label">EN</span></button>'
|
||
+ '<span class="mini">v1.0.9</span></div></header>'
|
||
+ '<section id="view" class="view">' + inner
|
||
+ '</section></main></div></body></html>';
|
||
fs.writeFileSync(path.join(dir, n + '.html'), page, 'utf8');
|
||
}
|
||
if (process.env.DUMP_VIEW === n) {
|
||
console.log('--- ' + n + ' 渲染 HTML(前 2500 字符)---');
|
||
console.log(html.slice(0, parseInt(process.env.DUMP_LIMIT || '2500', 10)));
|
||
console.log('--- 结束 ---');
|
||
}
|
||
if (html.length < 40) {
|
||
console.log(` ⚠ ${n}:渲染内容过少(${html.length} 字符)`);
|
||
empty++;
|
||
}
|
||
// ⛔ 本文检查器的**唯一目的**:英文界面不许有汉字
|
||
const cjk = visibleHan(html);
|
||
if (cjk) {
|
||
const uniq = [...new Set(cjk)].slice(0, 8).join(' / ');
|
||
console.log(` ✗ ${n}:英文界面残留中文 ${cjk.length} 处→ ${uniq}`);
|
||
// 打印每处中文的上下文,省掉「grep 全文找」的往返
|
||
if (process.env.SHOW_CTX) {
|
||
const vis = String(html)
|
||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<!--[\s\S]*?-->/g, '')
|
||
.replace(/\sdata-[a-z-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||
const re = /[\u4e00-\u9fff]+/g;
|
||
let m2, k = 0;
|
||
while ((m2 = re.exec(vis)) && k < 8) {
|
||
const a = Math.max(0, m2.index - 70);
|
||
console.log(' [%d] %s', ++k,
|
||
vis.slice(a, m2.index + m2[0].length + 20).replace(/\n/g, ' '));
|
||
}
|
||
}
|
||
han++;
|
||
}
|
||
// 带标签页的视图:逐个标签渲染一遍,否则只有默认标签会被真正执行。
|
||
// 主题之家(#12)的三个标签页各自 $("#tm-pane").innerHTML = ...,逻辑完全不同。
|
||
// 向导:逐步验。每一步重渲某个步骤再检 #wz-body。
|
||
// 步骤条与顶部结论一并一并检,不得只看一个容器。
|
||
if (WIZ_STEPS_IDS[n]) {
|
||
for (let st = 0; st < WIZ_STEPS_IDS[n]; st++) {
|
||
try {
|
||
vm.runInContext('WIZ_STEP = ' + st, ctx);
|
||
await vm.runInContext('wizRender()', ctx);
|
||
const hb = getEl('#wz-body').innerHTML || '';
|
||
if (hb.length < 40) {
|
||
console.log(` ⚠ ${n}/step${st}:步骤内容过少(${hb.length} 字符)`);
|
||
empty++;
|
||
}
|
||
if (/[\u4e00-\u9fff]/.test(hb)) {
|
||
const c = [...new Set(visibleHan(hb))].slice(0, 8).join(' / ');
|
||
console.log(` ✗ ${n}/step${st}:步骤内容残留中文→ ${c}`);
|
||
han++;
|
||
}
|
||
} catch (e) {
|
||
console.log(` ✗ ${n}/step${st}:渲染异常 ${e.message}`);
|
||
bad++;
|
||
}
|
||
}
|
||
}
|
||
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 (visibleHan(h2)) {
|
||
const cj = [...new Set(visibleHan(h2))].slice(0, 6).join(' / ');
|
||
console.log(` ✗ ${n}/${tab}:英文界面残留中文 → ${cj}`);
|
||
han++;
|
||
} 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++;
|
||
}
|
||
}
|
||
// —— accel 是 netstat 内嵌的异步子区块,不在 VIEWS 顶层键里,单独渲染检查 ——
|
||
try {
|
||
await vm.runInContext('loadAccel()', ctx);
|
||
const accelBox = getEl('#accel-box').innerHTML || '';
|
||
const accelImpact = getEl('#accel-impact').innerHTML || '';
|
||
const ahtml = accelBox + accelImpact;
|
||
if (ahtml.length < 40) {
|
||
console.log(' ⚠ accel:渲染内容过少(' + ahtml.length + ' 字符)');
|
||
empty++;
|
||
} else {
|
||
const acjk = visibleHan(ahtml);
|
||
if (acjk) {
|
||
const au = [...new Set(acjk)].slice(0, 8).join(' / ');
|
||
console.log(' ✗ accel:英文界面残留中文 ' + acjk.length + ' 处→ ' + au);
|
||
han++;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log(' ✗ accel:渲染异常 ' + e.message);
|
||
bad++;
|
||
}
|
||
|
||
// —— 顶栏时钟不属于任何 VIEWS:日期/星期/农历是最容易残留中文的地方
|
||
// (顶层常量那批 bug 里它排第一),这里单独渲染一次扫一遍。
|
||
try {
|
||
ELS.clear();
|
||
await vm.runInContext('tickClock()', ctx);
|
||
const chtml = [...ELS.values()].map(e => (e._html || '') + '\n' + (e.textContent || '')).join('\n');
|
||
const ccjk = visibleHan(chtml);
|
||
if (process.env.CLOCK_DEBUG) {
|
||
for (const [k, e] of ELS) {
|
||
if (String(k).indexOf('clock') >= 0) console.log(' [clock] ' + k + ' = ' + JSON.stringify(e.textContent));
|
||
}
|
||
}
|
||
if (ccjk) {
|
||
console.log(' ✗ clock:英文界面残留中文 → ' + [...new Set(ccjk)].slice(0, 8).join(' / '));
|
||
han++;
|
||
}
|
||
} catch (e) {
|
||
console.log(' ⚠ clock:渲染异常 ' + e.message);
|
||
}
|
||
|
||
// —— 独立模块渲染:容器不在 #view 里,必须单独跑它们的渲染钩子 ——
|
||
// ⚠️ 计数必须进汇总:否则「模块根本没渲染」也会显示 0 残留(假绿)。
|
||
let modOk = 0, modBad = 0;
|
||
for (const _h of [
|
||
['upstream', 'drouterRenderUpstream'],
|
||
['netdetail', 'drouterRenderNetDetail'],
|
||
['realtime', 'drouterRenderRealtime'],
|
||
['update', 'drouterRenderUpdateCard'],
|
||
]) {
|
||
try {
|
||
ELS.clear();
|
||
await vm.runInContext(_h[1] + '()', ctx);
|
||
await new Promise(r => setTimeout(r, 300));
|
||
const mhtml = [...ELS.values()]
|
||
.map(e => (e._html || '') + '\n' + (e.textContent || '')).join('\n');
|
||
if (mhtml.length < 40) {
|
||
console.log(' ⚠ ' + _h[0] + ':渲染内容过少(' + mhtml.length + ' 字符)');
|
||
empty++;
|
||
continue;
|
||
}
|
||
const mcjk = visibleHan(mhtml);
|
||
if (mcjk) {
|
||
console.log(' ✗ ' + _h[0] + ':英文界面残留中文 ' + mcjk.length + ' 处→ '
|
||
+ [...new Set(mcjk)].slice(0, 8).join(' / '));
|
||
if (process.env.SHOW_CTX) {
|
||
const re3 = /[\u4e00-\u9fff]+/g; let m3, k3 = 0;
|
||
while ((m3 = re3.exec(mhtml)) && k3 < 8) {
|
||
const a3 = Math.max(0, m3.index - 70);
|
||
console.log(' [%d] %s', ++k3,
|
||
mhtml.slice(a3, m3.index + m3[0].length + 20).replace(/\n/g, ' '));
|
||
}
|
||
}
|
||
han++; modBad++;
|
||
} else {
|
||
modOk++;
|
||
}
|
||
} catch (e) {
|
||
console.log(' ✗ ' + _h[0] + ':渲染异常 ' + e.message);
|
||
bad++; modBad++;
|
||
}
|
||
}
|
||
|
||
// —— update 卡的**失败分支**(!d.ok:msg_cn + errors)——
|
||
// 真机 check 有缓存走的是 ok:true 分支,这里换一份失败 payload 再渲一次。
|
||
try {
|
||
vm.runInContext('window.__updCheckOverride = ' +
|
||
JSON.stringify(REAL.REAL_UPDATE_CHECK_FAIL) + ';', ctx);
|
||
ELS.clear();
|
||
await vm.runInContext('drouterRenderUpdateCard()', ctx);
|
||
await new Promise(r => setTimeout(r, 300));
|
||
const fhtml = [...ELS.values()]
|
||
.map(e => (e._html || '') + '\n' + (e.textContent || '')).join('\n');
|
||
const fcjk = visibleHan(fhtml);
|
||
if (fcjk) {
|
||
console.log(' ✗ update(失败分支):英文界面残留中文 ' + fcjk.length + ' 处→ '
|
||
+ [...new Set(fcjk)].slice(0, 8).join(' / '));
|
||
han++; modBad++;
|
||
} else { modOk++; }
|
||
} catch (e) {
|
||
console.log(' ✗ update(失败分支):渲染异常 ' + e.message);
|
||
bad++; modBad++;
|
||
}
|
||
|
||
// —— 后端 update 模块的文案钩子直测 ——
|
||
// 卡片/进度弹窗不都能靠一次渲染覆盖(进度态要真跑一次更新),
|
||
// 所以直接喂后端源码里的全部文案。字符串取自 backend/drouter-update.py。
|
||
try {
|
||
const I = vm.runInContext('window.__drouterUpdI18n', ctx);
|
||
if (!I) { console.log(' ✗ update 文案钩子缺失'); bad++; }
|
||
else {
|
||
const CASES = [
|
||
['src', 'GitHub 官方'], ['src', 'ghproxy 镜像'], ['src', 'kkgithub 镜像'],
|
||
['asset', { key: 'deb', desc: 'Deb 安装包(推荐,523 KB)' }],
|
||
['asset', { key: 'offline', desc: '离线安装包(87 MB,断网可装)' }],
|
||
['asset', { key: 'docker', desc: 'Docker 镜像(169 MB)' }],
|
||
['text', '下载中'], ['text', '准备下载…'], ['text', '准备开始'],
|
||
['text', '正在备份当前配置…'], ['text', '备份完成,开始下载…'],
|
||
['text', '正在从 ghproxy 镜像 下载…'], ['text', '正在校验文件完整性…'],
|
||
['text', '下载完成并已保存到 /var/lib/drouter/update'],
|
||
['text', '已取消,未做任何改动'], ['text', '下载失败:连接超时,外网可能不通'],
|
||
['text', '下载失败:所有下载源均不可用'],
|
||
['text', '备份失败,已中止更新:备份程序未产出备份包(可能未开启自动备份)'],
|
||
['text', '更新过程出错:连接被拒绝'],
|
||
['text', '更新过程出错:HTTPS 证书校验失败(可能被劫持或需要安装 CA)'],
|
||
['text', '更新过程出错:附件不存在(该版本可能没发布这个文件)'],
|
||
['text', '当前没有进行中的任务'],
|
||
['text', '校验不通过:文件与官方 SHA256 不一致'],
|
||
['text', '无法访问 GitHub,请检查本机外网连通性'],
|
||
['text', '版本号格式不对'], ['text', '未知的附件类型:tar'],
|
||
['text', '已有更新任务在进行中'], ['text', '已开始更新'],
|
||
['text', '官方 SHA256(下载源:ghproxy 镜像)'],
|
||
['text', '未获取到 SHA256 清单,本次下载**未做完整性校验**(下载源:ghproxy 镜像)'],
|
||
['text', '安装:sudo apt install ./drouter_1.0.9_all.deb(或 dpkg -i 后 apt --fix-broken install)'],
|
||
['text', '离线安装:解包后进入目录执行 ./install-offline.sh,全程不需要外网'],
|
||
['text', 'Docker:docker load -i drouter-1.0.9-docker.tar 然后 docker compose up -d'],
|
||
['err', 'GitHub 官方:连接超时'],
|
||
['err', 'kkgithub 镜像:返回内容里没有可识别的版本号'],
|
||
['err', 'GitHub 官方:HTTP 403'],
|
||
];
|
||
let ubad = 0;
|
||
for (const [kind, v] of CASES) {
|
||
const out = String(kind === 'asset' ? I.asset(v)
|
||
: kind === 'src' ? I.src(v) : kind === 'err' ? I.err(v) : I.text(v));
|
||
const c = visibleHan(out);
|
||
if (c) { ubad++; console.log(' ✗ update 文案残留中文 [' + kind + '] '
|
||
+ JSON.stringify(v) + ' → ' + out); }
|
||
}
|
||
if (ubad) { han++; modBad++; } else { modOk++; }
|
||
}
|
||
} catch (e) {
|
||
console.log(' ✗ update 文案钩子异常 ' + e.message);
|
||
bad++; modBad++;
|
||
}
|
||
if (modOk + modBad !== 6) { console.log(' ✗ 独立模块渲染数 != 6,覆盖不完整'); bad++; }
|
||
|
||
// —— 健康总览的**有问题**分支(红/黄灯 + 「去处理」按钮)——
|
||
// 真机当前 22 个模块全绿,异常分支拿不到,这里换一份派生 payload 再渲一次。
|
||
try {
|
||
vm.runInContext('window.__stOverride = ' +
|
||
JSON.stringify(REAL.REAL_STATUS_BAD) + ';', ctx);
|
||
ELS.clear();
|
||
await vm.runInContext('viewHealth()', ctx);
|
||
await new Promise(r => setTimeout(r, 300));
|
||
const bhtml = [...ELS.values()]
|
||
.map(e => (e._html || '') + '\n' + (e.textContent || '')).join('\n');
|
||
const bcjk = visibleHan(bhtml);
|
||
if (bcjk) {
|
||
console.log(' ✗ health(异常分支):英文界面残留中文 ' + bcjk.length + ' 处→ '
|
||
+ [...new Set(bcjk)].slice(0, 8).join(' / '));
|
||
han++; modBad++;
|
||
} else { modOk++; }
|
||
} catch (e) {
|
||
console.log(' ✗ health(异常分支):渲染异常 ' + e.message);
|
||
bad++; modBad++;
|
||
}
|
||
if (modOk + modBad !== 7) { console.log(' ✗ 独立模块/分支渲染数 != 7,覆盖不完整'); bad++; }
|
||
console.log('='.repeat(60));
|
||
console.log(`视图总数 ${names.length}|异常 ${bad}|疑似空渲染 ${empty}|英文残留中文的视图 ${han}`);
|
||
console.log(`独立模块 4 个 + update 失败分支 + 文案钩子 + health 异常分支|干净 ${modOk}|有问题 ${modBad}`);
|
||
const dockerCalls = CALLED.filter(c => /\/api\/docker/.test(c.url)).length;
|
||
console.log(`docker 接口调用次数:${dockerCalls}`);
|
||
process.exit(bad || han ? 1 : 0);
|
||
})();
|