feat: Drouter 1.0.0 —— Debian 13 软路由管理系统
一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的 Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、 访问控制、DDNS、文件共享、Docker 管理等。 核心设计原则:不重新实现任何协议 PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。 Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。 面板删掉后,系统仍是一台配置完好的 Debian 路由器。 安全默认值 · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务 · 所有改网络的动作都需要用户在面板上亲手点「应用」 · 避开 5900 端口,不触碰 VNC / X 会话 · 配置写入前先做语法预检,失败则回滚 架构 · backend/ Python 3 标准库,零第三方依赖(9 个模块) · web/ 原生 JS/CSS,无构建步骤 · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess · 鉴权走 X-Token 请求头 打包 · packaging/build-deb.sh → drouter_1.0.0_all.deb(含 6 个 systemd 单元) · scripts/make-offline-deps.sh → 236 个依赖 .deb + 本地仓库索引 · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装 · packaging/build-docker.sh → 容器镜像(体验用) 测试 40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配), 全部挂在 devtools/sync.sh 的预检链里。 许可:MIT
This commit is contained in:
commit
64df3cf331
135 files changed
+56059
No files matched your search
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
echo "yyfmo64102"
|
||||
@@ -0,0 +1,132 @@
|
||||
// 手机适配(#14)专项检查
|
||||
// 1) 静态要素:viewport、抽屉遮罩、断点规则、抽屉开关逻辑、表格包裹逻辑
|
||||
// 2) 功能验证:用最小 DOM 桩跑一遍 wrapTables,确认真的包出了 .tw 且不会重复包
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const APP = path.join(__dirname, '..', 'web', 'app.js');
|
||||
const CSS = path.join(__dirname, '..', 'web', 'app.css');
|
||||
const HTML = path.join(__dirname, '..', 'web', 'index.html');
|
||||
const code = fs.readFileSync(APP, 'utf8');
|
||||
const css = fs.readFileSync(CSS, 'utf8');
|
||||
const html = fs.readFileSync(HTML, 'utf8');
|
||||
|
||||
let PASS = 0, FAIL = 0;
|
||||
const MSG = [];
|
||||
function has(name, cond, extra) {
|
||||
if (cond) PASS++;
|
||||
else { FAIL++; MSG.push(' ✘ ' + name + (extra ? ' → ' + extra : '')); }
|
||||
}
|
||||
|
||||
// ---------------- 1. 静态要素 ----------------
|
||||
has('index.html 有 viewport', /<meta\s+name="viewport"/.test(html));
|
||||
has('index.html 有抽屉遮罩 #m-mask', /id="m-mask"/.test(html));
|
||||
has('index.html 有侧栏开关 #btn-side', /id="btn-side"/.test(html));
|
||||
|
||||
has('CSS 有 .tw 横向滚动容器', /\.tw\{[^}]*overflow-x:auto/.test(css));
|
||||
has('CSS 有 m-mask 遮罩样式', /\.m-mask\{/.test(css));
|
||||
has('CSS 有 760px 断点', /@media \(?max-width:760px\)?/.test(css));
|
||||
has('CSS 抽屉滑入规则', /#app\.m-drawer \.sidebar\{[^}]*transform:translateX\(0\)/.test(css));
|
||||
has('CSS 侧栏在窄屏为 fixed 抽屉',
|
||||
/@media \(?max-width:760px\)?\{[\s\S]*?\.sidebar\{[^}]*position:fixed/.test(css));
|
||||
has('CSS 窄屏输入框 16px(防 iOS 缩放)',
|
||||
/@media \(?max-width:760px\)?\{[\s\S]*?input,select,textarea\{font-size:16px\}/.test(css));
|
||||
has('CSS 有安全区适配', /env\(safe-area-inset/.test(css));
|
||||
has('CSS 弹窗窄屏适配', /\.modal-box\{[^}]*margin:0 12px/.test(css));
|
||||
|
||||
has('JS 有 isNarrow 判定', /const isNarrow\s*=/.test(code));
|
||||
has('JS 有 closeDrawer', /function closeDrawer\(/.test(code));
|
||||
has('JS 切换 m-drawer', /classList\.toggle\('m-drawer'\)/.test(code));
|
||||
has('JS go() 里会关抽屉', /function go\([\s\S]{0,200}?closeDrawer\(\)/.test(code));
|
||||
has('JS 遮罩点击关闭', /mMask\.onclick\s*=\s*closeDrawer/.test(code));
|
||||
has('JS Esc 关闭抽屉', /e\.key === 'Escape'\) closeDrawer\(\)/.test(code));
|
||||
has('JS 有 wrapTables', /function wrapTables\(/.test(code));
|
||||
has('JS 用 MutationObserver 兜异步表格', /new MutationObserver\(/.test(code));
|
||||
has('JS 窗口变宽会清抽屉', /mq\.matches\) closeDrawer\(\)/.test(code));
|
||||
|
||||
// ---------------- 2. wrapTables 功能验证 ----------------
|
||||
// 最小 DOM 桩:只要能表达「节点 / 父节点 / class 列表 / 插入」即可
|
||||
function mk(tag, cls) {
|
||||
const el = {
|
||||
tag, children: [], parentNode: null,
|
||||
classList: {
|
||||
_s: new Set(cls ? [cls] : []),
|
||||
add(c) { this._s.add(c); }, contains(c) { return this._s.has(c); },
|
||||
},
|
||||
insertBefore(node, ref) {
|
||||
const i = this.children.indexOf(ref);
|
||||
node.parentNode = this;
|
||||
if (i < 0) this.children.push(node); else this.children.splice(i, 0, node);
|
||||
return node;
|
||||
},
|
||||
appendChild(node) {
|
||||
// 从原父节点摘下,再挂到新父节点
|
||||
if (node.parentNode) {
|
||||
const j = node.parentNode.children.indexOf(node);
|
||||
if (j >= 0) node.parentNode.children.splice(j, 1);
|
||||
}
|
||||
node.parentNode = this;
|
||||
this.children.push(node);
|
||||
return node;
|
||||
},
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
(function walk(n) {
|
||||
n.children.forEach(c => {
|
||||
if (sel === 'table' && c.tag === 'table') out.push(c);
|
||||
walk(c);
|
||||
});
|
||||
})(this);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
// 真实 DOM 里 el.className = 'tw' 会同步反映到 classList,
|
||||
// 桩必须复刻这个联动,否则 wrapTables 的「已包过」判断永远失效。
|
||||
Object.defineProperty(el, 'className', {
|
||||
get() { return Array.from(el.classList._s).join(' '); },
|
||||
set(v) { el.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)); },
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
const documentStub = { createElement: t => mk(t) };
|
||||
const sandbox = { document: documentStub, console };
|
||||
vm.createContext(sandbox);
|
||||
|
||||
// 只抽取 wrapTables 函数体,避免牵扯整个 app.js
|
||||
const m = code.match(/function wrapTables\(root\) \{[\s\S]*?\n\}/);
|
||||
has('能抽到 wrapTables 源码', !!m);
|
||||
if (m) {
|
||||
vm.runInContext(m[0], sandbox);
|
||||
|
||||
const root = mk('div');
|
||||
const holder = mk('div', 'card');
|
||||
const t1 = mk('table'), t2 = mk('table');
|
||||
root.appendChild(holder); holder.appendChild(t1); holder.appendChild(t2);
|
||||
|
||||
// 第一次:两个表都该被包上一层 .tw
|
||||
sandbox.wrapTables(root);
|
||||
const wrapped = holder.children.filter(c => c.classList.contains('tw'));
|
||||
has('首次包裹:两个表格都套上 .tw', wrapped.length === 2, wrapped.length);
|
||||
has('包裹后 table 的父节点是 .tw',
|
||||
wrapped.length === 2 && wrapped.every(w => w.children.length === 1 && w.children[0].tag === 'table'));
|
||||
|
||||
// 第二次:幂等,不能重复套(否则每次刷新 DOM 都多一层)
|
||||
sandbox.wrapTables(root);
|
||||
const wrapped2 = holder.children.filter(c => c.classList.contains('tw'));
|
||||
has('二次包裹:幂等不重复', wrapped2.length === 2, wrapped2.length);
|
||||
has('二次包裹后结构仍是一层',
|
||||
wrapped2.every(w => w.children.length === 1 && w.children[0].tag === 'table'));
|
||||
|
||||
// 空树不应报错
|
||||
let threw = false;
|
||||
try { sandbox.wrapTables(mk('div')); } catch (e) { threw = true; }
|
||||
has('空容器不抛异常', !threw);
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log(`手机适配检查:通过 ${PASS}|失败 ${FAIL}`);
|
||||
if (FAIL) { console.log('-'.repeat(60)); MSG.forEach(x => console.log(x)); }
|
||||
process.exit(FAIL ? 1 : 0);
|
||||
@@ -0,0 +1,289 @@
|
||||
// 离线视图渲染回归:用最小 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: { 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: '丢弃 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 () {},
|
||||
};
|
||||
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);
|
||||
})();
|
||||
@@ -0,0 +1,34 @@
|
||||
// 校验 PAGES 的 key 是否都在 VIEWS 里有映射。
|
||||
// tag:'soon' 的占位页不需要渲染函数(点进去显示「等待作者完善」),自动排除。
|
||||
const fs = require('fs');
|
||||
// 以脚本自身位置定位,避免依赖运行时的当前目录(否则换个目录跑就 ENOENT)。
|
||||
const WEB = require('path').join(__dirname, '..', 'web', 'app.js');
|
||||
const path = process.argv[2] || WEB;
|
||||
const s = fs.readFileSync(path, 'utf8');
|
||||
|
||||
const nav = s.match(/const NAV_GROUPS = \[([\s\S]*?)\n\];/);
|
||||
if (!nav) { console.log('✘ 未找到 NAV_GROUPS'); process.exit(1); }
|
||||
|
||||
const items = [];
|
||||
for (const m of nav[1].matchAll(/\{([^{}]*?k:\s*'([a-z0-9]+)'[^{}]*?)\}/g)) {
|
||||
const body = m[1];
|
||||
const key = m[2];
|
||||
const soon = /tag:\s*'soon'/.test(body);
|
||||
items.push({ key, soon });
|
||||
}
|
||||
|
||||
const block = s.match(/const VIEWS = \{([\s\S]*?)\n\};/);
|
||||
if (!block) { console.log('✘ 未找到 VIEWS 定义'); process.exit(1); }
|
||||
const mapped = [];
|
||||
for (const m of block[1].matchAll(/^\s*([a-z0-9_]+):/gm)) mapped.push(m[1]);
|
||||
|
||||
const real = items.filter((x) => !x.soon);
|
||||
const soon = items.filter((x) => x.soon);
|
||||
const missing = real.filter((x) => !mapped.includes(x.key)).map((x) => x.key);
|
||||
const keys = items.map((x) => x.key);
|
||||
const extra = mapped.filter((k) => !keys.includes(k));
|
||||
|
||||
console.log('菜单项:', items.length, '(已实现', real.length, '/ 规划中', soon.length, ')');
|
||||
console.log('缺失映射:', missing.length ? missing.join(', ') + ' ✘' : '无 ✔');
|
||||
console.log('多余映射:', extra.length ? extra.join(', ') : '无 ✔');
|
||||
process.exit(missing.length ? 1 : 0);
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生成 docs/screenshots/ 下的占位截图(SVG → 直接当 PNG 用不合适,这里输出 SVG)。
|
||||
|
||||
设计取舍:
|
||||
README 里引用的是 .png。但仓库里没有真实截图,硬引用会得到 6 个死链。
|
||||
两种做法:
|
||||
A) 生成真 PNG(需要 Pillow 或 cairosvg,构建机上不一定有)
|
||||
B) 生成 SVG 占位,README 里改引用 .svg
|
||||
选 B:SVG 是纯文本、零依赖、可随仓库走、而且缩放不糊。
|
||||
用户日后截了真图,按同名替换 + 改扩展名即可(README 里已写明)。
|
||||
"""
|
||||
import os
|
||||
|
||||
PRI = "#1f6feb"
|
||||
BG = "#eef2f9"
|
||||
PANEL = "#ffffff"
|
||||
LINE = "#e2e7f0"
|
||||
TXT = "#161f30"
|
||||
TXT2 = "#556480"
|
||||
TXT3 = "#8894a8"
|
||||
|
||||
W, H = 1440, 900
|
||||
|
||||
SHOTS = [
|
||||
("01-dash", "系统概览", "CPU / 内存 / 磁盘 / 负载 / 温度 + 实时网速与历史流量"),
|
||||
("02-iface", "网卡与桥接", "以 MAC 为主键管理网卡,标注备注 / 角色 / 链路 / 速率 / MTU"),
|
||||
("03-fw4", "防火墙 IPv4", "nftables 规则可视化编辑,支持链 / 表 / 规则级操作"),
|
||||
("04-qos", "智能限速 QoS", "基于 IFB 的入向整形 + 分类限速,规则实时下发"),
|
||||
("05-flowlog", "连接与流日志", "conntrack 连接跟踪 + 实时流日志滚动查看"),
|
||||
("06-webshell", "Web 终端", "浏览器内直接操作 shell,支持多会话"),
|
||||
]
|
||||
|
||||
def logo_mark(x, y, s=1.0):
|
||||
"""复刻 web/logo.svg 的路由环,作为占位图的水印标识。"""
|
||||
return f'''<g transform="translate({x},{y}) scale({s})">
|
||||
<circle cx="24" cy="24" r="21" fill="none" stroke="{PRI}" stroke-width="2.4"
|
||||
stroke-linecap="round" stroke-dasharray="96 36" transform="rotate(-42 24 24)" opacity=".55"/>
|
||||
<path d="M11 31.5 20.5 22 27 27.5 37 15.5" fill="none" stroke="{PRI}" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="11" cy="31.5" r="3.1" fill="{PRI}"/>
|
||||
<circle cx="20.5" cy="22" r="2.4" fill="{PRI}"/>
|
||||
<circle cx="27" cy="27.5" r="2.4" fill="{PRI}"/>
|
||||
<path d="M33.2 12.6h4.6v4.6" fill="none" stroke="{PRI}" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 38.5h24" fill="none" stroke="{PRI}" stroke-width="2.4"
|
||||
stroke-linecap="round" opacity=".45"/>
|
||||
</g>'''
|
||||
|
||||
def skeleton(w, h, x, y, rows=4, gap=14):
|
||||
"""画一块灰色的内容骨架,暗示「这里将来是真的界面」。"""
|
||||
out = []
|
||||
out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="12" '
|
||||
f'fill="{PANEL}" stroke="{LINE}"/>')
|
||||
yy = y + 22
|
||||
for i in range(rows):
|
||||
# 行宽交替,看起来像文字块
|
||||
ww = int(w * (0.72 if i % 2 == 0 else 0.52))
|
||||
out.append(f'<rect x="{x+20}" y="{yy}" width="{ww}" height="10" rx="5" '
|
||||
f'fill="{LINE}"/>')
|
||||
yy += 10 + gap
|
||||
return "\n ".join(out)
|
||||
|
||||
def svg(key, title, desc):
|
||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}"
|
||||
width="{W}" height="{H}" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',system-ui,sans-serif">
|
||||
<rect width="{W}" height="{H}" fill="{BG}"/>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<rect x="0" y="0" width="236" height="{H}" fill="{PANEL}"/>
|
||||
<line x1="236" y1="0" x2="236" y2="{H}" stroke="{LINE}"/>
|
||||
{logo_mark(18, 14, 0.9)}
|
||||
<text x="58" y="34" font-size="17" font-weight="700" fill="{TXT}">Drouter</text>
|
||||
<text x="58" y="50" font-size="11" fill="{TXT3}">Debian 13 路由管理</text>
|
||||
|
||||
<!-- 侧栏分组骨架 -->
|
||||
<g opacity=".85">
|
||||
<rect x="14" y="86" width="208" height="30" rx="8" fill="{PRI}" opacity=".10"/>
|
||||
<rect x="26" y="96" width="96" height="10" rx="5" fill="{PRI}" opacity=".55"/>
|
||||
<rect x="26" y="132" width="128" height="10" rx="5" fill="{LINE}"/>
|
||||
<rect x="26" y="164" width="104" height="10" rx="5" fill="{LINE}"/>
|
||||
<rect x="26" y="196" width="140" height="10" rx="5" fill="{LINE}"/>
|
||||
<rect x="26" y="228" width="88" height="10" rx="5" fill="{LINE}"/>
|
||||
<rect x="26" y="260" width="120" height="10" rx="5" fill="{LINE}"/>
|
||||
<rect x="26" y="292" width="100" height="10" rx="5" fill="{LINE}"/>
|
||||
</g>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<rect x="236" y="0" width="{W-236}" height="62" fill="{PANEL}"/>
|
||||
<line x1="236" y1="62" x2="{W}" y2="62" stroke="{LINE}"/>
|
||||
<text x="268" y="39" font-size="19" font-weight="700" fill="{TXT}">{title}</text>
|
||||
<rect x="{W-330}" y="19" width="120" height="24" rx="12" fill="{LINE}"/>
|
||||
<circle cx="{W-56}" cy="31" r="15" fill="{PRI}" opacity=".13"/>
|
||||
<text x="{W-61}" y="36" font-size="13" font-weight="700" fill="{PRI}">A</text>
|
||||
|
||||
<!-- 内容骨架 -->
|
||||
{skeleton(540, 214, 268, 92, rows=4)}
|
||||
{skeleton(540, 214, 832, 92, rows=4)}
|
||||
{skeleton(1104, 300, 268, 330, rows=6)}
|
||||
{skeleton(468, 300, 600, 330, rows=6)}
|
||||
{skeleton(468, 300, 1096, 330, rows=6)}
|
||||
{skeleton(1104, 168, 268, 660, rows=3)}
|
||||
|
||||
<!-- 水印:占位提示 -->
|
||||
<g opacity=".92">
|
||||
<rect x="{W/2-330}" y="{H/2-58}" width="660" height="116" rx="16"
|
||||
fill="{PANEL}" stroke="{PRI}" stroke-opacity=".35" stroke-width="1.5"/>
|
||||
<text x="{W/2}" y="{H/2-14}" font-size="21" font-weight="700" fill="{TXT}"
|
||||
text-anchor="middle">【占位图】{title}</text>
|
||||
<text x="{W/2}" y="{H/2+16}" font-size="14" fill="{TXT2}"
|
||||
text-anchor="middle">{desc}</text>
|
||||
<text x="{W/2}" y="{H/2+42}" font-size="12" fill="{TXT3}"
|
||||
text-anchor="middle">真实界面请以安装后为准 · 替换 docs/screenshots/{key}.svg 即可</text>
|
||||
</g>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
out_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"docs", "screenshots")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for key, title, desc in SHOTS:
|
||||
p = os.path.join(out_dir, key + ".svg")
|
||||
# newline="\n" 是必须的:Windows 上 Python 默认把 \n 翻成 \r\n,
|
||||
# 生成的 SVG 在 git 里会被 .gitattributes 回写成 LF,导致每次重建都
|
||||
# 产生"改了又没改"的假 diff。显式指定 \n 后,生成结果与入库结果一致。
|
||||
with open(p, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(svg(key, title, desc))
|
||||
print("wrote", p)
|
||||
print("done:", len(SHOTS), "placeholders")
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# scp 到目标机也需要密码;复用 SSH_ASKPASS
|
||||
#
|
||||
# 用法(两种都可以):
|
||||
# rscp.sh 本地文件 /tmp/远端路径 # 自动补 ajeef@192.168.7.3: 前缀
|
||||
# rscp.sh 本地文件 ajeef@192.168.7.3:/tmp/x # 已是远端形式,原样透传
|
||||
#
|
||||
# 陷阱:Windows 的 scp 在目标不含 "user@host:" 时会退化成本地 cp,
|
||||
# 静默返回 0 却什么都没传出去。所以这里强制补前缀。
|
||||
HERE="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00"
|
||||
TARGET="ajeef@192.168.7.3"
|
||||
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-*|*@*:*) args+=("$a");; # scp 选项 / 已是远端形式
|
||||
*) args+=("$a");;
|
||||
esac
|
||||
done
|
||||
|
||||
# 最后一个参数视为远端目标;若不是远端形式则补前缀
|
||||
n=${#args[@]}
|
||||
if [ "$n" -ge 1 ]; then
|
||||
last="${args[$((n-1))]}"
|
||||
case "$last" in
|
||||
-*|*@*:*) ;; # 选项 / 已是远端,不动
|
||||
*) args[$((n-1))]="$TARGET:$last";;
|
||||
esac
|
||||
fi
|
||||
|
||||
SSH_ASKPASS="$HERE/router-build/devtools/askpass.sh" \
|
||||
SSH_ASKPASS_REQUIRE=force \
|
||||
DISPLAY=:0 \
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 "${args[@]}"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# askpass.sh 在 devtools/ 下(此前误写成根目录的 _dev/,导致连不上目标机)
|
||||
SSH_ASKPASS="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00/router-build/devtools/askpass.sh" \
|
||||
SSH_ASKPASS_REQUIRE=force \
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 ajeef@192.168.7.3 "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# 以 sudo 在目标机执行命令(密码通过 stdin 传给 sudo -S)
|
||||
HERE="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00"
|
||||
"$HERE/router-build/devtools/rsh.sh" "echo 'yyfmo64102' | sudo -S -p '' bash -c $(printf %q "$1")" 2>&1
|
||||
@@ -0,0 +1,124 @@
|
||||
// 在 Node 里用最小 DOM 桩模拟前端启动流程,验证:
|
||||
// 1) 登录能拿到 token 2) loadAll 后每个页面都能渲染出内容(不再是「正在载入…」)
|
||||
// 用法:node sim-ui.js [host] [port] 默认 192.168.7.3:8080
|
||||
// 说明:这是"无浏览器"回归,能抓到 VIEWS 未定义、接口 404、字段名不匹配等
|
||||
// 只有真实点击才会暴露的问题。
|
||||
const args = process.argv.slice(2);
|
||||
const HOST = args[0] || '192.168.7.3';
|
||||
const PORT = Number(args[1] || 8080);
|
||||
let TOKEN = '';
|
||||
|
||||
// ---- 极简 DOM 桩 ----
|
||||
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); },
|
||||
querySelector(s) { return document.querySelector(s); },
|
||||
querySelectorAll() { return []; },
|
||||
focus() {}, onclick: null, onchange: null, oninput: null, onkeydown: null,
|
||||
getContext() { return {}; },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
const ELS = {};
|
||||
global.document = {
|
||||
getElementById: id => (ELS[id] = ELS[id] || mkEl(id)),
|
||||
// 关键:querySelector 必须返回同一个实例(视图函数会先赋值 innerHTML 再读回来)
|
||||
querySelector: sel => {
|
||||
const key = '@sel:' + sel.replace(/^#/, '').replace(/\..*$/, '').replace(/^[^a-zA-Z0-9_-]*/, '');
|
||||
return (ELS[key] = ELS[key] || mkEl(key));
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
createElement: t => mkEl('created:' + t),
|
||||
body: mkEl('body'),
|
||||
};
|
||||
global.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
global.window = global;
|
||||
global.setTimeout = (f, t) => 0;
|
||||
global.setInterval = () => 0;
|
||||
global.clearTimeout = () => {};
|
||||
const http = require('http');
|
||||
// 用 node 内置 http 直接请求,避免 Node 的 fetch 在纯 IPv4 环境下的兼容问题
|
||||
global.fetch = (path, opts = {}) => new Promise((resolve, reject) => {
|
||||
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
|
||||
if (TOKEN) headers['X-Token'] = TOKEN;
|
||||
const body = opts.body ? Buffer.from(String(opts.body)) : null;
|
||||
if (body) headers['Content-Length'] = body.length;
|
||||
const req = http.request({
|
||||
host: HOST, port: PORT, path, method: opts.method || 'GET', headers,
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode,
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
json: async () => JSON.parse(data || '{}'),
|
||||
text: async () => data,
|
||||
}));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
// ---- 载入前端逻辑(只取需要的函数,避免真跑入口绑定)----
|
||||
const fs = require('fs');
|
||||
let src = fs.readFileSync('../web/app.js', 'utf8');
|
||||
// 去掉入口绑定与自动登录,手动驱动
|
||||
src = src.replace(/\/\* ={10,} 入口 ={10,} \*\/[\s\S]*$/, '');
|
||||
src += '\nmodule.exports={S,api,doLogin,loadAll,go,PAGES,VIEWS,logout};\n';
|
||||
fs.writeFileSync('/tmp/_app_sim.js', src);
|
||||
const app = require('/tmp/_app_sim.js');
|
||||
|
||||
(async () => {
|
||||
// 1. 登录
|
||||
const r = await app.api('/api/login', { method: 'POST', body: { username: 'admin', password: 'admin123' } });
|
||||
if (!r.ok) { console.log('❌ 登录失败:', r.msg_cn); process.exit(1); }
|
||||
app.S.token = r.data.token; TOKEN = r.data.token;
|
||||
console.log('✅ 登录成功, token =', TOKEN.slice(0, 14) + '...');
|
||||
|
||||
// 2. loadAll
|
||||
await app.loadAll();
|
||||
console.log('✅ loadAll 完成');
|
||||
console.log(' S.buildMode =', app.S.buildMode);
|
||||
console.log(' S.ifaces =', app.S.ifaces.map(i => i.name).join(', '));
|
||||
console.log(' S.cfg keys =', Object.keys(app.S.cfg).join(', '));
|
||||
|
||||
// 3. 逐页渲染
|
||||
const allPages = app.PAGES.filter(p => !p.sep);
|
||||
const keys = allPages.map(p => p.k);
|
||||
let bad = 0, soon = 0;
|
||||
console.log('\n=== 逐页渲染 ===');
|
||||
const vEl = document.querySelector('#view');
|
||||
for (const k of keys) {
|
||||
vEl._html = '';
|
||||
// 标注"待完善"的页面走 renderSoon 占位,属预期,跳过
|
||||
const page = allPages.find(p => p.k === k) || {};
|
||||
if (page.tag === 'soon') { console.log(` ○ ${k.padEnd(9)} 规划中(等待作者完善)`); soon++; continue; }
|
||||
try {
|
||||
const fn = app.VIEWS[k];
|
||||
if (!fn) { console.log(` ❌ ${k}: 无视图函数`); bad++; continue; }
|
||||
await fn();
|
||||
const h = vEl._html || '';
|
||||
// 真正的"卡在载入"是整页只剩一个占位卡(长度很短);
|
||||
// 若页面已渲染出大量内容,只在个别子区域出现"正在载入"属正常。
|
||||
if (!h || (h.length < 400 && h.includes('正在载入'))) {
|
||||
console.log(` ❌ ${k}: 仍是占位符 (len=${h.length})`); bad++;
|
||||
} else if (h.includes('页面渲染失败')) { console.log(` ❌ ${k}: 渲染失败 ${h.slice(0, 90)}`); bad++; }
|
||||
else console.log(` ✅ ${k.padEnd(9)} 渲染 ${h.length} 字符`);
|
||||
} catch (e) {
|
||||
console.log(` ❌ ${k}: 抛异常 ${e.message}`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
console.log(`\n已完成模块 ${keys.length - soon} 个,规划中 ${soon} 个`);
|
||||
console.log(bad === 0 ? '\nUI_SIM_OK ✔ 全部已实现页面渲染正常' : `\nUI_SIM_FAIL ❌ ${bad} 个页面异常`);
|
||||
})();
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# 把本地 router-build 源码同步到目标机 /tmp/drouter-src 并执行 deploy.sh
|
||||
# 不会启动任何网络服务、不会改网络。部署后用 build_probe.sh 自检。
|
||||
set -e
|
||||
HERE="C:/Users/lyrz-pve-win10/WorkBuddy/2026-09-27-19-57-00"
|
||||
SRC="$HERE/router-build"
|
||||
RSH="$HERE/router-build/devtools/rsh.sh"
|
||||
|
||||
echo "=== 0. 本地预检(语法 + 编译)==="
|
||||
cd "$SRC"
|
||||
# Python 用 py_compile 才能抓出 "global 声明位置错误" 这类只有编译期才报的 bug
|
||||
PYBIN="C:/Users/lyrz-pve-win10/.workbuddy/binaries/python/versions/3.13.12/python.exe"
|
||||
NODEBIN="C:/Users/lyrz-pve-win10/.workbuddy/binaries/node/versions/22.22.2-3/node.exe"
|
||||
"$PYBIN" - <<'PY' || { echo "❌ Python 预检失败"; exit 1; }
|
||||
import py_compile, tempfile, sys, glob
|
||||
bad = 0
|
||||
for f in sorted(glob.glob('backend/*.py')):
|
||||
try:
|
||||
py_compile.compile(f, cfile=tempfile.mktemp(), doraise=True)
|
||||
print(' 编译OK:', f)
|
||||
except Exception as e:
|
||||
print(' 编译失败:', f, e); bad += 1
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
"$NODEBIN" --check web/app.js && echo " 语法OK: web/app.js"
|
||||
# 后端内部契约:GET /api/config 的模块清单必须与 save_config 白名单一致
|
||||
"$PYBIN" _dev/t-web-contract.py || { echo "❌ 配置模块契约预检失败"; exit 1; }
|
||||
# 页面 key ↔ VIEWS 映射完整性:缺一个就会让菜单点进去卡在「正在载入…」
|
||||
"$NODEBIN" devtools/check-views.js web/app.js || { echo "❌ 视图映射预检失败"; exit 1; }
|
||||
# 离线渲染回归:真正把每个视图函数跑一遍,能抓出「语法没问题但一调用就抛异常」
|
||||
"$NODEBIN" devtools/check-render.js web/app.js || { echo "❌ 渲染回归预检失败"; exit 1; }
|
||||
# 手机适配要素(#14):viewport / 抽屉 / 表格横向滚动,防止改版时被悄悄改掉
|
||||
"$NODEBIN" devtools/check-mobile.js || { echo "❌ 手机适配预检失败"; exit 1; }
|
||||
# 入口页破缓存:部署后用户不手动强刷也能拿到新前端
|
||||
"$PYBIN" _dev/t-cachebust.py || { echo "❌ 缓存版本号预检失败"; exit 1; }
|
||||
# 历史累计流量(概览页),含「计数器回绕 / 重启清零」容错
|
||||
"$PYBIN" _dev/t-nettotals.py || { echo "❌ 累计流量预检失败"; exit 1; }
|
||||
# 环境契约:守护进程 PATH 归一化 + 虚拟网卡归类(都是「接口 ok 但功能悄悄不工作」)
|
||||
"$PYBIN" _dev/t-env-path.py || { echo "❌ 环境契约预检失败"; exit 1; }
|
||||
# 打包产物结构自检:deb 元数据 / 维护脚本 / Dockerfile(挡住「打出来的包不能用」)
|
||||
"$PYBIN" _dev/t-packaging.py || { echo "❌ 打包自检失败"; exit 1; }
|
||||
# 防火墙日志「为什么一条都没有」的状态判定:规则未载入 / 开关未开必须分开提示
|
||||
"$PYBIN" _dev/t-fwlog-state.py || { echo "❌ 防火墙日志状态预检失败"; exit 1; }
|
||||
# 外置存储(USB/Type-C/雷电):格式化的三重保护是最容易造成不可逆损失的地方
|
||||
"$PYBIN" _dev/t-storage.py || { echo "❌ 外置存储预检失败"; exit 1; }
|
||||
# NAT 检测详情:后端是对象、前端曾当纯文本渲染成 "[object Object]",别再回退
|
||||
"$PYBIN" _dev/t-nat-detail.py || { echo "❌ NAT 详情渲染预检失败"; exit 1; }
|
||||
# Web 终端(真 PTY):helper/shelld/web 三端的协议契约 + 超时分级
|
||||
"$PYBIN" _dev/t-webshell.py || { echo "❌ Web 终端契约预检失败"; exit 1; }
|
||||
# 终端模拟器行为:ANSI 光标 / 颜色 / 换行 / 分帧中文,在 node 里真跑一遍前端代码
|
||||
"$NODEBIN" _dev/t-webterm.js || { echo "❌ 终端模拟器预检失败"; exit 1; }
|
||||
# 依赖自检清单:一键安装绝不能把 xfce4 / samba / docker 这类可选大件一起装上
|
||||
"$PYBIN" _dev/t-deps.py || { echo "❌ 依赖清单预检失败"; exit 1; }
|
||||
# 全项目静态审计:动作名未注册 / 前端调了后端没有的路由 / 同名函数重复定义 /
|
||||
# CSS 类缺失。这类 bug 语法检查全过,只在点进去那一刻才爆。
|
||||
"$PYBIN" _dev/t-audit.py || { echo "❌ 全项目静态审计失败"; exit 1; }
|
||||
# 快照覆盖率与容错:一个 0600 的 root 文件就让整个 /etc/drouter 进不了快照,
|
||||
# 而界面还显示「已创建快照」——这种静默失败必须挡住。
|
||||
"$PYBIN" _dev/t-snapshot.py || { echo "❌ 快照覆盖预检失败"; exit 1; }
|
||||
# 常驻执行守护(#7):socket 快路径 + subprocess 回退,两者都不能少。
|
||||
# 少了回退,守护一挂面板就全废;少了快路径,每个请求白付 236ms 启动开销。
|
||||
"$PYBIN" _dev/t-helpd.py || { echo "❌ 常驻执行守护预检失败"; exit 1; }
|
||||
# 统一日志:conntrack -E 是阻塞事件流,曾经让每次查询固定白等 2 秒
|
||||
"$PYBIN" _dev/t-ulog-perf.py || { echo "❌ 统一日志性能预检失败"; exit 1; }
|
||||
# 响应压缩与 ETag 协商缓存:少了 Vary 会让代理把 gzip 喂给不支持的客户端
|
||||
"$PYBIN" _dev/t-gzip.py || { echo "❌ 响应压缩预检失败"; exit 1; }
|
||||
# 模块联动:前端「保存/应用」要处理的模块,后端必须接得住。
|
||||
# 曾经 system / pppoe / portfwd 三个页面点了应用直接弹「未知的模块」。
|
||||
"$PYBIN" _dev/t-linkage.py || { echo "❌ 模块联动预检失败"; exit 1; }
|
||||
# 常驻守护重启契约:deploy.sh 里 shelld 的指纹取样必须在 install 之前,
|
||||
# 否则「代码变了就重启」永远不触发,改了守护代码真机上还是旧行为。
|
||||
"$PYBIN" _dev/t-daemon-restart.py || { echo "❌ 守护重启契约预检失败"; exit 1; }
|
||||
# 磁盘与日志清理:这是唯一会真删文件的模块,安全边界表 + glob 不重叠必须每次验。
|
||||
# 边界表漏一项就是误删配置库,glob 重叠则会让页面虚报可释放量。
|
||||
"$PYBIN" _dev/t-cleanup.py || { echo "❌ 磁盘清理预检失败"; exit 1; }
|
||||
# 内核转发与加速:默认值是我替用户做的判断,改错会直接改变这台机器的网络行为;
|
||||
# 渲染器侧的 masquerade / MSS 也必须跟着校验,否则页面开了但规则集里没有。
|
||||
"$PYBIN" _dev/t-kern.py || { echo "❌ 内核转发预检失败"; exit 1; }
|
||||
# Docker 引擎配置:这一页会覆写 /etc/docker/daemon.json,合并逻辑是唯一的防线 ——
|
||||
# 用户自己写的 data-root / insecure-registries 被静默丢掉是最糟的体验;
|
||||
# 保存流程里多一个 systemctl 就会在用户不知情时中断所有容器。
|
||||
"$PYBIN" _dev/t-dcfg.py || { echo "❌ Docker 引擎配置预检失败"; exit 1; }
|
||||
# 打印服务:CUPS 与 USB RAW 抢同一个设备,切模式时没把对面停干净就是两边都打不出;
|
||||
# cupsctl --remote-any 写的 Allow all 必须收窄,否则等于把打印机暴露给所有来源。
|
||||
"$PYBIN" _dev/t-print.py || { echo "❌ 打印服务预检失败"; exit 1; }
|
||||
|
||||
# AC/AP 管理中心:升级或改端口绝不能重新生成加密密钥(历史配置会解不开);
|
||||
# 安装包是外部下载的,解包必须挡住 zip slip。
|
||||
"$PYBIN" _dev/t-opensoho.py || { echo "❌ AC/AP 管理中心预检失败"; exit 1; }
|
||||
# CA 证书管理:部署是唯一能把用户关在门外的动作 —— 换证书前必须过四道闸,
|
||||
# 换完必须握手比对指纹,不对就自动还原;有效期/SAN 的 falsy 陷阱也要每次验。
|
||||
"$PYBIN" _dev/t-ca.py || { echo "❌ CA 证书管理预检失败"; exit 1; }
|
||||
# 新手向导:这是新手唯一会看的页面,填错了他没有任何判断依据。
|
||||
# 地址池「圈进本机自己」这类错要在服务端就拦住 —— 不拦的表现是网络时通时不通,
|
||||
# 且没有任何日志提示原因。另外「保存账号」绝不能顺带拨号(会在用户不知情下断网)。
|
||||
"$PYBIN" _dev/t-wizard.py || { echo "❌ 新手向导预检失败"; exit 1; }
|
||||
|
||||
# 性能回归:单文件 SPA 最容易悄悄退化的三类问题 —— 切页漏停定时器(后端被空打)、
|
||||
# 高频读接口里滥 fork subprocess(2 核机上每次切页面白烧几十毫秒)、
|
||||
# 写了参数却两个分支一样(quick 形同虚设)。这类问题不影响功能,只咬性能,
|
||||
# 功能测试永远抓不到,必须单独钉住。
|
||||
"$PYBIN" _dev/t-perf.py || { echo "❌ 性能回归预检失败"; exit 1; }
|
||||
|
||||
# 换行符检查:Windows 上写出的 CRLF 到了 Linux 会让 bash 报 "$'\r': 未找到命令",
|
||||
# 整个 deploy.sh 会静默跑成一堆错误。必须在打包前拦住。
|
||||
# 注意:不能用 grep —— Git Bash 的 grep 在文本模式下会把 LF 读成 CRLF,全是误报。
|
||||
"$PYBIN" - <<'PY' || { echo "❌ 换行符预检失败"; exit 1; }
|
||||
import glob, sys
|
||||
bad = []
|
||||
for ext in ('.sh', '.py', '.js', '.css', '.html'):
|
||||
for f in glob.glob('**/*' + ext, recursive=True):
|
||||
if '__pycache__' in f:
|
||||
continue
|
||||
if b'\r' in open(f, 'rb').read():
|
||||
bad.append(f)
|
||||
if bad:
|
||||
print('以下文件含 CRLF 换行,Linux 上会执行失败:')
|
||||
for f in bad:
|
||||
print(' ' + f)
|
||||
print('修正:python3 -c "open(f,\'wb\').write(open(f,\'rb\').read().replace(b\'\\r\\n\',b\'\\n\'))"')
|
||||
sys.exit(1)
|
||||
print(' 换行符OK: 全部为 LF')
|
||||
PY
|
||||
|
||||
echo "=== 打包源码 ==="
|
||||
# docs 也要带上:deploy.sh 会装到 /opt/drouter/docs(systemd 的 Documentation 指向它)
|
||||
tar czf /tmp/drouter-src.tgz backend web scripts docs 2>/dev/null
|
||||
ls -la /tmp/drouter-src.tgz
|
||||
|
||||
echo "=== 上传 ==="
|
||||
"$HERE/router-build/devtools/rscp.sh" /tmp/drouter-src.tgz ajeef@192.168.7.3:/tmp/drouter-src.tgz 2>&1 | tail -3
|
||||
|
||||
echo "=== 解包 + 部署 ==="
|
||||
"$RSH" "rm -rf /tmp/drouter-src && mkdir -p /tmp/drouter-src && tar xzf /tmp/drouter-src.tgz -C /tmp/drouter-src" 2>&1 | tail -3
|
||||
bash "$HERE/router-build/devtools/rsudo.sh" "bash /tmp/drouter-src/scripts/deploy.sh" 2>&1 | tail -45
|
||||
Reference in new issue
Block a user