一套跑在 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
11309 lines
567 KiB
JavaScript
11309 lines
567 KiB
JavaScript
/* drouter 管理台前端逻辑 —— 原生 JS,无依赖 */
|
||
'use strict';
|
||
|
||
/* ============================ 基础 ============================ */
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => Array.from(document.querySelectorAll(s));
|
||
const esc = s => {
|
||
// 后端偶尔会把「对象」塞进本该是文本的字段(例如 NAT 检测的 detail 是 dict)。
|
||
// 直接 String(obj) 会得到 "[object Object]",用户完全看不出是什么、也没法排查。
|
||
// 这里退化成紧凑 JSON:仍然一眼能看出「这里的数据结构不对」,但至少有内容可读。
|
||
if (s != null && typeof s === 'object') {
|
||
try { s = JSON.stringify(s); } catch (e) { /* 循环引用等,交给下面兜底 */ }
|
||
}
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||
c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
};
|
||
const fmtBytes = n => {
|
||
n = Number(n) || 0;
|
||
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return n.toFixed(i ? 1 : 0) + ' ' + u[i];
|
||
};
|
||
const fmtDur = s => {
|
||
s = Math.floor(Number(s) || 0);
|
||
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
|
||
return (d ? d + '天 ' : '') + (h ? h + '小时 ' : '') + m + '分';
|
||
};
|
||
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
||
|
||
const S = { token: localStorage.getItem('drouter_token') || '', user: '', cfg: {}, ifaces: [], meta: {},
|
||
page: 'dash', buildMode: true, navOpen: null, navKw: '',
|
||
docker: {}, share: {}, shareData: {}, acl: {},
|
||
theme: {}, themeMeta: {}, themeEdit: null, themeTab: 'gallery' };
|
||
|
||
function toast(msg, kind = 'ok', ms = 3600) {
|
||
const t = $('#toast');
|
||
t.className = 'toast ' + kind;
|
||
t.textContent = msg;
|
||
t.classList.remove('hidden');
|
||
clearTimeout(t._t);
|
||
t._t = setTimeout(() => t.classList.add('hidden'), ms);
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
|
||
if (S.token) headers['X-Token'] = S.token;
|
||
let res;
|
||
try {
|
||
res = await fetch(path, Object.assign({ method: opts.method || 'GET' }, opts,
|
||
{ headers, body: opts.body ? JSON.stringify(opts.body) : undefined }));
|
||
} catch (e) {
|
||
return { ok: false, code: 'NET', msg_cn: '无法连接到路由器管理服务:' + e.message };
|
||
}
|
||
if (res.status === 401) {
|
||
logout(true);
|
||
return { ok: false, code: 'UNAUTH', msg_cn: '登录已过期,请重新登录' };
|
||
}
|
||
try { return await res.json(); }
|
||
catch (e) { return { ok: false, code: 'PARSE', msg_cn: '服务返回数据异常' }; }
|
||
}
|
||
|
||
function setActionMsg(msg, kind = '') {
|
||
const el = $('#ab-msg');
|
||
el.className = 'ab-msg ' + kind;
|
||
el.textContent = msg;
|
||
}
|
||
|
||
/* ============================ 登录 ============================ */
|
||
async function doLogin() {
|
||
const u = $('#lg-user').value.trim(), p = $('#lg-pass').value;
|
||
$('#lg-btn').disabled = true;
|
||
const r = await api('/api/login', { method: 'POST', body: { username: u, password: p } });
|
||
$('#lg-btn').disabled = false;
|
||
if (!r.ok) { $('#lg-msg').className = 'msg err'; $('#lg-msg').textContent = r.msg_cn; return; }
|
||
S.token = r.data.token; S.user = r.data.user;
|
||
localStorage.setItem('drouter_token', S.token);
|
||
$('#login').classList.add('hidden');
|
||
$('#app').classList.remove('hidden');
|
||
boot();
|
||
}
|
||
|
||
function logout(silent) {
|
||
S.token = ''; localStorage.removeItem('drouter_token');
|
||
$('#app').classList.add('hidden');
|
||
$('#login').classList.remove('hidden');
|
||
if (!silent) api('/api/logout', { method: 'POST' });
|
||
}
|
||
|
||
/* ============================ 导航 ============================ */
|
||
/* 菜单结构:分组(可折叠、带主题色)→ 页面。
|
||
每个分组有独立色相 hue,用于左侧色条/图标底色,便于快速区分功能域。
|
||
tag:'off' 表示该页尚未开发,点进去显示「等待作者完善」。 */
|
||
const NAV_GROUPS = [
|
||
{
|
||
g: '概览', icon: '◎', hue: 210, items: [
|
||
{ k: 'wizard', n: '新手向导', t: '第一次用这台机器上网?四步走完:接上外网 · 给内网发地址 · 配 DNS · 要不要 IPv6。每一步都带范例和体检结果', needSave: false },
|
||
{ k: 'dash', n: '系统概览', t: '系统概览' },
|
||
{ k: 'netstat', n: '网络状态 / 加速', t: '网络状态 · NAT 检测 · 软加速' },
|
||
],
|
||
},
|
||
{
|
||
g: '接口', icon: '⇄', hue: 268, items: [
|
||
{ k: 'iface', n: '网卡与桥接', t: '网卡与桥接', needSave: true },
|
||
{ k: 'wan', n: 'WAN 口', t: 'WAN 口设置', needSave: true },
|
||
{ k: 'pppmulti', n: 'PPPoE 多拨', t: 'PPPoE 多拨 / 多会话聚合' },
|
||
{ k: 'lan', n: 'LAN 口', t: 'LAN 口设置', needSave: true },
|
||
{ k: 'vlan', n: 'VLAN / IPTV', t: 'VLAN 与 IPTV 划分' },
|
||
{ k: 'wol', n: '网络唤醒 (WOL)', t: '网卡远程唤醒 WOL' },
|
||
],
|
||
},
|
||
{
|
||
g: '寻址与路由', icon: '⌘', hue: 190, items: [
|
||
{ k: 'dhcp', n: 'DHCP 服务', t: 'DHCP 服务与 Options', needSave: true },
|
||
{ k: 'dns', n: 'DNS 服务', t: 'DNS 服务', needSave: true },
|
||
{ k: 'ipv6', n: 'IPv6 / RA', t: 'IPv6 与路由通告', needSave: true },
|
||
{ k: 'dhcpv6', n: 'DHCPv6 / 前缀委派', t: 'DHCPv6 与前缀委派', needSave: true },
|
||
{ k: 'ddns', n: '动态域名 DDNS', t: '动态域名解析 DDNS(IPv4 / IPv6,国内外服务商)', needSave: false },
|
||
{ k: 'pubip', n: '真·公网 IP 判定', t: '判定出口 IP 是不是真的能被外网连进来(入向可达性实测)', needSave: false },
|
||
],
|
||
},
|
||
{
|
||
g: '安全', icon: '⛨', hue: 0, items: [
|
||
{ k: 'fw4', n: '防火墙 IPv4', t: 'IPv4 防火墙', needSave: true },
|
||
{ k: 'fw6', n: '防火墙 IPv6', t: 'IPv6 防火墙', needSave: true },
|
||
{ k: 'portfwd', n: '端口转发 / DMZ', t: '端口转发与 DMZ(nftables DNAT)', needSave: true },
|
||
{ k: 'upnp', n: 'UPnP / NAT-PMP', t: 'UPnP / NAT-PMP', needSave: true },
|
||
{ k: 'acl', n: '访问控制 / 时间组', t: '访问控制与家长时间组', needSave: true },
|
||
],
|
||
},
|
||
{
|
||
g: '服务', icon: '✦', hue: 155, items: [
|
||
{ k: 'qos', n: '智能限速 QoS', t: '智能限速与流量整形(CAKE / HTB)' },
|
||
{ k: 'dpi', n: '应用识别 DPI', t: '应用识别与规则库更新(nDPI)' },
|
||
{ k: 'ntp', n: 'NTP 时间同步', t: 'NTP 时间同步', needSave: true },
|
||
{ k: 'nfs', n: '文件共享 SMB/NFS', t: '内网文件共享(SMB / NFS,跨平台模板)', needSave: true },
|
||
{ k: 'print', n: '打印服务', t: 'CUPS 打印服务器 / USB 打印机 RAW 直通(互斥二选一),共享给手机与电脑', needSave: false },
|
||
{ k: 'opensoho', n: 'AC/AP 管理中心', t: 'OpenSOHO 无线控制器:集中管理 OpenWRT AP 的 Wi-Fi · VLAN · PoE', needSave: false },
|
||
],
|
||
},
|
||
{
|
||
g: '工具', icon: '⚙', hue: 35, items: [
|
||
{ k: 'webshell', n: 'Web 终端 / 文件', t: 'Web SSH 终端与文件管理器' },
|
||
{ k: 'api', n: '通用 API 接口', t: '对外通用 API 接口与调用范例' },
|
||
{ k: 'diag', n: '网络诊断工具', t: '网络诊断工具' },
|
||
{ k: 'v6test', n: 'IPv6 连通性测试', t: 'IPv6 连通性测试' },
|
||
{ k: 'docker', n: 'Docker / Compose', t: 'Docker 与 Docker Compose 管理面板' },
|
||
{ k: 'dcfg', n: 'Docker 引擎配置', t: 'daemon.json 可视化配置:IPv6 一键开启 · 国内镜像源切换与测速 · 日志滚动 · 默认网桥网段', needSave: false },
|
||
],
|
||
},
|
||
{
|
||
g: '日志与审计', icon: '☰', hue: 220, items: [
|
||
{ k: 'log', n: '系统日志', t: '日志查看' },
|
||
{ k: 'flowlog', n: '连接与流日志', t: '连接跟踪与流量日志(统一日志系统)' },
|
||
],
|
||
},
|
||
{
|
||
g: '系统', icon: '⛁', hue: 250, items: [
|
||
{ k: 'depcheck', n: '依赖自检与安装', t: '运行依赖自检与一键安装' },
|
||
{ k: 'cleanup', n: '磁盘与日志清理', t: '回收日志 / 缓存 / 临时文件,可设定阈值自动清理,防止小硬盘被占满', needSave: false },
|
||
{ k: 'kern', n: '内核转发与加速', t: 'IP 转发 · 出向伪装 · MSS 钳制 · BBR · SNMP,五项内核级开关(每项附说明与联动影响)', needSave: false },
|
||
{ k: 'ca', n: '证书 / SSL', t: 'CA 证书管理:建自己的 CA · 签发服务器证书 · 导入 · 部署给管理后台;附带 SSL/TLS 握手体检工具', needSave: false },
|
||
{ k: 'power', n: '电源控制', t: '电源控制' },
|
||
{ k: 'user', n: '用户与密钥', t: '系统用户与 SSH 公钥' },
|
||
{ k: 'sys', n: '系统设置', t: '系统设置' },
|
||
{ k: 'theme', n: '主题之家', t: '主题之家(Web 设计 · 离线预览 · 导入导出)' },
|
||
{ k: 'update', n: '升级与保护', t: '系统升级与 RealVNC 保护' },
|
||
],
|
||
},
|
||
];
|
||
|
||
/* 扁平化:供 go() 查标题等 */
|
||
const PAGES = (() => {
|
||
const out = [];
|
||
NAV_GROUPS.forEach(gr => gr.items.forEach(it => out.push(Object.assign({ grp: gr.g }, it))));
|
||
return out;
|
||
})();
|
||
|
||
/* 页面 → 「保存」与「应用」各自要处理哪些配置模块。
|
||
两者必须分开声明,原因来自两个真实踩过的坑:
|
||
1) system(网卡与桥接 / LAN 口)与 portfwd(端口转发)是「纯配置」模块:
|
||
只入库、没有自己的配置文件,拿去 /api/apply 会直接报「未知的模块」;
|
||
2) 端口转发的规则是作为上下文注入 nft_v4 / nft_v6 一起渲染的,
|
||
所以要「保存 portfwd、应用 nft_v4+nft_v6」,顺序不能反。
|
||
|
||
VLAN / WOL / QoS / DPI / 多拨 这些页面有自己的页内按钮(走 /api/vlan、
|
||
/api/qos …),故意不登记在这里 —— 没登记就不会显示全局动作条,
|
||
免得用户点了顶栏按钮却得到一个「当前页面无需保存」的假提示。 */
|
||
const PAGE_MODULES = {
|
||
iface: { save: ['system'], apply: [] },
|
||
lan: { save: ['system'], apply: [] },
|
||
wan: { save: ['pppoe'], apply: ['pppoe'] },
|
||
dhcp: { save: ['dnsmasq'], apply: ['dnsmasq'] },
|
||
dns: { save: ['dnsmasq'], apply: ['dnsmasq'] },
|
||
dhcpv6: { save: ['dhcpv6'], apply: ['dhcpv6'] },
|
||
ipv6: { save: ['radvd', 'dhcpv6'], apply: ['radvd', 'dhcpv6'] },
|
||
fw4: { save: ['nft_v4'], apply: ['nft_v4'] },
|
||
fw6: { save: ['nft_v6'], apply: ['nft_v6'] },
|
||
portfwd: { save: ['portfwd'], apply: ['nft_v4', 'nft_v6'] },
|
||
upnp: { save: ['upnp'], apply: ['upnp'] },
|
||
ntp: { save: ['ntp'], apply: ['ntp'] },
|
||
};
|
||
|
||
/* 页面 key → 渲染函数。缺失此项会导致点击菜单时报
|
||
"VIEWS is not defined",页面永远停在「正在载入…」。 */
|
||
const VIEWS = {
|
||
wizard: viewWizard,
|
||
dash: viewDash,
|
||
netstat: viewNetStat,
|
||
iface: viewIface,
|
||
wan: viewWan,
|
||
pppmulti: viewPppMulti,
|
||
lan: viewLan,
|
||
vlan: viewVlan,
|
||
wol: viewWol,
|
||
dhcp: viewDhcp,
|
||
dns: viewDns,
|
||
ipv6: viewIpv6,
|
||
dhcpv6: viewDhcpv6,
|
||
ddns: viewDdns,
|
||
pubip: viewPubip,
|
||
fw4: viewFw,
|
||
fw6: viewFw,
|
||
portfwd: viewPortfwd,
|
||
acl: viewAcl,
|
||
nfs: viewNfs,
|
||
docker: viewDocker,
|
||
dcfg: viewDcfg,
|
||
print: viewPrint,
|
||
opensoho: viewOpensoho,
|
||
upnp: viewUpnp,
|
||
qos: viewQos,
|
||
dpi: viewDpi,
|
||
ntp: viewNtp,
|
||
diag: viewDiag,
|
||
v6test: viewV6Test,
|
||
webshell: viewWebShell,
|
||
api: viewApi,
|
||
log: viewLog,
|
||
flowlog: viewFlowLog,
|
||
theme: viewTheme,
|
||
depcheck: viewDepCheck,
|
||
cleanup: viewCleanup,
|
||
kern: viewKern,
|
||
ca: viewCa,
|
||
power: viewPower,
|
||
user: viewUser,
|
||
sys: viewSys,
|
||
update: viewUpdate,
|
||
};
|
||
|
||
/* 折叠状态持久化(默认只展开当前页所在分组 + 概览) */
|
||
const NAV_OPEN = (() => {
|
||
try {
|
||
const raw = localStorage.getItem('drouter_nav_open');
|
||
if (raw) return JSON.parse(raw);
|
||
} catch (e) { /* ignore */ }
|
||
return null;
|
||
})();
|
||
|
||
function navSaveOpen() {
|
||
try { localStorage.setItem('drouter_nav_open', JSON.stringify(S.navOpen || {})); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
/* 分组图标:用色相生成柔和徽标底色 */
|
||
function grpIcon(gr, on) {
|
||
return `<span class="ng-ico" style="--h:${gr.hue}">${gr.icon}</span>`;
|
||
}
|
||
|
||
function renderNav() {
|
||
if (!S.navOpen) {
|
||
S.navOpen = NAV_OPEN || {};
|
||
if (!NAV_OPEN) { S.navOpen['概览'] = true; }
|
||
}
|
||
const cur = PAGES.find(p => p.k === S.page);
|
||
if (cur) S.navOpen[cur.grp] = true;
|
||
const kw = (S.navKw || '').trim().toLowerCase();
|
||
const html = NAV_GROUPS.map((gr, gi) => {
|
||
const items = gr.items.filter(it => !kw || (it.n + it.t + gr.g).toLowerCase().includes(kw));
|
||
if (!items.length) return '';
|
||
const open = !!S.navOpen[gr.g] || !!kw;
|
||
const hasCur = items.some(it => it.k === S.page);
|
||
return `<div class="ngroup${open ? ' open' : ''}${hasCur ? ' has-cur' : ''}" style="--h:${gr.hue}" data-g="${esc(gr.g)}">
|
||
<button class="ng-head" data-gh="${esc(gr.g)}" aria-expanded="${open}">
|
||
${grpIcon(gr, open)}
|
||
<span class="ng-t">${esc(gr.g)}</span>
|
||
<span class="ng-n">${items.length}</span>
|
||
<svg class="ng-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" stroke-width="2.6" stroke-linecap="round"
|
||
stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>
|
||
</button>
|
||
<div class="ng-body">${items.map(it => `
|
||
<button class="nitem${S.page === it.k ? ' on' : ''}" data-k="${esc(it.k === 'files' ? 'webshell' : it.k)}">
|
||
<span class="nitem-dot"></span>
|
||
<span class="nitem-t">${esc(it.n)}</span>
|
||
${it.tag === 'soon' ? '<span class="nitem-tag">待完善</span>' : ''}
|
||
</button>`).join('')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
$('#nav').innerHTML = html || '<div class="nav-empty">没有匹配的菜单</div>';
|
||
|
||
$$('#nav .ng-head').forEach(b => b.onclick = () => {
|
||
const g = b.dataset.gh;
|
||
S.navOpen[g] = !S.navOpen[g];
|
||
navSaveOpen();
|
||
renderNav();
|
||
});
|
||
$$('#nav .nitem').forEach(b => b.onclick = () => go(b.dataset.k));
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
页面级轮询定时器统一管理
|
||
------------------------------------------------------------------
|
||
为什么要有这个函数:单文件 SPA 里最容易漏的就是「切走页面却不停轮询」。
|
||
之前每个视图只在自己函数开头清自己的定时器 —— 那是「同一个页面重复进入」
|
||
的清理,**切到别的页面时没人清**。结果是:去 WAN 口开了「自动刷新」,
|
||
然后切到概览,WAN 日志仍在每 3 秒查一次 journal;DHCP 租约同理每 10 秒一次。
|
||
在只有 2 核的软路由上,这些后台空转是实打实的浪费(还会持续拉起
|
||
python helper 子进程)。
|
||
|
||
所以:所有「只在某个页面内有效」的定时器都登记在这里,go() 一进来先全停,
|
||
再由目标视图按需重新起。视图内的自检(如 `if (!box) return`)只能防住
|
||
DOM 写入,防不住请求,必须靠这里统一关。
|
||
------------------------------------------------------------------ */
|
||
function stopPageTimers() {
|
||
// 公网 IP 判定页有自己的一套轮询(含并发请求),交给它自己的停止函数
|
||
if (typeof pubipProbeStopPoll === 'function') pubipProbeStopPoll();
|
||
// 以下都是 interval 型定时器,清掉后置 null,view 里会重新创建
|
||
[DASH_TIMER, WANLOG_TIMER, FWL_TIMER, DK_TIMER].forEach(t => clearInterval(t));
|
||
DASH_TIMER = WANLOG_TIMER = FWL_TIMER = DK_TIMER = null;
|
||
// 这两个是「闭包内局部变量」,只能在各自的 view 里清 ——
|
||
// 用全局钩子让它们把清理动作挂出来(见 viewDhcp / viewV6Test)
|
||
if (typeof stopLeaseTimer === 'function') stopLeaseTimer();
|
||
if (typeof stopV6Timer === 'function') stopV6Timer();
|
||
}
|
||
|
||
/* 导航代次(navigation generation)——
|
||
每次切页面 +1。视图函数在 await 之前记下当前代次,await 返回后如果代次
|
||
变了,说明用户已经切走,直接放弃写入,避免「旧页面的数据覆盖新页面」。
|
||
这是单文件 SPA 里比「每个视图各自判断」更可靠的统一解法:
|
||
视图作者不需要记住加守卫,框架层兜住。 */
|
||
let NAV_GEN = 0;
|
||
|
||
/* 视图内部调用的辅助:在 await 前取一次代次,返回后用它校验 */
|
||
function navGen() { return NAV_GEN; }
|
||
function navStale(g) { return g !== NAV_GEN; }
|
||
|
||
function go(k) {
|
||
// 先停掉上一个页面留下的所有轮询,再切页面。
|
||
// 顺序很重要:必须在 S.page 赋值之前或之后都行,但一定要早于目标视图渲染。
|
||
stopPageTimers();
|
||
NAV_GEN++; // 作废所有在途的旧视图请求
|
||
S.page = k;
|
||
closeDrawer(); // 手机端:选完菜单自动收起抽屉
|
||
renderNav();
|
||
const p = PAGES.find(x => x.k === k) || {};
|
||
$('#page-title').textContent = p.t || '';
|
||
const bar = $('#actionbar');
|
||
// 动作条只给「有全局保存/应用目标」的页面:PAGE_MODULES 里登记过的,
|
||
// 以及 ACL / 文件共享这两个走专属接口、在处理器里单独分支的页面。
|
||
const hasBar = !!PAGE_MODULES[k] || k === 'acl' || k === 'nfs';
|
||
if (hasBar) { bar.classList.remove('hidden'); setActionMsg('改动后请点击「保存并应用」'); }
|
||
else bar.classList.add('hidden');
|
||
// 尚未开发的页面:给出统一占位,避免点进去像"坏了"
|
||
if (p.tag === 'soon') { renderSoon(p); return; }
|
||
$('#view').innerHTML = '<div class="card">正在载入…</div>';
|
||
const fn = VIEWS[k];
|
||
if (!fn) { renderSoon(p); return; }
|
||
// 视图可能是 async;必须捕获异常,否则渲染失败会永久停在「正在载入…」
|
||
// 同时用代次校验:如果渲染期间用户已经切到别的页面,就不要再用旧结果
|
||
// 覆盖新页面(无论是成功渲染还是错误面板)。
|
||
const gen = NAV_GEN;
|
||
Promise.resolve()
|
||
.then(() => fn())
|
||
.then(() => { if (navStale(gen)) return; })
|
||
.catch(e => {
|
||
if (navStale(gen)) return; // 已经切走,错误面板不该盖在新页面上
|
||
const el = $('#view');
|
||
if (el) el.innerHTML = `<div class="card" style="border-color:var(--err)">
|
||
<h3 style="color:var(--err)">页面渲染失败</h3>
|
||
<p class="desc">${esc(e && e.message || String(e))}</p>
|
||
<p class="desc">可点击右上角「刷新」重试,或查看「日志」页排查。</p></div>`;
|
||
toast('页面渲染失败:' + (e && e.message || e), 'err', 6000);
|
||
});
|
||
}
|
||
|
||
/* 占位页:功能已在规划中,界面结构先占位 */
|
||
function renderSoon(p) {
|
||
$('#view').innerHTML = `
|
||
<div class="card soon-card">
|
||
<div class="soon-ico"><svg width="52" height="52"><use href="#drouter-logo"/></svg></div>
|
||
<h3>${esc(p.t || '该功能')}</h3>
|
||
<p class="desc" style="max-width:560px;margin:6px auto 0">
|
||
这个模块的界面框架已经就位,具体功能还在排期开发中。
|
||
</p>
|
||
<div class="soon-pill">等待作者完善</div>
|
||
<p class="desc" style="margin-top:14px">
|
||
你可以先继续使用其他已完成的模块。如果你希望优先开发这一项,
|
||
欢迎到项目主页反馈:
|
||
<a href="https://github.com/liuzhuohua" target="_blank" rel="noopener noreferrer">github.com/liuzhuohua</a>
|
||
</p>
|
||
</div>`;
|
||
}
|
||
|
||
/* ============================ 概览 ============================ */
|
||
async function viewDash() {
|
||
const v = $('#view');
|
||
if (DASH_TIMER) { clearInterval(DASH_TIMER); DASH_TIMER = null; }
|
||
const [si, met, svc, ifc, v6, route] = await Promise.all([
|
||
api('/api/sysinfo'), api('/api/metrics'), api('/api/services'),
|
||
api('/api/ifaces'), api('/api/ipv6'), api('/api/routes')]);
|
||
const d = (si.data || {});
|
||
const m = (met.data || {});
|
||
const ifs = (ifc.data || []).filter(x => !x.is_virtual || x.name === 'docker0');
|
||
const ppp = ifs.find(x => x.name.startsWith('ppp'));
|
||
const lan = $w('lan') || {}, sys = S.cfg.system || {};
|
||
const wanset = sys.wan_iface ? ifs.find(x => x.name === sys.wan_iface) : null;
|
||
|
||
const bios = d.bios_info || {};
|
||
const virt = d.virt || {};
|
||
const cpu = d.cpu || {};
|
||
const now = new Date();
|
||
const timeStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日${now.getHours()}时${String(now.getMinutes()).padStart(2, '0')}分`;
|
||
|
||
// 磁盘展示:disk_root 是对象(之前直接拼进模板导致 [object Object])
|
||
const dk = m.disk || {};
|
||
const diskSub = dk.total_h
|
||
? `根分区 ${dk.used_h} / ${dk.total_h}(可用 ${dk.free_h})`
|
||
: '根分区(/)使用情况';
|
||
|
||
v.innerHTML = `
|
||
<div class="card" style="margin-bottom:16px">
|
||
<h3>主机信息</h3>
|
||
<div class="row" style="align-items:center">
|
||
<div style="flex:1 1 0">
|
||
<div class="kv"><b>主机名</b><span class="mono">${esc(d.hostname || '—')}</span></div>
|
||
<div class="kv"><b>BIOS 启动方式</b><span>${bios.firmware
|
||
? `<span class="tag ${bios.is_efi ? 'info' : 'ok'}">${esc(bios.firmware)}</span>`
|
||
: '<span class="tag gray">检测中…</span>'}
|
||
<span style="color:var(--txt3);font-size:12px">${esc(bios.vendor || '')} ${esc(bios.version || '')} ${esc(bios.date || '')}</span></span></div>
|
||
<div class="kv"><b>运行平台</b><span>${virt.is_vm
|
||
? `<span class="tag warn" title="${esc(virt.note || '')}">⚠ ${esc(virt.name || '虚拟机')}</span>
|
||
<span style="color:var(--txt3);font-size:12px">${esc(virt.product || '')}</span>`
|
||
: `<span class="tag ok">物理机</span>
|
||
<span style="color:var(--txt3);font-size:12px">${esc(virt.product || '')}</span>`}</span></div>
|
||
<div class="kv"><b>操作系统</b><span>${esc(d.os || d.distro || 'Debian GNU/Linux 13 (trixie)')} · 内核 ${esc(d.kernel || '')}</span></div>
|
||
<div class="kv"><b>运行时间</b><span id="dash-uptime">${fmtDur(d.uptime_s)}</span>
|
||
<span style="color:var(--txt3);font-size:12px">负载 <span id="dash-load">${esc((m.load || d.load || []).join(' / '))}</span></span></div>
|
||
<div class="kv"><b>当前时间</b><span id="dash-clock">${esc(timeStr)}</span></div>
|
||
</div>
|
||
<div style="flex:0 0 240px;text-align:center">
|
||
<div style="font-size:12px;color:var(--txt3)"
|
||
title="自开始统计以来,本机所有物理网卡累计收发的数据总量(重启不清零)。实时速率请看下方「网络实时质量」。">
|
||
累计已下载 / 已上传</div>
|
||
<div style="margin-top:4px">
|
||
<div id="dash-rx" style="font-size:20px;font-weight:600;color:#10b981">↓ — </div>
|
||
<div id="dash-tx" style="font-size:20px;font-weight:600;color:#3b82f6">↑ — </div>
|
||
</div>
|
||
<div id="dash-net-extra" style="font-size:12px;color:var(--txt3);margin-top:6px">统计起始时间读取中…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid g4" style="margin-bottom:16px" id="dash-gauges">
|
||
${gauge('CPU 使用率', m.cpu_pct, cpu.model || (d.cpu_count || '—') + ' 核', '#f59e0b',
|
||
`${cpu.cores || d.cpu_count || '—'} 逻辑核心 · ${cpu.physical_cores || '—'} 物理核心${cpu.mhz ? ' · ' + cpu.mhz + ' MHz' : ''}`)}
|
||
${gauge('内存使用率', (m.mem || {}).pct, `${(m.mem || {}).used_mb || 0} / ${(m.mem || {}).total_mb || 0} MB`, '#3b82f6',
|
||
`可用 ${(m.mem || {}).avail_mb || 0} MB 共 ${(m.mem || {}).total_mb || 0} MB`)}
|
||
${gauge('磁盘使用率', dk.pct, '', '#10b981', diskSub)}
|
||
${gauge('进程 / 温度', d.proc_pct_scale != null ? d.proc_pct_scale : null,
|
||
`${(m.proc_count || d.proc_count || 0)} 个进程`, '#8b5cf6',
|
||
((m.temp_available != null ? m.temp_available : d.temp_available)
|
||
? `CPU 温度 ${m.temp_c} °C${m.temp_src ? '(' + m.temp_src + ')' : ''}`
|
||
: '未读取到温度传感器,仅显示进程数')
|
||
+ ` 进程上限 ${Number(d.proc_max || 0).toLocaleString()}(占用 ${d.proc_pct != null ? d.proc_pct : '—'}%)`,
|
||
((m.temp_available != null ? !m.temp_available : !d.temp_available) && d.temp_hint) ? { warn: d.temp_hint } : null)}
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<h3>网络实时质量</h3>
|
||
<p class="desc">每 2 秒自动刷新。速率按物理网卡合计;抖动 / 响应对网关(或公共 DNS)探测,结果缓存 20 秒以节省资源。</p>
|
||
<div class="grid g4" id="dash-quality">
|
||
${stat('下行速率', '<span id="q-rx">—</span>', '<span id="q-rx-sub">实时</span>')}
|
||
${stat('上行速率', '<span id="q-tx">—</span>', '<span id="q-tx-sub">实时</span>')}
|
||
${stat('响应延迟(RTT)', '<span id="q-rtt">—</span>', '<span id="q-rtt-sub">探测中…</span>')}
|
||
${stat('抖动 / 丢包', '<span id="q-jit">—</span>', '<span id="q-jit-sub">—</span>')}
|
||
</div>
|
||
<div class="row" style="margin-top:8px;gap:16px">
|
||
<span class="desc" style="margin:0">包速率:<span id="q-pps" class="mono">—</span></span>
|
||
<span class="desc" style="margin:0">已建立连接:<span id="q-estab" class="mono">—</span></span>
|
||
<span class="desc" style="margin:0">TCP 重传累计:<span id="q-retrans" class="mono">—</span></span>
|
||
<span class="desc" style="margin:0">统计网卡:<span id="q-dev" class="mono">全部物理口</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="grid g2">
|
||
<div class="card">
|
||
<h3>网络状态</h3><p class="desc">当前接口地址与默认路由</p>
|
||
${ifs.length ? ifs.map(i => `<div class="kv"><b>${esc(i.name)}</b><span>
|
||
${i.addrs.filter(a => a.family === 'inet' || a.family === 'inet6').slice(0, 3)
|
||
.map(a => `<span class="mono">${esc(a.addr)}${a.family === 'inet' ? '/' + a.prefix : ''}</span>`).join('<br>') || '<span class="tag gray">无地址</span>'}
|
||
${i.oper === 'UP' ? '<span class="tag ok">UP</span>' : '<span class="tag gray">DOWN</span>'}
|
||
</span></div>`).join('') : '<div class="kv">无</div>'}
|
||
<div class="kv"><b>默认路由</b><span class="mono">${esc(((route.data || {}).v4 || [])
|
||
.filter(r => r.dst === 'default').map(r => r.gateway + ' (dev ' + r.dev + ')').join('<br>') || '无')}</span></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>服务状态</h3><p class="desc">核心服务运行情况(灰色为未启用)</p>
|
||
${Object.entries(svc.data || {}).map(([k, x]) => {
|
||
const on = x.active === 'active';
|
||
const cn = ({ dnsmasq: 'DHCP/DNS 服务', radvd: 'IPv6 RA', dhcpcd: 'DHCPv6 客户端', miniupnpd: 'UPnP', chrony: 'NTP 客户端', 'systemd-networkd': 'networkd', NetworkManager: 'NetworkManager', sshd: 'SSH', 'drouter-web': '管理后台', lightdm: 'LightDM 桌面', rsyslog: '日志服务' })[k] || k;
|
||
return `<div class="kv"><b>${esc(cn)}</b><span><span class="dot ${on ? 'on' : 'off'}"></span>
|
||
${on ? '运行中' : '未运行'} <span class="tag ${x.enabled === 'enabled' ? 'info' : 'gray'}">${x.enabled === 'enabled' ? '开机自启' : '禁用'}</span></span></div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>IPv6 状态</h3><p class="desc">WAN 地址 / 前缀委派(PD)/ 邻居表</p>
|
||
${(() => {
|
||
const v6d = v6.data || {};
|
||
const g = (v6d.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6')
|
||
.map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope })));
|
||
const global = g.filter(x => x.sc === 'global');
|
||
return `<div class="kv"><b>公网 IPv6 地址</b><span class="mono">${global.length ? global.map(x => esc(x.a) + '/' + x.p + ' <span class="tag gray">' + esc(x.if) + '</span>').join('<br>') : '<span class="tag warn">未获取</span>'}</span></div>
|
||
<div class="kv"><b>默认路由</b><span class="mono">${esc(v6d.default_route || '无')}</span></div>
|
||
<div class="kv"><b>IPv6 转发</b><span>${(v6d.sysctl || {})['all.forwarding'] === '1' ? '<span class="tag ok">已开启</span>' : '<span class="tag gray">未开启</span>'}</span></div>
|
||
<div class="kv"><b>邻居数量</b><span>${(v6d.neigh || []).length}</span></div>`;
|
||
})()}
|
||
</div>
|
||
<div class="card">
|
||
<h3>当前阶段说明</h3>
|
||
<p class="desc">本项目采用「先构建、后切换」策略,确保不影响正在运行的局域网与 RealVNC 会话。</p>
|
||
<div class="kv"><b>构建阶段</b><span><span class="tag info">进行中</span>
|
||
DHCP 服务、防火墙规则、拨号均<strong>只保存不启用</strong></span></div>
|
||
<div class="kv"><b>WAN 口</b><span>${sys.wan_iface ? esc(sys.wan_iface) : '<span class="tag warn">未分配(请先添加网卡,然后在「网卡与桥接」中指派)</span>'}</span></div>
|
||
<div class="kv"><b>LAN 口</b><span class="mono">${esc(sys.lan_iface || '—')} · ${esc(sys.lan_address || '')}</span></div>
|
||
<div class="kv"><b>切换方式</b><span>填写完 PPPoE 账号密码并保存后,由你决定何时执行切换</span></div>
|
||
</div>`;
|
||
startDashLive(d, m);
|
||
}
|
||
|
||
/* ---------- 概览实时刷新(#2 / #3 / #13) ----------
|
||
只轮询一个轻量接口 /api/metrics(后端已做差分缓存,成本极低),
|
||
每 2 秒局部更新数字,不重建 DOM,避免闪烁与卡顿。 */
|
||
let DASH_TIMER = null;
|
||
|
||
/* 页面级定时器的「停止钩子」——
|
||
租约与 IPv6 测试的定时器声明在各自 view 的闭包里,外面拿不到引用。
|
||
这里放两个全局钩子,由 view 在创建定时器时挂上、在 go() 里统一调用。
|
||
默认是空函数,所以即使从没进过那个页面也不会报错。 */
|
||
let stopLeaseTimer = function () {};
|
||
let stopV6Timer = function () {};
|
||
|
||
function startDashLive(d, m) {
|
||
if (DASH_TIMER) { clearInterval(DASH_TIMER); DASH_TIMER = null; }
|
||
const apply = (x) => {
|
||
if (!x) return;
|
||
const set = (id, val, color) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.innerHTML = val;
|
||
if (color) el.style.color = color;
|
||
};
|
||
const mem = x.mem || {}, net = x.net || {}, rtt = x.rtt || {},
|
||
tcp = x.tcp || {}, dk = x.disk || {};
|
||
// 顶部仪表卡(局部更新文字,不重绘卡片结构)
|
||
const g = document.getElementById('dash-gauges');
|
||
if (g) {
|
||
const cards = g.querySelectorAll('.stat');
|
||
const put = (i, pct, mid, sub) => {
|
||
const c = cards[i]; if (!c) return;
|
||
const valEl = c.querySelector('.val');
|
||
const barI = c.querySelector('.bar > i');
|
||
const subEl = c.querySelector('.sub');
|
||
const hasV = pct !== null && pct !== undefined && isFinite(Number(pct));
|
||
const p = hasV ? Math.max(0, Math.min(100, Number(pct))) : 0;
|
||
const col = !hasV ? 'var(--txt3)' : (p >= 85 ? '#ef4444' : (p >= 65 ? '#f59e0b' : null));
|
||
if (valEl) valEl.innerHTML = hasV
|
||
? `<span style="color:${col || 'inherit'}">${p % 1 ? p.toFixed(1) : p}%</span>
|
||
<span style="font-size:12px;color:var(--txt3);font-weight:400">${esc(mid || '')}</span>`
|
||
: valEl.innerHTML;
|
||
if (barI && hasV) { barI.style.width = p + '%'; if (col) barI.style.background = col; }
|
||
if (subEl && sub != null) subEl.textContent = sub;
|
||
};
|
||
put(0, x.cpu_pct, (d.cpu || {}).model || '', null);
|
||
put(1, mem.pct, `${mem.used_mb || 0} / ${mem.total_mb || 0} MB`,
|
||
`可用 ${mem.avail_mb || 0} MB 共 ${mem.total_mb || 0} MB`);
|
||
put(2, dk.pct, '', dk.total_h
|
||
? `根分区 ${dk.used_h} / ${dk.total_h}(可用 ${dk.free_h})` : null);
|
||
if (cards[3]) {
|
||
const subEl = cards[3].querySelector('.sub');
|
||
if (subEl) subEl.textContent = `${x.proc_count || 0} 个进程 ${x.temp_available
|
||
? 'CPU 温度 ' + x.temp_c + ' °C' : '未读取到温度传感器,仅显示进程数'}`;
|
||
}
|
||
}
|
||
// 副卡:累计流量(实时速率只保留在下方的「网络实时质量」,避免同页重复显示)
|
||
const nt = x.net_total || {};
|
||
set('dash-rx', '↓ ' + (nt.rx_h || '—'), '#10b981');
|
||
set('dash-tx', '↑ ' + (nt.tx_h || '—'), '#3b82f6');
|
||
set('q-rx', fmtRate(net.rx_bps), '#10b981');
|
||
set('q-tx', fmtRate(net.tx_bps), '#3b82f6');
|
||
set('q-rx-sub', (net.rx_pps != null ? net.rx_pps + ' 包/秒' : '实时'));
|
||
set('q-tx-sub', (net.tx_pps != null ? net.tx_pps + ' 包/秒' : '实时'));
|
||
set('q-rtt', rtt.ms != null ? rtt.ms + ' ms' : (rtt.loss_pct === 100 ? '超时' : '—'));
|
||
set('q-rtt-sub', '目标 ' + esc(rtt.target || '—') + (rtt.cached ? '(缓存)' : ''));
|
||
set('q-jit', rtt.jitter_ms != null ? rtt.jitter_ms + ' ms' : '—');
|
||
set('q-jit-sub', rtt.loss_pct != null ? '丢包 ' + rtt.loss_pct + '%' : '—');
|
||
set('q-pps', `${fmtRate(net.rx_pps)} / ${fmtRate(net.tx_pps)}`);
|
||
set('q-estab', String(tcp.estab != null ? tcp.estab : '—'));
|
||
set('q-retrans', String(tcp.retrans != null ? tcp.retrans : '—'));
|
||
set('q-dev', (x.ifaces || []).map(i => i.name).join(' / ') || '全部物理口');
|
||
if (x.load) set('dash-load', esc(x.load.join(' / ')));
|
||
set('dash-net-extra', nt.since
|
||
? `自 ${esc(nt.since)} 起累计`
|
||
: '尚未开始累计统计');
|
||
};
|
||
apply(m);
|
||
DASH_TIMER = setInterval(async () => {
|
||
// 页面切走就停,避免后台白白耗资源
|
||
if (S.page !== 'dash' || !document.getElementById('dash-gauges')) {
|
||
clearInterval(DASH_TIMER); DASH_TIMER = null; return;
|
||
}
|
||
const r = await api('/api/metrics');
|
||
if (r.ok) apply(r.data);
|
||
}, 2000);
|
||
}
|
||
|
||
/* 速率格式化:B/s → 人类可读 */
|
||
function fmtRate(bps) {
|
||
const n = Number(bps) || 0;
|
||
if (n < 1024) return n.toFixed(0) + ' B/s';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB/s';
|
||
if (n < 1024 * 1024 * 1024) return (n / 1048576).toFixed(2) + ' MB/s';
|
||
return (n / 1073741824).toFixed(2) + ' GB/s';
|
||
}
|
||
|
||
const stat = (l, v, s, extra = '') => `<div class="stat"><div class="lbl">${l}</div>
|
||
<div class="val">${v}</div>${extra}<div class="sub">${s || ''}</div></div>`;
|
||
const bar = p => `<div class="bar"><i style="width:${Math.min(100, p)}%"></i></div>`;
|
||
|
||
/* 彩色进度条 + 数值的仪表卡(用于 CPU / 内存 / 磁盘) */
|
||
/* 彩色进度条卡。
|
||
pct 为 null/undefined 时表示"该项不可用"(例如虚拟机没有温度传感器),
|
||
此时不画进度条、不显示数字,避免把真实数值(如进程数)误当成百分比。
|
||
注意:第 2 个参数必须是"百分比 0-100",绝不要把原始数量直接传进来。 */
|
||
function gauge(label, pct, mid, color, sub, opt) {
|
||
opt = opt || {};
|
||
const hasVal = pct !== null && pct !== undefined && pct !== '' && isFinite(Number(pct));
|
||
const p = hasVal ? Math.max(0, Math.min(100, Number(pct))) : 0;
|
||
const warn = !hasVal ? 'var(--txt3)' : (p >= 85 ? '#ef4444' : (p >= 65 ? '#f59e0b' : color));
|
||
const head = hasVal
|
||
? `<span style="color:${warn}">${p % 1 ? p.toFixed(1) : p}%</span>
|
||
<span style="font-size:12px;color:var(--txt3);font-weight:400">${esc(String(mid == null ? '' : mid))}</span>`
|
||
: `<span style="color:var(--txt3);font-size:15px;font-weight:500">—</span>
|
||
<span style="font-size:12px;color:var(--txt3);font-weight:400">${esc(String(mid == null ? '不可用' : mid))}</span>`;
|
||
const bar = hasVal
|
||
? `<div class="bar" style="margin:10px 0 6px"><i style="width:${p}%;background:${warn}"></i></div>`
|
||
: `<div class="bar" style="margin:10px 0 6px;background:repeating-linear-gradient(90deg,var(--line2) 0 6px,transparent 6px 12px)"></div>`;
|
||
// 惊叹号提示:用于"该项因环境原因不可用"(虚拟机无温度传感器等)
|
||
const warnIcon = opt.warn ? `<span class="gauge-warn" title="${esc(opt.warn)}"
|
||
role="button" tabindex="0" aria-label="查看原因">!</span>` : '';
|
||
const warnBox = opt.warn ? `<div class="gauge-note">${esc(opt.warn)}</div>` : '';
|
||
return `<div class="stat">
|
||
<div class="lbl" style="display:flex;align-items:center;gap:6px">${esc(label)}${warnIcon}</div>
|
||
<div class="val" style="display:flex;align-items:baseline;gap:8px">${head}</div>
|
||
${bar}
|
||
<div class="sub">${esc(String(sub == null ? '' : sub))}</div>
|
||
${warnBox}
|
||
</div>`;
|
||
}
|
||
|
||
/* 环形饼图(SVG),用于内存等关键指标 */
|
||
function ring(pct, label, color) {
|
||
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||
const warn = p >= 85 ? '#ef4444' : (p >= 65 ? '#f59e0b' : color);
|
||
const r = 52, c = 2 * Math.PI * r, off = c * (1 - p / 100);
|
||
return `<svg viewBox="0 0 130 130" width="130" height="130" role="img" aria-label="${esc(label)}">
|
||
<circle cx="65" cy="65" r="${r}" fill="none" stroke="var(--line)" stroke-width="12"></circle>
|
||
<circle cx="65" cy="65" r="${r}" fill="none" stroke="${warn}" stroke-width="12"
|
||
stroke-linecap="round" stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
|
||
transform="rotate(-90 65 65)"></circle>
|
||
<text x="65" y="62" text-anchor="middle" font-size="24" font-weight="600"
|
||
fill="var(--txt)">${p}%</text>
|
||
<text x="65" y="82" text-anchor="middle" font-size="11" fill="var(--txt3)">${esc(label)}</text>
|
||
</svg>`;
|
||
}
|
||
|
||
const $w = k => (S.cfg[k] || {});
|
||
|
||
/* ============================ 网卡与桥接 ============================ */
|
||
const IFACE_ROLES = [
|
||
{ v: '', n: '未分配' },
|
||
{ v: 'wan', n: 'WAN(上行 / 光猫)' },
|
||
{ v: 'lan', n: 'LAN(局域网主口)' },
|
||
{ v: 'bridge', n: '桥接成员(并入网桥)' },
|
||
{ v: 'guest', n: '访客网络' },
|
||
{ v: 'iot', n: 'IoT 设备专网' },
|
||
{ v: 'dmz', n: 'DMZ(对外暴露)' },
|
||
{ v: 'mgmt', n: '管理口(仅管理访问)' },
|
||
{ v: 'unused', n: '保留 / 未接线' },
|
||
];
|
||
|
||
async function viewIface() {
|
||
const [r, m, meth] = await Promise.all([
|
||
api('/api/ifaces'), api('/api/iface/meta'), api('/api/iface_method')]);
|
||
const ifs = (r.data || []).filter(x => !x.is_virtual && x.name !== 'lo');
|
||
const meta = {};
|
||
(m.data || []).forEach(x => meta[x.mac] = x);
|
||
const method = {};
|
||
((meth.data || {}).ifaces || meth.data || []).forEach(x => {
|
||
if (x && x.name) method[x.name] = x;
|
||
});
|
||
S.meta = meta; S.ifaces = ifs; S.ifaceMethod = method;
|
||
const known = new Set((m.data || []).map(x => x.mac));
|
||
// 只统计「可插拔的物理网卡」:本机自建的虚拟设备(如 QoS 的 ifb-*)不该要求指派角色
|
||
const newOnes = ifs.filter(i => !known.has(i.mac) && !i.is_virtual && !i.managed_by);
|
||
|
||
const tip = $('#newiface-tip');
|
||
// 关闭状态按「网卡 MAC 集合」记忆:用户关掉后不再打扰,
|
||
// 但将来真插了一张新卡、集合变了,提示会重新出现。
|
||
const pendingKey = newOnes.map(x => x.mac).sort().join(',');
|
||
const dismissed = () => {
|
||
try { return localStorage.getItem('drouter_newiface_dismissed') || ''; } catch (e) { return ''; }
|
||
};
|
||
const show = newOnes.length > 0 && dismissed() !== pendingKey;
|
||
tip.classList.toggle('hidden', !show);
|
||
if (show) {
|
||
tip.innerHTML = `<div class="tip-body">检测到 ${newOnes.length} 个新网卡(${newOnes.map(x => esc(x.name)).join('、')})尚未分类,请指派角色。</div>`
|
||
+ `<button class="tip-x" id="newiface-x" title="关闭此提示(将来出现新的未分类网卡时会再次提醒)" aria-label="关闭提示">✕</button>`;
|
||
const x = $('#newiface-x');
|
||
if (x) {
|
||
x.onclick = () => {
|
||
try { localStorage.setItem('drouter_newiface_dismissed', pendingKey); } catch (e) { /* ignore */ }
|
||
tip.classList.add('hidden');
|
||
};
|
||
}
|
||
}
|
||
|
||
const roleOpts = v => IFACE_ROLES.map(o =>
|
||
`<option value="${o.v}" ${v === o.v ? 'selected' : ''}>${o.n}</option>`).join('');
|
||
|
||
const drvTag = src => {
|
||
if (!src) return '<span class="tag gray">未知</span>';
|
||
if (src.indexOf('厂商') >= 0) return `<span class="tag info">${esc(src)}</span>`;
|
||
if (src.indexOf('内核') >= 0 || src.indexOf('发行版') >= 0) return `<span class="tag ok">${esc(src)}</span>`;
|
||
return `<span class="tag warn">${esc(src)}</span>`;
|
||
};
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>物理网卡</h3>
|
||
<p class="desc">以 <strong>MAC 地址</strong>作为唯一标识,即使网卡名变化(ens18 → enp6s18)也能找回你的备注与角色。
|
||
每一行都显示:Debian 13 当前的配置方式(手动 / DHCP / 静态)、驱动版本与来源、角色。</p>
|
||
${ifs.length ? ifs.map(i => {
|
||
const m2 = meta[i.mac] || {};
|
||
const mt = method[i.name] || {};
|
||
const spd = i.speed ? i.speed + ' Mbps' : '—';
|
||
const stTag = mt.state === 'UP' || i.oper === 'UP'
|
||
? '<span class="tag ok">UP</span>' : '<span class="tag gray">DOWN</span>';
|
||
const mTag = mt.method
|
||
? `<span class="tag ${mt.method === 'DHCP 自动' ? 'info' : (mt.method === '静态地址' ? 'warn' : (mt.method === '手动' ? 'gray' : 'gray'))}">${esc(mt.method)}</span>`
|
||
: '<span class="tag gray">未配置</span>';
|
||
return `<div class="ifrow" style="align-items:flex-start">
|
||
<div style="flex:0 0 170px">
|
||
<div class="nm">${esc(i.name)} ${stTag}</div>
|
||
<div class="mac mono">${esc(i.mac)}</div>
|
||
<div style="margin-top:4px">${mTag}</div>
|
||
</div>
|
||
<div style="flex:0 0 180px;font-size:12px;color:var(--txt3)">
|
||
速率 ${spd}<br>MTU ${i.mtu}<br>
|
||
驱动 ${esc(i.driver || '—')}
|
||
${i.driver_version && i.driver_version !== '未知' ? '<span class="tag gray">v' + esc(i.driver_version) + '</span>' : ''}<br>
|
||
${drvTag(i.driver_source)}
|
||
</div>
|
||
<div style="flex:0 0 150px;font-size:12px;color:var(--txt3)">
|
||
${(i.addrs || []).filter(a => a.family === 'inet').map(a => `<span class="mono">${esc(a.addr)}/${a.prefix}</span>`).join('<br>') || '无 IPv4'}
|
||
${mt.connection ? '<br>连接:' + esc(mt.connection) : ''}
|
||
</div>
|
||
<label style="margin:0;flex:1 1 0">备注
|
||
<input data-mac="${esc(i.mac)}" data-f="remark" value="${esc(m2.remark || '')}" placeholder="例如:电信光猫 / 客厅交换"></label>
|
||
<label style="margin:0;flex:0 0 190px">角色
|
||
<select data-mac="${esc(i.mac)}" data-f="role">${roleOpts(m2.role || '')}</select></label>
|
||
<button class="small" data-ifsave="${esc(i.mac)}" data-name="${esc(i.name)}">保存本卡</button>
|
||
</div>`;
|
||
}).join('') : '<p class="desc">未检测到物理网卡。</p>'}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>驱动来源说明</h3>
|
||
<p class="desc">帮助判断网卡驱动的可靠性。绿色为随 Debian 内核自带(最稳),蓝色为硬件厂商官方驱动,橙色为第三方编译(升级内核后可能失效)。</p>
|
||
<div class="kv"><b><span class="tag ok">内核内建</span></b><span>随 Debian 内核一起发布,无需额外安装,升级内核自动跟随。</span></div>
|
||
<div class="kv"><b><span class="tag info">厂商官方</span></b><span>硬件厂商(如 Intel、Realtek、Broadcom)发布的官方版本,稳定性好但需手动更新。</span></div>
|
||
<div class="kv"><b><span class="tag ok">发行版官方源</span></b><span>来自 Debian 官方仓库的固件包(如 firmware-iwlwifi),随 apt 升级。</span></div>
|
||
<div class="kv"><b><span class="tag warn">第三方(DKMS)</span></b><span>为较新硬件手动编译的驱动(如 r8125、r8168),内核升级时会自动重编译。</span></div>
|
||
<div class="kv"><b><span class="tag warn">第三方(手工编译)</span></b><span>手工 make install 的驱动,内核升级后需要重新编译,否则网卡会失联。</span></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>网桥(桥接剩余网口)</h3>
|
||
<p class="desc">将多个网口桥接为一个二层交换域。把多个网口的角色设为「桥接成员」后,可在此统一启用网桥。</p>
|
||
<label class="switch"><input type="checkbox" id="br-en" ${$w('system').bridge_enabled ? 'checked' : ''}><i></i>启用网桥</label>
|
||
<div class="row">
|
||
<label>网桥名称<input id="br-name" value="${esc(($w('system').bridge_name) || 'br0')}"></label>
|
||
<label>生成树协议 STP<select id="br-stp">
|
||
<option value="0">关闭(推荐)</option><option value="1" ${$w('system').bridge_stp ? 'selected' : ''}>开启</option></select></label>
|
||
</div>
|
||
<div id="br-members">${ifs.map(i => `<label class="switch"><input type="checkbox" class="brm" value="${esc(i.name)}"
|
||
${(($w('system').bridge_members) || []).includes(i.name) ? 'checked' : ''}><i></i>${esc(i.name)} <span class="mono" style="color:var(--txt3)">${esc(i.mac)}</span></label>`).join('')}</div>
|
||
<p class="hint-inline">注意:勾选成员口后应用会重配该网卡,可能导致链路瞬时中断。</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>新增网卡监测</h3>
|
||
<p class="desc">页面每次加载都会比对当前网卡 MAC 集合与已知清单,出现未知 MAC 时会在顶部提示。</p>
|
||
<div class="kv"><b>当前网卡数</b><span>${ifs.length}</span></div>
|
||
<div class="kv"><b>已知(已分类)</b><span>${(m.data || []).filter(x => x.role).length}</span></div>
|
||
<div class="kv"><b>待分类</b><span>${newOnes.length ? '<span class="tag warn">' + newOnes.length + ' 个</span>' : '<span class="tag ok">无</span>'}</span></div>
|
||
</div>`;
|
||
|
||
$$('[data-ifsave]').forEach(b => b.onclick = async () => {
|
||
const mac = b.dataset.ifsave;
|
||
const remark = $(`input[data-mac="${mac}"][data-f=remark]`).value;
|
||
const role = $(`select[data-mac="${mac}"][data-f=role]`).value;
|
||
const r2 = await api('/api/iface/save', { method: 'POST', body: { mac, name: b.dataset.name, remark, role } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
if (r2.ok) { setActionMsg('网卡信息已保存:' + b.dataset.name + ' 角色=' + (role || '未分配'), 'ok'); }
|
||
});
|
||
$('#br-en').onchange = e => { S.cfg.system = Object.assign($w('system'), { bridge_enabled: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); };
|
||
$('#br-name').oninput = e => { S.cfg.system = Object.assign($w('system'), { bridge_name: e.target.value }); setActionMsg('已修改,请点击保存并应用'); };
|
||
$$('.brm').forEach(c => c.onchange = () => {
|
||
const sel = $$('.brm').filter(x => x.checked).map(x => x.value);
|
||
S.cfg.system = Object.assign($w('system'), { bridge_members: sel });
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
});
|
||
}
|
||
|
||
/* ============================ WAN ============================ */
|
||
let WANLOG_TIMER = null;
|
||
function viewWan() {
|
||
const p = $w('pppoe'), sys = $w('system');
|
||
const hasWan = !!sys.wan_iface;
|
||
// 若 pppoe.iface 未设置,默认选中角色为 wan 的网卡(免去用户手选)
|
||
const roleWan = (S.ifaces.find(i => i.role === 'wan') || {}).name || '';
|
||
const defaultWan = sys.wan_iface || roleWan;
|
||
// 当前连接方式:优先取已保存的,其次按是否填了账号推断
|
||
const curMode = p.mode || 'pppoe';
|
||
$('#view').innerHTML = `
|
||
${hasWan ? `<div class="card" style="border-color:#bfe3c4;background:var(--ok-l)">
|
||
<h3 style="color:var(--ok)">已检测到 WAN 口:${esc(sys.wan_iface)}</h3>
|
||
<p class="desc" style="color:var(--ok);margin:0">
|
||
可在下方填写宽带账号与密码。点击「仅保存」只写入配置,不会拨号。
|
||
需要正式拨号时,先解除构建保护模式,再点「开始拨号」。</p></div>`
|
||
: `<div class="card" style="border-color:#f0e0a8;background:var(--warn-l)">
|
||
<h3 style="color:var(--warn)">尚未分配 WAN 口</h3>
|
||
<p class="desc" style="color:var(--warn);margin:0">
|
||
请在虚拟机平台中为该主机添加第二张网卡,然后在「网卡与桥接」页面将其角色设为 WAN。
|
||
在此之前,下面填写的 PPPoE 凭据只会被保存,不会执行拨号。</p></div>`}
|
||
<div class="card">
|
||
<h3>连接方式</h3>
|
||
<p class="desc">支持 PPPoE 拨号、DHCP 自动获取、静态地址三种模式,可一键切换(切换会影响全网)。</p>
|
||
<div class="seg" id="wan-mode">
|
||
<button data-m="pppoe" class="${curMode === 'pppoe' ? 'on' : ''}">PPPoE 拨号</button>
|
||
<button data-m="dhcp" class="${curMode === 'dhcp' ? 'on' : ''}">DHCP 自动</button>
|
||
<button data-m="static" class="${curMode === 'static' ? 'on' : ''}">静态地址</button>
|
||
</div>
|
||
<div class="kv" style="margin-top:12px"><b>当前模式</b><span id="wan-mode-cur">${wanModeName(curMode)}</span></div>
|
||
<div class="hint-inline" id="wan-mode-hint"></div>
|
||
<div id="wan-mode-panel" style="margin-top:10px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>PPPoE 宽带拨号</h3>
|
||
<p class="desc">适用于中国大陆运营商:中国电信 / 中国联通 / 中国移动 / 中国广电。
|
||
实际认证方式与是否需要服务名取决于当地运营商,请以实测为准。</p>
|
||
<div class="row">
|
||
<label>运营商模板<select id="pp-isp">
|
||
<option value="auto" ${p.isp === 'auto' ? 'selected' : ''}>自动 / 通用</option>
|
||
<option value="ct" ${p.isp === 'ct' ? 'selected' : ''}>中国电信</option>
|
||
<option value="cu" ${p.isp === 'cu' ? 'selected' : ''}>中国联通</option>
|
||
<option value="cm" ${p.isp === 'cm' ? 'selected' : ''}>中国移动</option>
|
||
<option value="cbn" ${p.isp === 'cbn' ? 'selected' : ''}>中国广电</option>
|
||
</select></label>
|
||
<label>拨号网卡(WAN)<select id="pp-iface">
|
||
<option value="">未分配</option>
|
||
${S.ifaces.map(i => `<option value="${esc(i.name)}" ${(p.iface || defaultWan) === i.name ? 'selected' : ''}>${esc(i.name)} (${esc(i.mac)})${i.role === 'wan' ? ' — WAN' : ''}</option>`).join('')}
|
||
</select></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>宽带账号<input id="pp-user" value="${esc(p.username || '')}" placeholder="例如 0512xxxxxxxx" autocomplete="off"></label>
|
||
<label>宽带密码<input id="pp-pass" type="password" value="${esc(p.password || '')}" placeholder="运营商提供的密码" autocomplete="new-password"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>服务名(可留空)<input id="pp-svc" value="${esc(p.service_name || '')}" placeholder="多数地区留空"></label>
|
||
<label>MTU<input id="pp-mtu" type="number" value="${p.mtu || 1492}"></label>
|
||
<label>MRU<input id="pp-mru" type="number" value="${p.mru || 1492}"></label>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="pp-persist" ${p.persist !== false ? 'checked' : ''}><i></i>断线自动重连(persist)</label>
|
||
<div class="row">
|
||
<label>重拨间隔(秒)<input id="pp-holdoff" type="number" value="${p.holdoff || 5}"></label>
|
||
<label>最大失败次数(0=无限)<input id="pp-maxfail" type="number" value="${p.maxfail || 0}"></label>
|
||
</div>
|
||
<p class="hint-inline">凭据保存在本机 SQLite 数据库中,应用时写入 <span class="mono">/etc/ppp/chap-secrets</span>(权限 600)。</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>WAN 静态地址(静态模式时生效)</h3>
|
||
<div class="row">
|
||
<label>IP 地址 / 掩码<input id="wn-addr" value="${esc(p.static_address || '')}" placeholder="192.168.1.2/24"></label>
|
||
<label>网关<input id="wn-gw" value="${esc(p.static_gateway || '')}" placeholder="192.168.1.1"></label>
|
||
<label>DNS<input id="wn-dns" value="${esc(p.static_dns || '')}" placeholder="223.5.5.5,119.29.29.29"></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>WAN 接入方式实时状态与日志</h3>
|
||
<p class="desc">支持中国大陆家庭宽带全部主流接入方式,日志每 3 秒自动滚动刷新,全部中文呈现,
|
||
并针对每种方式给出排错提示。日志来源:<span id="wan-src" class="mono" style="font-size:12px">—</span></p>
|
||
<div class="row">
|
||
<label>接入方式<select id="wan-access">
|
||
<option value="pppoe">PPPoE 拨号</option>
|
||
<option value="dhcp">DHCP 自动(IPoE)</option>
|
||
<option value="static">静态地址(专线 / 固定 IP)</option>
|
||
<option value="pppoe_dhcp6">PPPoE + DHCPv6-PD(双栈)</option>
|
||
<option value="ipoe_dhcp6">IPoE + DHCPv6-PD(双栈)</option>
|
||
<option value="bridge">桥接旁路(不改 WAN)</option>
|
||
</select></label>
|
||
<label>时间范围<select id="wan-since">
|
||
<option value="30 min ago">最近 30 分钟</option>
|
||
<option value="2 hours ago" selected>最近 2 小时</option>
|
||
<option value="12 hours ago">最近 12 小时</option>
|
||
<option value="2 days ago">最近 2 天</option>
|
||
</select></label>
|
||
<button class="ghost small fixed" id="wan-refresh">立即刷新</button>
|
||
</div>
|
||
<p class="hint-inline" id="wan-access-desc"></p>
|
||
<div id="pp-statusbar" class="ppbar">
|
||
<span class="dot off" id="pp-dot"></span>
|
||
<b id="pp-stext">读取中…</b>
|
||
<span id="pp-smeta" style="color:var(--txt3);font-size:12px"></span>
|
||
</div>
|
||
<div id="wan-meta" class="kv" style="margin-top:8px"></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label class="switch"><input type="checkbox" id="pp-log-auto" checked><i></i>自动刷新日志(每 3 秒)</label>
|
||
<button class="ghost small fixed" id="pp-log-clear">暂停自动刷新</button>
|
||
</div>
|
||
<div id="pp-live" class="pplog"><div class="ppline">正在读取接入日志…</div></div>
|
||
<p class="hint-inline" id="wan-tip"></p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>当前 WAN 状态</h3>
|
||
<p class="desc">实时读取,无需手动刷新</p>
|
||
<div id="wan-live">读取中…</div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="ghost fixed" id="wan-test">测试连通性(ping 223.5.5.5)</button>
|
||
<button class="ghost fixed" id="pp-status">查询拨号状态</button>
|
||
</div>
|
||
<div id="wan-out"></div>
|
||
</div>
|
||
<div class="card" style="border-color:#f0c8c3">
|
||
<h3 style="color:var(--err)">执行切换(请在我方确认后进行)</h3>
|
||
<p class="desc">以下按钮会真正改变网络状态,导致当前连接短暂中断。请在填写并保存凭据、且确认 WAN 口就绪后再执行。</p>
|
||
<div class="row">
|
||
<button class="fixed" id="pp-conn">开始拨号(PPPoE connect)</button>
|
||
<button class="fixed danger" id="pp-disc">断开拨号(PPPoE disconnect)</button>
|
||
</div>
|
||
</div>`;
|
||
liveWan();
|
||
liveWanLog();
|
||
// 接入日志自动刷新(3 秒)
|
||
const pppAuto = $('#pp-log-auto');
|
||
const startPpp = () => {
|
||
if (WANLOG_TIMER) { clearInterval(WANLOG_TIMER); WANLOG_TIMER = null; }
|
||
if (pppAuto && pppAuto.checked) WANLOG_TIMER = setInterval(liveWanLog, 3000);
|
||
};
|
||
if (pppAuto) pppAuto.onchange = startPpp;
|
||
startPpp();
|
||
|
||
// 接入方式切换
|
||
const accEl = $('#wan-access');
|
||
if (accEl) {
|
||
accEl.value = p.access_type || 'pppoe';
|
||
accEl.onchange = () => {
|
||
S.cfg.pppoe = Object.assign($w('pppoe'), { access_type: accEl.value });
|
||
const d = $('#wan-access-desc');
|
||
const info = (WAN_ACCESS_MAP[accEl.value] || {});
|
||
if (d) d.textContent = info.desc || '';
|
||
setActionMsg('WAN 接入方式已选为「' + (info.n || accEl.value) + '」,请点击保存并应用');
|
||
liveWanLog();
|
||
};
|
||
}
|
||
const sinceEl = $('#wan-since');
|
||
if (sinceEl) sinceEl.onchange = liveWanLog;
|
||
const wr = $('#wan-refresh'); if (wr) wr.onclick = () => { liveWanLog(); toast('已刷新接入日志', 'ok'); };
|
||
const plc = $('#pp-log-clear'); if (plc) plc.onclick = () => {
|
||
if (pppAuto) { pppAuto.checked = false; startPpp(); }
|
||
toast('已暂停自动刷新', 'ok');
|
||
};
|
||
|
||
$$('#wan-mode button').forEach(b => b.onclick = () => {
|
||
$$('#wan-mode button').forEach(x => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
const m = b.dataset.m;
|
||
S.cfg.pppoe = Object.assign($w('pppoe'), { mode: m });
|
||
const cur = $('#wan-mode-cur');
|
||
if (cur) cur.innerHTML = wanModeName(m);
|
||
renderWanModePanel(m);
|
||
setActionMsg('连接方式已改为「' + wanModeName(m) + '」,请点击右上角「保存并应用」');
|
||
});
|
||
renderWanModePanel(curMode);
|
||
const bind = (id, key, conv) => {
|
||
const el = $(id); if (!el) return;
|
||
const h = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { [key]: conv ? conv(el.value) : el.value }); setActionMsg('已修改,请点击保存并应用'); };
|
||
el.oninput = h; if (el.tagName === 'SELECT') el.onchange = h;
|
||
};
|
||
bind('#pp-isp', 'isp'); bind('#pp-user', 'username');
|
||
bind('#pp-pass', 'password'); bind('#pp-svc', 'service_name');
|
||
// 拨号网卡单独处理,确保 iface 一定被写入
|
||
const ifEl = $('#pp-iface');
|
||
if (ifEl) {
|
||
ifEl.onchange = () => {
|
||
S.cfg.pppoe = Object.assign($w('pppoe'), { iface: ifEl.value });
|
||
setActionMsg('拨号网卡已选为 ' + (ifEl.value || '未分配') + ',请点击保存并应用');
|
||
};
|
||
}
|
||
bind('#pp-mtu', 'mtu', Number); bind('#pp-mru', 'mru', Number);
|
||
bind('#pp-holdoff', 'holdoff', Number); bind('#pp-maxfail', 'maxfail', Number);
|
||
bind('#wn-addr', 'static_address'); bind('#wn-gw', 'static_gateway'); bind('#wn-dns', 'static_dns');
|
||
$('#pp-persist').onchange = e => { S.cfg.pppoe = Object.assign($w('pppoe'), { persist: e.target.checked }); setActionMsg('已修改'); };
|
||
$('#wan-test').onclick = async () => {
|
||
$('#wan-out').innerHTML = '<pre>正在测试…</pre>';
|
||
const r = await api('/api/diag', { method: 'POST', body: { tool: 'ping', target: '223.5.5.5', count: 4 } });
|
||
$('#wan-out').innerHTML = `<pre>${esc((r.data || {}).out || r.msg_cn)}</pre>`;
|
||
};
|
||
$('#pp-status').onclick = async () => {
|
||
const r = await api('/api/ppp', { method: 'POST', body: { op: 'status' } });
|
||
toast(r.msg_cn || (r.data && r.data.up ? '已连接' : '未连接'), r.ok ? 'ok' : 'err');
|
||
};
|
||
const confirmThen = (op, word, label) => {
|
||
modal(`${label}`, `<p>此操作会立即改变 WAN 连接状态,可能导致当前网络中断。</p>
|
||
<p>请输入 <b>${word}</b> 以确认:</p><input id="cfm-in" placeholder="${word}">`,
|
||
async () => {
|
||
if ($('#cfm-in').value.trim() !== word) { toast('确认文字不正确', 'err'); return false; }
|
||
const r = await api('/api/ppp', { method: 'POST', body: { op, confirm: true } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
setTimeout(liveWan, 3000);
|
||
});
|
||
};
|
||
$('#pp-conn').onclick = () => confirmThen('connect', '确认拨号', '开始 PPPoE 拨号');
|
||
$('#pp-disc').onclick = () => confirmThen('disconnect', '确认断开', '断开 PPPoE 拨号');
|
||
}
|
||
|
||
/* WAN 接入方式统一实时状态栏 + 中文日志(#4) */
|
||
const WAN_ACCESS_MAP = {
|
||
pppoe: { n: 'PPPoE 拨号', desc: '光猫桥接 + 本机拨号,需要宽带账号密码(电信/联通/移动/广电均常见)。' },
|
||
dhcp: { n: 'DHCP 自动(IPoE)', desc: '光猫已拨号,下挂设备自动获取地址;也用于部分地区「IPoE 免拨号」接入。' },
|
||
static: { n: '静态地址(专线 / 固定 IP)', desc: '运营商分配固定公网 IP,手动配置地址、网关与 DNS,常见于企业专线。' },
|
||
pppoe_dhcp6: { n: 'PPPoE + DHCPv6-PD(双栈)', desc: 'IPv4 走 PPPoE、IPv6 通过 DHCPv6-PD 下发前缀,国内主流双栈方式。' },
|
||
ipoe_dhcp6: { n: 'IPoE + DHCPv6-PD(双栈)', desc: 'IPv4/IPv6 均由上级自动下发,适用于光猫路由模式 + IPv6 直连。' },
|
||
bridge: { n: '桥接旁路(不改 WAN)', desc: '仅做二层透传/旁路,不参与拨号,WAN 状态由上级设备决定。' },
|
||
};
|
||
|
||
async function liveWanLog() {
|
||
const box = $('#pp-live');
|
||
// 先判 DOM 再发请求。早期写法是先 fetch 再 `if (!box) return`,
|
||
// 切走页面后请求照发(每 3 秒一次 journal 查询),只是结果被丢弃。
|
||
if (!box) { if (WANLOG_TIMER) { clearInterval(WANLOG_TIMER); WANLOG_TIMER = null; } return; }
|
||
const acc = ($('#wan-access') || {}).value || (($w('pppoe').access_type) || 'pppoe');
|
||
const since = ($('#wan-since') || {}).value || '2 hours ago';
|
||
const ifname = $w('system').wan_iface || $w('pppoe').iface || '';
|
||
const r = await api('/api/wan/log?access=' + encodeURIComponent(acc) +
|
||
'&since=' + encodeURIComponent(since) + '&iface=' + encodeURIComponent(ifname) +
|
||
'&limit=150');
|
||
const d = r.data || {};
|
||
if (!r.ok) {
|
||
box.innerHTML = `<div class="ppline lv-err">读取失败:${esc(r.msg_cn || '未知错误')}</div>`;
|
||
return;
|
||
}
|
||
// 说明与来源
|
||
const sd = $('#wan-src'); if (sd) sd.textContent = d.source || '—';
|
||
const ad = $('#wan-access-desc'); if (ad) ad.textContent = d.access_desc || '';
|
||
// 状态栏
|
||
const dot = $('#pp-dot'), stext = $('#pp-stext'), smeta = $('#pp-smeta');
|
||
if (dot && stext) {
|
||
const state = d.state || 'unknown';
|
||
const cls = /connect|up/.test(state) ? 'on' : (/connect|retry|config/.test(state) ? 'warn' : 'off');
|
||
dot.className = 'dot ' + cls;
|
||
stext.textContent = d.state_cn || '未知状态';
|
||
if (smeta) smeta.textContent = (d.access_cn || '') + (d.iface ? ' 接口 ' + d.iface : '');
|
||
}
|
||
// 元信息
|
||
const mt = $('#wan-meta');
|
||
if (mt) {
|
||
const rows = d.meta || [];
|
||
mt.innerHTML = rows.length
|
||
? rows.map(x => `<div class="kv"><b>·</b><span>${esc(x)}</span></div>`).join('')
|
||
: '';
|
||
}
|
||
// 日志(最新在最下,自动滚到底)
|
||
const lines = d.lines || [];
|
||
if (lines.length) {
|
||
box.innerHTML = lines.map(l => {
|
||
const lv = l.level || 'info';
|
||
return `<div class="ppline lv-${esc(lv)}"><span class="ppt">${esc(l.ts || '')}</span>${esc(l.cn || l.raw || '')}</div>`;
|
||
}).join('');
|
||
box.scrollTop = box.scrollHeight;
|
||
} else {
|
||
box.innerHTML = `<div class="ppline">${esc(d.tip || '暂无日志。')}</div>`;
|
||
}
|
||
const tp = $('#wan-tip'); if (tp) tp.textContent = lines.length ? '' : (d.tip || '');
|
||
}
|
||
|
||
const WAN_MODES = {
|
||
pppoe: { n: 'PPPoE 拨号', hint: '通过宽带账号密码拨号上网,适用于中国大陆运营商光猫桥接场景。' },
|
||
dhcp: { n: 'DHCP 自动获取', hint: '由上级光猫或路由器自动分配 IP,适用于光猫已拨号后再接本机的情况。' },
|
||
static: { n: '静态地址', hint: '手动指定 IP / 掩码 / 网关 / DNS,适用于专线或固定 IP 场景。' },
|
||
};
|
||
const wanModeName = m => (WAN_MODES[m] || WAN_MODES.pppoe).n;
|
||
|
||
function renderWanModePanel(mode) {
|
||
const el = $('#wan-mode-panel'); if (!el) return;
|
||
const hint = $('#wan-mode-hint');
|
||
const info = WAN_MODES[mode] || WAN_MODES.pppoe;
|
||
if (hint) hint.innerHTML = info.hint;
|
||
const p = $w('pppoe');
|
||
if (mode === 'dhcp') {
|
||
el.innerHTML = `
|
||
<div class="kv"><b>地址获取</b><span>自动从上级 DHCP 服务器获取 IPv4 地址与网关</span></div>
|
||
<label>MTU<input id="dh-mtu" type="number" value="${p.dhcp_mtu || 1500}"></label>
|
||
<label class="switch"><input type="checkbox" id="dh-dns" ${p.dhcp_use_dns !== false ? 'checked' : ''}><i></i>使用上级下发的 DNS</label>
|
||
<p class="hint-inline">DHCP 模式下,WAN 口会自动获取地址;DNS 可沿用上级下发,也可在「DNS 服务」页面自行指定。</p>`;
|
||
const mt = $('#dh-mtu'); if (mt) mt.oninput = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { dhcp_mtu: Number(mt.value) }); setActionMsg('已修改,请点击保存并应用'); };
|
||
const dn = $('#dh-dns'); if (dn) dn.onchange = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { dhcp_use_dns: dn.checked }); setActionMsg('已修改,请点击保存并应用'); };
|
||
} else if (mode === 'static') {
|
||
el.innerHTML = `
|
||
<div class="row">
|
||
<label>IP 地址 / 掩码<input id="st-addr" value="${esc(p.static_address || '')}" placeholder="例如 192.168.1.2/24"></label>
|
||
<label>网关<input id="st-gw" value="${esc(p.static_gateway || '')}" placeholder="例如 192.168.1.1"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>DNS 1<input id="st-dns1" value="${esc((p.static_dns || '').split(',')[0] || '')}" placeholder="223.5.5.5"></label>
|
||
<label>DNS 2<input id="st-dns2" value="${esc((p.static_dns || '').split(',')[1] || '')}" placeholder="119.29.29.29"></label>
|
||
</div>
|
||
<p class="hint-inline">静态地址需与上级网络同网段,否则无法上网。填写后可点下方「测试连通性」验证。</p>`;
|
||
const bs = (id, k) => { const e = $(id); if (e) e.oninput = () => { S.cfg.pppoe = Object.assign($w('pppoe'), { [k]: e.value }); setActionMsg('已修改,请点击保存并应用'); }; };
|
||
bs('#st-addr', 'static_address'); bs('#st-gw', 'static_gateway');
|
||
const collectDns = () => {
|
||
const a = ($('#st-dns1') || {}).value || '', b = ($('#st-dns2') || {}).value || '';
|
||
S.cfg.pppoe = Object.assign($w('pppoe'), { static_dns: [a, b].filter(Boolean).join(',') });
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
if ($('#st-dns1')) $('#st-dns1').oninput = collectDns;
|
||
if ($('#st-dns2')) $('#st-dns2').oninput = collectDns;
|
||
} else {
|
||
el.innerHTML = `<div class="kv"><b>拨号参数</b><span>请在下方的「PPPoE 宽带拨号」卡片中填写账号密码与网卡</span></div>`;
|
||
}
|
||
return el;
|
||
}
|
||
|
||
async function liveWan() {
|
||
const el = $('#wan-live'); if (!el) return;
|
||
const r = await api('/api/ifaces');
|
||
const ifs = r.data || [];
|
||
const wan = ifs.find(i => /^ppp/.test(i.name)) || ifs.find(i => i.name === ($w('system').wan_iface || ''));
|
||
const sys = $w('system');
|
||
if (!sys.wan_iface) { el.innerHTML = `<div class="kv"><b>WAN 口</b><span><span class="tag warn">未分配</span></span></div>`; return; }
|
||
if (!wan) { el.innerHTML = `<div class="kv"><b>WAN 口</b><span class="mono">${esc(sys.wan_iface)}</span></div>
|
||
<div class="kv"><b>连接状态</b><span><span class="tag gray">未连接</span></span></div>`; return; }
|
||
const rx = (wan.stat || {}).rx_bytes || 0, tx = (wan.stat || {}).tx_bytes || 0;
|
||
const ip4 = (wan.addrs || []).filter(a => a.family === 'inet')[0];
|
||
const ip6 = (wan.addrs || []).filter(a => a.family === 'inet6' && a.scope === 'global')[0];
|
||
el.innerHTML = `
|
||
<div class="kv"><b>接口</b><span class="mono">${esc(wan.name)} ${wan.oper === 'UP' ? '<span class="tag ok">UP</span>' : '<span class="tag gray">DOWN</span>'}</span></div>
|
||
<div class="kv"><b>IPv4 地址</b><span class="mono">${ip4 ? esc(ip4.addr) + '/' + ip4.prefix : '<span class="tag warn">未获取</span>'}</span></div>
|
||
<div class="kv"><b>IPv6 地址</b><span class="mono">${ip6 ? esc(ip6.addr) + '/' + ip6.prefix : '<span class="tag warn">未获取</span>'}</span></div>
|
||
<div class="kv"><b>MTU</b><span>${wan.mtu}</span></div>
|
||
<div class="kv"><b>累计接收</b><span>${fmtBytes(rx)}</span></div>
|
||
<div class="kv"><b>累计发送</b><span>${fmtBytes(tx)}</span></div>
|
||
<div class="kv"><b>实时速率</b><span id="wan-rate">测量中…</span></div>`;
|
||
const t0 = Date.now(), r0 = rx, x0 = tx;
|
||
setTimeout(async () => {
|
||
const r2 = await api('/api/ifaces');
|
||
const w2 = (r2.data || []).find(i => i.name === wan.name); if (!w2) return;
|
||
const dt = (Date.now() - t0) / 1000;
|
||
const dr = ((w2.stat.rx_bytes || 0) - r0) / dt, dx = ((w2.stat.tx_bytes || 0) - x0) / dt;
|
||
const e2 = $('#wan-rate');
|
||
if (e2) e2.innerHTML = `↓ ${fmtBytes(dr)}/s ↑ ${fmtBytes(dx)}/s`;
|
||
}, 1500);
|
||
}
|
||
|
||
/* ============================ LAN ============================ */
|
||
function viewLan() {
|
||
const sys = $w('system');
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>局域网接口</h3>
|
||
<p class="desc">指定本路由器在局域网中的地址。当前使用 DHCP 获取的地址,建议在正式切换时改为静态。</p>
|
||
<div class="row">
|
||
<label>LAN 网卡<select id="ln-if">
|
||
<option value="">未指定</option>
|
||
${S.ifaces.map(i => `<option value="${esc(i.name)}" ${sys.lan_iface === i.name ? 'selected' : ''}>${esc(i.name)} (${esc(i.mac)})</option>`).join('')}
|
||
</select></label>
|
||
<label>LAN 地址 / 掩码<input id="ln-addr" value="${esc(sys.lan_address || '192.168.7.3/24')}" placeholder="192.168.7.3/24"></label>
|
||
<label>MTU<input id="ln-mtu" type="number" value="${sys.lan_mtu || 1500}"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>网关地址(下发给客户端)<input id="ln-gw" value="${esc(sys.gateway || '')}"></label>
|
||
<label>域名后缀<input id="ln-dom" value="${esc($w('dnsmasq').domain || 'lan')}"></label>
|
||
</div>
|
||
<p class="hint-inline">当前系统实际地址:<span class="mono" id="ln-cur"></span></p>
|
||
</div>
|
||
<div class="card">
|
||
<h3>桥接成员</h3>
|
||
<p class="desc">桥接后在「网卡与桥接」页勾选成员口;此处显示当前桥接状态。</p>
|
||
<div class="kv"><b>网桥状态</b><span>${sys.bridge_enabled ? '<span class="tag ok">已启用</span>' : '<span class="tag gray">未启用</span>'}</span></div>
|
||
<div class="kv"><b>成员口</b><span class="mono">${(sys.bridge_members || []).join(', ') || '无'}</span></div>
|
||
<div class="kv"><b>说明</b><span>当前仅一个物理网口,无剩余口可桥接</span></div>
|
||
</div>`;
|
||
const bind = (id, key, conv) => {
|
||
const el = $(id); if (!el) return;
|
||
el.oninput = el.onchange = () => { S.cfg.system = Object.assign($w('system'), { [key]: conv ? conv(el.value) : el.value }); setActionMsg('已修改,请点击保存并应用'); };
|
||
};
|
||
bind('#ln-if', 'lan_iface'); bind('#ln-addr', 'lan_address');
|
||
bind('#ln-mtu', 'lan_mtu', Number); bind('#ln-gw', 'gateway'); bind('#ln-dom', 'domain');
|
||
api('/api/ifaces').then(r => {
|
||
const i = (r.data || []).find(x => x.name === sys.lan_iface);
|
||
const a = i && (i.addrs || []).find(x => x.family === 'inet');
|
||
const e = $('#ln-cur'); if (e) e.textContent = a ? a.addr + '/' + a.prefix + '(' + (a.valid ? '动态获取' : '静态') + ')' : '—';
|
||
});
|
||
}
|
||
|
||
/* ============================ DHCP ============================ */
|
||
function viewDhcp() {
|
||
const d = $w('dnsmasq');
|
||
const opts = d.options || [];
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>DHCP 服务开关</h3>
|
||
<p class="desc">关闭时仅做 DNS 转发,不分配地址。构建阶段默认关闭,避免与现有网络中的 DHCP 冲突。</p>
|
||
<label class="switch"><input type="checkbox" id="dh-en" ${d.dhcp_enabled ? 'checked' : ''}><i></i>启用 DHCP 服务</label>
|
||
</div>
|
||
<div class="card">
|
||
<h3>地址池</h3>
|
||
<div class="row">
|
||
<label>起始地址<input id="dh-s" value="${esc(d.pool_start || '')}"></label>
|
||
<label>结束地址<input id="dh-e" value="${esc(d.pool_end || '')}"></label>
|
||
<label>子网掩码<input id="dh-m" value="${esc(d.pool_netmask || '255.255.255.0')}"></label>
|
||
<label>租期(秒)<input id="dh-t" type="number" value="${d.lease_time || 7200}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>DHCP Options(RouterOS 风格)</h3>
|
||
<p class="desc">对应 <span class="mono">dhcp-option=<code>,<value></span>。
|
||
<b>code 3</b> = 默认网关,<b>code 6</b> = DNS 服务器。可自由添加任意 code(如 43、121 等)。</p>
|
||
<div class="row" style="margin-bottom:12px">
|
||
<label style="flex:0 0 160px">Option code 3 · 网关<input id="op-gw" value="${esc(d.option_gateway || '')}" placeholder="192.168.7.3"></label>
|
||
<label style="flex:0 0 260px">Option code 6 · DNS<input id="op-dns" value="${esc(d.option_dns || '')}" placeholder="223.5.5.5,119.29.29.29"></label>
|
||
</div>
|
||
<table><thead><tr><th style="width:44px">启用</th><th style="width:90px">Code</th>
|
||
<th>值(Value)</th><th style="width:150px">备注</th><th style="width:88px">强制</th><th style="width:64px"></th></tr></thead>
|
||
<tbody id="op-body">
|
||
${opts.map((o, i) => `<tr>
|
||
<td><input type="checkbox" class="op-en" data-i="${i}" ${o.enabled ? 'checked' : ''}></td>
|
||
<td><input class="op-code mono" data-i="${i}" value="${esc(o.code)}"></td>
|
||
<td><input class="op-val mono" data-i="${i}" value="${esc(o.value)}" placeholder="IP 或文本"></td>
|
||
<td><input class="op-rm" data-i="${i}" value="${esc(o.remark || '')}"></td>
|
||
<td><input type="checkbox" class="op-fc" data-i="${i}" ${o.force ? 'checked' : ''}></td>
|
||
<td><button class="small danger op-del" data-i="${i}">删除</button></td></tr>`).join('')}
|
||
</tbody></table>
|
||
<div style="margin-top:12px"><button class="small" id="op-add">+ 添加自定义 Option</button></div>
|
||
<p class="hint-inline">提示:勾选「强制」会使用 dhcp-option-force,即使客户端未请求也会下发。</p>
|
||
</div>
|
||
<div class="card">
|
||
<h3>静态地址绑定</h3>
|
||
<p class="desc">按 MAC 固定分配 IP,格式:<span class="mono">dhcp-host=MAC,IP[,名称]</span></p>
|
||
<table><thead><tr><th style="width:44px">启用</th><th>MAC</th><th>IP</th><th>名称</th><th style="width:64px"></th></tr></thead>
|
||
<tbody id="sl-body">
|
||
${(d.static_leases || []).map((s, i) => `<tr>
|
||
<td><input type="checkbox" class="sl-en" data-i="${i}" ${s.enabled ? 'checked' : ''}></td>
|
||
<td><input class="sl-mac mono" data-i="${i}" value="${esc(s.mac || '')}"></td>
|
||
<td><input class="sl-ip mono" data-i="${i}" value="${esc(s.ip || '')}"></td>
|
||
<td><input class="sl-nm" data-i="${i}" value="${esc(s.name || '')}"></td>
|
||
<td><button class="small danger sl-del" data-i="${i}">删除</button></td></tr>`).join('')}
|
||
</tbody></table>
|
||
<div style="margin-top:12px"><button class="small" id="sl-add">+ 添加静态绑定</button></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>当前租约(实时)</h3>
|
||
<p class="desc">dnsmasq 已分配的客户端,类似 RouterOS 的 Lease 列表。
|
||
<b>双击 IP</b> 可为该客户端选择 DHCP Options;每行右侧可一键转为静态绑定、或回收该 IP 回地址池。</p>
|
||
<div class="row" style="margin-bottom:10px">
|
||
<button class="ghost small fixed" id="dh-lease-refresh">刷新租约</button>
|
||
<label class="switch" style="margin-left:14px"><input type="checkbox" id="dh-lease-auto" checked><i></i>每 10 秒自动刷新</label>
|
||
</div>
|
||
<div id="dh-leases">读取中…</div>
|
||
</div>`;
|
||
|
||
const sync = () => {
|
||
const o = (d.options || []).map((x, i) => {
|
||
const g = k => $(`.op-${k}[data-i="${i}"]`);
|
||
return { enabled: g('en').checked, code: g('code').value, value: g('val').value, remark: g('rm').value, force: g('fc').checked };
|
||
});
|
||
const s = (d.static_leases || []).map((x, i) => {
|
||
const g = k => $(`.sl-${k}[data-i="${i}"]`);
|
||
return { enabled: g('en').checked, mac: g('mac').value, ip: g('ip').value, name: g('nm').value };
|
||
});
|
||
S.cfg.dnsmasq = Object.assign($w('dnsmasq'), {
|
||
dhcp_enabled: $('#dh-en').checked,
|
||
pool_start: $('#dh-s').value, pool_end: $('#dh-e').value,
|
||
pool_netmask: $('#dh-m').value, lease_time: Number($('#dh-t').value),
|
||
option_gateway: $('#op-gw').value, option_dns: $('#op-dns').value,
|
||
options: o, static_leases: s,
|
||
});
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
const rebuild = () => { S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { options: d.options, static_leases: d.static_leases }); viewDhcp(); };
|
||
$$('.op-en,.op-code,.op-val,.op-rm,.op-fc').forEach(e => e.oninput = e.onchange = sync);
|
||
$$('.sl-en,.sl-mac,.sl-ip,.sl-nm').forEach(e => e.oninput = e.onchange = sync);
|
||
$('#dh-en').onchange = sync; $('#dh-s').oninput = sync; $('#dh-e').oninput = sync;
|
||
$('#dh-m').oninput = sync; $('#dh-t').oninput = sync;
|
||
$('#op-gw').oninput = sync; $('#op-dns').oninput = sync;
|
||
$$('.op-del').forEach(b => b.onclick = () => { d.options.splice(+b.dataset.i, 1); rebuild(); });
|
||
$$('.sl-del').forEach(b => b.onclick = () => { d.static_leases.splice(+b.dataset.i, 1); rebuild(); });
|
||
$('#op-add').onclick = () => { d.options.push({ enabled: true, code: '', value: '', remark: '', force: false }); rebuild(); };
|
||
$('#sl-add').onclick = () => { d.static_leases.push({ enabled: true, mac: '', ip: '', name: '' }); rebuild(); };
|
||
// 租约刷新(手动 / 自动)
|
||
let leaseTimer = null;
|
||
const loadLeases = () => {
|
||
// 先判 DOM 再发请求:切走页面后 box 就不存在了,早期实现是先 fetch 再判,
|
||
// 结果切页后仍在每 10 秒空打一次 /api/leases。这里前置判断 + 停表双保险。
|
||
if (!$('#dh-leases')) {
|
||
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
||
return Promise.resolve();
|
||
}
|
||
return api('/api/leases').then(r => {
|
||
const rows = r.data || [];
|
||
const box = $('#dh-leases'); if (!box) return;
|
||
box.innerHTML = rows.length ? `<table><thead><tr>
|
||
<th style="width:130px">IP 地址</th><th style="width:150px">MAC 地址</th><th>主机名</th>
|
||
<th style="width:110px">剩余时间</th><th style="width:96px">类型</th><th style="width:210px">操作</th></tr></thead><tbody>
|
||
${rows.map(x => `<tr>
|
||
<td class="mono lease-ip" data-ip="${esc(x.ip)}" data-mac="${esc(x.mac)}" data-host="${esc(x.name || '')}"
|
||
style="cursor:pointer;text-decoration:underline dotted" title="双击选择 DHCP Options">${esc(x.ip)}</td>
|
||
<td class="mono">${esc(x.mac)}</td>
|
||
<td>${esc(x.name || '—')}</td>
|
||
<td>${esc(x.remain_text || '—')}</td>
|
||
<td>${x.is_static ? '<span class="tag ok">静态</span>' : '<span class="tag gray">动态</span>'}</td>
|
||
<td>
|
||
<button class="small lease-mk" data-ip="${esc(x.ip)}" data-mac="${esc(x.mac)}" data-host="${esc(x.name || '')}"
|
||
${x.is_static ? 'disabled' : ''}>转为静态</button>
|
||
<button class="small danger lease-rm" data-ip="${esc(x.ip)}" data-mac="${esc(x.mac)}">回收 IP</button>
|
||
</td></tr>`).join('')}
|
||
</tbody></table>` : '<p class="desc">暂无租约记录(DHCP 服务未启动或尚无客户端)。</p>';
|
||
bindLeaseActions();
|
||
});
|
||
};
|
||
// 把「停表」动作挂到全局钩子,go() 切页时会调用它(闭包内变量外面拿不到)
|
||
stopLeaseTimer = () => { if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; } };
|
||
const rf = $('#dh-lease-refresh');
|
||
if (rf) rf.onclick = () => { loadLeases(); toast('租约已刷新', 'ok'); };
|
||
const auto = $('#dh-lease-auto');
|
||
if (auto) {
|
||
auto.onchange = () => {
|
||
stopLeaseTimer();
|
||
if (auto.checked) leaseTimer = setInterval(loadLeases, 10000);
|
||
};
|
||
if (auto.checked) leaseTimer = setInterval(loadLeases, 10000);
|
||
}
|
||
}
|
||
|
||
/* 绑定租约表格上的按钮:转为静态 / 回收 / 双击选 Options */
|
||
function bindLeaseActions() {
|
||
$$('.lease-mk').forEach(b => b.onclick = async () => {
|
||
const r = await api('/api/lease/static', { method: 'POST',
|
||
body: { ip: b.dataset.ip, mac: b.dataset.mac, host: b.dataset.host } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
if (r.ok && S.cfg.dnsmasq) {
|
||
// 同步到页面上的静态绑定表
|
||
const lst = ($w('dnsmasq').static_leases || []);
|
||
if (!lst.some(x => (x.mac || '').toLowerCase() === b.dataset.mac.toLowerCase())) {
|
||
lst.push({ enabled: true, mac: b.dataset.mac, ip: b.dataset.ip, name: b.dataset.host });
|
||
S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { static_leases: lst });
|
||
}
|
||
setActionMsg('已把 ' + b.dataset.ip + ' 转为静态绑定,请点击保存并应用');
|
||
}
|
||
});
|
||
$$('.lease-rm').forEach(b => b.onclick = () => {
|
||
modal('回收 IP 地址', `<p>即将回收客户端 <b class="mono">${esc(b.dataset.ip)}</b>(${esc(b.dataset.mac)})的租约。</p>
|
||
<p>该客户端会暂时断网,下次续租时将重新分配地址。</p>
|
||
<p>请输入 <b>确认回收</b> 以继续:</p><input id="cfm-lease" placeholder="确认回收">`, async () => {
|
||
if ($('#cfm-lease').value.trim() !== '确认回收') { toast('确认文字不正确', 'err'); return false; }
|
||
const r = await api('/api/lease/release', { method: 'POST',
|
||
body: { ip: b.dataset.ip, mac: b.dataset.mac, confirm: true } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
if (r.ok) {
|
||
const rf = $('#dh-lease-refresh'); if (rf) rf.click();
|
||
}
|
||
});
|
||
});
|
||
$$('.lease-ip').forEach(td => td.ondblclick = () => leaseOptions(td.dataset));
|
||
}
|
||
|
||
/* 双击租约 IP:为该客户端配置 DHCP Options(RouterOS 风格) */
|
||
function leaseOptions(ds) {
|
||
const ip = ds.ip, mac = ds.mac, host = ds.host || '';
|
||
modal('为该客户端下发 DHCP Options', `
|
||
<p>客户端:<b class="mono">${esc(ip)}</b> 主机名:${esc(host || '—')} MAC:<span class="mono">${esc(mac)}</span></p>
|
||
<p class="desc">这些 Options 仅对当前客户端生效,等价于 dnsmasq 的
|
||
<span class="mono">dhcp-host</span> + <span class="mono">dhcp-option</span> 组合。</p>
|
||
<label>网关(code 3)<input id="lo-gw" placeholder="留空则用全局网关"></label>
|
||
<label>DNS(code 6)<input id="lo-dns" placeholder="223.5.5.5,119.29.29.29"></label>
|
||
<label>自定义 Options(每行一个 code=值)<textarea id="lo-extra" rows="3" placeholder="43=... 121=..."></textarea></label>
|
||
<label>同时转为静态绑定<select id="lo-static">
|
||
<option value="1">是(推荐)</option><option value="0">否,仅下发 Options</option></select></label>`,
|
||
async () => {
|
||
const gw = $('#lo-gw').value.trim(), dns = $('#lo-dns').value.trim();
|
||
const extra = $('#lo-extra').value.split('\n').map(x => x.trim()).filter(Boolean);
|
||
const mkStatic = $('#lo-static').value === '1';
|
||
const lst = ($w('dnsmasq').static_leases || []);
|
||
const found = lst.find(x => (x.mac || '').toLowerCase() === mac.toLowerCase());
|
||
const entry = found || { enabled: true, mac, ip, name: host };
|
||
entry.enabled = true; entry.mac = mac; entry.ip = ip; entry.name = host;
|
||
entry.options = [];
|
||
if (gw) entry.options.push({ enabled: true, code: '3', value: gw, remark: '客户端网关', force: false });
|
||
if (dns) entry.options.push({ enabled: true, code: '6', value: dns, remark: '客户端 DNS', force: false });
|
||
extra.forEach(line => {
|
||
const i = line.indexOf('=');
|
||
if (i > 0) entry.options.push({ enabled: true, code: line.slice(0, i).trim(), value: line.slice(i + 1).trim(), remark: '自定义', force: false });
|
||
});
|
||
if (!found) lst.push(entry);
|
||
S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { static_leases: lst });
|
||
if (mkStatic) {
|
||
const r = await api('/api/lease/static', { method: 'POST', body: { ip, mac, host } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
}
|
||
setActionMsg('已为该客户端配置 Options,请点击保存并应用');
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/* ============================ 防火墙 ============================ */
|
||
|
||
/* 防火墙示例规则:全部用 # 注释包裹,用户删除注释符即可启用 */
|
||
function FW_EXAMPLE(kind) {
|
||
const v4 = kind === '4';
|
||
const head = `# =====================================================================
|
||
# drouter ${v4 ? 'IPv4' : 'IPv6'} 防火墙示例规则
|
||
# 说明:以下每一行都以 # 开头(注释状态),不会生效。
|
||
# 删除行首的 # 即可启用该条规则;改完点击下方「仅语法检查」再「保存并应用」。
|
||
# 留空整个文本框 = 由系统按页面顶部参数自动生成默认规则。
|
||
# =====================================================================
|
||
|
||
# --- 表与链的骨架(一般保持启用) ---
|
||
table inet drouter {
|
||
chain input {
|
||
type filter hook input priority 0; policy drop;
|
||
# --- 放行已建立连接(务必保留) ---
|
||
ct state established,related accept
|
||
# --- 放行本机回环 ---
|
||
iif lo accept
|
||
# --- 放行 ICMP(ping) ---
|
||
ip protocol icmp accept
|
||
# --- 放行 SSH(按需修改端口) ---
|
||
tcp dport 22 accept
|
||
# --- 放行 Web 管理端口 ---
|
||
tcp dport { 8443, 8080 } accept
|
||
}
|
||
`;
|
||
|
||
const body4 = `
|
||
# --- 放行 LAN 侧全部流量 ---
|
||
chain forward {
|
||
type filter hook forward priority 0; policy drop;
|
||
ct state established,related accept
|
||
iifname "ens18" accept
|
||
oifname "ens18" accept
|
||
}
|
||
# --- 出站 NAT(PPPoE / WAN 共享上网) ---
|
||
chain postrouting {
|
||
type nat hook postrouting priority 100; policy accept;
|
||
oifname "ppp0" masquerade
|
||
}
|
||
# --- PPPoE MSS 钳制(避免部分网站打不开) ---
|
||
chain forward_mss {
|
||
type filter hook forward priority -1; policy accept;
|
||
tcp flags syn tcp option maxseg size set rt mtu
|
||
}
|
||
# =====================================================================
|
||
# 常用可选规则(取消注释后生效)
|
||
# =====================================================================
|
||
# 禁止某台设备联网:
|
||
# iifname "ens18" ether saddr 00:11:22:33:44:55 drop
|
||
# 限制某端口仅内网访问:
|
||
# iifname "ppp0" tcp dport 3389 drop
|
||
# 只允许指定 DNS 服务器:
|
||
# iifname "ens18" udp dport 53 ip daddr != 192.168.7.3 drop
|
||
}
|
||
|
||
# 如需端口转发(把外网 8080 转到内网 192.168.7.100:80):
|
||
# table ip drouter_nat {
|
||
# chain prerouting {
|
||
# type nat hook prerouting priority -100; policy accept;
|
||
# iifname "ppp0" tcp dport 8080 dnat to 192.168.7.100:80
|
||
# }
|
||
# }`;
|
||
|
||
const body6 = `
|
||
# --- 放行 LAN 侧全部流量 ---
|
||
chain forward {
|
||
type filter hook forward priority 0; policy drop;
|
||
ct state established,related accept
|
||
iifname "ens18" accept
|
||
oifname "ens18" accept
|
||
}
|
||
# =====================================================================
|
||
# 常用可选规则(取消注释后生效)
|
||
# =====================================================================
|
||
# 放行 ICMPv6(IPv6 必需,建议保留启用):
|
||
# icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, echo-request } accept
|
||
# 禁止某台设备使用 IPv6 上网:
|
||
# iifname "ens18" ether saddr 00:11:22:33:44:55 drop
|
||
# 限制某端口仅内网访问:
|
||
# iifname "ppp0" tcp dport 22 drop
|
||
}
|
||
|
||
# 如需 IPv6 端口转发(把外网 8080 转到内网 [fd00::100]:80):
|
||
# table ip6 drouter_nat6 {
|
||
# chain prerouting {
|
||
# type nat hook prerouting priority -100; policy accept;
|
||
# iifname "ppp0" tcp dport 8080 dnat to [fd00::100]:80
|
||
# }
|
||
# }`;
|
||
|
||
return head + (v4 ? body4 : body6);
|
||
}
|
||
|
||
function viewFw(kind) {
|
||
const key = kind === '4' ? 'nft_v4' : 'nft_v6';
|
||
const c = $w(key);
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>IPv${kind} 防火墙规则</h3>
|
||
<p class="desc">使用 nftables 语法。应用前会自动执行 <span class="mono">nft -c</span> 语法预检,
|
||
失败会阻止应用并保留现有规则;应用前自动创建快照,可一键回滚。</p>
|
||
<div class="row" style="margin-bottom:12px">
|
||
<label style="flex:0 0 190px">WAN 接口<input id="fw-wan" value="${esc(c.wan_iface || 'ppp0')}"></label>
|
||
<label style="flex:0 0 250px">LAN 接口(逗号分隔)<input id="fw-lan" value="${esc((c.lan_ifaces || []).join(','))}"></label>
|
||
${kind === '4' ? `<label style="flex:0 0 150px">PPPoE MSS 钳制<select id="fw-mss">
|
||
<option value="1" ${c.mss_clamp !== false ? 'selected' : ''}>开启(推荐)</option>
|
||
<option value="0" ${c.mss_clamp === false ? 'selected' : ''}>关闭</option></select></label>` : ''}
|
||
</div>
|
||
<div class="row" style="margin-bottom:12px">
|
||
<label style="flex:0 0 190px">…</label>
|
||
<button class="ghost small fixed" id="fw-def">按当前参数生成默认规则</button>
|
||
<button class="ghost small fixed" id="fw-view">查看系统当前生效规则</button>
|
||
</div>
|
||
<div class="card" style="background:var(--bg2);margin-bottom:12px">
|
||
<h3 style="font-size:14px">防火墙日志(滚动)</h3>
|
||
<p class="desc">把 nftables 命中的包记录到内核日志,并在下面实时滚动显示(已翻译成简体中文)。
|
||
记录本身会占用少量 CPU,因此内置限速(默认 20 包/秒),避免日志风暴拖垮小内存设备。</p>
|
||
<div class="row" style="margin-bottom:8px">
|
||
<label class="switch"><input type="checkbox" id="fw-logdrop" ${c.log_drop !== false ? 'checked' : ''}><i></i>记录被拒绝 / 丢弃的包(推荐)</label>
|
||
</div>
|
||
<div class="row">
|
||
<label class="switch"><input type="checkbox" id="fw-logaccept" ${c.log_accept === true ? 'checked' : ''}><i></i>同时记录被放通的包(数据量大)</label>
|
||
<label style="flex:0 0 170px">日志限速(包/秒)<input id="fw-lograte" type="number" min="1" max="1000" value="${Number(c.log_rate || 20)}"></label>
|
||
</div>
|
||
<p class="hint-inline">修改后请点击右上角「保存并应用」才会写入规则并生效。</p>
|
||
</div>
|
||
<label>规则内容(可直接编辑)
|
||
<textarea id="fw-raw" rows="26" spellcheck="false" placeholder="留空则按上面参数自动生成默认规则">${esc(c.raw || FW_EXAMPLE(kind))}</textarea></label>
|
||
<p class="hint-inline">留空 = 使用系统按参数生成的默认规则(含 NAT、转发、MSS 钳制)。
|
||
下方默认内容已用 <span class="mono">#</span> 注释包裹,删除注释符即可启用对应规则。</p>
|
||
</div>
|
||
<div class="card">
|
||
<h3>防火墙实时日志(滚动显示)</h3>
|
||
<p class="desc">数据来自内核日志中带 <span class="mono">DROUTER-FW</span> 前缀的规则命中记录,已逐条翻译。
|
||
开启自动滚动后可当作实时监控窗口使用。</p>
|
||
<div class="row" style="margin-bottom:10px">
|
||
<label style="flex:0 0 130px">协议族<select id="fwl-fam">
|
||
<option value="all">IPv4 + IPv6</option>
|
||
<option value="4">仅 IPv4</option>
|
||
<option value="6">仅 IPv6</option>
|
||
</select></label>
|
||
<label style="flex:0 0 140px">动作<select id="fwl-act">
|
||
<option value="">全部动作</option>
|
||
<option value="REJECT">拒绝</option>
|
||
<option value="DROP">丢弃</option>
|
||
<option value="ACCEPT">放通</option>
|
||
</select></label>
|
||
<label style="flex:0 0 150px">时间范围<select id="fwl-since">
|
||
<option value="15 min ago">最近 15 分钟</option>
|
||
<option value="60 min ago" selected>最近 1 小时</option>
|
||
<option value="6 hours ago">最近 6 小时</option>
|
||
<option value="24 hours ago">最近 24 小时</option>
|
||
</select></label>
|
||
<label style="flex:1 1 160px">关键字<input id="fwl-q" placeholder="IP / 端口 / 接口名"></label>
|
||
</div>
|
||
<div class="row" style="margin-bottom:10px">
|
||
<button class="ghost fixed" id="fwl-refresh">立即刷新</button>
|
||
<label class="switch"><input type="checkbox" id="fwl-auto"><i></i>自动滚动(每 3 秒)</label>
|
||
<button class="ghost small fixed" id="fwl-export">导出 JSON</button>
|
||
<button class="ghost small fixed" id="fwl-clear">清空显示</button>
|
||
</div>
|
||
<div id="fwl-stat" class="desc"></div>
|
||
<div id="fwl-out" style="margin-top:10px"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>预检与应用</h3>
|
||
<div class="row">
|
||
<button class="ghost fixed" id="fw-check">仅语法检查(不应用)</button>
|
||
<button class="fixed" id="fw-rollback">查看快照列表</button>
|
||
</div>
|
||
<div id="fw-out" style="margin-top:12px"></div>
|
||
</div>`;
|
||
const sync = () => {
|
||
S.cfg[key] = Object.assign($w(key), {
|
||
wan_iface: $('#fw-wan').value,
|
||
lan_ifaces: $('#fw-lan').value.split(',').map(x => x.trim()).filter(Boolean),
|
||
raw: $('#fw-raw').value,
|
||
log_drop: $('#fw-logdrop').checked,
|
||
log_accept: $('#fw-logaccept').checked,
|
||
log_rate: Number($('#fw-lograte').value) || 20,
|
||
...(kind === '4' ? { mss_clamp: $('#fw-mss').value === '1' } : {}),
|
||
});
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
['#fw-wan', '#fw-lan', '#fw-raw', '#fw-lograte'].forEach(s => { const e = $(s); if (e) e.oninput = sync; });
|
||
['#fw-logdrop', '#fw-logaccept'].forEach(s => { const e = $(s); if (e) e.onchange = sync; });
|
||
if (kind === '4') $('#fw-mss').onchange = sync;
|
||
$('#fw-def').onclick = () => { $('#fw-raw').value = ''; sync(); toast('已切换为按参数自动生成默认规则', 'ok'); };
|
||
$('#fw-view').onclick = async () => {
|
||
$('#fw-out').innerHTML = '<pre>读取中…</pre>';
|
||
const r = await api('/api/nft');
|
||
$('#fw-out').innerHTML = `<pre>${esc((r.data || {}).raw || r.msg_cn)}</pre>`;
|
||
};
|
||
$('#fw-check').onclick = async () => {
|
||
toast('正在执行语法预检…', 'ok');
|
||
const r = await api('/api/apply', { method: 'POST', body: { module: key, data: $w(key) } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 6000);
|
||
};
|
||
$('#fw-rollback').onclick = listSnapshots;
|
||
fwLogBind(kind);
|
||
}
|
||
|
||
/* ---------- 防火墙实时日志(#1) ---------- */
|
||
let FWL_TIMER = null;
|
||
|
||
function fwLogBind(kind) {
|
||
if (FWL_TIMER) { clearInterval(FWL_TIMER); FWL_TIMER = null; }
|
||
const fam = $('#fwl-fam');
|
||
if (fam) fam.value = 'all';
|
||
const load = () => fwLogLoad();
|
||
$('#fwl-refresh').onclick = load;
|
||
['#fwl-fam', '#fwl-act', '#fwl-since'].forEach(s => { const e = $(s); if (e) e.onchange = load; });
|
||
const q = $('#fwl-q');
|
||
if (q) q.oninput = debounce(load, 400);
|
||
$('#fwl-export').onclick = async () => {
|
||
const r = await api(fwLogUrl());
|
||
const blob = new Blob([JSON.stringify((r.data || {}).items || [], null, 2)],
|
||
{ type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'drouter-firewall-log-' + Date.now() + '.json';
|
||
a.click();
|
||
};
|
||
$('#fwl-clear').onclick = () => { $('#fwl-out').innerHTML = '<p class="desc">已清空显示(不影响内核日志)。</p>'; };
|
||
const auto = $('#fwl-auto');
|
||
if (auto) auto.onchange = () => {
|
||
if (FWL_TIMER) { clearInterval(FWL_TIMER); FWL_TIMER = null; }
|
||
if (auto.checked) {
|
||
load();
|
||
FWL_TIMER = setInterval(load, 3000);
|
||
toast('已开启自动滚动(每 3 秒)', 'ok', 2000);
|
||
} else {
|
||
toast('已关闭自动滚动', 'ok', 2000);
|
||
}
|
||
};
|
||
load();
|
||
}
|
||
|
||
function fwLogUrl() {
|
||
const fam = ($('#fwl-fam') || {}).value || 'all';
|
||
const act = ($('#fwl-act') || {}).value || '';
|
||
const since = ($('#fwl-since') || {}).value || '60 min ago';
|
||
const q = ($('#fwl-q') || {}).value || '';
|
||
return `/api/fw/log?family=${encodeURIComponent(fam)}&action=${encodeURIComponent(act)}`
|
||
+ `&since=${encodeURIComponent(since)}&q=${encodeURIComponent(q)}&limit=300`;
|
||
}
|
||
|
||
async function fwLogLoad() {
|
||
const out = $('#fwl-out');
|
||
if (!out) return;
|
||
const r = await api(fwLogUrl());
|
||
if (!r.ok) { out.innerHTML = `<p class="desc" style="color:var(--err)">${esc(r.msg_cn || '读取失败')}</p>`; return; }
|
||
const d = r.data || {};
|
||
const items = d.items || [];
|
||
const st = d.stat || {};
|
||
const statEl = $('#fwl-stat');
|
||
if (statEl) {
|
||
const en = d.enabled !== false;
|
||
// 规则没载入内核时,后面两个 tag 会直接解释「为什么一条都没有」,
|
||
// 避免用户对着空白页以为这个功能根本没做。
|
||
const ap = d.applied !== false;
|
||
const fw = d.forwarding === true;
|
||
statEl.innerHTML = `${en ? '<span class="tag ok">日志开关已开启</span>' : '<span class="tag warn">日志开关已关闭</span>'}
|
||
${ap ? '<span class="tag ok">规则已载入</span>' : '<span class="tag warn">规则未载入</span>'}
|
||
${fw ? '<span class="tag ok">IP 转发已开启</span>' : '<span class="tag gray" title="本机当前不转发流量,通常是还没接管路由">IP 转发未开启</span>'}
|
||
<span class="tag gray">共 ${st.total || 0} 条</span>
|
||
<span class="tag err">拒绝/丢弃 ${st.reject || 0}</span>
|
||
<span class="tag info">放通 ${st.accept || 0}</span>
|
||
<span class="tag gray">IPv4 ${st.v4 || 0}</span>
|
||
<span class="tag gray">IPv6 ${st.v6 || 0}</span>
|
||
<span style="color:var(--txt3);font-size:12px"> 区间:${esc(d.since || '')}</span>`;
|
||
}
|
||
if (d.warn) {
|
||
out.innerHTML = `<p class="desc" style="color:var(--warn,#f59e0b)">${esc(d.warn)}</p>`;
|
||
return;
|
||
}
|
||
if (!items.length) {
|
||
out.innerHTML = '<p class="desc">该范围内没有命中记录(这是好事:说明没有异常流量被拒绝)。</p>';
|
||
return;
|
||
}
|
||
// 最新的排在最上面,便于滚动观察
|
||
const rows = items.slice().reverse();
|
||
out.innerHTML = `<div style="max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:8px;background:var(--bg2)">
|
||
${rows.map(x => `<div class="logline" title="${esc(x.raw || '')}">
|
||
<span class="lv ${x.action === 'ACCEPT' ? 'ok' : (x.action === 'INVALID' ? 'warn' : 'err')}">${esc(x.family === 'IPv6' ? 'v6' : 'v4')}</span>
|
||
<span class="ts">${esc(x.ts || '')}</span>
|
||
<span class="mo">${esc(x.action_cn || '')}</span>
|
||
<span class="ms">${esc(x.summary_cn || '')}
|
||
${x.len ? `<span style="color:var(--txt3)">· ${esc(x.len)} 字节${x.ttl ? ' · TTL/Hop ' + esc(x.ttl) : ''}</span>` : ''}
|
||
</span>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
|
||
/* ============================ IPv6 ============================ */
|
||
|
||
/* IPv6 池策略(对应 RouterOS pool-policy) */
|
||
const V6P_POLICIES = [
|
||
{ v: 'recommend', n: '推荐(recommend)', d: '默认策略,优先选用推荐前缀,兼容性最好' },
|
||
{ v: 'strict', n: '严格(strict)', d: '严格按池范围分配,池用尽时不分新地址' },
|
||
{ v: 'without-acquire', n: '不主动获取(without-acquire)', d: '仅使用已有前缀,不主动向运营商索取新的 PD' },
|
||
{ v: 'auto-link-local', n: '自动链路本地(auto link-local)', d: '无公网前缀时回退使用 fe80:: 链路本地地址' },
|
||
];
|
||
|
||
function viewIpv6() {
|
||
const r = $w('radvd'), d = $w('dhcpv6');
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>WAN 侧 IPv6 获取</h3>
|
||
<p class="desc">通过 DHCPv6 客户端获取地址与前缀委派(PD)。
|
||
<b>注意:</b>运营商是否下发 PD 需实测,有链路地址不代表能拿到可分配给内网的前缀。</p>
|
||
<div id="v6-live">读取中…</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>DHCPv6 / 前缀委派(PD)</h3>
|
||
<div class="row">
|
||
<label>WAN 接口<input id="dv-wan" value="${esc(d.wan_iface || 'ppp0')}"></label>
|
||
<label>LAN 接口<input id="dv-lan" value="${esc(d.lan_iface || 'ens18')}"></label>
|
||
<label>子网前缀长度<input id="dv-plen" type="number" value="${d.prefix_len || 64}"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>SLA-ID 后缀<input id="dv-sla" value="${esc(d.sla_id || '::1')}"></label>
|
||
<label>IAID<input id="dv-iaid" type="number" value="${d.iaid || 0}"></label>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="dv-pd" ${d.request_pd !== false ? 'checked' : ''}><i></i>请求前缀委派(PD)</label>
|
||
<label class="switch"><input type="checkbox" id="dv-na" ${d.slaac ? 'checked' : ''}><i></i>同时请求接口地址(IA_NA)</label>
|
||
</div>
|
||
<div class="card">
|
||
<h3>局域网 RA 通告(radvd)</h3>
|
||
<p class="desc">向局域网设备通告 IPv6 路由与地址前缀。</p>
|
||
<div class="row">
|
||
<label>通告接口<input id="ra-if" value="${esc(r.iface || 'ens18')}"></label>
|
||
<label>通告前缀(如运营商下发 /64)<input id="ra-pfx" value="${esc(r.prefix || '')}" placeholder="2408:xxxx:xxxx:xxxx::/64"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>最大通告间隔(秒)<input id="ra-max" type="number" value="${r.max_interval || 600}"></label>
|
||
<label>最小通告间隔(秒)<input id="ra-min" type="number" value="${r.min_interval || 200}"></label>
|
||
<label>默认路由生命期<input id="ra-life" type="number" value="${r.lifetime || 1800}"></label>
|
||
<label>跳数限制<input id="ra-hop" type="number" value="${r.hop_limit || 64}"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>前缀有效生命期<input id="ra-vl" type="number" value="${r.valid_life || 86400}"></label>
|
||
<label>前缀首选生命期<input id="ra-pl" type="number" value="${r.pref_life || 14400}"></label>
|
||
<label>RDNSS(下发 DNS)<input id="ra-rdnss" value="${esc(r.rdnss || '')}" placeholder="2400:3200::1"></label>
|
||
<label>DNSSL(域名)<input id="ra-dnssl" value="${esc(r.dnssl || '')}" placeholder="lan"></label>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="ra-mg" ${r.managed ? 'checked' : ''}><i></i>M 标志(客户端用 DHCPv6 取地址)</label>
|
||
<label class="switch"><input type="checkbox" id="ra-oc" ${r.other_config ? 'checked' : ''}><i></i>O 标志(客户端用 DHCPv6 取其他配置)</label>
|
||
</div>
|
||
<div class="card">
|
||
<h3>ND 邻居发现参数</h3>
|
||
<p class="desc">来自内核 /proc/sys/net/ipv6,只读展示;如需修改可在服务器上用 sysctl 调整。</p>
|
||
<div id="v6-nd">读取中…</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>IPv6 地址池 / 前缀下发(RouterOS 风格)</h3>
|
||
<p class="desc">把从运营商获取的 IPv6 前缀分配给内网。类似 RouterOS 的
|
||
<span class="mono">/ipv6 address from-pool=</span> + <span class="mono">pool-policy</span>。</p>
|
||
<label class="switch"><input type="checkbox" id="v6p-en" ${($w('radvd').pool_enabled !== false) ? 'checked' : ''}><i></i>启用地址池(从池中分配地址给内网接口)</label>
|
||
<div class="row">
|
||
<label>地址来源<select id="v6p-from">
|
||
<option value="pool" ${($w('radvd').pool_from || 'pool') === 'pool' ? 'selected' : ''}>从地址池分配(from pool)</option>
|
||
<option value="manual" ${$w('radvd').pool_from === 'manual' ? 'selected' : ''}>手动指定前缀</option>
|
||
<option value="pd" ${$w('radvd').pool_from === 'pd' ? 'selected' : ''}>自动跟随 PD 前缀</option>
|
||
</select></label>
|
||
<label>池名称<input id="v6p-name" value="${esc($w('radvd').pool_name || 'drouter-wan-pool')}"></label>
|
||
<label>池前缀(可留空,取自 PD)<input id="v6p-prefix" value="${esc($w('radvd').pool_prefix || '')}" placeholder="2408:xxxx::/56"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>子网 ID 长度(借用主机位做子网)<input id="v6p-sub" type="number" value="${$w('radvd').pool_subnet_bits || 8}"></label>
|
||
<label>分配起始子网 ID<input id="v6p-start" type="number" value="${$w('radvd').pool_start || 1}"></label>
|
||
<label>分配结束子网 ID<input id="v6p-end" type="number" value="${$w('radvd').pool_end || 254}"></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>池策略(Pool Policy)</h3>
|
||
<p class="desc">对应 RouterOS 的 pool-policy,可多选。勾选的策略会在生成地址与 RA 通告时生效。</p>
|
||
<div id="v6p-policies">
|
||
${V6P_POLICIES.map(p => `<label class="switch"><input type="checkbox" class="v6pol" value="${p.v}"
|
||
${(($w('radvd').pool_policy || ['recommend']).includes(p.v)) ? 'checked' : ''}><i></i>
|
||
<b>${p.n}</b> <span style="color:var(--txt3);font-size:12px">${p.d}</span></label>`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>当前获取到的 IPv6 前缀(实时)</h3>
|
||
<p class="desc">系统自动探测,可在此为每个前缀添加备注(例如「电信 /56」)。</p>
|
||
<div id="v6-pool"><p class="desc">读取中…</p></div>
|
||
</div>`;
|
||
const rsync = () => {
|
||
S.cfg.radvd = Object.assign($w('radvd'), {
|
||
iface: $('#ra-if').value, prefix: $('#ra-pfx').value,
|
||
max_interval: Number($('#ra-max').value), min_interval: Number($('#ra-min').value),
|
||
lifetime: Number($('#ra-life').value), hop_limit: Number($('#ra-hop').value),
|
||
valid_life: Number($('#ra-vl').value), pref_life: Number($('#ra-pl').value),
|
||
rdnss: $('#ra-rdnss').value, dnssl: $('#ra-dnssl').value,
|
||
managed: $('#ra-mg').checked, other_config: $('#ra-oc').checked,
|
||
});
|
||
S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), {
|
||
wan_iface: $('#dv-wan').value, lan_iface: $('#dv-lan').value,
|
||
prefix_len: Number($('#dv-plen').value), sla_id: $('#dv-sla').value,
|
||
iaid: Number($('#dv-iaid').value),
|
||
request_pd: $('#dv-pd').checked, slaac: $('#dv-na').checked,
|
||
});
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
['#ra-if', '#ra-pfx', '#ra-max', '#ra-min', '#ra-life', '#ra-hop', '#ra-vl', '#ra-pl',
|
||
'#ra-rdnss', '#ra-dnssl', '#dv-wan', '#dv-lan', '#dv-plen', '#dv-sla', '#dv-iaid']
|
||
.forEach(s => { const e = $(s); if (e) e.oninput = rsync; });
|
||
['#ra-mg', '#ra-oc', '#dv-pd', '#dv-na'].forEach(s => { const e = $(s); if (e) e.onchange = rsync; });
|
||
|
||
// IPv6 地址池 / 池策略
|
||
const psync = () => {
|
||
const policy = $$('.v6pol').filter(x => x.checked).map(x => x.value);
|
||
S.cfg.radvd = Object.assign($w('radvd'), {
|
||
pool_enabled: $('#v6p-en').checked,
|
||
pool_from: $('#v6p-from').value,
|
||
pool_name: $('#v6p-name').value,
|
||
pool_prefix: $('#v6p-prefix').value,
|
||
pool_subnet_bits: Number($('#v6p-sub').value),
|
||
pool_start: Number($('#v6p-start').value),
|
||
pool_end: Number($('#v6p-end').value),
|
||
pool_policy: policy,
|
||
});
|
||
setActionMsg('地址池设置已修改,请点击保存并应用');
|
||
};
|
||
['#v6p-name', '#v6p-prefix', '#v6p-sub', '#v6p-start', '#v6p-end'].forEach(s => {
|
||
const e = $(s); if (e) e.oninput = psync;
|
||
});
|
||
['#v6p-en', '#v6p-from'].forEach(s => { const e = $(s); if (e) e.onchange = psync; });
|
||
$$('.v6pol').forEach(c => c.onchange = psync);
|
||
|
||
api('/api/ipv6').then(r2 => {
|
||
const d2 = r2.data || {};
|
||
const g = (d2.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6')
|
||
.map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope })));
|
||
const glob = g.filter(x => x.sc === 'global');
|
||
$('#v6-live').innerHTML = `
|
||
<div class="kv"><b>公网地址</b><span class="mono">${glob.length ? glob.map(x => esc(x.a) + '/' + x.p + ' @' + esc(x.if)).join('<br>') : '<span class="tag warn">未获取</span>'}</span></div>
|
||
<div class="kv"><b>默认路由</b><span class="mono">${esc(d2.default_route || '无')}</span></div>
|
||
<div class="kv"><b>PD 前缀</b><span class="mono">${glob.some(x => x.p <= 60) ? '<span class="tag ok">似乎已获取委派前缀</span>' : '<span class="tag gray">暂未检测到 PD(需运营商支持)</span>'}</span></div>`;
|
||
$('#v6-nd').innerHTML = Object.entries(d2.sysctl || {}).map(([k, v]) =>
|
||
`<div class="kv"><b class="mono">${esc(k)}</b><span class="mono">${esc(v)}</span></div>`).join('') || '<p class="desc">无数据</p>';
|
||
$('#v6-pool').innerHTML = glob.length
|
||
? `<table><thead><tr><th>接口</th><th>地址</th><th>前缀长度</th><th>备注</th></tr></thead><tbody>
|
||
${glob.map(x => `<tr><td class="mono">${esc(x.if)}</td><td class="mono">${esc(x.a)}</td><td>/${x.p}</td>
|
||
<td><input class="v6prem" data-pfx="${esc(x.a)}/${x.p}" placeholder="例如 电信 /56" style="width:200px"
|
||
value="${esc(($w('radvd').pool_remarks || {})[x.a + '/' + x.p] || '')}"></td></tr>`).join('')}</tbody></table>`
|
||
: '<p class="desc">暂无可记录的前缀(运营商可能未下发公网 IPv6)。</p>';
|
||
$$('.v6prem').forEach(inp => inp.oninput = () => {
|
||
const rem = Object.assign({}, $w('radvd').pool_remarks || {});
|
||
rem[inp.dataset.pfx] = inp.value;
|
||
S.cfg.radvd = Object.assign($w('radvd'), { pool_remarks: rem });
|
||
setActionMsg('前缀备注已修改,请点击保存并应用');
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ============================ DNS ============================ */
|
||
function viewDns() {
|
||
const d = $w('dnsmasq');
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>DNS 上游来源</h3>
|
||
<p class="desc">可继承运营商通过 PPPoE 下发的 DNS,也可使用自定义公共 DNS 或内网 DNS 服务器。</p>
|
||
<div class="seg" id="dns-mode">
|
||
<button data-m="isp" class="${d.dns_mode === 'isp' ? 'on' : ''}">仅运营商下发</button>
|
||
<button data-m="custom" class="${(d.dns_mode || 'custom') === 'custom' ? 'on' : ''}">仅自定义</button>
|
||
<button data-m="both" class="${d.dns_mode === 'both' ? 'on' : ''}">两者合并</button>
|
||
</div>
|
||
<div class="row" style="margin-top:14px">
|
||
<label>自定义公共 DNS<input id="dn-c" value="${esc(d.dns_custom || '')}" placeholder="223.5.5.5,119.29.29.29"></label>
|
||
<label>内网 DNS 服务器<input id="dn-l" value="${esc(d.dns_lan_server || '')}" placeholder="192.168.7.x(可留空)"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>缓存条数<input id="dn-cs" type="number" value="${d.dns_cache_size || 1000}"></label>
|
||
<label>本地域名<input id="dn-dom" value="${esc(d.domain || 'lan')}"></label>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="dn-neg" ${d.dns_negcache !== false ? 'checked' : ''}><i></i>缓存否定结果(no-negcache 的反向)</label>
|
||
<label class="switch"><input type="checkbox" id="dn-log" ${d.dns_query_log ? 'checked' : ''}><i></i>记录 DNS 查询日志(调试用,较占资源,默认关闭)</label>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="ghost fixed" id="dns-test">测试解析(本机 dig)</button>
|
||
<button class="ghost fixed" id="dns-cur">查看系统当前 DNS</button>
|
||
</div>
|
||
<div id="dns-out" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>常用公共 DNS 参考</h3>
|
||
<table><thead><tr><th>服务商</th><th>IPv4</th><th>IPv6</th></tr></thead><tbody>
|
||
<tr><td>阿里 AliDNS</td><td class="mono">223.5.5.5 / 223.6.6.6</td><td class="mono">2400:3200::1</td></tr>
|
||
<tr><td>腾讯 DNSPod</td><td class="mono">119.29.29.29 / 182.254.116.116</td><td class="mono">2402:4e00::</td></tr>
|
||
<tr><td>114 DNS</td><td class="mono">114.114.114.114 / 114.114.115.115</td><td class="mono">—</td></tr>
|
||
<tr><td>Cloudflare</td><td class="mono">1.1.1.1 / 1.0.0.1</td><td class="mono">2606:4700:4700::1111</td></tr>
|
||
<tr><td>Google</td><td class="mono">8.8.8.8 / 8.8.4.4</td><td class="mono">2001:4860:4860::8888</td></tr>
|
||
</tbody></table>
|
||
</div>`;
|
||
const sync = () => {
|
||
S.cfg.dnsmasq = Object.assign($w('dnsmasq'), {
|
||
dns_custom: $('#dn-c').value, dns_lan_server: $('#dn-l').value,
|
||
dns_cache_size: Number($('#dn-cs').value), domain: $('#dn-dom').value,
|
||
dns_negcache: $('#dn-neg').checked, dns_query_log: $('#dn-log').checked,
|
||
});
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
['#dn-c', '#dn-l', '#dn-cs', '#dn-dom'].forEach(s => { const e = $(s); if (e) e.oninput = sync; });
|
||
['#dn-neg', '#dn-log'].forEach(s => { const e = $(s); if (e) e.onchange = sync; });
|
||
$$('#dns-mode button').forEach(b => b.onclick = () => {
|
||
$$('#dns-mode button').forEach(x => x.classList.remove('on')); b.classList.add('on');
|
||
S.cfg.dnsmasq = Object.assign($w('dnsmasq'), { dns_mode: b.dataset.m });
|
||
setActionMsg('DNS 来源已改为「' + b.textContent + '」,请点击保存并应用');
|
||
});
|
||
$('#dns-test').onclick = async () => {
|
||
$('#dns-out').innerHTML = '<pre>解析中…</pre>';
|
||
const r = await api('/api/diag', { method: 'POST', body: { tool: 'dns', target: 'www.baidu.com' } });
|
||
$('#dns-out').innerHTML = `<pre>${esc((r.data || {}).out || r.msg_cn)}</pre>`;
|
||
};
|
||
$('#dns-cur').onclick = async () => {
|
||
const r = await api('/api/diag', { method: 'POST', body: { tool: 'ping', target: '223.5.5.5', count: 1 } });
|
||
$('#dns-out').innerHTML = `<pre>${esc(r.msg_cn)}\n${esc((r.data || {}).out || '')}</pre>`;
|
||
};
|
||
}
|
||
|
||
/* ============================ 端口转发 / DMZ(#7) ============================ */
|
||
const PF_PROTO_CN = { tcp: 'TCP', udp: 'UDP', 'tcp/udp': 'TCP+UDP' };
|
||
|
||
function viewPortfwd() {
|
||
const c = $w('portfwd');
|
||
const rules = (c.rules || []).map(x => Object.assign({}, x));
|
||
S.pfRules = rules.length ? rules : [newPfRule()];
|
||
const hasWan = !!($w('system').wan_iface);
|
||
|
||
$('#view').innerHTML = `
|
||
${hasWan ? '' : `<div class="card" style="border-color:#f0e0a8;background:var(--warn-l)">
|
||
<h3 style="color:var(--warn)">尚未分配 WAN 口</h3>
|
||
<p class="desc" style="color:var(--warn);margin:0">端口转发需要先有一个 WAN 口。
|
||
请到「网卡与桥接」把外网网卡角色设为 WAN。配置可先保存,应用时会给出提示。</p></div>`}
|
||
|
||
<div class="card">
|
||
<h3>端口转发(DNAT)</h3>
|
||
<p class="desc">把外网访问的某个端口,转发到内网某台设备的端口。这是最主流、最稳定的做法:
|
||
只在 <span class="mono">prerouting</span> 链做目的地址改写,并自动在 <span class="mono">forward</span> 链放通,
|
||
同时兼容内网回环访问(hairpin)。</p>
|
||
<label class="switch"><input type="checkbox" id="pf-en4" ${c.enable_v4 ? 'checked' : ''}><i></i>启用 IPv4 端口转发</label>
|
||
<label class="switch" style="margin-top:6px"><input type="checkbox" id="pf-en6" ${c.enable_v6 ? 'checked' : ''}><i></i>启用 IPv6 端口转发</label>
|
||
<p class="hint-inline">IPv6 是直连路由,通常无需 NAT;此处的 IPv6 转发用于上级做过前缀转换的场景。</p>
|
||
<div id="pf-list" style="margin-top:14px"></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="ghost fixed" id="pf-add">+ 新增一条转发</button>
|
||
<button class="ghost fixed" id="pf-clear">清空全部</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>DMZ 主机</h3>
|
||
<p class="desc">DMZ 会把<strong>所有</strong>外网端口都转发到指定的一台内网主机,等价于「1:1 全端口映射」。
|
||
常用于游戏主机、监控 NVR 等需要大量端口的设备。<strong>注意:开启后该主机将完全暴露在公网,
|
||
安全性取决于该主机自身的防护,请谨慎使用。</strong></p>
|
||
<div class="row">
|
||
<label class="switch" style="flex:0 0 auto"><input type="checkbox" id="pf-dmz4" ${c.dmz_v4_enable ? 'checked' : ''}><i></i>IPv4 DMZ</label>
|
||
<label>DMZ 主机(内网 IPv4)<input id="pf-dmz4host" value="${esc(c.dmz_v4_host || '')}" placeholder="例如 192.168.7.50"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label class="switch" style="flex:0 0 auto"><input type="checkbox" id="pf-dmz6" ${c.dmz_v6_enable ? 'checked' : ''}><i></i>IPv6 DMZ</label>
|
||
<label>DMZ 主机(IPv6)<input id="pf-dmz6host" value="${esc(c.dmz_v6_host || '')}" placeholder="例如 2408:8207:1234::50"></label>
|
||
</div>
|
||
<p class="hint-inline">DMZ 与上面的端口转发可同时存在;同名端口以端口转发规则优先。</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>规则预览(将会写入防火墙)</h3>
|
||
<p class="desc">下面是当前配置渲染出的 nftables 规则片段,仅显示与端口转发相关的部分。</p>
|
||
<button class="ghost small fixed" id="pf-preview">生成预览</button>
|
||
<div id="pf-out" style="margin-top:10px"></div>
|
||
</div>`;
|
||
|
||
renderPfList();
|
||
|
||
const bindSwitch = (id, k) => {
|
||
const e = $(id); if (!e) return;
|
||
e.onchange = () => { S.cfg.portfwd = Object.assign($w('portfwd'), { [k]: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); };
|
||
};
|
||
bindSwitch('#pf-en4', 'enable_v4'); bindSwitch('#pf-en6', 'enable_v6');
|
||
bindSwitch('#pf-dmz4', 'dmz_v4_enable'); bindSwitch('#pf-dmz6', 'dmz_v6_enable');
|
||
const bindText = (id, k) => {
|
||
const e = $(id); if (!e) return;
|
||
e.oninput = () => { S.cfg.portfwd = Object.assign($w('portfwd'), { [k]: e.value }); setActionMsg('已修改,请点击保存并应用'); };
|
||
};
|
||
bindText('#pf-dmz4host', 'dmz_v4_host'); bindText('#pf-dmz6host', 'dmz_v6_host');
|
||
|
||
$('#pf-add').onclick = () => {
|
||
S.pfRules.push(newPfRule());
|
||
renderPfList();
|
||
setActionMsg('已新增一条空白转发规则,请填写后保存');
|
||
};
|
||
$('#pf-clear').onclick = () => {
|
||
modal('清空全部端口转发', '<p>确定要清空所有端口转发规则吗?此操作需在保存并应用后生效。</p>', () => {
|
||
S.pfRules = [newPfRule()];
|
||
renderPfList();
|
||
S.cfg.portfwd = Object.assign($w('portfwd'), { rules: [] });
|
||
setActionMsg('已清空端口转发规则,请点击保存并应用');
|
||
});
|
||
};
|
||
$('#pf-preview').onclick = async () => {
|
||
syncPfRules();
|
||
$('#pf-out').innerHTML = '<pre>正在渲染…</pre>';
|
||
const r2 = await api('/api/render', { method: 'POST', body: { module: 'nft_v4' } });
|
||
const txt = (r2.data || {}).text || '';
|
||
if (!r2.ok || !txt) {
|
||
$('#pf-out').innerHTML = `<pre>${esc(r2.msg_cn || '无法获取预览')}</pre>`;
|
||
return;
|
||
}
|
||
const lines = txt.split('\n').filter(x => /dnat|DMZ|放通转发|nat_pre|dstnat/.test(x));
|
||
$('#pf-out').innerHTML = `<pre>${esc(lines.length ? lines.join('\n') : '当前无端口转发规则。')}</pre>`;
|
||
};
|
||
}
|
||
|
||
function newPfRule() {
|
||
return { name: '', proto: 'tcp', ext_port: '', int_ip: '', int_port: '', enable: true, family: 'v4' };
|
||
}
|
||
|
||
function renderPfList() {
|
||
const el = $('#pf-list'); if (!el) return;
|
||
const rows = S.pfRules || [];
|
||
el.innerHTML = rows.map((r, i) => `
|
||
<div class="pfrow" data-i="${i}" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;
|
||
padding:10px;border:1px solid var(--line);border-radius:8px;margin-bottom:8px">
|
||
<label style="flex:1 1 130px;margin:0">名称
|
||
<input data-f="name" value="${esc(r.name || '')}" placeholder="例如 群晖 DSM"></label>
|
||
<label style="flex:0 0 110px;margin:0">协议
|
||
<select data-f="proto">
|
||
${['tcp', 'udp', 'tcp/udp'].map(p => `<option value="${p}" ${r.proto === p ? 'selected' : ''}>${PF_PROTO_CN[p]}</option>`).join('')}
|
||
</select></label>
|
||
<label style="flex:0 0 110px;margin:0">IP 版本
|
||
<select data-f="family">
|
||
<option value="v4" ${(r.family || 'v4') === 'v4' ? 'selected' : ''}>IPv4</option>
|
||
<option value="v6" ${r.family === 'v6' ? 'selected' : ''}>IPv6</option>
|
||
</select></label>
|
||
<label style="flex:0 0 110px;margin:0">外网端口
|
||
<input data-f="ext_port" type="number" value="${esc(r.ext_port || '')}" placeholder="5000"></label>
|
||
<label style="flex:1 1 160px;margin:0">内网地址
|
||
<input data-f="int_ip" value="${esc(r.int_ip || '')}" placeholder="${(r.family === 'v6') ? '2408:...::10' : '192.168.7.10'}"></label>
|
||
<label style="flex:0 0 110px;margin:0">内网端口
|
||
<input data-f="int_port" type="number" value="${esc(r.int_port || '')}" placeholder="同外网"></label>
|
||
<label class="switch" style="margin:0;flex:0 0 auto"><input type="checkbox" data-f="enable" ${r.enable !== false ? 'checked' : ''}><i></i>启用</label>
|
||
<button class="ghost small" data-del="${i}" style="flex:0 0 auto">删除</button>
|
||
</div>`).join('') || '<p class="desc">暂无规则,点击下方「新增一条转发」开始添加。</p>';
|
||
|
||
$$('#pf-list [data-f]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const row = e.closest('.pfrow');
|
||
const i = Number(row.dataset.i);
|
||
const f = e.dataset.f;
|
||
let v = e.type === 'checkbox' ? e.checked : e.value;
|
||
if (f === 'ext_port' || f === 'int_port') v = v === '' ? '' : Number(v);
|
||
S.pfRules[i][f] = v;
|
||
if (f === 'family') renderPfList();
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#pf-list [data-del]').forEach(b => b.onclick = () => {
|
||
const i = Number(b.dataset.del);
|
||
if (S.pfRules.length <= 1) { S.pfRules = [newPfRule()]; }
|
||
else { S.pfRules.splice(i, 1); }
|
||
renderPfList();
|
||
setActionMsg('已删除一条规则,请点击保存并应用');
|
||
});
|
||
}
|
||
|
||
function syncPfRules() {
|
||
const clean = (S.pfRules || []).filter(r => String(r.ext_port || '').trim() !== ''
|
||
|| String(r.int_ip || '').trim() !== '');
|
||
S.cfg.portfwd = Object.assign($w('portfwd'), { rules: clean });
|
||
return clean;
|
||
}
|
||
|
||
/* ===================== 访问控制 / 家长时间组(#8) ===================== */
|
||
const ACL_DAY_CN = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
const ACL_ACTION_TAG = { block: 'err', allow: 'ok', limit: 'warn', log: 'gray' };
|
||
|
||
async function viewAcl() {
|
||
$('#view').innerHTML = `<div class="card"><h3>访问控制 / 家长时间组</h3>
|
||
<p class="desc">正在读取配置与联动状态…</p></div>`;
|
||
const r = await api('/api/acl');
|
||
if (!r.ok) {
|
||
$('#view').innerHTML = `<div class="card"><h3 style="color:var(--err)">读取失败</h3>
|
||
<p class="desc">${esc(r.msg_cn || '无法读取访问控制配置')}</p></div>`;
|
||
return;
|
||
}
|
||
const d = r.data || {};
|
||
S.acl = Object.assign({}, d, {
|
||
time_groups: (d.time_groups || []).map(x => Object.assign({ days: [0, 1, 2, 3, 4, 5, 6] }, x)),
|
||
groups: (d.groups || []).map(x => Object.assign({}, x)),
|
||
rules: (d.rules || []).map(x => Object.assign({}, x)),
|
||
});
|
||
renderAcl();
|
||
}
|
||
|
||
function renderAcl() {
|
||
const d = S.acl || {};
|
||
const tgs = d.time_groups || [];
|
||
const grps = d.groups || [];
|
||
const rules = d.rules || [];
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>总开关
|
||
<span class="tag ${d.enable ? 'ok' : 'gray'}" style="float:right">${d.enable ? '已启用' : '已停用'}</span></h3>
|
||
<p class="desc">访问控制基于「设备组 + 时间组 + 应用分类」三段式匹配:命中后在转发链上执行阻断 / 放通 / 限速 / 仅记录。
|
||
应用分类直接复用 <b>DPI(nDPI)</b> 分类库,时间规则按<b>北京时间(UTC+${esc(String(d.tz_offset || 8))})</b>换算,
|
||
跨零点的时间段会自动拆成两段写入内核。</p>
|
||
<label class="switch"><input type="checkbox" id="acl-en" ${d.enable ? 'checked' : ''}><i></i>启用访问控制</label>
|
||
<div class="row" style="margin-top:8px">
|
||
<span class="tag ${d.dpi_ready ? 'ok' : 'warn'}">DPI 分类库:${d.dpi_ready ? '已就绪' : '未安装(应用识别不可用)'}</span>
|
||
<span class="tag ${d.qos_enabled ? 'ok' : 'warn'}">QoS:${d.qos_enabled ? '已启用(限速动作可用)' : '未启用(限速动作不生效)'}</span>
|
||
<span class="tag gray">时间组 ${tgs.length} · 设备组 ${grps.length} · 规则 ${rules.length}</span>
|
||
</div>
|
||
<p class="hint-inline">${esc(d.note || '')}</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>① 设备组 <button class="ghost small fixed" id="acl-add-grp" style="float:right">+ 新增设备组</button></h3>
|
||
<p class="desc">把需要统一管控的设备归到一组,支持 IPv4 / IPv6 混合填写,也支持网段(如 192.168.7.0/24)。</p>
|
||
<div id="acl-grps"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>② 时间组 <button class="ghost small fixed" id="acl-add-tg" style="float:right">+ 新增时间组</button></h3>
|
||
<p class="desc">常用的作息模板已内置,点击即可套用,再按需微调。</p>
|
||
<div id="acl-presets" style="margin-bottom:12px"></div>
|
||
<div id="acl-tgs"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>③ 访问规则 <button class="ghost small fixed" id="acl-add-rule" style="float:right">+ 新增规则</button></h3>
|
||
<p class="desc">规则 = 谁(设备组)+ 何时(时间组)+ 什么应用(DPI 分类)→ 怎么做(动作)。规则自上而下匹配,命中即止。</p>
|
||
<div id="acl-rules"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>规则预览(将会写入内核)</h3>
|
||
<p class="desc">下面是当前配置渲染出的 nftables 规则,可直接核对时间换算与星期映射是否正确。</p>
|
||
<button class="ghost small fixed" id="acl-preview">生成预览</button>
|
||
<div id="acl-out" style="margin-top:10px"></div>
|
||
</div>`;
|
||
|
||
renderAclPresets();
|
||
renderAclGroups();
|
||
renderAclTimeGroups();
|
||
renderAclRules();
|
||
|
||
const en = $('#acl-en');
|
||
if (en) en.onchange = () => {
|
||
S.acl.enable = en.target.checked;
|
||
setActionMsg('总开关已修改,请在下方点击「保存并应用」');
|
||
};
|
||
$('#acl-add-grp').onclick = () => {
|
||
S.acl.groups.push({ id: 'g' + Date.now().toString(36), name: '', hosts: [] });
|
||
renderAclGroups();
|
||
setActionMsg('已新增一个空白设备组,请填写后保存');
|
||
};
|
||
$('#acl-add-tg').onclick = () => {
|
||
S.acl.time_groups.push({
|
||
id: 't' + Date.now().toString(36), name: '', days: [0, 1, 2, 3, 4, 5, 6],
|
||
start: '00:00', end: '23:59',
|
||
});
|
||
renderAclTimeGroups();
|
||
setActionMsg('已新增一个空白时间组,请填写后保存');
|
||
};
|
||
$('#acl-add-rule').onclick = () => {
|
||
S.acl.rules.push({
|
||
name: '', enable: true, action: 'block', group: (S.acl.groups[0] || {}).id || '',
|
||
time_group: (S.acl.time_groups[0] || {}).id || '', apps: [],
|
||
});
|
||
renderAclRules();
|
||
setActionMsg('已新增一条空白规则,请填写后保存');
|
||
};
|
||
$('#acl-preview').onclick = async () => {
|
||
$('#acl-out').innerHTML = '<pre>正在渲染…</pre>';
|
||
const r2 = await api('/api/acl', { method: 'POST', body: { op: 'plan', enable: S.acl.enable,
|
||
time_groups: S.acl.time_groups, groups: S.acl.groups, rules: S.acl.rules } });
|
||
if (!r2.ok) {
|
||
$('#acl-out').innerHTML = `<pre>${esc(r2.msg_cn || '渲染失败')}</pre>`;
|
||
return;
|
||
}
|
||
const dd = r2.data || {};
|
||
$('#acl-out').innerHTML =
|
||
`<div class="kv"><b>统计</b><span>启用规则 ${dd.rules} 条 · 时间组 ${dd.time_groups} 个 · 设备组 ${dd.groups} 个</span></div>
|
||
<pre>${esc(dd.text || '')}</pre>`;
|
||
};
|
||
}
|
||
|
||
function renderAclPresets() {
|
||
const el = $('#acl-presets'); if (!el) return;
|
||
const presets = (S.acl || {}).time_presets || [];
|
||
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:8px">${
|
||
presets.map((p, i) => `<button class="ghost small" data-preset="${i}"
|
||
title="${esc(p.why || '')}">${esc(p.name)}</button>`).join('')}</div>
|
||
<p class="hint-inline">点击模板会新建一个时间组;已存在同名则跳过。</p>`;
|
||
$$('#acl-presets [data-preset]').forEach(b => b.onclick = () => {
|
||
const p = ((S.acl || {}).time_presets || [])[Number(b.dataset.preset)];
|
||
if (!p) return;
|
||
if ((S.acl.time_groups || []).some(x => x.name === p.name)) {
|
||
toast('已存在同名时间组「' + p.name + '」', 'warn'); return;
|
||
}
|
||
S.acl.time_groups.push({
|
||
id: 't' + Date.now().toString(36), name: p.name,
|
||
days: (p.days || [0, 1, 2, 3, 4, 5, 6]).slice(),
|
||
start: p.start || '00:00', end: p.end || '23:59',
|
||
});
|
||
renderAclTimeGroups();
|
||
setActionMsg('已套用模板「' + p.name + '」,请点击保存并应用');
|
||
});
|
||
}
|
||
|
||
function renderAclGroups() {
|
||
const el = $('#acl-grps'); if (!el) return;
|
||
const rows = (S.acl || {}).groups || [];
|
||
const templates = (S.acl || {}).group_templates || [];
|
||
el.innerHTML = rows.map((g, i) => `
|
||
<div class="aclrow" data-i="${i}" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;
|
||
padding:10px;border:1px solid var(--line);border-radius:8px;margin-bottom:8px">
|
||
<label style="flex:1 1 180px;margin:0">名称
|
||
<input data-f="name" value="${esc(g.name || '')}" placeholder="例如 孩子的设备"></label>
|
||
<label style="flex:2 1 320px;margin:0">地址列表(逗号或空格分隔)
|
||
<input data-f="hosts" value="${esc((g.hosts || []).join(', '))}"
|
||
placeholder="192.168.7.20, 192.168.7.21, 2408:8207:1234::20"></label>
|
||
<button class="ghost small" data-tpl="${i}" style="flex:0 0 auto"
|
||
title="套用预设名称">模板</button>
|
||
<button class="ghost small" data-del="${i}" style="flex:0 0 auto">删除</button>
|
||
</div>`).join('') || '<p class="desc">暂无设备组,点击右上角「新增设备组」开始。</p>';
|
||
|
||
$$('#acl-grps [data-f]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const i = Number(e.closest('.aclrow').dataset.i);
|
||
const f = e.dataset.f;
|
||
if (f === 'hosts') {
|
||
S.acl.groups[i].hosts = e.value.split(/[\s,;]+/).map(x => x.trim()).filter(Boolean);
|
||
} else {
|
||
S.acl.groups[i][f] = e.value;
|
||
}
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#acl-grps [data-del]').forEach(b => b.onclick = () => {
|
||
const i = Number(b.dataset.del);
|
||
const gid = (S.acl.groups[i] || {}).id;
|
||
S.acl.groups.splice(i, 1);
|
||
// 同步清理引用了该组的规则
|
||
(S.acl.rules || []).forEach(r => { if (r.group === gid) r.group = ''; });
|
||
renderAclGroups(); renderAclRules();
|
||
setActionMsg('已删除设备组,请点击保存并应用');
|
||
});
|
||
$$('#acl-grps [data-tpl]').forEach(b => b.onclick = () => {
|
||
const i = Number(b.dataset.tpl);
|
||
if (!templates.length) { toast('没有可用的预设模板', 'warn'); return; }
|
||
modal('套用设备组模板', '<div style="display:flex;flex-direction:column;gap:8px">'
|
||
+ templates.map(t => `<button class="ghost" data-pick="${t.id}">${esc(t.name)}<br>
|
||
<span style="font-size:12px;color:var(--txt3)">${esc(t.why || '')}</span></button>`).join('')
|
||
+ '</div>');
|
||
$$('#modal [data-pick]').forEach(pb => pb.onclick = () => {
|
||
const t = templates.find(x => x.id === pb.dataset.pick);
|
||
if (t) { S.acl.groups[i].name = t.name; }
|
||
$('#modal').classList.add('hidden');
|
||
renderAclGroups();
|
||
setActionMsg('已套用模板名称,请补充地址后保存');
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderAclTimeGroups() {
|
||
const el = $('#acl-tgs'); if (!el) return;
|
||
const rows = (S.acl || {}).time_groups || [];
|
||
el.innerHTML = rows.map((t, i) => `
|
||
<div class="aclrow" data-i="${i}" style="padding:10px;border:1px solid var(--line);
|
||
border-radius:8px;margin-bottom:8px">
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||
<label style="flex:1 1 180px;margin:0">名称
|
||
<input data-f="name" value="${esc(t.name || '')}" placeholder="例如 上学日"></label>
|
||
<label style="flex:0 0 130px;margin:0">开始(本地)
|
||
<input data-f="start" type="time" value="${esc(t.start || '00:00')}"></label>
|
||
<label style="flex:0 0 130px;margin:0">结束(本地)
|
||
<input data-f="end" type="time" value="${esc(t.end || '23:59')}"></label>
|
||
<button class="ghost small" data-del="${i}" style="flex:0 0 auto">删除</button>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<span style="font-size:12px;color:var(--txt3);margin-right:8px">生效星期:</span>
|
||
${ACL_DAY_CN.map((n, di) => `<label class="switch" style="margin:0 10px 0 0;display:inline-flex">
|
||
<input type="checkbox" data-day="${di}" ${(t.days || []).includes(di) ? 'checked' : ''}><i></i>${n}</label>`).join('')}
|
||
</div>
|
||
<div class="hint-inline" style="margin-top:6px">${
|
||
aclCrossHint(t)}</div>
|
||
</div>`).join('') || '<p class="desc">暂无时间组,点击右上角「新增时间组」或直接点上面的模板。</p>';
|
||
|
||
$$('#acl-tgs [data-f]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const i = Number(e.closest('.aclrow').dataset.i);
|
||
S.acl.time_groups[i][e.dataset.f] = e.value;
|
||
if (e.dataset.f === 'start' || e.dataset.f === 'end') {
|
||
const row = e.closest('.aclrow');
|
||
const hint = row.querySelector('.hint-inline');
|
||
if (hint) hint.textContent = aclCrossHint(S.acl.time_groups[i]);
|
||
}
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#acl-tgs [data-day]').forEach(e => e.onchange = () => {
|
||
const i = Number(e.closest('.aclrow').dataset.i);
|
||
const di = Number(e.dataset.day);
|
||
const t = S.acl.time_groups[i];
|
||
const set = new Set(t.days || []);
|
||
if (e.target.checked) set.add(di); else set.delete(di);
|
||
t.days = Array.from(set).sort();
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
});
|
||
$$('#acl-tgs [data-del]').forEach(b => b.onclick = () => {
|
||
const i = Number(b.dataset.del);
|
||
const tid = (S.acl.time_groups[i] || {}).id;
|
||
S.acl.time_groups.splice(i, 1);
|
||
(S.acl.rules || []).forEach(r => { if (r.time_group === tid) r.time_group = ''; });
|
||
renderAclTimeGroups(); renderAclRules();
|
||
setActionMsg('已删除时间组,请点击保存并应用');
|
||
});
|
||
}
|
||
|
||
function aclCrossHint(t) {
|
||
const s = (t.start || '00:00'), e = (t.end || '23:59');
|
||
const toMin = x => { const p = String(x).split(':'); return (Number(p[0]) || 0) * 60 + (Number(p[1]) || 0); };
|
||
const days = (t.days || []).length;
|
||
if (!days) return '⚠ 未选择任何星期,该时间组不会命中任何流量。';
|
||
if (toMin(s) > toMin(e)) {
|
||
return 'ℹ 本地时间跨零点(' + s + ' 次日 ' + e + '),会自动拆成两段;注意「生效星期」指的是<b>起始那天</b>。';
|
||
}
|
||
if (toMin(s) < 8 * 60 && toMin(e) >= 8 * 60) {
|
||
return 'ℹ 该时段横跨本地 08:00(UTC 日分界),会自动拆成两段写入内核。';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function renderAclRules() {
|
||
const el = $('#acl-rules'); if (!el) return;
|
||
const rows = (S.acl || {}).rules || [];
|
||
const tgs = (S.acl || {}).time_groups || [];
|
||
const grps = (S.acl || {}).groups || [];
|
||
const apps = (S.acl || {}).app_groups || [];
|
||
const acts = (S.acl || {}).actions || [];
|
||
|
||
el.innerHTML = rows.map((r, i) => `
|
||
<div class="aclrow" data-i="${i}" style="padding:10px;border:1px solid var(--line);
|
||
border-left:4px solid var(--${ACL_ACTION_TAG[r.action] === 'err' ? 'err' : (ACL_ACTION_TAG[r.action] === 'ok' ? 'ok' : 'warn')});
|
||
border-radius:8px;margin-bottom:8px">
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||
<label style="flex:1 1 160px;margin:0">规则名称
|
||
<input data-f="name" value="${esc(r.name || '')}" placeholder="例如 夜间断娱乐"></label>
|
||
<label style="flex:0 0 150px;margin:0">设备组
|
||
<select data-f="group">
|
||
<option value="">全部设备</option>
|
||
${grps.map(g => `<option value="${esc(g.id)}" ${r.group === g.id ? 'selected' : ''}>${esc(g.name || g.id)}</option>`).join('')}
|
||
</select></label>
|
||
<label style="flex:0 0 160px;margin:0">时间组
|
||
<select data-f="time_group">
|
||
<option value="">始终生效</option>
|
||
${tgs.map(t => `<option value="${esc(t.id)}" ${r.time_group === t.id ? 'selected' : ''}>${esc(t.name || t.id)}</option>`).join('')}
|
||
</select></label>
|
||
<label style="flex:0 0 150px;margin:0">动作
|
||
<select data-f="action">
|
||
${acts.map(a => `<option value="${esc(a.v)}" ${r.action === a.v ? 'selected' : ''}>${esc(a.n)}</option>`).join('')}
|
||
</select></label>
|
||
<label class="switch" style="margin:0;flex:0 0 auto"><input type="checkbox" data-f="enable" ${r.enable !== false ? 'checked' : ''}><i></i>启用</label>
|
||
<button class="ghost small" data-del="${i}" style="flex:0 0 auto">删除</button>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<span style="font-size:12px;color:var(--txt3);margin-right:8px">应用范围(DPI 分类,可多选):</span>
|
||
${apps.map(a => `<label class="switch" style="margin:0 10px 0 0;display:inline-flex">
|
||
<input type="checkbox" data-app="${esc(a.id)}" ${(r.apps || []).includes(a.id) ? 'checked' : ''}><i></i>${esc(a.name)}</label>`).join('')}
|
||
</div>
|
||
${(r.apps || []).length ? '' : '<div class="hint-inline" style="margin-top:6px">未选择应用分类 → 该规则匹配<b>该设备组的全部流量</b>。</div>'}
|
||
</div>`).join('') || '<p class="desc">暂无规则,点击右上角「新增规则」开始。</p>';
|
||
|
||
$$('#acl-rules [data-f]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const i = Number(e.closest('.aclrow').dataset.i);
|
||
const f = e.dataset.f;
|
||
S.acl.rules[i][f] = (e.type === 'checkbox') ? e.checked : e.value;
|
||
if (f === 'action') renderAclRules();
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#acl-rules [data-app]').forEach(e => e.onchange = () => {
|
||
const i = Number(e.closest('.aclrow').dataset.i);
|
||
const r = S.acl.rules[i];
|
||
const set = new Set(r.apps || []);
|
||
if (e.target.checked) set.add(e.dataset.app); else set.delete(e.dataset.app);
|
||
r.apps = Array.from(set);
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
});
|
||
$$('#acl-rules [data-del]').forEach(b => b.onclick = () => {
|
||
S.acl.rules.splice(Number(b.dataset.del), 1);
|
||
renderAclRules();
|
||
setActionMsg('已删除规则,请点击保存并应用');
|
||
});
|
||
}
|
||
|
||
/* ================== 内网文件共享 SMB / NFS(#9) ================== */
|
||
function sb() {
|
||
S.share = S.share || {};
|
||
S.share.samba = S.share.samba || { shares: [] };
|
||
S.share.nfs = S.share.nfs || { exports: [] };
|
||
S.share.samba.shares = S.share.samba.shares || [];
|
||
S.share.nfs.exports = S.share.nfs.exports || [];
|
||
return S.share;
|
||
}
|
||
|
||
async function viewNfs() {
|
||
$('#view').innerHTML = `<div class="card"><h3>内网文件共享</h3>
|
||
<p class="desc">正在读取共享配置与依赖状态…</p></div>`;
|
||
const r = await api('/api/share');
|
||
if (!r.ok) {
|
||
$('#view').innerHTML = `<div class="card"><h3 style="color:var(--err)">读取失败</h3>
|
||
<p class="desc">${esc(r.msg_cn || '无法读取文件共享配置')}</p></div>`;
|
||
return;
|
||
}
|
||
S.shareData = r.data || {};
|
||
S.share = (r.data || {}).cfg || {};
|
||
sb();
|
||
renderShare();
|
||
}
|
||
|
||
function renderShare() {
|
||
const D = S.shareData || {};
|
||
const c = sb();
|
||
const pk = D.pkgs || {};
|
||
const svc = D.services || {};
|
||
const dirs = D.dirs || {};
|
||
const hints = D.hints || {};
|
||
const smbOn = !!c.enable_smb, nfsOn = !!c.enable_nfs;
|
||
const smCfg = c.samba || {}, nfCfg = c.nfs || {};
|
||
|
||
const smbSvc = ['smbd', 'nmbd'].map(n => `${n}:${(svc[n] || {}).active || '?'}`).join(' · ');
|
||
const nfsSvc = ['nfs-server', 'rpcbind'].map(n => `${n}:${(svc[n] || {}).active || '?'}`).join(' · ');
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>内网文件共享
|
||
<span class="tag ${smbOn ? 'ok' : 'gray'}" style="float:right">SMB ${smbOn ? '已启用' : '未启用'}</span>
|
||
<span class="tag ${nfsOn ? 'ok' : 'gray'}" style="float:right;margin-right:6px">NFS ${nfsOn ? '已启用' : '未启用'}</span></h3>
|
||
<p class="desc">${esc(D.note || '')}</p>
|
||
<div class="grid2" style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||
<div class="kv"><b>Samba 软件包</b><span>
|
||
${pk.samba && pk.samba.installed ? '<span class="tag ok">已安装</span>' : '<span class="tag warn">未安装</span>'}
|
||
<span class="mono" style="font-size:12px">${esc(((pk.samba || {}).pkgs || []).join(' '))}</span></span></div>
|
||
<div class="kv"><b>NFS 软件包</b><span>
|
||
${pk.nfs && pk.nfs.installed ? '<span class="tag ok">已安装</span>' : '<span class="tag warn">未安装</span>'}
|
||
<span class="mono" style="font-size:12px">${esc(((pk.nfs || {}).pkgs || []).join(' '))}</span></span></div>
|
||
<div class="kv"><b>SMB 服务</b><span class="mono">${esc(smbSvc)}</span></div>
|
||
<div class="kv"><b>NFS 服务</b><span class="mono">${esc(nfsSvc)}</span></div>
|
||
<div class="kv"><b>本机 LAN 地址</b><span class="mono">${esc((D.host || {}).lan_ip || '—')}</span></div>
|
||
<div class="kv"><b>主配置 include</b><span>${D.smb_configured ? '<span class="tag ok">已写入</span>' : '<span class="tag gray">未写入</span>'}</span></div>
|
||
</div>
|
||
${(!pk.samba || !pk.samba.installed) || (!pk.nfs || !pk.nfs.installed) ? `
|
||
<p class="hint-inline" style="color:var(--warn)">尚未安装的软件包可用下面的命令安装(Debian 13 官方源):
|
||
${!pk.samba || !pk.samba.installed ? `<br><span class="mono">${esc(pk.install_cmd_smb || '')}</span>` : ''}
|
||
${!pk.nfs || !pk.nfs.installed ? `<br><span class="mono">${esc(pk.install_cmd_nfs || '')}</span>` : ''}</p>` : ''}
|
||
</div>
|
||
|
||
<div class="card" id="stg-card">
|
||
<h3>外置存储设备(U 盘 · 移动硬盘 · Type-C · 雷电)
|
||
<button class="ghost small" id="stg-refresh" style="float:right">刷新</button></h3>
|
||
<p class="desc">插上的外置盘会列在下面。可直接挂载、格式化、设为开机自动挂载,
|
||
并把挂载点一键加入上面的 SMB / NFS 共享。
|
||
<b>「连接方式」显示的是总线类型</b>:Type-C 只是插头形状,电气上仍归 USB 或雷电,
|
||
所以按真实总线展示更准确。</p>
|
||
<div id="stg-list"><p class="desc">正在读取块设备…</p></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>SMB / CIFS(推荐:Windows · macOS · Linux 通吃)</h3>
|
||
<p class="desc">面向全平台,兼容性最好。<b>Windows 双击就能用</b>,macOS 与 Linux 也原生支持。
|
||
配置会写入 <span class="mono">/etc/samba/drouter.conf</span>,并在主配置中 include 引入。</p>
|
||
<label class="switch"><input type="checkbox" id="sh-smb" ${smbOn ? 'checked' : ''}><i></i>启用 SMB 文件共享</label>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>工作组<input id="sh-wg" value="${esc(smCfg.workgroup || 'WORKGROUP')}" placeholder="WORKGROUP"></label>
|
||
<label>服务器描述<input id="sh-ss" value="${esc(smCfg.server_string || '')}" placeholder="drouter 文件共享"></label>
|
||
<label>监听网卡<input id="sh-if" value="${esc(smCfg.interfaces || '')}" placeholder="留空=全部(如 br0 ens18)"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:4px">
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="sh-nb" ${smCfg.disable_netbios ? 'checked' : ''}><i></i>关闭 NetBIOS(推荐)</label>
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="sh-wins" ${smCfg.wins_enable ? 'checked' : ''}><i></i>本机充当 WINS 服务器</label>
|
||
</div>
|
||
<p class="hint-inline">关闭 NetBIOS 可减少局域网广播;若局域网里有老旧设备(XP / 老打印机)找不到共享,可尝试取消勾选。</p>
|
||
<h4 style="margin:16px 0 8px">共享目录</h4>
|
||
<div id="sh-shares"></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="ghost small fixed" id="sh-add-share">+ 新增共享</button>
|
||
<button class="ghost small fixed" id="sh-tpl-share">套用目录模板</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>NFS(推荐:Linux / macOS 之间,性能更高)</h3>
|
||
<p class="desc">NFS 是 Linux 原生协议,局域网内传输性能通常优于 SMB。
|
||
导出规则写入 <span class="mono">/etc/exports</span>,服务参数写入 <span class="mono">/etc/nfs.conf.d/drouter.conf</span>,
|
||
端口已固定,便于防火墙精确放通。</p>
|
||
<label class="switch"><input type="checkbox" id="sh-nfs" ${nfsOn ? 'checked' : ''}><i></i>启用 NFS 文件共享</label>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>服务线程数<input id="sh-th" type="number" value="${nfCfg.threads || 8}" min="1" max="128"></label>
|
||
</div>
|
||
<p class="hint-inline">线程数决定并发处理能力,家用 4–16 足够;调大反而会占用内存。</p>
|
||
<h4 style="margin:16px 0 8px">导出目录</h4>
|
||
<div id="sh-exports"></div>
|
||
<button class="ghost small fixed" id="sh-add-exp" style="margin-top:10px">+ 新增导出</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>什么都不用改:三平台连接方式</h3>
|
||
<p class="desc">下面的命令可以直接复制使用,把 <span class="mono">${esc((D.host || {}).lan_ip || '192.168.7.1')}</span>
|
||
换成你的路由器地址即可。</p>
|
||
<div id="sh-hints"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>配置预览(将会写入磁盘)</h3>
|
||
<p class="desc">核对生成的 SMB / NFS 配置是否符合预期。</p>
|
||
<button class="ghost small fixed" id="sh-preview">生成预览</button>
|
||
<div id="sh-out" style="margin-top:10px"></div>
|
||
</div>`;
|
||
|
||
renderShareList();
|
||
renderExportList();
|
||
renderShareHints();
|
||
stgLoad(); // 外置存储设备(挂载 / 格式化后可直接共享)
|
||
// 目录存在性提示
|
||
const miss = Object.values(dirs).filter(x => !x.exists);
|
||
if (miss.length) {
|
||
const box = document.createElement('div');
|
||
box.className = 'hint-inline';
|
||
box.style.color = 'var(--warn)';
|
||
box.innerHTML = '⚠ 以下共享目录还不存在:' + miss.map(x =>
|
||
`<span class="mono">${esc(x.path)}</span>`).join('、') +
|
||
'。可在下方共享项里点「创建」按钮一键建立。';
|
||
const first = $('#view').querySelector('.card');
|
||
if (first) first.appendChild(box);
|
||
}
|
||
|
||
const sw = (id, k, sub) => {
|
||
const e = $(id); if (!e) return;
|
||
e.onchange = () => {
|
||
const t = sub ? sb()[sub] : sb();
|
||
t[k] = e.target.checked;
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
};
|
||
sw('#sh-smb', 'enable_smb'); sw('#sh-nfs', 'enable_nfs');
|
||
sw('#sh-nb', 'disable_netbios', 'samba'); sw('#sh-wins', 'wins_enable', 'samba');
|
||
const tx = (id, k, sub) => {
|
||
const e = $(id); if (!e) return;
|
||
e.oninput = () => {
|
||
const t = sub ? sb()[sub] : sb();
|
||
t[k] = e.type === 'number' ? Number(e.value) : e.value;
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
};
|
||
tx('#sh-wg', 'workgroup', 'samba'); tx('#sh-ss', 'server_string', 'samba');
|
||
tx('#sh-if', 'interfaces', 'samba'); tx('#sh-th', 'threads', 'nfs');
|
||
|
||
$('#sh-add-share').onclick = () => {
|
||
sb().samba.shares.push(newShare());
|
||
renderShareList();
|
||
setActionMsg('已新增一个共享,请填写后保存');
|
||
};
|
||
$('#sh-add-exp').onclick = () => {
|
||
sb().nfs.exports.push(newExport());
|
||
renderExportList();
|
||
setActionMsg('已新增一个 NFS 导出,请填写后保存');
|
||
};
|
||
$('#sh-tpl-share').onclick = () => {
|
||
const tpls = D.dir_templates || [];
|
||
modal('套用目录模板', '<div style="display:flex;flex-direction:column;gap:8px">'
|
||
+ tpls.map((t, i) => `<button class="ghost" data-dt="${i}">${esc(t.name)}<br>
|
||
<span style="font-size:12px;color:var(--txt3)">${esc(t.path)} | ${esc(t.note)}</span></button>`).join('')
|
||
+ '</div>');
|
||
$$('#modal [data-dt]').forEach(b => b.onclick = () => {
|
||
const t = tpls[Number(b.dataset.dt)];
|
||
if (t) {
|
||
const s = newShare();
|
||
s.name = t.name.split('(')[0];
|
||
s.path = t.path;
|
||
s.comment = t.name;
|
||
if (t.id === 'public') { s.mode = 'public'; s.writable = true; }
|
||
else if (t.id === 'media') { s.mode = 'readonly'; s.writable = false; }
|
||
else { s.mode = 'auth'; s.writable = true; }
|
||
sb().samba.shares.push(s);
|
||
}
|
||
$('#modal').classList.add('hidden');
|
||
renderShareList();
|
||
setActionMsg('已套用模板,请补充地址后保存');
|
||
});
|
||
};
|
||
$('#sh-preview').onclick = async () => {
|
||
$('#sh-out').innerHTML = '<pre>正在渲染…</pre>';
|
||
const r2 = await api('/api/share', { method: 'POST', body: { op: 'plan', ...sb() } });
|
||
if (!r2.ok) { $('#sh-out').innerHTML = `<pre>${esc(r2.msg_cn || '渲染失败')}</pre>`; return; }
|
||
$('#sh-out').innerHTML = `<div class="kv"><b>涉及文件</b><span class="mono">${
|
||
esc(((r2.data || {}).files || []).join('、'))}</span></div>
|
||
<pre>${esc((r2.data || {}).text || '')}</pre>`;
|
||
};
|
||
}
|
||
|
||
/* ============================ 外置存储设备 ============================
|
||
U 盘 / 移动硬盘 / Type-C / 雷电硬盘盒:识别 → 格式化 → 挂载 → 一键共享。
|
||
数据来自 /api/storage(后端 lsblk + sysfs 总线判定)。 */
|
||
let STG = null;
|
||
|
||
async function stgLoad() {
|
||
const box = $('#stg-list');
|
||
if (!box) return;
|
||
box.innerHTML = '<p class="desc">正在读取块设备…</p>';
|
||
const r = await api('/api/storage', { method: 'POST', body: { op: 'get' } });
|
||
if (!r.ok) {
|
||
box.innerHTML = `<p class="desc" style="color:var(--err)">${esc(r.msg_cn || '读取失败')}</p>`;
|
||
return;
|
||
}
|
||
STG = r.data || {};
|
||
stgRender();
|
||
}
|
||
|
||
function stgRender() {
|
||
const box = $('#stg-list');
|
||
if (!box) return;
|
||
const devs = (STG && STG.devices) || [];
|
||
if (!devs.length) {
|
||
box.innerHTML = '<p class="desc">没有检测到块设备。虚拟机里没有直通硬盘时属于正常现象。</p>';
|
||
return;
|
||
}
|
||
const fstab = (STG && STG.fstab) || '';
|
||
const row = d => {
|
||
const auto = d.uuid && fstab.indexOf('UUID=' + d.uuid) >= 0;
|
||
const plat = f => ['win', 'mac', 'linux'].filter(k => f[k])
|
||
.map(k => ({ win: 'Win', mac: 'macOS', linux: 'Linux' }[k])).join(' · ');
|
||
const meta = ((STG && STG.fs_types) || []).find(f => f.v === d.fstype);
|
||
return `<tr>
|
||
<td><span class="mono">${esc(d.dev)}</span>
|
||
<div style="font-size:12px;color:var(--txt3)">${esc(d.type_cn)}${
|
||
d.model ? ' · ' + esc(d.model) : ''}</div></td>
|
||
<td>${esc(d.size_h)}</td>
|
||
<td><span class="tag ${d.external ? 'info' : 'gray'}">${esc(d.bus_cn)}</span>${
|
||
d.external ? '' : '<div style="font-size:12px;color:var(--txt3)">内置</div>'}</td>
|
||
<td>${d.fstype ? `<span class="mono">${esc(d.fstype)}</span>${
|
||
d.label ? '<div style="font-size:12px;color:var(--txt3)">卷标 ' + esc(d.label) + '</div>' : ''}`
|
||
: '<span style="color:var(--txt3)">未格式化</span>'}</td>
|
||
<td>${d.mounted ? `<span class="mono">${esc(d.mountpoint)}</span>`
|
||
: '<span style="color:var(--txt3)">未挂载</span>'}${
|
||
auto ? '<div><span class="tag ok">开机自动挂载</span></div>' : ''}</td>
|
||
<td style="white-space:nowrap">
|
||
${d.mounted
|
||
? `<button class="ghost small" data-stg="umount" data-n="${esc(d.name)}">卸载</button>
|
||
<button class="ghost small" data-stg="share" data-n="${esc(d.name)}">共享此目录</button>`
|
||
: (d.fstype
|
||
? `<button class="primary small" data-stg="mount" data-n="${esc(d.name)}">挂载</button>`
|
||
: '<span style="font-size:12px;color:var(--txt3)">需先格式化</span>')}
|
||
<button class="ghost small" data-stg="format" data-n="${esc(d.name)}"
|
||
${d.mounted ? 'disabled title="请先卸载再格式化"' : ''}>格式化</button>
|
||
${d.uuid ? `<button class="ghost small" data-stg="auto" data-n="${esc(d.name)}"
|
||
data-auto="${auto ? '0' : '1'}">${auto ? '取消开机挂载' : '开机挂载'}</button>` : ''}
|
||
</td></tr>`;
|
||
};
|
||
// .tw 是项目统一的「表格横向滚动」容器(手机适配检查会认它),别换成自定义 class
|
||
const tbl = list => `<div class="tw"><table>
|
||
<thead><tr><th>设备</th><th>容量</th><th>连接方式</th><th>文件系统</th>
|
||
<th>挂载点</th><th>操作</th></tr></thead>
|
||
<tbody>${list.map(row).join('')}</tbody></table></div>`;
|
||
const ext = devs.filter(d => d.external);
|
||
const inner = devs.filter(d => !d.external);
|
||
const miss = ((STG && STG.fs_state) || {});
|
||
const unready = Object.keys(miss).filter(k => !miss[k].ready);
|
||
box.innerHTML = `
|
||
${ext.length ? `<h4 style="margin:0 0 6px">外置设备(${ext.length})</h4>${tbl(ext)}`
|
||
: '<p class="desc">未检测到外置设备。插上 U 盘或移动硬盘后点「刷新」。</p>'}
|
||
${inner.length ? `<details style="margin-top:10px"><summary style="cursor:pointer">
|
||
内置磁盘(${inner.length})</summary>${tbl(inner)}</details>` : ''}
|
||
${unready.length ? `<p class="hint-inline" style="color:var(--warn);margin-top:8px">
|
||
以下文件系统缺少格式化工具:${unready.map(k =>
|
||
`<span class="mono">${esc(k)}</span>`).join('、')}
|
||
—— 可到「系统 · 依赖自检与安装」页一键安装。</p>` : ''}
|
||
<p class="hint-inline" style="margin-top:8px">${esc((STG && STG.note) || '')}</p>`;
|
||
stgBind();
|
||
}
|
||
|
||
function stgBind() {
|
||
$$('#stg-list [data-stg]').forEach(b => {
|
||
if (b.disabled) return;
|
||
b.onclick = () => {
|
||
const name = b.dataset.n;
|
||
const d = ((STG && STG.devices) || []).find(x => x.name === name);
|
||
if (!d) return;
|
||
const act = b.dataset.stg;
|
||
if (act === 'mount') return stgMount(d);
|
||
if (act === 'umount') return stgUmount(d);
|
||
if (act === 'format') return stgFormat(d);
|
||
if (act === 'auto') return stgAuto(d, b.dataset.auto === '1');
|
||
if (act === 'share') return stgShare(d);
|
||
};
|
||
});
|
||
const rf = $('#stg-refresh');
|
||
if (rf && !rf._bound) { rf._bound = 1; rf.onclick = stgLoad; }
|
||
}
|
||
|
||
async function stgOp(body, okMsg) {
|
||
const r = await api('/api/storage', { method: 'POST', body });
|
||
if (!r.ok) { toast(r.msg_cn || '操作失败', 'err', 6000); return null; }
|
||
toast(okMsg || (r.msg_cn || '完成'), 'ok', 4000);
|
||
await stgLoad();
|
||
return r.data || {};
|
||
}
|
||
|
||
async function stgMount(d) {
|
||
await stgOp({ op: 'mount', name: d.name }, '已挂载 /dev/' + d.name);
|
||
}
|
||
|
||
async function stgUmount(d) {
|
||
modal('卸载 /dev/' + d.name,
|
||
`<p class="desc">确定要卸载挂载在 <span class="mono">${esc(d.mountpoint)}</span> 的设备吗?
|
||
正在读写该目录的程序会中断。</p>`,
|
||
async () => { await stgOp({ op: 'umount', name: d.name }); });
|
||
}
|
||
|
||
async function stgAuto(d, on) {
|
||
if (on) {
|
||
await stgOp({ op: 'fstab_add', name: d.name, target: d.mountpoint || '' });
|
||
} else {
|
||
modal('取消开机自动挂载',
|
||
`<p class="desc">将从 <span class="mono">/etc/fstab</span> 移除该设备的条目。
|
||
原文件已备份在 <span class="mono">/etc/drouter/generated/fstab.drouter.bak</span>。</p>`,
|
||
async () => { await stgOp({ op: 'fstab_del', uuid: d.uuid }); });
|
||
}
|
||
}
|
||
|
||
/* 格式化:三重保护中的「二次确认」在前端这一层 —— 必须手输设备名才能提交,
|
||
后端还会再校验一次(系统盘 / 已挂载 / 整盘含分区 都会被拒)。 */
|
||
function stgFormat(d) {
|
||
const types = (STG && STG.fs_types) || [];
|
||
const state = (STG && STG.fs_state) || {};
|
||
const opts = types.map(f => {
|
||
const st = state[f.v] || {};
|
||
return `<option value="${esc(f.v)}" ${st.ready ? '' : 'disabled'}>${
|
||
esc(f.n)}${st.ready ? '' : '(缺工具,需安装 ' + esc(f.pkg) + ')'}</option>`;
|
||
}).join('');
|
||
modal('格式化 /dev/' + d.name,
|
||
`<p class="desc" style="color:var(--err)">
|
||
⚠ 格式化会清除 <span class="mono">${esc(d.dev)}</span>(${esc(d.size_h)})上的
|
||
<b>全部数据,且无法恢复</b>。</p>
|
||
<div class="row">
|
||
<label>文件系统<select id="stg-fs">${opts}</select></label>
|
||
<label>卷标(可留空)<input id="stg-label" maxlength="16" placeholder="例如 backup"></label>
|
||
</div>
|
||
<p class="desc" id="stg-fs-note"></p>
|
||
<p class="desc" style="margin-top:10px">确认请输入设备名
|
||
<span class="mono" style="color:var(--err)">${esc(d.name)}</span>:</p>
|
||
<input id="stg-confirm" class="mono" placeholder="${esc(d.name)}">`,
|
||
async () => {
|
||
const fs = $('#stg-fs').value;
|
||
const confirm = ($('#stg-confirm').value || '').trim();
|
||
if (confirm !== d.name) {
|
||
toast('安全确认未通过:请输入设备名 ' + d.name, 'err', 5000);
|
||
return false; // 保持弹窗打开,让用户改
|
||
}
|
||
const r = await api('/api/storage', { method: 'POST', body: {
|
||
op: 'format', name: d.name, fs, label: $('#stg-label').value.trim(),
|
||
confirm } });
|
||
if (!r.ok) { toast(r.msg_cn || '格式化失败', 'err', 8000); return false; }
|
||
toast(r.msg_cn || '格式化完成', 'ok', 5000);
|
||
await stgLoad();
|
||
}, '确认格式化');
|
||
const sel = $('#stg-fs');
|
||
const note = $('#stg-fs-note');
|
||
const upd = () => {
|
||
const f = types.find(x => x.v === sel.value);
|
||
if (f && note) {
|
||
const plat = [['win', 'Windows'], ['mac', 'macOS'], ['linux', 'Linux']]
|
||
.filter(([k]) => f[k]).map(([, n]) => n).join(' · ');
|
||
note.innerHTML = `${esc(f.n)}:${esc(f.note)}<br>
|
||
<b>可直接读写:</b>${esc(plat || '无(需额外驱动)')}`;
|
||
}
|
||
};
|
||
if (sel) { sel.onchange = upd; upd(); }
|
||
}
|
||
|
||
/* 把外置盘的挂载点一键加入 SMB 共享,省掉手工填路径 */
|
||
async function stgShare(d) {
|
||
if (!d.mountpoint) { toast('该设备尚未挂载', 'err'); return; }
|
||
const name = (d.label || d.name).replace(/[^A-Za-z0-9_-]/g, '_');
|
||
const s = newShare();
|
||
s.name = name;
|
||
s.path = d.mountpoint + '/';
|
||
s.comment = '外置存储 ' + d.dev + '(' + d.size_h + ')';
|
||
s.mode = 'public';
|
||
s.writable = true;
|
||
sb().samba.shares.push(s);
|
||
if (!sb().enable_smb) sb().enable_smb = true;
|
||
renderShare();
|
||
setActionMsg('已把 ' + d.mountpoint + ' 加入 SMB 共享,请点击保存并应用');
|
||
toast('已加入 SMB 共享列表:' + d.mountpoint, 'ok', 4000);
|
||
}
|
||
|
||
function newShare() {
|
||
return { name: '', path: '/srv/share/', comment: '', enable: true,
|
||
mode: 'auth', writable: true, users: 'drouter', allow_hosts: '', deny_hosts: '' };
|
||
}
|
||
|
||
function newExport() {
|
||
return { path: '/srv/share/', enable: true, comment: '',
|
||
clients: [{ net: '192.168.7.0/24', preset: 'linux', options: '' }] };
|
||
}
|
||
|
||
function renderShareList() {
|
||
const el = $('#sh-shares'); if (!el) return;
|
||
const rows = sb().samba.shares || [];
|
||
const dirs = (S.shareData || {}).dirs || {};
|
||
const MODE = { auth: '需账号密码', public: '所有人可访问', readonly: '只读公开' };
|
||
el.innerHTML = rows.map((s, i) => {
|
||
const dm = dirs[s.path] || {};
|
||
return `
|
||
<div class="sharecard" data-i="${i}" style="padding:10px;border:1px solid var(--line);
|
||
border-radius:8px;margin-bottom:10px;background:#fbfcfe">
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||
<label style="flex:0 0 140px;margin:0">共享名
|
||
<input data-f="name" value="${esc(s.name || '')}" placeholder="Public"></label>
|
||
<label style="flex:2 1 260px;margin:0">目录路径
|
||
<input data-f="path" value="${esc(s.path || '')}" placeholder="/srv/share/public"></label>
|
||
<button class="ghost small" data-mkdir="${i}" style="flex:0 0 auto"
|
||
title="在路由器上创建该目录">${dm.exists ? '目录已有' + (dm.size ? '(' + esc(dm.size) + ')' : '') : '创建目录'}</button>
|
||
<label class="switch" style="margin:0;flex:0 0 auto"><input type="checkbox" data-f="enable" ${s.enable !== false ? 'checked' : ''}><i></i>启用</label>
|
||
<button class="ghost small" data-del="${i}" style="flex:0 0 auto">删除</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-top:8px">
|
||
<label style="flex:0 0 170px;margin:0">访问方式
|
||
<select data-f="mode">
|
||
${Object.keys(MODE).map(k => `<option value="${k}" ${(s.mode || 'auth') === k ? 'selected' : ''}>${MODE[k]}</option>`).join('')}
|
||
</select></label>
|
||
<label style="flex:1 1 220px;margin:0">备注
|
||
<input data-f="comment" value="${esc(s.comment || '')}" placeholder="例如 全家共享"></label>
|
||
<label class="switch" style="margin:0;flex:0 0 auto"><input type="checkbox" data-f="writable" ${s.writable !== false ? 'checked' : ''}><i></i>可写入</label>
|
||
</div>
|
||
${(s.mode === 'auth') ? `
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-top:8px">
|
||
<label style="flex:1 1 240px;margin:0">允许的用户(逗号分隔,需系统里已存在)
|
||
<input data-f="users" value="${esc(s.users || '')}" placeholder="drouter, alice"></label>
|
||
</div>` : ''}
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-top:8px">
|
||
<label style="flex:1 1 240px;margin:0">允许网段(留空=不限制)
|
||
<input data-f="allow_hosts" value="${esc(s.allow_hosts || '')}" placeholder="192.168.7.0/24"></label>
|
||
<label style="flex:1 1 200px;margin:0">拒绝网段(可选)
|
||
<input data-f="deny_hosts" value="${esc(s.deny_hosts || '')}" placeholder="192.168.7.99"></label>
|
||
</div>
|
||
${dm.exists === false ? `<div class="hint-inline" style="color:var(--warn);margin-top:6px">
|
||
⚠ 目录 <span class="mono">${esc(s.path)}</span> 不存在,点上面的「创建目录」即可建立。</div>` : ''}
|
||
</div>`;
|
||
}).join('') || '<p class="desc">暂无共享目录,点下面「新增共享」或「套用目录模板」。</p>';
|
||
|
||
$$('#sh-shares [data-f]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const i = Number(e.closest('.sharecard').dataset.i);
|
||
const f = e.dataset.f;
|
||
const v = (e.type === 'checkbox') ? e.target.checked : e.value;
|
||
sb().samba.shares[i][f] = v;
|
||
if (f === 'mode') renderShareList();
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#sh-shares [data-del]').forEach(b => b.onclick = () => {
|
||
sb().samba.shares.splice(Number(b.dataset.del), 1);
|
||
renderShareList();
|
||
setActionMsg('已删除共享,请点击保存并应用');
|
||
});
|
||
$$('#sh-shares [data-mkdir]').forEach(b => b.onclick = async () => {
|
||
const i = Number(b.dataset.mkdir);
|
||
const s = sb().samba.shares[i];
|
||
const mode = (s.mode === 'public') ? 'guest' : 'auth';
|
||
setActionMsg('正在创建目录 ' + s.path + ' …');
|
||
const r = await api('/api/share', { method: 'POST', body: { op: 'mkdir', path: s.path, mode } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 6000);
|
||
if (r.ok) {
|
||
const r2 = await api('/api/share');
|
||
if (r2.ok) { S.shareData = r2.data; renderShare(); }
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderExportList() {
|
||
const el = $('#sh-exports'); if (!el) return;
|
||
const rows = sb().nfs.exports || [];
|
||
const presets = (S.shareData || {}).nfs_presets || [];
|
||
el.innerHTML = rows.map((x, i) => `
|
||
<div class="sharecard" data-i="${i}" style="padding:10px;border:1px solid var(--line);
|
||
border-radius:8px;margin-bottom:10px;background:#fbfcfe">
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||
<label style="flex:2 1 260px;margin:0">目录路径
|
||
<input data-f="path" value="${esc(x.path || '')}" placeholder="/srv/share/public"></label>
|
||
<label style="flex:1 1 180px;margin:0">备注
|
||
<input data-f="comment" value="${esc(x.comment || '')}" placeholder="例如 影音库"></label>
|
||
<label class="switch" style="margin:0;flex:0 0 auto"><input type="checkbox" data-f="enable" ${x.enable !== false ? 'checked' : ''}><i></i>启用</label>
|
||
<button class="ghost small" data-del="${i}" style="flex:0 0 auto">删除</button>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<span style="font-size:12px;color:var(--txt3)">客户端与权限:</span>
|
||
<div style="margin-top:6px">${(x.clients || []).map((cl, j) => `
|
||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:6px">
|
||
<label style="flex:0 0 220px;margin:0">客户端
|
||
<input data-c="net" data-ci="${j}" value="${esc(cl.net || '')}" placeholder="192.168.7.0/24 或 *"></label>
|
||
<label style="flex:0 0 200px;margin:0">平台模板
|
||
<select data-c="preset" data-ci="${j}">
|
||
${presets.map(p => `<option value="${esc(p.v)}" ${cl.preset === p.v ? 'selected' : ''}>${esc(p.n)}</option>`).join('')}
|
||
</select></label>
|
||
<label style="flex:1 1 240px;margin:0">选项(留空用模板默认)
|
||
<input data-c="options" data-ci="${j}" value="${esc(cl.options || '')}" placeholder="rw,sync,no_subtree_check"></label>
|
||
<button class="ghost small" data-cdel="${j}" style="flex:0 0 auto">删</button>
|
||
</div>`).join('')}</div>
|
||
<button class="ghost small fixed" data-cadd="${i}">+ 添加客户端</button>
|
||
</div>
|
||
</div>`).join('') || '<p class="desc">暂无 NFS 导出目录,点下面「新增导出」。</p>';
|
||
|
||
$$('#sh-exports [data-f]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const i = Number(e.closest('.sharecard').dataset.i);
|
||
const f = e.dataset.f;
|
||
sb().nfs.exports[i][f] = (e.type === 'checkbox') ? e.target.checked : e.value;
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#sh-exports [data-c]').forEach(e => {
|
||
e.onchange = e.oninput = () => {
|
||
const wrap = e.closest('.sharecard');
|
||
const i = Number(wrap.dataset.i);
|
||
const j = Number(e.dataset.ci);
|
||
const ex = sb().nfs.exports[i];
|
||
ex.clients = ex.clients || [];
|
||
ex.clients[j] = ex.clients[j] || {};
|
||
ex.clients[j][e.dataset.c] = e.value;
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
});
|
||
$$('#sh-exports [data-del]').forEach(b => b.onclick = () => {
|
||
sb().nfs.exports.splice(Number(b.dataset.del), 1);
|
||
renderExportList();
|
||
setActionMsg('已删除导出,请点击保存并应用');
|
||
});
|
||
$$('#sh-exports [data-cadd]').forEach(b => b.onclick = () => {
|
||
const i = Number(b.dataset.cadd);
|
||
sb().nfs.exports[i].clients = sb().nfs.exports[i].clients || [];
|
||
sb().nfs.exports[i].clients.push({ net: '192.168.7.0/24', preset: 'linux', options: '' });
|
||
renderExportList();
|
||
setActionMsg('已添加客户端,请填写后保存');
|
||
});
|
||
$$('#sh-exports [data-cdel]').forEach(b => b.onclick = () => {
|
||
const i = Number(b.closest('.sharecard').dataset.i);
|
||
const j = Number(b.dataset.cdel);
|
||
sb().nfs.exports[i].clients.splice(j, 1);
|
||
renderExportList();
|
||
setActionMsg('已删除客户端,请点击保存并应用');
|
||
});
|
||
}
|
||
|
||
function renderShareHints() {
|
||
const el = $('#sh-hints'); if (!el) return;
|
||
const h = (S.shareData || {}).hints || {};
|
||
const order = [['windows', '🪟'], ['macos', ''], ['linux', '🐧']];
|
||
el.innerHTML = order.map(([k, icon]) => {
|
||
const x = h[k]; if (!x) return '';
|
||
return `<div style="padding:10px;border:1px solid var(--line);border-radius:8px;margin-bottom:10px">
|
||
<b>${icon} ${esc(x.title)}</b>
|
||
<ol style="margin:8px 0 0 18px;color:var(--txt2);font-size:13px;line-height:1.9">
|
||
${(x.steps || []).map(s => `<li>${esc(s)}</li>`).join('')}
|
||
</ol>
|
||
${(x.cmds || []).length ? `<div style="margin-top:8px">
|
||
${(x.cmds || []).map(c => `<div class="mono" style="font-size:12px;background:#f2f4f8;
|
||
padding:6px 8px;border-radius:5px;margin-bottom:5px;user-select:all">${esc(c)}</div>`).join('')}
|
||
</div>` : ''}
|
||
${x.nfs_note ? `<p class="hint-inline" style="margin-top:6px">${esc(x.nfs_note)}</p>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ============================ UPnP ============================ */
|
||
function viewUpnp() {
|
||
const u = $w('upnp');
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>UPnP / NAT-PMP</h3>
|
||
<p class="desc">允许局域网设备自动申请端口映射。出于安全考虑,<b>默认禁止在 WAN 侧开放</b>,
|
||
仅对指定内网地址生效。建议同时开启「安全模式」以限制误映射。</p>
|
||
<label class="switch"><input type="checkbox" id="up-en" ${u.enable ? 'checked' : ''}><i></i>启用 UPnP 服务</label>
|
||
<label class="switch"><input type="checkbox" id="up-np" ${u.natpmp ? 'checked' : ''}><i></i>同时启用 NAT-PMP</label>
|
||
<label class="switch"><input type="checkbox" id="up-sec" ${u.secure_mode !== false ? 'checked' : ''}><i></i>安全模式(仅允许映射到内网地址)</label>
|
||
<label class="switch"><input type="checkbox" id="up-igd" ${u.igd_v1 ? 'checked' : ''}><i></i>强制使用 IGD v1 描述(兼容老旧设备)</label>
|
||
<div class="row">
|
||
<label>WAN 接口<input id="up-ext" value="${esc(u.ext_iface || 'ppp0')}"></label>
|
||
<label>监听地址(LAN 侧)<input id="up-lis" value="${esc(u.listen_ip || '192.168.7.3')}"></label>
|
||
<label>监听端口<input id="up-port" type="number" value="${u.port || 5000}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>当前映射表</h3>
|
||
<p class="desc">实时展示哪个内网设备申请了哪个端口,便于安全审查。</p>
|
||
<div class="row"><button class="ghost small fixed" id="up-list">刷新映射表</button></div>
|
||
<div id="up-out" style="margin-top:12px"><p class="desc">点击上方按钮查询。</p></div>
|
||
</div>`;
|
||
const sync = () => {
|
||
S.cfg.upnp = Object.assign($w('upnp'), {
|
||
enable: $('#up-en').checked, natpmp: $('#up-np').checked,
|
||
secure_mode: $('#up-sec').checked, igd_v1: $('#up-igd').checked,
|
||
ext_iface: $('#up-ext').value, listen_ip: $('#up-lis').value,
|
||
port: Number($('#up-port').value),
|
||
});
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
['#up-en', '#up-np', '#up-sec', '#up-igd'].forEach(s => $(s).onchange = sync);
|
||
['#up-ext', '#up-lis', '#up-port'].forEach(s => $(s).oninput = sync);
|
||
$('#up-list').onclick = async () => {
|
||
$('#up-out').innerHTML = '<pre>读取中…</pre>';
|
||
const r = await api('/api/service', { method: 'POST', body: { name: 'miniupnpd', op: 'start' } });
|
||
const r2 = await api('/api/journal', { method: 'GET' });
|
||
$('#up-out').innerHTML = `<pre>${esc(r.msg_cn)}\n\n注意:查询映射表需 UPnP 服务运行中。\n可在「日志」页查看 miniupnpd 输出。</pre>`;
|
||
};
|
||
}
|
||
|
||
/* ============================ NTP ============================ */
|
||
function viewNtp() {
|
||
const n = $w('ntp');
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>NTP 时间同步</h3>
|
||
<p class="desc">可自定义时间服务器地址,支持多个(逗号分隔)。</p>
|
||
<label>时间服务器<input id="nt-s" value="${esc(n.servers || '')}" placeholder="ntp.aliyun.com,time.cloudflare.com"></label>
|
||
<label class="switch"><input type="checkbox" id="nt-allow" ${n.allow_lan ? 'checked' : ''}><i></i>允许局域网设备向本机同步时间</label>
|
||
<div class="row">
|
||
<label>允许同步的网段<input id="nt-net" value="${esc(n.allow_net || '')}" placeholder="192.168.7.0/24"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px"><button class="ghost small fixed" id="nt-refresh">刷新同步状态</button></div>
|
||
<div id="nt-out" style="margin-top:14px">读取中…</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>常见时间服务器</h3>
|
||
<table><thead><tr><th>来源</th><th>地址</th></tr></thead><tbody>
|
||
<tr><td>阿里云</td><td class="mono">ntp.aliyun.com</td></tr>
|
||
<tr><td>腾讯云</td><td class="mono">ntp.tencent.com</td></tr>
|
||
<tr><td>国家授时中心</td><td class="mono">ntp.ntsc.ac.cn</td></tr>
|
||
<tr><td>Cloudflare</td><td class="mono">time.cloudflare.com</td></tr>
|
||
<tr><td>Google</td><td class="mono">time.google.com</td></tr>
|
||
</tbody></table>
|
||
</div>`;
|
||
const sync = () => {
|
||
S.cfg.ntp = Object.assign($w('ntp'), {
|
||
servers: $('#nt-s').value, allow_lan: $('#nt-allow').checked, allow_net: $('#nt-net').value,
|
||
});
|
||
setActionMsg('已修改,请点击保存并应用');
|
||
};
|
||
$('#nt-s').oninput = sync; $('#nt-net').oninput = sync; $('#nt-allow').onchange = sync;
|
||
const load = async () => {
|
||
const r = await api('/api/ntp');
|
||
const d = r.data || {};
|
||
const tracking = (d.tracking || '').split('\n').reduce((o, l) => {
|
||
const m = l.match(/^([^:]+):\s*(.*)$/); if (m) o[m[1].trim()] = m[2].trim(); return o;
|
||
}, {});
|
||
$('#nt-out').innerHTML = `
|
||
<div class="kv"><b>系统时间</b><span class="mono">${esc(d.now || '—')}</span></div>
|
||
<div class="kv"><b>同步状态</b><span>${tracking['Leap status'] === 'Normal' ? '<span class="tag ok">已同步</span>' : '<span class="tag warn">' + esc(tracking['Leap status'] || '未同步') + '</span>'}</span></div>
|
||
<div class="kv"><b>时间偏移</b><span class="mono">${esc(tracking['System time'] || '—')} 秒</span></div>
|
||
<div class="kv"><b>参考源</b><span class="mono">${esc(tracking['Reference ID'] || '—')}</span></div>
|
||
<div class="kv"><b>层级</b><span>${esc(tracking['Stratum'] || '—')}</span></div>
|
||
<div class="kv"><b>源列表</b><pre style="margin-top:6px">${esc(d.sources || '(chrony 未运行)')}</pre></div>`;
|
||
};
|
||
$('#nt-refresh').onclick = load;
|
||
load();
|
||
}
|
||
|
||
/* ============================ 诊断 ============================ */
|
||
function viewDiag() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>Ping 连通性测试</h3>
|
||
<div class="row">
|
||
<label>目标地址<input id="dg-ping-t" value="223.5.5.5"></label>
|
||
<label style="flex:0 0 120px">次数<input id="dg-ping-c" type="number" value="4"></label>
|
||
<button class="ghost fixed" data-diag="ping">开始 Ping</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>路由追踪 / MTR</h3>
|
||
<div class="row">
|
||
<label>目标地址<input id="dg-tr-t" value="223.5.5.5"></label>
|
||
<button class="ghost fixed" data-diag="traceroute">Traceroute</button>
|
||
<button class="ghost fixed" data-diag="mtr">MTR(5 次)</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>吞吐测试</h3>
|
||
<p class="desc">内网测速使用 iperf3(需对端运行 <span class="mono">iperf3 -s</span>);互联网测速通过下载测速文件估算下行带宽。</p>
|
||
<div class="row">
|
||
<label>对端地址<input id="dg-ip-t" placeholder="192.168.7.x"></label>
|
||
<button class="ghost fixed" data-diag="iperf">iperf3 内网测速</button>
|
||
<button class="ghost fixed" data-diag="speedtest">互联网测速</button>
|
||
<button class="ghost fixed" data-diag="iperf_server">启动 iperf3 服务端</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>抓包与连接状态</h3>
|
||
<div class="row">
|
||
<label>抓包接口<select id="dg-cp-i">${S.ifaces.map(i => `<option>${esc(i.name)}</option>`).join('')}</select></label>
|
||
<label style="flex:0 0 120px">包数<input id="dg-cp-c" type="number" value="50"></label>
|
||
<button class="ghost fixed" data-diag="tcpdump">开始抓包</button>
|
||
<button class="ghost fixed" data-diag="conntrack">连接跟踪</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>输出结果</h3>
|
||
<div id="dg-out"><p class="desc">请在上方选择工具并执行。</p></div>
|
||
</div>`;
|
||
$$('[data-diag]').forEach(b => b.onclick = () => runDiag(b.dataset.diag));
|
||
}
|
||
|
||
async function runDiag(tool) {
|
||
const out = $('#dg-out');
|
||
out.innerHTML = '<pre>执行中,请稍候…</pre>';
|
||
let body = { tool };
|
||
if (tool === 'ping') body = { tool, target: $('#dg-ping-t').value, count: Number($('#dg-ping-c').value) };
|
||
if (tool === 'traceroute' || tool === 'mtr') body = { tool, target: $('#dg-tr-t').value };
|
||
if (tool === 'iperf') body = { tool, target: $('#dg-ip-t').value };
|
||
if (tool === 'tcpdump') body = { tool, iface: $('#dg-cp-i').value, count: Number($('#dg-cp-c').value) };
|
||
const r = await api('/api/diag', { method: 'POST', body });
|
||
const d = r.data || {};
|
||
out.innerHTML = `<div style="margin-bottom:8px"><span class="tag ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn)}</span></div>
|
||
<pre>${esc(d.out || d.err || '(无输出)')}</pre>`;
|
||
}
|
||
|
||
/* ============================ IPv6 连通性测试 ============================ */
|
||
function viewV6Test() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>IPv6 连通性测试</h3>
|
||
<p class="desc">一键检测本机与互联网的 IPv6 连通情况。测试过程会实时回显结果。</p>
|
||
<div class="row">
|
||
<button class="fixed" id="v6t-cn">国内 IPv6 测试(testipv6.cn)</button>
|
||
<button class="fixed" id="v6t-intl">国际 IPv6 测试(testipv6.com)</button>
|
||
<button class="ghost fixed" id="v6t-all">全部测试</button>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="v6t-auto" checked><i></i>自动实时刷新(每 30 秒)</label>
|
||
<div class="kv"><b>上次测试时间</b><span id="v6t-time">尚未测试</span></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>本机 IPv6 环境</h3>
|
||
<p class="desc">WAN/LAN 是否取得公网 IPv6、是否开启转发、默认路由是否就绪。</p>
|
||
<div id="v6t-env"><p class="desc">读取中…</p></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>测试结果</h3>
|
||
<div id="v6t-out"><p class="desc">点击上方按钮开始测试。</p></div>
|
||
</div>`;
|
||
loadV6Env();
|
||
$('#v6t-cn').onclick = () => runV6Test('cn');
|
||
$('#v6t-intl').onclick = () => runV6Test('intl');
|
||
$('#v6t-all').onclick = () => runV6Test('all');
|
||
let timer = null;
|
||
const auto = $('#v6t-auto');
|
||
const setupTimer = () => {
|
||
if (timer) { clearInterval(timer); timer = null; }
|
||
if (auto.checked) timer = setInterval(() => runV6Test('cn', true), 30000);
|
||
};
|
||
// 挂到全局钩子:go() 切页时会调用它停表(闭包内变量外面拿不到)
|
||
stopV6Timer = () => { if (timer) { clearInterval(timer); timer = null; } };
|
||
auto.onchange = setupTimer;
|
||
setupTimer();
|
||
}
|
||
|
||
async function loadV6Env() {
|
||
const el = $('#v6t-env'); if (!el) return;
|
||
const r = await api('/api/ipv6');
|
||
const d = r.data || {};
|
||
const g = (d.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6')
|
||
.map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope })));
|
||
const global = g.filter(x => x.sc === 'global');
|
||
const fwd = (d.sysctl || {})['all.forwarding'] === '1';
|
||
const ready = global.length > 0 && fwd && d.default_route;
|
||
el.innerHTML = `
|
||
<div class="kv"><b>整体判定</b><span>${ready
|
||
? '<span class="tag ok">IPv6 环境就绪</span>'
|
||
: '<span class="tag warn">尚未就绪</span>'}</span></div>
|
||
<div class="kv"><b>公网 IPv6</b><span class="mono">${global.length
|
||
? global.slice(0, 4).map(x => esc(x.a) + '/' + x.p + ' <span class="tag gray">' + esc(x.if) + '</span>').join('<br>')
|
||
: '<span class="tag warn">未获取公网地址</span>'}</span></div>
|
||
<div class="kv"><b>默认路由</b><span class="mono">${esc(d.default_route || '无')}</span></div>
|
||
<div class="kv"><b>IPv6 转发</b><span>${fwd ? '<span class="tag ok">已开启</span>' : '<span class="tag warn">未开启</span>'}</span></div>
|
||
<div class="kv"><b>邻居表条数</b><span>${(d.neigh || []).length}</span></div>`;
|
||
}
|
||
|
||
async function runV6Test(which, silent) {
|
||
const out = $('#v6t-out');
|
||
if (!out) return;
|
||
if (!silent) out.innerHTML = '<pre>测试中,正在连接测试站点…</pre>';
|
||
const r = await api('/api/diag', { method: 'POST', body: { tool: 'ipv6test', which } });
|
||
const d = r.data || {};
|
||
const t = $('#v6t-time');
|
||
if (t) t.textContent = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||
if (silent && r.ok && out.querySelector('pre')) {
|
||
// 静默刷新时仅在结果区已有内容时更新
|
||
}
|
||
const blocks = (d.results || []).map(x => `
|
||
<div class="card" style="margin:0 0 12px;border-color:${x.ok ? 'var(--ok)' : 'var(--line)'}">
|
||
<h3 style="margin:0 0 6px;font-size:14px">${esc(x.name)} <span class="tag ${x.ok ? 'ok' : 'err'}">${x.ok ? '连通' : '不通'}</span></h3>
|
||
<div class="kv"><b>目标</b><span class="mono">${esc(x.target || '—')}</span></div>
|
||
<div class="kv"><b>耗时</b><span>${x.ms != null ? x.ms + ' ms' : '—'}</span></div>
|
||
${x.detail ? `<pre style="margin-top:6px">${esc(x.detail)}</pre>` : ''}
|
||
</div>`).join('');
|
||
out.innerHTML = `<div style="margin-bottom:8px"><span class="tag ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</span></div>
|
||
${blocks || `<pre>${esc(d.out || d.err || '(无输出)')}</pre>`}`;
|
||
}
|
||
|
||
/* ============================ DHCPv6 / 前缀委派 ============================ */
|
||
function viewDhcpv6() {
|
||
const c = $w('dhcpv6');
|
||
const enabled = c.enabled !== false;
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>DHCPv6 前缀委派(PD)</h3>
|
||
<p class="desc">向运营商申请 IPv6 前缀(如 /60、/56),再下发给内网设备。
|
||
大陆家庭宽带通常需要 PD 才能获得可用的公网 IPv6。</p>
|
||
<label class="switch"><input type="checkbox" id="d6-en" ${enabled ? 'checked' : ''}><i></i>启用 DHCPv6 客户端</label>
|
||
<div class="row">
|
||
<label>请求前缀长度<select id="d6-len">
|
||
${['/56', '/60', '/62', '/64'].map(x => `<option ${(c.prefix_len || '/60') === x ? 'selected' : ''}>${x}</option>`).join('')}
|
||
</select></label>
|
||
<label>PD 网卡(WAN)<select id="d6-iface">
|
||
<option value="">跟随 WAN 口</option>
|
||
${S.ifaces.map(i => `<option value="${esc(i.name)}" ${c.iface === i.name ? 'selected' : ''}>${esc(i.name)}${i.role === 'wan' ? ' — WAN' : ''}</option>`).join('')}
|
||
</select></label>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="d6-rapid" ${c.rapid_commit ? 'checked' : ''}><i></i>快速提交(Rapid Commit)</label>
|
||
<p class="hint-inline">保存后需点击「保存并应用」才会写入 <span class="mono">/etc/dhcpcd.conf</span> 与网络配置。</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>下游地址分配</h3>
|
||
<p class="desc">LAN 侧如何把前缀下发给内网设备(配合「IPv6 / RA」页面的地址池)。</p>
|
||
<div class="row">
|
||
<label>分配方式<select id="d6-method">
|
||
<option value="slaac" ${(c.method || 'slaac') === 'slaac' ? 'selected' : ''}>SLAAC(无状态,推荐)</option>
|
||
<option value="dhcpv6" ${c.method === 'dhcpv6' ? 'selected' : ''}>DHCPv6 有状态</option>
|
||
<option value="both" ${c.method === 'both' ? 'selected' : ''}>两者同时</option>
|
||
</select></label>
|
||
<label>子网 ID<input id="d6-sid" value="${esc(c.subnet_id || '0')}" placeholder="例如 0"></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>当前 IPv6 状态</h3>
|
||
<div id="d6-live"><p class="desc">读取中…</p></div>
|
||
</div>`;
|
||
const bind = (id, k, conv) => {
|
||
const e = $(id); if (!e) return;
|
||
const h = () => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { [k]: conv ? conv(e.value) : e.value }); setActionMsg('已修改,请点击保存并应用'); };
|
||
e.onchange = h; if (e.tagName === 'INPUT') e.oninput = h;
|
||
};
|
||
bind('#d6-len', 'prefix_len'); bind('#d6-iface', 'iface');
|
||
bind('#d6-method', 'method'); bind('#d6-sid', 'subnet_id');
|
||
$('#d6-en').onchange = e => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { enabled: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); };
|
||
$('#d6-rapid').onchange = e => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { rapid_commit: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); };
|
||
liveV6();
|
||
}
|
||
|
||
/* ============================ 真·公网 IP 判定(#2) ============================ */
|
||
/* 「有没有公网地址」和「外网能不能连进来」是两件事。
|
||
很多宽带给的是公网地址却在入向做了封锁 —— 这时 DDNS 能更新成功、
|
||
域名也解析得到,但别人就是连不上。本页把判定拆成四条独立证据,
|
||
其中只有「入向实测」能拍板,所以页面会把这一步摆在最显眼的位置。 */
|
||
|
||
let PUBIP_TIMER = null;
|
||
|
||
async function viewPubip() {
|
||
const v = $('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>真·公网 IP 判定
|
||
<button class="ghost small" id="pi-check" style="float:right">重新判定</button></h3>
|
||
<p class="desc">判定出口 IP 是不是<b>真的能被外网主动连进来</b>。
|
||
只看「地址是不是公网段」会误判 —— 不少宽带给的是公网地址却在入向做了封锁,
|
||
从别的 VPS 根本 ping 不通。</p>
|
||
<div id="pi-body"><p class="desc">正在判定…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>入向实测(唯一能拍板的一步)</h3>
|
||
<p class="desc" id="pi-probe-desc">下面会启动一次监听,然后给你一条命令。
|
||
在<b>另一台能上外网的主机</b>(你的 VPS、云主机,或手机开热点连着的电脑)上执行它,
|
||
本机能收到就说明外网确实可以主动访问进来。</p>
|
||
<div class="row" style="gap:8px;flex-wrap:wrap;margin-top:8px">
|
||
<button class="primary" id="pi-start">开始入向实测</button>
|
||
<button class="ghost" id="pi-stop">停止</button>
|
||
<span class="desc" id="pi-probe-msg" style="margin:0"></span>
|
||
</div>
|
||
<div id="pi-cmd" class="hidden" style="margin-top:12px">
|
||
<p class="desc" id="pi-how"></p>
|
||
<div class="code-wrap">
|
||
<button class="ghost small copy-btn" data-copy="#pi-cmdtext">复制命令</button>
|
||
<pre id="pi-cmdtext" class="log-pre"></pre>
|
||
</div>
|
||
<p class="desc" id="pi-left"></p>
|
||
</div>
|
||
</div>`;
|
||
$('#pi-check').onclick = () => pubipCheck(true);
|
||
$('#pi-start').onclick = () => pubipProbe('probe_start');
|
||
$('#pi-stop').onclick = () => pubipProbe('probe_stop');
|
||
bindCopyButtons(v);
|
||
pubipCheck(false);
|
||
}
|
||
|
||
function pubipVerdictTag(level) {
|
||
return { ok: 'ok', warn: 'warn', err: 'err', info: 'info' }[level] || 'gray';
|
||
}
|
||
|
||
async function pubipCheck(force) {
|
||
const box = $('#pi-body');
|
||
if (!box) return;
|
||
box.innerHTML = '<p class="desc">正在判定(会并发查询多个外部回显服务,约需 10–30 秒)…</p>';
|
||
const r = await api('/api/pubip', { method: 'POST', body: { op: 'check', force: !!force } });
|
||
if (!r.ok) { box.innerHTML = `<div class="notice err">${esc(r.msg_cn || '判定失败')}</div>`; return; }
|
||
const d = r.data || {};
|
||
const vd = d.verdict || {};
|
||
const tag = pubipVerdictTag(vd.level);
|
||
|
||
const ev = (d.evidence || []).map(e => `
|
||
<div class="dep-item ${e.pass ? 'ok' : (e.probed === false ? 'warn' : 'err')}">
|
||
<span class="dep-ico">${e.pass ? '✓' : (e.probed === false ? '?' : '✕')}</span>
|
||
<div class="dep-body">
|
||
<b>${esc(e.name)}:${esc(String(e.value))}</b>
|
||
<div class="desc">${esc(e.detail || '')}</div>
|
||
</div>
|
||
<span class="tag ${e.pass ? 'ok' : (e.probed === false ? 'gray' : 'err')}">${e.pass ? '通过' : (e.probed === false ? '未测' : '不通过')}</span>
|
||
</div>`).join('');
|
||
|
||
box.innerHTML = `
|
||
<div class="notice ${tag}" style="margin-bottom:12px">
|
||
<b>${esc(vd.title || '')}</b>
|
||
<div style="margin-top:6px">${esc(vd.conclusion || '')}</div>
|
||
</div>
|
||
<div class="grid2" style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||
<div class="kv"><b>出口 IPv4</b><span class="mono">${esc(d.ip || '—')}</span></div>
|
||
<div class="kv"><b>地址分类</b><span class="mono">${esc(d.class || '—')}</span></div>
|
||
<div class="kv"><b>本机地址</b><span class="mono">${esc(d.v4_local || '—')}</span></div>
|
||
<div class="kv"><b>出口网卡</b><span class="mono">${esc(d.egress || '—')}</span></div>
|
||
</div>
|
||
<h4 style="margin:14px 0 6px">四条判定依据</h4>
|
||
<div class="dep-grid">${ev}</div>
|
||
${(vd.advice || []).length ? `
|
||
<h4 style="margin:14px 0 6px">接下来该怎么做</h4>
|
||
<ul class="desc" style="margin:0;padding-left:20px;line-height:1.9">
|
||
${vd.advice.map(a => `<li>${esc(a)}</li>`).join('')}
|
||
</ul>` : ''}
|
||
<p class="desc" style="margin-top:10px">判定时间:${esc(d.checked_at || '')}</p>`;
|
||
}
|
||
|
||
async function pubipProbe(op) {
|
||
const msg = $('#pi-probe-msg');
|
||
const r = await api('/api/pubip', { method: 'POST', body: { op: op } });
|
||
const d = r.data || {};
|
||
if (msg) msg.textContent = r.msg_cn || (r.ok ? '已提交' : '操作失败');
|
||
if (!r.ok) return;
|
||
|
||
if (op === 'probe_start') {
|
||
const card = $('#pi-cmd');
|
||
card.classList.remove('hidden');
|
||
$('#pi-how').textContent = d.how || '';
|
||
$('#pi-cmdtext').textContent = d.cmd || '';
|
||
pubipProbePoll();
|
||
return;
|
||
}
|
||
if (op === 'probe_stop') {
|
||
pubipProbeStopPoll();
|
||
$('#pi-cmd').classList.add('hidden');
|
||
return;
|
||
}
|
||
}
|
||
|
||
function pubipProbeStopPoll() {
|
||
if (PUBIP_TIMER) { clearInterval(PUBIP_TIMER); PUBIP_TIMER = null; }
|
||
}
|
||
|
||
function pubipProbePoll() {
|
||
pubipProbeStopPoll();
|
||
PUBIP_TIMER = setInterval(async () => {
|
||
const r = await api('/api/pubip', { method: 'POST', body: { op: 'probe_status' } });
|
||
const d = r.data || {};
|
||
const msg = $('#pi-probe-msg');
|
||
const left = $('#pi-left');
|
||
if (d.state === 'hit') {
|
||
pubipProbeStopPoll();
|
||
if (msg) msg.textContent = '已收到入向报文 —— 外网可以主动访问本机';
|
||
if (left) left.innerHTML = `<span class="tag ok">入向可达</span> 来源:${esc((d.hits || []).join('、'))}`;
|
||
toast('入向实测成功:外网确实能连进来', 'ok', 5000);
|
||
await pubipCheck(true);
|
||
return;
|
||
}
|
||
if (d.state === 'timeout') {
|
||
pubipProbeStopPoll();
|
||
if (msg) msg.textContent = '等待超时,没有收到来自外网的报文';
|
||
if (left) left.innerHTML = '<span class="tag err">入向不可达</span> 已判定为「有公网地址但入向被拦」';
|
||
await pubipCheck(true);
|
||
return;
|
||
}
|
||
if (d.state === 'running') {
|
||
if (left) left.textContent = `仍在等待外网报文,还剩 ${d.left || 0} 秒`;
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
/* ======================== 磁盘与日志清理(#3) ========================
|
||
菜单放在「系统」组下,位置紧挨依赖自检 —— 两者同属日常维护。
|
||
页面刻意把「策略」和「清理项」分成两张卡片:策略决定"什么时候自动做",
|
||
清理项决定"做哪些、留多久"。混在一张卡里用户很难分清哪个开关管什么。 */
|
||
|
||
let CLEAN_DATA = null;
|
||
|
||
const CLEAN_TRIGGER_TEXT = { disk: '仅水位触发', schedule: '仅定时触发', both: '水位 + 定时' };
|
||
|
||
async function viewCleanup() {
|
||
const v = $('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>磁盘占用
|
||
<button class="ghost small" id="cl-rescan" style="float:right">重新扫描</button></h3>
|
||
<p class="desc">路由器常年不关机,日志与缓存会一直长。这里按「可再生产物」分类回收 ——
|
||
配置文件、证书、代码、配置快照<b>一律不在清理范围内</b>,正在写入的活跃日志也不动。</p>
|
||
<div id="cl-disk"><p class="desc">正在扫描…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>自动清理策略</h3>
|
||
<p class="desc">决定「什么时候自动动手」。清理哪些内容、各留多久,在下面一张卡片里单独设置。</p>
|
||
<div id="cl-policy"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>清理项
|
||
<span class="tag gray" id="cl-sum"></span>
|
||
<button class="ghost small" id="cl-dry" style="float:right">试运行</button></h3>
|
||
<p class="desc">每一项都可以单独开关,并自定义保留天数。
|
||
<b>试运行</b>只统计不删除,用来先看清楚会清掉多少;确认无误再点「立即清理」。</p>
|
||
<div id="cl-items"><p class="desc">正在扫描…</p></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="primary" id="cl-run">立即清理选中项</button>
|
||
<button class="ghost" id="cl-selectall">全选</button>
|
||
<button class="ghost" id="cl-selectnone">全不选</button>
|
||
</div>
|
||
<div id="cl-result" class="hidden" style="margin-top:14px"></div>
|
||
</div>`;
|
||
$('#cl-rescan').onclick = () => cleanupLoad(true);
|
||
$('#cl-dry').onclick = () => cleanupRun(true);
|
||
$('#cl-run').onclick = () => {
|
||
if (!confirm('确定立即清理所有勾选的项目吗?被删的日志文件无法恢复。\n\n(建议先点「试运行」看清楚会清掉多少)')) return;
|
||
cleanupRun(false);
|
||
};
|
||
$('#cl-selectall').onclick = () => cleanupSelectAll(true);
|
||
$('#cl-selectnone').onclick = () => cleanupSelectAll(false);
|
||
cleanupLoad(false);
|
||
}
|
||
|
||
function cleanupSelectAll(on) {
|
||
document.querySelectorAll('#cl-items .cl-on').forEach(el => { el.checked = on; });
|
||
}
|
||
|
||
async function cleanupLoad(toastIt) {
|
||
const r = await api('/api/cleanup', { method: 'POST', body: { op: 'status' } });
|
||
if (!r.ok) {
|
||
$('#cl-disk').innerHTML = `<div class="notice err">${esc(r.msg_cn || '扫描失败')}</div>`;
|
||
return;
|
||
}
|
||
CLEAN_DATA = r.data;
|
||
if (toastIt) toast('扫描完成', 'ok');
|
||
cleanupRenderDisk();
|
||
cleanupRenderPolicy();
|
||
cleanupRenderItems();
|
||
}
|
||
|
||
function cleanupRenderDisk() {
|
||
const d = (CLEAN_DATA && CLEAN_DATA.disk) || {};
|
||
const pct = Number(d.percent || 0);
|
||
const warn = pct >= 85 ? 'err' : (pct >= 70 ? 'warn' : 'ok');
|
||
const reclaim = CLEAN_DATA ? (CLEAN_DATA.reclaimable || 0) : 0;
|
||
$('#cl-disk').innerHTML = `
|
||
<div class="row" style="align-items:center;margin-bottom:6px">
|
||
<div><b style="font-size:22px">${pct}%</b>
|
||
<span class="tag ${warn}" style="margin-left:8px">${pct >= 85 ? '紧张' : (pct >= 70 ? '偏高' : '正常')}</span></div>
|
||
<div class="desc" style="margin:0;text-align:right">
|
||
已用 ${fmtBytes((d.used_mb || 0) * 1048576)} / 共 ${fmtBytes((d.total_mb || 0) * 1048576)}
|
||
,可用 ${fmtBytes((d.free_mb || 0) * 1048576)}
|
||
</div>
|
||
</div>
|
||
<div class="bar"><i style="width:${Math.min(100, pct)}%;background:${
|
||
warn === 'err' ? 'var(--err)' : (warn === 'warn' ? 'var(--warn)' : 'var(--pri)')}"></i></div>
|
||
<p class="desc" style="margin:10px 0 0">按当前策略,勾选中的项目<b>可释放 ${fmtBytes(reclaim)}</b>。</p>`;
|
||
}
|
||
|
||
function cleanupRenderPolicy() {
|
||
const c = (CLEAN_DATA && CLEAN_DATA.config) || {};
|
||
const hours = Array.from({ length: 24 }, (_, i) =>
|
||
`<option value="${i}"${i === Number(c.hour || 4) ? ' selected' : ''}>${String(i).padStart(2, '0')}:00</option>`).join('');
|
||
const st = (CLEAN_DATA && CLEAN_DATA.state) || {};
|
||
$('#cl-policy').innerHTML = `
|
||
<div class="row" style="align-items:center">
|
||
<label class="switch"><input type="checkbox" id="cl-enabled"${c.enabled ? ' checked' : ''}><i></i>
|
||
<span>启用自动清理</span></label>
|
||
<label class="desc" style="flex:1 1 260px;margin:0">触发方式
|
||
<select id="cl-trigger" style="width:100%;margin-top:4px">
|
||
<option value="both"${c.trigger === 'both' ? ' selected' : ''}>水位 + 定时(推荐)</option>
|
||
<option value="disk"${c.trigger === 'disk' ? ' selected' : ''}>仅水位触发</option>
|
||
<option value="schedule"${c.trigger === 'schedule' ? ' selected' : ''}>仅定时触发</option>
|
||
</select></label>
|
||
<label class="desc" style="flex:0 0 120px;margin:0">水位阈值
|
||
<input id="cl-pct" type="number" min="50" max="99" value="${Number(c.disk_percent || 85)}"
|
||
style="width:100%;margin-top:4px"><span style="font-size:12px">% 使用率超过就清</span></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px;align-items:center">
|
||
<label class="desc" style="flex:0 0 130px;margin:0">周期
|
||
<select id="cl-sched" style="width:100%;margin-top:4px">
|
||
<option value="daily"${c.schedule === 'daily' ? ' selected' : ''}>每天</option>
|
||
<option value="weekly"${c.schedule === 'weekly' ? ' selected' : ''}>每周一</option>
|
||
</select></label>
|
||
<label class="desc" style="flex:0 0 130px;margin:0">执行时间
|
||
<select id="cl-hour" style="width:100%;margin-top:4px">${hours}</select></label>
|
||
<label class="desc" style="flex:1 1 200px;margin:0">单次上限
|
||
<input id="cl-max" type="number" min="0" max="1048576" value="${Number(c.max_mb_per_run || 0)}"
|
||
style="width:100%;margin-top:4px"><span style="font-size:12px">MB,0 = 不限(防止一次删太多)</span></label>
|
||
</div>
|
||
<p class="desc" style="margin:10px 0 0">定时器每小时被拉起一次,自己判断该不该真动手 ——
|
||
水位没到、时间没到就什么都不做。当前定时器:
|
||
<span class="tag ${CLEAN_DATA && CLEAN_DATA.timer_active ? 'ok' : 'gray'}">${
|
||
CLEAN_DATA && CLEAN_DATA.timer_active ? '已启用' : '未启用'}</span>
|
||
${CLEAN_DATA && CLEAN_DATA.next_run ? `下次检查 ${esc(CLEAN_DATA.next_run)}` : ''}
|
||
${st.last_run ? ` 上次执行 ${esc(st.last_run)},释放 ${fmtBytes(st.last_freed || 0)}` : ''}</p>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="primary" id="cl-save">保存策略</button>
|
||
<button class="ghost" id="cl-now">按策略立即执行一次</button>
|
||
</div>`;
|
||
$('#cl-save').onclick = cleanupSave;
|
||
$('#cl-now').onclick = async () => {
|
||
const r = await api('/api/cleanup', { method: 'POST', body: { op: 'auto' } });
|
||
toast(r.msg_cn || '已执行', r.ok ? 'ok' : 'err', 5000);
|
||
if (r.ok && !(r.data || {}).skipped) cleanupLoad(false);
|
||
};
|
||
}
|
||
|
||
function cleanupRenderItems() {
|
||
const items = (CLEAN_DATA && CLEAN_DATA.items) || [];
|
||
let sum = 0;
|
||
items.forEach(it => { if (it.on) sum += it.reclaimable || 0; });
|
||
const el = $('#cl-sum');
|
||
if (el) el.textContent = `${items.length} 项 · 可释放 ${fmtBytes(sum)}`;
|
||
$('#cl-items').innerHTML = `<div class="dep-grid">${items.map(it => `
|
||
<div class="dep-item ${it.on ? 'warn' : ''}">
|
||
<span class="dep-ico" style="background:${it.on ? 'var(--warn)' : '#c3ccd9'}">${
|
||
it.on ? (it.reclaimable > 0 ? '!' : '·') : '-'}</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<label class="switch" style="margin:0"><input type="checkbox" class="cl-on" data-k="${esc(it.key)}"${
|
||
it.on ? ' checked' : ''}><i></i></label>
|
||
<b>${esc(it.name)}</b>
|
||
${it.reclaimable > 0 ? `<span class="tag warn">可释放 ${fmtBytes(it.reclaimable)}</span>`
|
||
: '<span class="tag gray">无需清理</span>'}
|
||
</div>
|
||
<div class="desc" style="margin:6px 0 0">${esc(it.why)}</div>
|
||
<div class="row" style="margin:8px 0 0;align-items:center;gap:8px">
|
||
${it.kind === 'age'
|
||
? `<label class="desc" style="flex:0 0 auto;margin:0">保留
|
||
<input class="cl-days" data-k="${esc(it.key)}" type="number" min="0" max="3650"
|
||
value="${Number(it.days || 0)}" style="width:72px"> 天内的文件</label>`
|
||
: '<span class="tag gray">由系统工具整体回收</span>'}
|
||
<span class="desc" style="flex:1 1 auto;margin:0;text-align:right">
|
||
轮转件 ${fmtBytes(it.total)} 命中 ${it.whole ? '全部' : (it.hit_count || 0) + ' 个文件'}${
|
||
it.active ? `<br><span style="color:var(--txt3)">正在写入 ${fmtBytes(it.active)}(保留不删)</span>` : ''}</span>
|
||
</div>
|
||
${(it.samples || []).length ? `<div class="desc" style="margin:6px 0 0;font-size:12px">例如:${
|
||
it.samples.map(s => esc(String(s.path).split('/').pop()) + '(' + fmtBytes(s.size) + ')').join('、')}</div>` : ''}
|
||
<div class="desc" style="margin:6px 0 0;font-size:12px;color:var(--txt3)">安全边界:${esc(it.risk)}</div>
|
||
</div>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
|
||
async function cleanupSave() {
|
||
const items = {};
|
||
document.querySelectorAll('#cl-items .cl-on').forEach(el => {
|
||
items[el.dataset.k] = { on: el.checked };
|
||
});
|
||
document.querySelectorAll('#cl-items .cl-days').forEach(el => {
|
||
items[el.dataset.k] = items[el.dataset.k] || {};
|
||
items[el.dataset.k].days = Number(el.value) || 0;
|
||
});
|
||
const body = {
|
||
op: 'save',
|
||
enabled: $('#cl-enabled').checked,
|
||
trigger: $('#cl-trigger').value,
|
||
disk_percent: Number($('#cl-pct').value) || 85,
|
||
schedule: $('#cl-sched').value,
|
||
hour: Number($('#cl-hour').value) || 4,
|
||
max_mb_per_run: Number($('#cl-max').value) || 0,
|
||
items: items,
|
||
};
|
||
const r = await api('/api/cleanup', { method: 'POST', body: body });
|
||
toast(r.msg_cn || '已保存', r.ok ? 'ok' : 'err', 4200);
|
||
if (r.ok) cleanupLoad(false);
|
||
}
|
||
|
||
async function cleanupRun(dry) {
|
||
const only = [];
|
||
document.querySelectorAll('#cl-items .cl-on').forEach(el => { if (el.checked) only.push(el.dataset.k); });
|
||
const box = $('#cl-result');
|
||
box.classList.remove('hidden');
|
||
box.innerHTML = `<p class="desc">${dry ? '正在试运行(只读扫描,不删除任何文件)…' : '正在清理…'}</p>`;
|
||
const r = await api('/api/cleanup', { method: 'POST', body: { op: 'run', dry: !!dry, only: only } });
|
||
if (!r.ok) { box.innerHTML = `<div class="notice err">${esc(r.msg_cn || '执行失败')}</div>`; return; }
|
||
const d = r.data || {};
|
||
const rows = (d.items || []).map(x => `
|
||
<div class="kv"><b>${esc(x.name)}</b><span>${fmtBytes(x.freed)}${
|
||
x.count ? ` ${x.count} 个文件` : ''}${
|
||
x.stopped ? ' <span class="tag warn">已达单次上限</span>' : ''}${
|
||
(x.errors || []).length ? ` <span class="tag err">${esc(x.errors[0])}</span>` : ''}</span></div>`).join('');
|
||
box.innerHTML = `
|
||
<div class="notice ${d.freed > 0 ? (dry ? 'info' : 'ok') : 'ok'}">
|
||
<b>${esc(r.msg_cn || '')}</b>
|
||
</div>
|
||
<div style="margin-top:10px">${rows || '<p class="desc">没有项目被选中。</p>'}</div>`;
|
||
toast(r.msg_cn || '完成', 'ok', 5000);
|
||
cleanupLoad(false);
|
||
}
|
||
|
||
/* ======================== 内核转发与加速(#4) ========================
|
||
五项开关的说明与「联动影响」写在后端 KERN_ITEMS / KERN_FW_ITEMS 里,
|
||
前端只负责渲染 —— 免得文案在两处各写一份后走偏。
|
||
注意 MSS 与 masquerade 的落点是 nft_v4 / nft_v6 配置库:本页改完要到
|
||
「防火墙 IPv4 / IPv6」页点一次「保存并应用」才会写进规则集。 */
|
||
|
||
let KERN_DATA = null;
|
||
|
||
function kernImpact(list) {
|
||
return (list || []).map(x => `<li>${esc(x)}</li>`).join('');
|
||
}
|
||
|
||
function kernCard(it, ctrl) {
|
||
return `
|
||
<div class="dep-item">
|
||
<span class="dep-ico" style="background:var(--info)">i</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>${esc(it.name)}</b>
|
||
<div class="desc" style="margin:6px 0 0">${esc(it.why)}</div>
|
||
<div style="margin:8px 0 0">${ctrl}</div>
|
||
<details style="margin-top:8px">
|
||
<summary class="desc" style="cursor:pointer;color:var(--pri)">打开/关闭会带来什么</summary>
|
||
<ul class="desc" style="margin:6px 0 0;padding-left:20px;line-height:1.85">${kernImpact(it.impact)}</ul>
|
||
</details>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function viewKern() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>内核转发与加速
|
||
<button class="ghost small" id="kn-reload" style="float:right">重新读取</button></h3>
|
||
<p class="desc">这五个开关决定「这台机器到底是不是一台路由器」。
|
||
每一项都写了<b>通俗说明</b>和<b>联动影响</b> —— 点开「打开/关闭会带来什么」看清楚再动。</p>
|
||
<div id="kn-body"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>出向地址伪装与 MSS 钳制</h3>
|
||
<p class="desc">这两项最终写进防火墙规则集。本页改完后,需要到
|
||
<b>「安全 → 防火墙 IPv4 / IPv6」</b>点一次「保存并应用」才会真正生效。</p>
|
||
<div id="kn-fw"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>SNMP 监控</h3>
|
||
<p class="desc">让网管软件能采集本机指标。默认关闭 —— 开了就多一个 UDP 监听。</p>
|
||
<div id="kn-snmp"><p class="desc">正在读取…</p></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="primary" id="kn-save">保存并生效</button>
|
||
</div>
|
||
<div id="kn-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>`;
|
||
$('#kn-reload').onclick = () => kernLoad();
|
||
$('#kn-save').onclick = kernSave;
|
||
kernLoad();
|
||
}
|
||
|
||
async function kernLoad() {
|
||
const r = await api('/api/kern', { method: 'POST', body: { op: 'status' } });
|
||
if (!r.ok) {
|
||
$('#kn-body').innerHTML = `<div class="notice err">${esc(r.msg_cn || '读取失败')}</div>`;
|
||
return;
|
||
}
|
||
KERN_DATA = r.data;
|
||
kernRender();
|
||
}
|
||
|
||
function kernRender() {
|
||
const d = KERN_DATA || {};
|
||
const c = d.config || {};
|
||
const live = d.live || {};
|
||
const fw = d.fw || {};
|
||
|
||
const sw = (id, on) =>
|
||
`<label class="switch"><input type="checkbox" id="${id}"${on ? ' checked' : ''}><i></i></label>`;
|
||
|
||
$('#kn-body').innerHTML = `
|
||
${d.build_mode ? '<div class="notice warn" style="margin-bottom:10px">当前处于<b>构建保护模式</b>:保存只会写盘,不会修改内核参数、不会启停服务。</div>' : ''}
|
||
<div class="dep-grid">
|
||
${(d.items || []).map(it => {
|
||
const key = it.key;
|
||
if (key === 'snmp') return '';
|
||
const on = c[key] !== undefined ? c[key] : it.default;
|
||
const nowLive = key === 'fwd_v4' ? live.ip_forward
|
||
: key === 'fwd_v6' ? live.ipv6_forwarding
|
||
: (live.congestion === 'bbr' ? '1' : '0');
|
||
const okOn = String(nowLive) === (on ? '1' : '0');
|
||
return kernCard(it, `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
${sw('kn-' + key, on)}
|
||
<span class="tag ${okOn ? 'ok' : 'warn'}">${
|
||
key === 'bbr'
|
||
? (live.congestion === 'bbr' ? '当前 bbr + ' + esc(live.qdisc || '') : '当前 ' + esc(live.congestion || '—'))
|
||
: (on ? '已开启' : '已关闭')}</span>
|
||
${key === 'bbr' && on && live.congestion !== 'bbr'
|
||
? '<span class="tag err">未生效(内核未加载 tcp_bbr)</span>' : ''}
|
||
</div>`);
|
||
}).join('')}
|
||
</div>
|
||
<div class="kv" style="margin-top:12px"><b>内核可用拥塞算法</b>
|
||
<span class="mono">${esc(live.available || '—')}</span></div>
|
||
<div class="kv"><b>持久化文件</b><span class="mono">${esc(d.sysctl_file || '—')}</span></div>`;
|
||
|
||
const fwm = {};
|
||
(d.fw_items || []).forEach(it => { fwm[it.key] = it; });
|
||
const mssCtrl = (fam) => {
|
||
const on = fw['mss_' + fam] !== undefined ? fw['mss_' + fam] : (fwm['mss_' + fam] || {}).default;
|
||
const mode = fw['mss_' + fam + '_mode'] || 'clamp';
|
||
const val = fw['mss_' + fam + '_value'] || (fam === 'v4' ? 1452 : 1432);
|
||
return `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
${sw('kn-mss-' + fam, on)}
|
||
<select id="kn-mssmode-${fam}" style="width:auto">
|
||
<option value="clamp"${mode === 'clamp' ? ' selected' : ''}>按 MTU 自动算(推荐)</option>
|
||
<option value="fixed"${mode === 'fixed' ? ' selected' : ''}>写死数值</option>
|
||
</select>
|
||
<input id="kn-mssval-${fam}" type="number" min="576" max="9000" value="${Number(val)}" style="width:96px">
|
||
<span class="desc" style="margin:0">字节</span>
|
||
</div>`;
|
||
};
|
||
const masqCtrl = (fam) => {
|
||
const on = fw['masquerade_' + fam] !== undefined
|
||
? fw['masquerade_' + fam] : (fwm['masquerade_' + fam] || {}).default;
|
||
return `<div style="display:flex;align-items:center;gap:10px">${sw('kn-masq-' + fam, on)}
|
||
<span class="tag ${on ? 'ok' : 'gray'}">${on ? '开启' : '关闭'}</span></div>`;
|
||
};
|
||
$('#kn-fw').innerHTML = `<div class="dep-grid">
|
||
${kernCard(fwm.masquerade_v4 || {}, masqCtrl('v4'))}
|
||
${kernCard(fwm.mss_v4 || {}, mssCtrl('v4'))}
|
||
${kernCard(fwm.masquerade_v6 || {}, masqCtrl('v6'))}
|
||
${kernCard(fwm.mss_v6 || {}, mssCtrl('v6'))}
|
||
</div>`;
|
||
|
||
const s = c.snmp || {};
|
||
const inst = d.snmp_installed;
|
||
$('#kn-snmp').innerHTML = `
|
||
${inst ? '' : '<div class="notice warn" style="margin-bottom:10px">未安装 <b>snmpd</b>。请到「系统 → 依赖自检与安装」安装后再回来开启。</div>'}
|
||
<div class="row" style="align-items:center">
|
||
<label class="switch"><input type="checkbox" id="kn-snmp-en"${s.enabled ? ' checked' : ''}><i></i>
|
||
<span>启用 SNMP 服务</span></label>
|
||
<span class="tag ${d.snmp_active ? 'ok' : 'gray'}">${d.snmp_active ? '运行中' : '未运行'}</span>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>团体名(只读口令)<input id="kn-snmp-comm" value="${esc(s.community || 'public')}"></label>
|
||
<label style="flex:0 0 130px">端口<input id="kn-snmp-port" type="number" min="1" max="65535" value="${Number(s.port || 161)}"></label>
|
||
<label>监听地址(留空=全部)<input id="kn-snmp-listen" value="${esc(s.listen || '')}" placeholder="0.0.0.0"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>设备名称<input id="kn-snmp-name" value="${esc(s.sysname || '')}"></label>
|
||
<label>位置<input id="kn-snmp-loc" value="${esc(s.location || '')}"></label>
|
||
<label>联系人<input id="kn-snmp-contact" value="${esc(s.contact || '')}"></label>
|
||
</div>`;
|
||
}
|
||
|
||
async function kernSave() {
|
||
const q = id => document.getElementById(id);
|
||
const body = {
|
||
op: 'save',
|
||
fwd_v4: q('kn-fwd_v4') ? q('kn-fwd_v4').checked : undefined,
|
||
fwd_v6: q('kn-fwd_v6') ? q('kn-fwd_v6').checked : undefined,
|
||
bbr: q('kn-bbr') ? q('kn-bbr').checked : undefined,
|
||
fw: {
|
||
masquerade_v4: q('kn-masq-v4') ? q('kn-masq-v4').checked : undefined,
|
||
masquerade_v6: q('kn-masq-v6') ? q('kn-masq-v6').checked : undefined,
|
||
mss_v4: q('kn-mss-v4') ? q('kn-mss-v4').checked : undefined,
|
||
mss_v6: q('kn-mss-v6') ? q('kn-mss-v6').checked : undefined,
|
||
mss_v4_mode: q('kn-mssmode-v4') ? q('kn-mssmode-v4').value : undefined,
|
||
mss_v6_mode: q('kn-mssmode-v6') ? q('kn-mssmode-v6').value : undefined,
|
||
mss_v4_value: q('kn-mssval-v4') ? Number(q('kn-mssval-v4').value) : undefined,
|
||
mss_v6_value: q('kn-mssval-v6') ? Number(q('kn-mssval-v6').value) : undefined,
|
||
},
|
||
snmp: {
|
||
enabled: q('kn-snmp-en') ? q('kn-snmp-en').checked : undefined,
|
||
community: q('kn-snmp-comm') ? q('kn-snmp-comm').value : undefined,
|
||
port: q('kn-snmp-port') ? Number(q('kn-snmp-port').value) : undefined,
|
||
listen: q('kn-snmp-listen') ? q('kn-snmp-listen').value : undefined,
|
||
sysname: q('kn-snmp-name') ? q('kn-snmp-name').value : undefined,
|
||
location: q('kn-snmp-loc') ? q('kn-snmp-loc').value : undefined,
|
||
contact: q('kn-snmp-contact') ? q('kn-snmp-contact').value : undefined,
|
||
},
|
||
};
|
||
const r = await api('/api/kern', { method: 'POST', body: body });
|
||
const box = $('#kn-out');
|
||
box.classList.remove('hidden');
|
||
const errs = ((r.data || {}).errors) || [];
|
||
box.innerHTML = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>
|
||
${errs.length ? `<ul class="desc" style="margin:8px 0 0;padding-left:20px">${
|
||
errs.map(e => `<li>${esc(e)}</li>`).join('')}</ul>` : ''}`;
|
||
toast(r.msg_cn || '已保存', r.ok ? 'ok' : 'err', 5000);
|
||
if (r.ok) kernLoad();
|
||
}
|
||
|
||
/* ==================== 新手向导(上网四步走) ====================
|
||
放在「概览」组第一项:这是第一次使用这台机器的人唯一需要先看的东西。
|
||
设计取向与其余页面不同 —— 别的页面默认「你已经知道自己在干什么」,
|
||
这个页面默认「你什么都不知道」。所以:
|
||
* 每一步的说明都先讲「这是干什么的」,再讲「怎么填」;
|
||
* 每个输入框旁边给一个可以直接抄的范例,而不是只给字段名;
|
||
* 体检结论用「能不能上网」的说法,不用「接口状态 / 路由表」这类词;
|
||
* IPv6 明确标成可选 —— 不通不影响上网,不该让新手以为卡住了。
|
||
全部文案尽量口语化,避免「DHCP 服务未启用」这种只有同行看得懂的句子。 */
|
||
|
||
let WIZ = null;
|
||
let WIZ_STEP = 0;
|
||
|
||
async function viewWizard() {
|
||
$('#view').innerHTML = '<div class="card"><p class="desc">正在给这台机器做体检…</p></div>';
|
||
await wizLoad();
|
||
}
|
||
|
||
async function wizLoad() {
|
||
const r = await api('/api/wizard', { method: 'POST', body: { op: 'probe' } });
|
||
if (!r.ok) {
|
||
$('#view').innerHTML = `<div class="card"><div class="notice err">${
|
||
esc(r.msg_cn || '体检失败,请稍后重试')}</div></div>`;
|
||
return;
|
||
}
|
||
WIZ = r.data;
|
||
wizRender();
|
||
}
|
||
|
||
function wizStepTag(done, optional) {
|
||
if (done) return '<span class="tag ok">已就绪</span>';
|
||
if (optional) return '<span class="tag gray">可选</span>';
|
||
return '<span class="tag warn">待设置</span>';
|
||
}
|
||
|
||
function wizRender() {
|
||
const d = WIZ || {};
|
||
const w = d.wan || {}, l = d.lan || {}, n = d.dns || {}, v = d.v6 || {};
|
||
|
||
// ---- 顶部:一句话结论 ----
|
||
const headline = d.internet_ok
|
||
? '这台机器已经能把内网接到外网了。下面的步骤可以随时回来复查或调整。'
|
||
: '还差几步就能上网。按下面的顺序一步步来,每步做完都能看到结果。';
|
||
const hcls = d.internet_ok ? 'ok' : 'warn';
|
||
|
||
// ---- 步骤条 ----
|
||
const steps = d.steps || [];
|
||
const stateOf = k => k === 'wan' ? !!w.online : k === 'lan' ? !!l.ok
|
||
: k === 'dns' ? !!n.ok : !!v.ok;
|
||
const stepsBar = steps.map((s, i) => {
|
||
const ok = stateOf(s.k);
|
||
const cur = i === WIZ_STEP;
|
||
return `<button class="wiz-step ${ok ? 'ok' : ''} ${cur ? 'on' : ''}" data-i="${i}">
|
||
<span class="wiz-step-ico">${ok ? '✓' : (i + 1)}</span>
|
||
<span class="wiz-step-n">${esc(s.n)}</span>
|
||
</button>`;
|
||
}).join('<span class="wiz-arrow">›</span>');
|
||
|
||
$('#view').innerHTML = `
|
||
${d.build_mode ? `<div class="notice warn" style="margin-bottom:12px">
|
||
<b>当前处于构建保护模式</b>:向导会把配置写到磁盘,但不会重启服务、
|
||
不会真正拨号。等你关闭保护模式后再点一次「保存并生效」即可。</div>` : ''}
|
||
|
||
<div class="card" style="border-color:${hcls === 'ok' ? '#bfe3c4' : '#f0e0a8'}">
|
||
<h3>新手向导
|
||
<span class="tag ${hcls}">${d.internet_ok ? '可以上网了' : '还差 ' + (d.todo || []).filter(x => !x.includes('可选')).length + ' 步'}</span>
|
||
<button class="ghost small fixed" id="wz-reload" style="float:right">重新体检</button></h3>
|
||
<p class="desc" style="margin-bottom:14px">${esc(headline)}</p>
|
||
<div class="wiz-steps">${stepsBar}</div>
|
||
${(d.todo || []).length ? `<div class="hint-inline" style="margin-top:12px">
|
||
还没完成的:${(d.todo || []).map(esc).join(' · ')}</div>` : ''}
|
||
</div>
|
||
|
||
<div id="wz-body"></div>`;
|
||
|
||
$('#wz-reload').onclick = () => wizLoad();
|
||
$$('.wiz-step').forEach(b => b.onclick = () => {
|
||
WIZ_STEP = Number(b.dataset.i);
|
||
wizRender();
|
||
});
|
||
|
||
wizRenderStep();
|
||
}
|
||
|
||
function wizRenderStep() {
|
||
const d = WIZ || {};
|
||
const box = $('#wz-body');
|
||
if (!box) return;
|
||
const k = ((d.steps || [])[WIZ_STEP] || {}).k;
|
||
if (k === 'wan') box.innerHTML = wizStepWan();
|
||
else if (k === 'lan') box.innerHTML = wizStepLan();
|
||
else if (k === 'dns') box.innerHTML = wizStepDns();
|
||
else if (k === 'v6') box.innerHTML = wizStepV6();
|
||
wizBindStep(k);
|
||
}
|
||
|
||
/* ---------- 第 1 步:接上外网 ---------- */
|
||
function wizStepWan() {
|
||
const d = WIZ || {}, w = d.wan || {};
|
||
const modes = d.wan_modes || [];
|
||
const cur = w.mode || 'pppoe';
|
||
return `
|
||
<div class="card">
|
||
<h3>第 1 步 · 接上外网 ${wizStepTag(w.online)}</h3>
|
||
<p class="desc">先告诉这台机器「宽带从哪里进来」。这一步做完,本机自己就能上网;
|
||
要让内网设备也上,还要做完第 2 步。</p>
|
||
|
||
<div class="kv"><b>上网方式</b><span>${esc(w.mode_cn || '还没设置')}</span></div>
|
||
<div class="kv"><b>接宽带的网卡</b><span class="mono">${esc(w.iface || '还没指定')}</span></div>
|
||
<div class="kv"><b>默认路由</b><span class="mono">${esc(w.default_route || '还没有(说明还没连上外网)')}</span></div>
|
||
<div class="kv"><b>能 ping 通外网</b><span>${w.online
|
||
? '<span class="tag ok">可以</span>'
|
||
: '<span class="tag err">不通</span>'}</span></div>
|
||
${w.ping_note ? `<div class="hint-inline">最近一次探测:${esc(w.ping_note)}</div>` : ''}
|
||
|
||
<h4 style="margin:18px 0 8px">你的宽带是哪一种?</h4>
|
||
<div class="seg" id="wz-wan-mode">
|
||
${modes.map(m => `<button data-m="${m.k}" class="${cur === m.k ? 'on' : ''}">${esc(m.n)}</button>`).join('')}
|
||
</div>
|
||
<div id="wz-wan-desc" style="margin-top:12px"></div>
|
||
|
||
<div id="wz-wan-form" style="margin-top:14px"></div>
|
||
|
||
<div class="row" style="margin-top:16px">
|
||
<button class="primary fixed" id="wz-wan-save">保存并生效</button>
|
||
${cur === 'pppoe' ? '<button class="ghost fixed" id="wz-wan-dial">立即拨号测试</button>' : ''}
|
||
</div>
|
||
<div id="wz-wan-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>`;
|
||
}
|
||
|
||
function wizWanForm(mode) {
|
||
const d = WIZ || {}, w = d.wan || {};
|
||
const ifaces = d.wan_ifaces || [];
|
||
const ifOpts = (sel) => ['<option value="">请选择网卡</option>']
|
||
.concat(ifaces.map(i => `<option value="${esc(i.name)}"${sel === i.name ? ' selected' : ''}>${
|
||
esc(i.name)}(${esc(i.mac)})${i.state === 'UP' ? ' 已连接' : ' 未连接'}</option>`)).join('');
|
||
const iface = w.iface || (ifaces[0] || {}).name || '';
|
||
|
||
if (mode === 'pppoe') {
|
||
return `
|
||
<div class="row">
|
||
<label>接宽带的网卡<select id="wz-iface">${ifOpts(iface)}</select></label>
|
||
<label>运营商<select id="wz-isp">
|
||
<option value="auto">自动 / 通用</option>
|
||
<option value="ct">中国电信</option>
|
||
<option value="cu">中国联通</option>
|
||
<option value="cm">中国移动</option>
|
||
<option value="cbn">中国广电</option>
|
||
</select></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>宽带账号<input id="wz-user" placeholder="范例:051212345678 或 13800000000" autocomplete="off"></label>
|
||
<label>宽带密码<input id="wz-pass" type="password" placeholder="办理宽带时给的密码" autocomplete="new-password"></label>
|
||
</div>
|
||
<p class="hint-inline"><b>范例说明:</b>账号通常是你办理宽带时的「宽带账号」或手机号,
|
||
电信多为 <span class="mono">0512</span> 开头的区号加号码;
|
||
密码不是 Wi-Fi 密码,是宽带密码,忘了就打运营商客服重置。
|
||
不确定的话先按上面格式填,保存后再点「立即拨号测试」看结果。</p>`;
|
||
}
|
||
if (mode === 'dhcp') {
|
||
return `
|
||
<label>接宽带的网卡<select id="wz-iface">${ifOpts(iface)}</select></label>
|
||
<p class="hint-inline"><b>范例说明:</b>这种方式不需要账号密码。
|
||
把网线从光猫的 LAN 口接到这台机器的这张网卡上就行。
|
||
光猫要是已经在拨号(大多数家庭就是这样),选这个最省事。</p>`;
|
||
}
|
||
return `
|
||
<label>接宽带的网卡<select id="wz-iface">${ifOpts(iface)}</select></label>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>IP 地址 / 掩码<input id="wz-addr" placeholder="范例:192.168.1.2/24"></label>
|
||
<label>网关<input id="wz-gw" placeholder="范例:192.168.1.1"></label>
|
||
</div>
|
||
<label>DNS<input id="wz-sdns" placeholder="范例:223.5.5.5,223.6.6.6"></label>
|
||
<p class="hint-inline"><b>范例说明:</b>这三个值运营商都会在开通单上写给你,
|
||
照抄即可。<span class="mono">/24</span> 表示掩码是 255.255.255.0,不用改。</p>`;
|
||
}
|
||
|
||
/* ---------- 第 2 步:给内网发地址 ---------- */
|
||
function wizStepLan() {
|
||
const d = WIZ || {}, l = d.lan || {}, h = d.pool_hint || {};
|
||
const lan_ifaces = d.lan_ifaces || [];
|
||
const ifOpts = ['<option value="">请选择网卡</option>'].concat(
|
||
lan_ifaces.map(i => `<option value="${esc(i.name)}"${l.iface === i.name ? ' selected' : ''}>${
|
||
esc(i.name)}(${esc(i.mac)})</option>`)).join('');
|
||
const lanIp = l.lan_ip || '';
|
||
const base = lanIp ? lanIp.rsplit('.', 1)[0] : '192.168.7';
|
||
return `
|
||
<div class="card">
|
||
<h3>第 2 步 · 给内网发地址 ${wizStepTag(l.ok)}</h3>
|
||
<p class="desc">手机、电脑连上来之后要自动拿到 IP 地址、网关和 DNS,
|
||
否则会出现「连上了 Wi-Fi 但打不开网页」。这个功能就叫 DHCP。</p>
|
||
|
||
<div class="kv"><b>本机内网地址</b><span class="mono">${esc(lanIp || '还没设置')}</span></div>
|
||
<div class="kv"><b>DHCP 服务</b><span>${l.dhcp_enabled
|
||
? '<span class="tag ok">已开启</span>' : '<span class="tag warn">未开启</span>'}</span>
|
||
${l.dnsmasq_active ? '<span class="tag ok">服务运行中</span>' : '<span class="tag err">服务未运行</span>'}</div>
|
||
<div class="kv"><b>当前地址池</b><span class="mono">${esc(l.pool_start || '—')} ~ ${esc(l.pool_end || '—')}</span></div>
|
||
${(l.pool_problems || []).length ? `<div class="notice err" style="margin-top:10px">
|
||
<b>当前地址池有问题:</b><ul style="margin:6px 0 0 18px">${
|
||
(l.pool_problems || []).map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>` : ''}
|
||
|
||
<h4 style="margin:18px 0 8px">填写地址池</h4>
|
||
<label>内网网卡<select id="wz-lan-iface">${ifOpts}</select></label>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>起始地址<input id="wz-ps" value="${esc(l.pool_start || base + '.100')}"></label>
|
||
<label>结束地址<input id="wz-pe" value="${esc(l.pool_end || base + '.200')}"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>子网掩码<input id="wz-pm" value="${esc(l.pool_netmask || '255.255.255.0')}"></label>
|
||
<label>租期(秒)<input id="wz-lt" type="number" value="${Number(l.lease_time || 7200)}"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>下发给设备的网关<input id="wz-og" value="${esc(l.gateway || lanIp)}"></label>
|
||
<label>下发给设备的 DNS<input id="wz-od" value="${esc(l.dns_option || lanIp)}"></label>
|
||
</div>
|
||
|
||
<div class="notice" style="margin-top:12px;background:var(--info-l);border-color:#cfe0f5;color:#1b4f8a">
|
||
<b>地址池怎么填?(照着抄就行)</b>
|
||
<ul style="margin:8px 0 0 18px;line-height:1.9">
|
||
${(h.rules || []).map(x => `<li>${esc(x)}</li>`).join('')}
|
||
</ul>
|
||
<div style="margin-top:8px">本机内网地址是 <span class="mono">${esc(lanIp || '—')}</span>,
|
||
所以地址池用 <span class="mono">${esc(base)}.100</span> ~
|
||
<span class="mono">${esc(base)}.200</span> 最稳妥。</div>
|
||
</div>
|
||
|
||
<div class="row" style="margin-top:16px">
|
||
<button class="primary fixed" id="wz-lan-save">保存并生效</button>
|
||
</div>
|
||
<div id="wz-lan-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------- 第 3 步:能打开网址(DNS) ---------- */
|
||
function wizStepDns() {
|
||
const d = WIZ || {}, n = d.dns || {};
|
||
const presets = n.presets || [];
|
||
const curMode = n.mode || 'custom';
|
||
return `
|
||
<div class="card">
|
||
<h3>第 3 步 · 能打开网址 ${wizStepTag(n.ok)}</h3>
|
||
<p class="desc">你在浏览器里输入的是网址(比如 www.baidu.com),
|
||
但机器之间只认 IP 地址。把它们翻译过来的服务就叫 DNS。
|
||
这一步决定用谁的 DNS。</p>
|
||
|
||
<div class="kv"><b>当前来源</b><span>${esc(n.mode_cn || '—')}</span></div>
|
||
<div class="kv"><b>正在用的 DNS</b><span class="mono">${esc(n.first || '还没配')}</span></div>
|
||
<div class="kv"><b>解析测试</b><span>${n.probe_ok
|
||
? '<span class="tag ok">正常</span>' : '<span class="tag warn">有问题</span>'}</span></div>
|
||
${n.probe_note ? `<div class="hint-inline">${esc(n.probe_note)}</div>` : ''}
|
||
|
||
<h4 style="margin:18px 0 8px">选一个 DNS 来源</h4>
|
||
<div class="seg" id="wz-dns-mode">
|
||
<button data-m="custom" class="${curMode === 'custom' ? 'on' : ''}">用公共 DNS</button>
|
||
<button data-m="isp" class="${curMode === 'isp' ? 'on' : ''}">跟随运营商下发</button>
|
||
<button data-m="both" class="${curMode === 'both' ? 'on' : ''}">两者合并</button>
|
||
</div>
|
||
|
||
<div id="wz-dns-custom" style="margin-top:14px">
|
||
<label>选择服务商</label>
|
||
<div class="wiz-dns-grid">
|
||
${presets.map(p => `<button class="wiz-dns ${p.k === 'isp' ? 'wiz-dns-isp' : ''}" data-v="${esc(p.v4)}">
|
||
<b>${esc(p.n)}</b>
|
||
<span class="mono">${esc(p.v4 || '拨号时自动获取')}</span>
|
||
<span class="wiz-dns-d">${esc(p.d)}</span>
|
||
</button>`).join('')}
|
||
</div>
|
||
<label style="margin-top:12px">也可以自己填(多个之间用英文逗号分隔)
|
||
<input id="wz-dns-custom" value="${esc(n.custom || n.default_custom || '')}"
|
||
placeholder="范例:223.5.5.5,119.29.29.29"></label>
|
||
<p class="hint-inline"><b>范例说明:</b>上面几个都是可以直接用的公共 DNS。
|
||
<span class="mono">223.5.5.5</span> 是阿里云的,国内速度快、出错少,
|
||
不知道选哪个就用它。填两个地址的作用是一个不通时自动换另一个。</p>
|
||
</div>
|
||
|
||
<div class="row" style="margin-top:16px">
|
||
<button class="primary fixed" id="wz-dns-save">保存并生效</button>
|
||
</div>
|
||
<div id="wz-dns-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------- 第 4 步:IPv6(可选) ---------- */
|
||
function wizStepV6() {
|
||
const d = WIZ || {}, v = d.v6 || {};
|
||
const lan_ifaces = d.lan_ifaces || [];
|
||
const ifOpts = ['<option value="">请选择网卡</option>'].concat(
|
||
lan_ifaces.map(i => `<option value="${esc(i.name)}"${v.lan_iface === i.name ? ' selected' : ''}>${
|
||
esc(i.name)}(${esc(i.mac)})</option>`)).join('');
|
||
return `
|
||
<div class="card">
|
||
<h3>第 4 步 · 要不要用 IPv6 ${wizStepTag(v.ok, true)}</h3>
|
||
<p class="desc">IPv6 是新一代的网络地址。国内宽带基本都支持了。
|
||
<b>不开也不影响上网</b>,只是有些网站和服务用 IPv6 会更快、更直接。
|
||
拿不准就开着,出问题的概率很低。</p>
|
||
|
||
<div class="kv"><b>宽带那边</b><span>${v.wan_has_global
|
||
? '<span class="tag ok">运营商已下发 IPv6</span>'
|
||
: '<span class="tag gray">还没拿到 IPv6 地址</span>'}</span></div>
|
||
<div class="kv"><b>内网这边</b><span>${v.lan_has_global
|
||
? '<span class="tag ok">已在用公网 IPv6</span>'
|
||
: '<span class="tag gray">还没启用</span>'}</span></div>
|
||
<div class="kv"><b>路由通告服务</b><span>${v.radvd_active
|
||
? '<span class="tag ok">运行中</span>'
|
||
: (v.radvd_installed ? '<span class="tag warn">已安装未运行</span>'
|
||
: '<span class="tag err">未安装</span>')}</span></div>
|
||
<div class="kv"><b>建议</b><span>${esc(v.advice || '')}</span></div>
|
||
|
||
<div class="notice ${v.wan_has_global ? 'ok' : 'warn'}" style="margin-top:12px">
|
||
${v.wan_has_global
|
||
? '运营商已经给你的宽带下发了 IPv6 地址,可以放心打开。'
|
||
: '目前还没从运营商那里拿到 IPv6 地址。<b>打开也不会坏</b>,'
|
||
+ '只是暂时不生效;等运营商下发后它会自动开始工作。'}
|
||
</div>
|
||
|
||
<div class="row" style="margin-top:16px;align-items:center">
|
||
<label class="switch"><input type="checkbox" id="wz-v6-en"${v.ok || v.radvd_active ? ' checked' : ''}><i></i>
|
||
<span>在内网启用 IPv6</span></label>
|
||
</div>
|
||
|
||
<div id="wz-v6-form" style="margin-top:12px">
|
||
<label>内网网卡<select id="wz-v6-iface">${ifOpts}</select></label>
|
||
<div class="row" style="margin-top:10px">
|
||
<label>内网 IPv6 前缀(可留空)<input id="wz-v6-prefix" value="${esc(v.prefix || '')}"
|
||
placeholder="范例:2408:8207:1234:5678::/64"></label>
|
||
<label>IPv6 DNS(RDNSS)<input id="wz-v6-rdnss" value="${esc(v.rdnss || '2400:3200::1')}"
|
||
placeholder="范例:2400:3200::1"></label>
|
||
</div>
|
||
<p class="hint-inline"><b>范例说明:</b>前缀留空即可 —— 大多数家庭宽带会在拨号后
|
||
自动把运营商的前缀交给内网用。要手工填的话,
|
||
运营商给的前缀一般是 <span class="mono">/56</span> 或 <span class="mono">/60</span>,
|
||
这里填 <span class="mono">/64</span> 那一段(比如把它写成
|
||
<span class="mono">2408:8207:1234:5678::/64</span>)。
|
||
内网通告只能用 /64,填别的位数手机和电脑会直接忽略。</p>
|
||
</div>
|
||
|
||
<div class="row" style="margin-top:16px">
|
||
<button class="primary fixed" id="wz-v6-save">保存并生效</button>
|
||
</div>
|
||
<div id="wz-v6-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------- 交互绑定 ---------- */
|
||
function wizBindStep(k) {
|
||
const d = WIZ || {};
|
||
if (k === 'wan') {
|
||
let mode = (d.wan || {}).mode || 'pppoe';
|
||
const form = $('#wz-wan-form'), desc = $('#wz-wan-desc');
|
||
const paint = () => {
|
||
const m = (d.wan_modes || []).find(x => x.k === mode) || {};
|
||
desc.innerHTML = `<div class="notice" style="margin:0"><b>${esc(m.n || '')}</b> —— ${esc(m.d || '')}
|
||
<div style="margin-top:6px;color:var(--txt2)">什么时候选它:${esc(m.when || '')}</div></div>`;
|
||
form.innerHTML = wizWanForm(mode);
|
||
};
|
||
$$('#wz-wan-mode button').forEach(b => b.onclick = () => {
|
||
$$('#wz-wan-mode button').forEach(x => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
mode = b.dataset.m;
|
||
paint();
|
||
// 拨号按钮只在 PPPoE 下出现:换了方式要重画那一行
|
||
const row = $('#wz-wan-save').parentElement;
|
||
const old = $('#wz-wan-dial');
|
||
if (mode === 'pppoe' && !old) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'ghost fixed'; btn.id = 'wz-wan-dial'; btn.textContent = '立即拨号测试';
|
||
row.appendChild(btn); wizBindDial();
|
||
} else if (mode !== 'pppoe' && old) {
|
||
old.remove();
|
||
}
|
||
});
|
||
paint();
|
||
wizBindDial();
|
||
$('#wz-wan-save').onclick = () => wizApplyWan(mode);
|
||
} else if (k === 'lan') {
|
||
$('#wz-lan-save').onclick = wizApplyLan;
|
||
} else if (k === 'dns') {
|
||
const custom = $('#wz-dns-custom');
|
||
$$('#wz-dns-mode button').forEach(b => b.onclick = () => {
|
||
$$('#wz-dns-mode button').forEach(x => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
const box = $('#wz-dns-custom').parentElement;
|
||
box.style.opacity = (b.dataset.m === 'isp') ? '.45' : '1';
|
||
});
|
||
$$('.wiz-dns').forEach(b => b.onclick = () => {
|
||
if (b.classList.contains('wiz-dns-isp')) {
|
||
const ispBtn = $$('#wz-dns-mode button').find(x => x.dataset.m === 'isp');
|
||
if (ispBtn) ispBtn.click();
|
||
return;
|
||
}
|
||
if (custom) custom.value = b.dataset.v;
|
||
const cBtn = $$('#wz-dns-mode button').find(x => x.dataset.m === 'custom');
|
||
if (cBtn) cBtn.click();
|
||
// click() 会把 custom 框变暗,这里立刻点回 custom 态再填值
|
||
if (custom) { custom.value = b.dataset.v; custom.parentElement.style.opacity = '1'; }
|
||
});
|
||
$('#wz-dns-save').onclick = wizApplyDns;
|
||
} else if (k === 'v6') {
|
||
const en = $('#wz-v6-en'), form = $('#wz-v6-form');
|
||
const paint = () => { form.style.opacity = en.checked ? '1' : '.45';
|
||
form.style.pointerEvents = en.checked ? '' : 'none'; };
|
||
en.onchange = paint;
|
||
paint();
|
||
$('#wz-v6-save').onclick = wizApplyV6;
|
||
}
|
||
}
|
||
|
||
function wizBindDial() {
|
||
const btn = $('#wz-wan-dial');
|
||
if (!btn) return;
|
||
btn.onclick = () => {
|
||
modal('现在拨号试试?',
|
||
`<p>这会真的向运营商发起一次拨号。如果这台机器正在承担现有网络,
|
||
拨号过程会让网络短暂中断几十秒。</p>
|
||
<p>只是保存账号密码的话,<b>不用点这个按钮</b> —— 点上面的「保存并生效」就够了。</p>
|
||
<p>确认现在拨号,请输入 <b>拨号</b>:</p><input id="wz-cfm" placeholder="拨号">`,
|
||
async () => {
|
||
if ($('#wz-cfm').value.trim() !== '拨号') { toast('确认文字不正确', 'err'); return false; }
|
||
wizOut('wz-wan-out', '<div class="notice">正在拨号,通常需要 5–20 秒…</div>');
|
||
const r = await api('/api/wizard', { method: 'POST', body: { op: 'dial', action: 'connect', confirm: true } });
|
||
wizOut('wz-wan-out', r.ok
|
||
? `<div class="notice ok">${esc(r.msg_cn || '拨号指令已发出')}</div>`
|
||
: `<div class="notice err">${esc(r.msg_cn || '拨号失败')}</div>`);
|
||
setTimeout(wizLoad, 4000);
|
||
});
|
||
};
|
||
}
|
||
|
||
function wizOut(id, html, reload) {
|
||
const box = document.getElementById(id);
|
||
if (!box) return;
|
||
box.classList.remove('hidden');
|
||
box.innerHTML = html;
|
||
if (reload) setTimeout(wizLoad, 2500);
|
||
}
|
||
|
||
function wizCollect(id) { const e = document.getElementById(id); return e ? e.value.trim() : ''; }
|
||
function wizChecked(id) { const e = document.getElementById(id); return e ? e.checked : false; }
|
||
|
||
async function wizSubmit(step, body, outId) {
|
||
wizOut(outId, '<div class="notice">正在保存并生效…</div>');
|
||
const r = await api('/api/wizard', { method: 'POST', body: Object.assign({ op: 'apply_step', step: step }, body) });
|
||
const dd = r.data || {};
|
||
const errs = dd.errors || [], notes = dd.notes || [], done = dd.done || [];
|
||
let html = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>`;
|
||
if (done.length || notes.length) {
|
||
html += `<ul class="desc" style="margin:8px 0 0;padding-left:20px;line-height:1.9">${
|
||
done.concat(notes).map(x => `<li>${esc(x)}</li>`).join('')}</ul>`;
|
||
}
|
||
if (errs.length) {
|
||
html += `<div class="notice err" style="margin-top:8px">未完成:<ul style="margin:6px 0 0 18px">${
|
||
errs.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>`;
|
||
}
|
||
wizOut(outId, html);
|
||
toast(r.ok ? '这一步已完成' : '这一步没有全部成功', r.ok ? 'ok' : 'err', 6000);
|
||
if (r.ok || done.length) setTimeout(wizLoad, 2200);
|
||
}
|
||
|
||
function wizApplyWan(mode) {
|
||
const body = {
|
||
mode: mode,
|
||
iface: wizCollect('wz-iface'),
|
||
username: wizCollect('wz-user'),
|
||
password: wizCollect('wz-pass'),
|
||
isp: wizCollect('wz-isp'),
|
||
static_address: wizCollect('wz-addr'),
|
||
static_gateway: wizCollect('wz-gw'),
|
||
static_dns: wizCollect('wz-sdns'),
|
||
};
|
||
if (!body.iface) { toast('请先选择接宽带的网卡', 'err'); return; }
|
||
wizSubmit('wan', body, 'wz-wan-out');
|
||
}
|
||
|
||
function wizApplyLan() {
|
||
wizSubmit('lan', {
|
||
iface: wizCollect('wz-lan-iface'),
|
||
pool_start: wizCollect('wz-ps'),
|
||
pool_end: wizCollect('wz-pe'),
|
||
pool_netmask: wizCollect('wz-pm'),
|
||
lease_time: wizCollect('wz-lt'),
|
||
option_gateway: wizCollect('wz-og'),
|
||
option_dns: wizCollect('wz-od'),
|
||
}, 'wz-lan-out');
|
||
}
|
||
|
||
function wizApplyDns() {
|
||
const modeBtn = $$('#wz-dns-mode button').find(x => x.classList.contains('on'));
|
||
const mode = modeBtn ? modeBtn.dataset.m : 'custom';
|
||
wizSubmit('dns', { mode: mode, custom: wizCollect('wz-dns-custom') }, 'wz-dns-out');
|
||
}
|
||
|
||
function wizApplyV6() {
|
||
wizSubmit('v6', {
|
||
enable: wizChecked('wz-v6-en'),
|
||
lan_iface: wizCollect('wz-v6-iface'),
|
||
prefix: wizCollect('wz-v6-prefix'),
|
||
rdnss: wizCollect('wz-v6-rdnss'),
|
||
}, 'wz-v6-out');
|
||
}
|
||
|
||
/* ============================ 动态域名 DDNS(#6) ============================ */
|
||
const DDNS_REGION_TAG = r => r === '国内' ? 'ok' : (r === '国际' ? 'info' : 'gray');
|
||
|
||
async function viewDdns() {
|
||
const r = await api('/api/ddns');
|
||
const d = r.data || {};
|
||
const c = d.cfg || {};
|
||
const pub = d.public || {};
|
||
const note = d.note || {};
|
||
S.ddns = d;
|
||
const provs = d.providers || [];
|
||
const prov = provs.find(x => x.v === c.provider) || provs[0] || {};
|
||
const isCustom = c.provider === 'custom';
|
||
|
||
// 厂商按地区分组,便于国内用户快速找到
|
||
const byRegion = {};
|
||
provs.forEach(p => { (byRegion[p.region] = byRegion[p.region] || []).push(p); });
|
||
|
||
const noteCls = note.level === 'ok' ? 'ok' : (note.level === 'err' ? 'err' : 'warn');
|
||
const comboTag = { both: 'ok', v4only: 'info', v6only: 'warn', neither: 'err' }[pub.combo] || 'gray';
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card ${noteCls === 'err' ? '' : ''}" style="border-color:${
|
||
noteCls === 'ok' ? '#bfe3c4' : (noteCls === 'err' ? '#f0c8c3' : '#f0e0a8')}">
|
||
<h3>公网能力检测
|
||
<span class="tag ${comboTag}">${esc(note.title || '检测中…')}</span>
|
||
<button class="ghost small fixed" id="dd-retest" style="float:right">重新检测</button></h3>
|
||
<p class="desc">${esc(note.conclusion || '')}</p>
|
||
<div class="grid2" style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||
<div class="kv"><b>IPv4 出口地址</b><span class="mono">${esc(pub.v4_local || '—')}
|
||
${pub.v4_has ? '<span class="tag ok">公网</span>' : (pub.v4_nat ? '<span class="tag warn">NAT 后</span>' : '<span class="tag gray">未获取</span>')}</span></div>
|
||
<div class="kv"><b>IPv4 回显地址</b><span class="mono">${esc(pub.v4_public || '—')}
|
||
${pub.v4_source ? '<span class="tag gray">' + esc(pub.v4_source) + '</span>' : ''}</span></div>
|
||
<div class="kv"><b>IPv6 全局地址</b><span class="mono">${esc(pub.v6_local || '—')}
|
||
${pub.v6_has ? '<span class="tag ok">公网</span>' : '<span class="tag gray">无</span>'}</span></div>
|
||
<div class="kv"><b>IPv6 回显地址</b><span class="mono">${esc(pub.v6_public || '—')}
|
||
${pub.v6_source ? '<span class="tag gray">' + esc(pub.v6_source) + '</span>' : ''}</span></div>
|
||
</div>
|
||
<div class="kv"><b>默认路由出口</b><span class="mono">${esc(pub.egress || '—')}
|
||
${pub.gw ? ' 网关 ' + esc(pub.gw) : ''}</span></div>
|
||
<div class="kv"><b>检测时间</b><span>${esc(pub.checked_at || '—')}</span></div>
|
||
<p class="desc" style="margin-top:10px"><b>建议做法</b></p>
|
||
<ul style="margin:6px 0 0 18px;color:var(--txt2);font-size:13px;line-height:1.9">
|
||
${(note.advice || []).map(x => `<li>${esc(x)}</li>`).join('')}
|
||
</ul>
|
||
${d.warn ? `<div class="hint-inline" style="color:var(--warn);margin-top:10px">⚠ ${esc(d.warn)}</div>` : ''}
|
||
<div class="notice warn" style="margin-top:12px">
|
||
这里只能说明「地址是不是公网段」,<b>不能说明外网能不能连进来</b>。
|
||
配 DDNS 之前建议先做一次入向实测 ——
|
||
不少宽带给的是公网地址却在入向做了封锁,那种情况域名解析得到、
|
||
但别人就是连不上。
|
||
<div style="margin-top:8px">
|
||
<button class="primary small" id="dd-gopubip">去做真·公网 IP 判定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>DDNS 开关</h3>
|
||
<p class="desc">开启后按设定的间隔检测地址变化,变化时自动更新域名解析记录。</p>
|
||
<label class="switch"><input type="checkbox" id="dd-en" ${c.enabled ? 'checked' : ''}><i></i>启用动态域名解析</label>
|
||
<div class="row">
|
||
<label>记录类型
|
||
<div style="display:flex;gap:16px;padding-top:6px">
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="dd-ipv4" ${c.ipv4 !== false ? 'checked' : ''}><i></i>A(IPv4)</label>
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="dd-ipv6" ${c.ipv6 ? 'checked' : ''}><i></i>AAAA(IPv6)</label>
|
||
</div>
|
||
</label>
|
||
<label>TTL(秒)<input id="dd-ttl" type="number" value="${c.ttl || 300}" min="60" max="86400"></label>
|
||
<label>检测间隔(秒)<input id="dd-int" type="number" value="${c.interval || 300}" min="60" max="86400"></label>
|
||
</div>
|
||
<p class="hint-inline">IPv6 地址常为动态前缀,建议间隔 ≤ 300 秒、TTL ≤ 300 秒,以加快变更生效。</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>服务商与接入方式</h3>
|
||
<p class="desc">已内置 ${provs.length} 家国内外主流服务商,接口依据 2026 年现行公开文档整理。</p>
|
||
<div class="row">
|
||
<label>服务商<select id="dd-prov">
|
||
${Object.keys(byRegion).map(reg => `<optgroup label="${esc(reg)}">${
|
||
byRegion[reg].map(p => `<option value="${p.v}" ${c.provider === p.v ? 'selected' : ''}>${esc(p.n)}</option>`).join('')
|
||
}</optgroup>`).join('')}
|
||
</select></label>
|
||
<label>主域名<input id="dd-domain" value="${esc(c.domain || '')}" placeholder="例如 example.com"></label>
|
||
<label>主机记录<input id="dd-sub" value="${esc(c.subdomain || '')}" placeholder="留空或 @ 表示主域名"></label>
|
||
</div>
|
||
<div class="kv"><b>当前服务商</b><span>${esc(prov.n || '')}
|
||
<span class="tag ${DDNS_REGION_TAG(prov.region)}">${esc(prov.region || '')}</span></span></div>
|
||
<p class="desc" style="margin-top:6px">${esc(prov.desc || '')}</p>
|
||
${prov.doc ? `<p class="hint-inline">官方文档:<a href="${esc(prov.doc)}" target="_blank" rel="noopener noreferrer">${esc(prov.doc)}</a></p>` : ''}
|
||
<div id="dd-fields" style="margin-top:10px"></div>
|
||
<div class="kv"><b>完整域名</b><span class="mono" id="dd-fqdn">—</span></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>实时状态</h3>
|
||
<p class="desc">每次进入本页或点击刷新都会重新检测;结果缓存 20 秒以免频繁请求外部接口。</p>
|
||
<div class="kv"><b>启用状态</b><span>${c.enabled
|
||
? '<span class="tag ok">已启用</span>' : '<span class="tag gray">未启用</span>'}</span></div>
|
||
<div class="kv"><b>上次更新</b><span>${esc(c.last_update || '从未更新')}</span></div>
|
||
<div class="kv"><b>上次 IPv4</b><span class="mono">${esc(c.last_ip4 || '—')}</span></div>
|
||
<div class="kv"><b>上次 IPv6</b><span class="mono">${esc(c.last_ip6 || '—')}</span></div>
|
||
<div class="kv"><b>上次结果</b><span>${esc(c.last_msg_cn || '—')}</span></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="fixed" id="dd-update">立即更新记录</button>
|
||
<button class="ghost fixed" id="dd-refresh">刷新状态</button>
|
||
</div>
|
||
<div id="dd-out"></div>
|
||
</div>`;
|
||
|
||
const bind = (id, k, conv) => {
|
||
const e = $(id); if (!e) return;
|
||
const h = () => {
|
||
S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { [k]: conv ? conv(e.value) : e.value });
|
||
setActionMsg('DDNS 已修改,点击下方「保存配置」生效');
|
||
};
|
||
e.oninput = h; if (e.tagName === 'SELECT') e.onchange = h;
|
||
};
|
||
const ddField = $('#dd-prov');
|
||
if (ddField) ddField.onchange = () => {
|
||
S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { provider: ddField.value, fields: {} });
|
||
renderDdFields(ddField.value, {}, true);
|
||
setActionMsg('已切换服务商,请填写对应凭据后保存');
|
||
};
|
||
bind('#dd-domain', 'domain'); bind('#dd-sub', 'subdomain');
|
||
bind('#dd-ttl', 'ttl', Number); bind('#dd-int', 'interval', Number);
|
||
$('#dd-en').onchange = e => { S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { enabled: e.target.checked }); setActionMsg('已修改,点击下方保存'); };
|
||
$('#dd-ipv4').onchange = e => { S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { ipv4: e.target.checked }); setActionMsg('已修改,点击下方保存'); };
|
||
$('#dd-ipv6').onchange = e => { S.ddnsDirty = Object.assign(S.ddnsDirty || {}, { ipv6: e.target.checked }); setActionMsg('已修改,点击下方保存'); };
|
||
S.ddnsDirty = { enabled: !!c.enabled, provider: c.provider, domain: c.domain || '',
|
||
subdomain: c.subdomain || '', ipv4: c.ipv4 !== false, ipv6: !!c.ipv6,
|
||
ttl: c.ttl || 300, interval: c.interval || 300,
|
||
url4: c.url4 || '', url6: c.url6 || '', fields: Object.assign({}, c.fields || {}) };
|
||
|
||
renderDdFields(c.provider, c.fields || {}, false);
|
||
updFqdn();
|
||
|
||
// 保存 / 重测 / 更新
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'fixed'; saveBtn.textContent = '保存配置';
|
||
saveBtn.id = 'dd-save';
|
||
$('#dd-out').parentElement.querySelector('.row').appendChild(saveBtn);
|
||
saveBtn.onclick = async () => {
|
||
const b = Object.assign({}, S.ddnsDirty, { op: 'save' });
|
||
b.fields = Object.assign({}, S.ddnsDirty.fields || {});
|
||
$$('#dd-fields input').forEach(i => { b.fields[i.dataset.f] = i.value; });
|
||
const r2 = await api('/api/ddns', { method: 'POST', body: b });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
if (r2.ok) setTimeout(viewDdns, 400);
|
||
};
|
||
$('#dd-retest').onclick = async () => {
|
||
const b = $('#dd-out'); if (b) b.innerHTML = '<pre>正在重新检测公网能力…</pre>';
|
||
const r2 = await api('/api/ddns', { method: 'POST', body: { op: 'test' } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
setTimeout(viewDdns, 400);
|
||
};
|
||
const gp = $('#dd-gopubip');
|
||
if (gp) gp.onclick = () => go('pubip');
|
||
$('#dd-update').onclick = async () => {
|
||
$('#dd-out').innerHTML = '<pre>正在生成更新计划…</pre>';
|
||
const r2 = await api('/api/ddns', { method: 'POST', body: { op: 'update' } });
|
||
const p = (r2.data || {}).plan || [];
|
||
$('#dd-out').innerHTML = `<pre>${esc(r2.msg_cn || '')}\n` +
|
||
p.map(x => ` • ${x.type} 记录 ${x.record} → ${x.ip}(${x.provider})`).join('\n') + '</pre>';
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
};
|
||
$('#dd-refresh').onclick = () => { toast('已刷新 DDNS 状态', 'ok'); viewDdns(); };
|
||
|
||
// 字段变化时实时更新 FQDN 预览
|
||
$$('#dd-domain,#dd-sub').forEach(e => e.addEventListener('input', updFqdn));
|
||
}
|
||
|
||
/* 按服务商渲染所需凭据字段 */
|
||
function renderDdFields(provider, vals, clear) {
|
||
const el = $('#dd-fields'); if (!el) return;
|
||
const d = (S.ddns || {});
|
||
const prov = (d.providers || []).find(x => x.v === provider) || {};
|
||
const fields = prov.fields || [];
|
||
if (provider === 'custom') {
|
||
el.innerHTML = `
|
||
<div class="row">
|
||
<label>IPv4 更新 URL<input id="dd-url4" value="${esc(clear ? '' : vals.url4 || '')}" placeholder="https://ddns.example.com/update?host={domain}&ip={ip}&token={token}"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>IPv6 更新 URL<input id="dd-url6" value="${esc(clear ? '' : vals.url6 || '')}" placeholder="留空则复用 IPv4 的 URL(自动替换 {ip} 为 IPv6)"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>Token<input id="dd-ftoken" data-f="token" value="${esc(clear ? '' : vals.token || '')}"></label>
|
||
<label>用户名<input id="dd-fuser" data-f="user" value="${esc(clear ? '' : vals.user || '')}"></label>
|
||
<label>密码<input id="dd-fpass" data-f="pass" type="password" value="${esc(clear ? '' : vals.pass || '')}"></label>
|
||
</div>
|
||
<p class="hint-inline">可用占位符:<span class="mono">{ip} {ipv6} {domain} {token} {user} {pass}</span></p>`;
|
||
const u4 = $('#dd-url4'), u6 = $('#dd-url6');
|
||
if (u4) u4.oninput = () => { S.ddnsDirty.url4 = u4.value; setActionMsg('已修改,点击下方保存'); };
|
||
if (u6) u6.oninput = () => { S.ddnsDirty.url6 = u6.value; setActionMsg('已修改,点击下方保存'); };
|
||
bindDdFieldInputs();
|
||
return;
|
||
}
|
||
if (!fields.length) { el.innerHTML = '<p class="desc">该服务商无需额外凭据。</p>'; return; }
|
||
const label = {
|
||
access_key_id: 'AccessKey ID', access_key_secret: 'AccessKey Secret',
|
||
secret_id: 'SecretId', secret_key: 'SecretKey',
|
||
access_key: 'Access Key', api_token: 'API Token', zone_id: 'Zone ID',
|
||
api_key: 'API Key', api_secret: 'API Secret',
|
||
app_key: 'Application Key', app_secret: 'Application Secret',
|
||
consumer_key: 'Consumer Key', token: 'Token / 更新密钥',
|
||
user: '用户名', pass: '密码 / DDNS Key', region: '区域(如 cn-north-4)',
|
||
proxied: 'Cloudflare 代理(1=开 / 0=关)',
|
||
};
|
||
const secretLike = /secret|token|pass|proxy/i;
|
||
el.innerHTML = `<div class="row">` + fields.map(f => {
|
||
const ty = secretLike.test(f) && !/zone|region|id$|key$/i.test(f) ? 'password' : 'text';
|
||
return `<label>${esc(label[f] || f)}<input id="dd-f-${f}" data-f="${f}" type="${ty}" value="${esc(clear ? '' : (vals[f] || ''))}" autocomplete="off"></label>`;
|
||
}).join('') + `</div>
|
||
<p class="hint-inline">凭据保存在本机数据库,仅用于向该服务商发起更新请求。</p>`;
|
||
bindDdFieldInputs();
|
||
}
|
||
|
||
function bindDdFieldInputs() {
|
||
$$('#dd-fields input').forEach(i => {
|
||
i.oninput = () => {
|
||
S.ddnsDirty.fields = S.ddnsDirty.fields || {};
|
||
S.ddnsDirty.fields[i.dataset.f] = i.value;
|
||
setActionMsg('已修改,点击下方保存');
|
||
};
|
||
});
|
||
}
|
||
|
||
function updFqdn() {
|
||
const el = $('#dd-fqdn'); if (!el) return;
|
||
const dom = (($('#dd-domain') || {}).value || '').trim();
|
||
const sub = (($('#dd-sub') || {}).value || '').trim().replace(/^\.+|\.+$/g, '');
|
||
el.textContent = dom ? (sub && sub !== '@' ? sub + '.' + dom : dom) : '—';
|
||
}
|
||
|
||
async function liveV6() {
|
||
const el = $('#d6-live'); if (!el) return;
|
||
const r = await api('/api/ipv6');
|
||
const d = r.data || {};
|
||
const g = (d.addrs || []).flatMap(a => (a.addr_info || []).filter(x => x.family === 'inet6')
|
||
.map(x => ({ if: a.ifname, a: x.local, p: x.prefixlen, sc: x.scope })));
|
||
const global = g.filter(x => x.sc === 'global');
|
||
el.innerHTML = `
|
||
<div class="kv"><b>公网 IPv6</b><span class="mono">${global.length
|
||
? global.slice(0, 4).map(x => esc(x.a) + '/' + x.p + ' <span class="tag gray">' + esc(x.if) + '</span>').join('<br>')
|
||
: '<span class="tag warn">未获取(运营商可能未下发 PD)</span>'}</span></div>
|
||
<div class="kv"><b>默认路由</b><span class="mono">${esc(d.default_route || '无')}</span></div>
|
||
<div class="kv"><b>转发开关</b><span>${(d.sysctl || {})['all.forwarding'] === '1' ? '<span class="tag ok">已开启</span>' : '<span class="tag warn">未开启</span>'}</span></div>`;
|
||
}
|
||
|
||
/* ============================ 网络状态 · NAT · 软加速 ============================ */
|
||
const NAT_STYLE = {
|
||
NAT1: { c: 'ok', ico: '◎', hint: '最理想的出口类型' },
|
||
NAT2: { c: 'info', ico: '◉', hint: '常见的家用级 NAT' },
|
||
NAT3: { c: 'warn', ico: '◍', hint: '对称型 NAT,穿透较难' },
|
||
NAT4: { c: 'err', ico: '●', hint: '双重 NAT 或运营商级 NAT' },
|
||
NONE: { c: 'gray', ico: '○', hint: '未获取到公网映射信息' },
|
||
NA: { c: 'gray', ico: '?', hint: '检测不可用' },
|
||
};
|
||
|
||
async function viewNetStat() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>NAT 类型检测</h3>
|
||
<p class="desc">通过公共 STUN 服务器自动探测当前出口的 NAT 行为(全锥型 / 受限 / 端口受限 / 对称)。
|
||
检测过程完全自动完成,约需 3–10 秒。</p>
|
||
<div class="row" style="align-items:center;gap:10px">
|
||
<button id="nat-btn" class="primary">开始检测</button>
|
||
<span class="desc" id="nat-when" style="margin:0"></span>
|
||
</div>
|
||
<div id="nat-out" style="margin-top:14px"><p class="desc">点击「开始检测」以自动判定当前 NAT 类型。</p></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>软加速(nftables flowtable)</h3>
|
||
<p class="desc">利用内核 flowtable 把已建立的连接直接卸载到转发快路径,
|
||
等价于 OpenWRT 的「软件流量分载 / FastTrack」。可显著降低 CPU 占用、提升吞吐。</p>
|
||
<div id="accel-box"><p class="desc">读取中…</p></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>加速影响说明</h3>
|
||
<p class="desc">开启前请了解可能受影响的程序与协议 —— 若你依赖下列任一功能,建议先保持关闭。</p>
|
||
<div id="accel-impact"><p class="desc">读取中…</p></div>
|
||
</div>`;
|
||
|
||
$('#nat-btn').onclick = runNatCheck;
|
||
loadAccel();
|
||
loadNatLast();
|
||
}
|
||
|
||
async function loadNatLast() {
|
||
const r = await api('/api/nat/last');
|
||
const d = (r.data || {});
|
||
if (d.nat && d.checked_at) {
|
||
renderNat({ ok: true, data: d });
|
||
}
|
||
}
|
||
|
||
async function runNatCheck() {
|
||
const btn = $('#nat-btn'), out = $('#nat-out');
|
||
btn.disabled = true; btn.textContent = '检测中…';
|
||
out.innerHTML = '<p class="desc">正在与 STUN 服务器通信…</p>';
|
||
const r = await api('/api/nat/check', { method: 'POST' });
|
||
btn.disabled = false; btn.textContent = '重新检测';
|
||
if (!r.ok) {
|
||
out.innerHTML = `<div class="notice err">${esc(r.msg_cn || '检测失败')}</div>`;
|
||
return;
|
||
}
|
||
renderNat(r);
|
||
}
|
||
|
||
function renderNat(r) {
|
||
const d = r.data || {};
|
||
const st = NAT_STYLE[d.nat] || NAT_STYLE.NA;
|
||
const when = d.checked_at ? `上次检测:${esc(d.checked_at)}` : '';
|
||
if ($('#nat-when')) $('#nat-when').innerHTML = when;
|
||
const out = $('#nat-out');
|
||
if (!out) return;
|
||
out.innerHTML = `
|
||
<div class="nat-badge ${st.c}">
|
||
<span class="nat-ico">${st.ico}</span>
|
||
<div class="nat-txt">
|
||
<div class="nat-t">${esc(d.title || d.nat || '未知')}</div>
|
||
<div class="nat-d">${esc(d.desc || st.hint)}</div>
|
||
</div>
|
||
<span class="tag ${st.c}">${esc(d.nat || '?')}</span>
|
||
</div>
|
||
${natDetailHtml(d.detail)}`;
|
||
}
|
||
|
||
/* NAT 检测的 detail 在后端是**对象**(local_ip / probes / servers_used …),
|
||
之前它被当成纯文本交给转义函数,页面上就出现一个 "[object Object]" 方块。
|
||
这里按结构拆开显示 —— 每个 STUN 服务器回显的公网 IP:端口 正是判断
|
||
NAT 类型的依据,对排障很有价值,本来就该展示出来。 */
|
||
function natDetailHtml(detail) {
|
||
if (!detail) return '';
|
||
// 兼容后端把它改成纯字符串的情况
|
||
if (typeof detail === 'string') {
|
||
return `<pre class="nat-detail">${esc(detail)}</pre>`;
|
||
}
|
||
if (typeof detail !== 'object') return '';
|
||
const probes = Array.isArray(detail.probes) ? detail.probes : [];
|
||
const row = p => `<tr>
|
||
<td class="mono">${esc(p.server || '—')}</td>
|
||
<td class="mono">${esc(p.ip || '—')}</td>
|
||
<td class="mono">${esc(p.port == null ? '—' : p.port)}</td></tr>`;
|
||
const used = Array.isArray(detail.servers_used) ? detail.servers_used : [];
|
||
return `<div class="nat-detail-box" style="margin-top:12px">
|
||
<div class="kv"><b>本机出口地址</b><span class="mono">${esc(detail.local_ip || '—')}
|
||
${detail.local_private === true
|
||
? '<span class="tag warn">私网地址(说明在 NAT 后)</span>'
|
||
: (detail.local_private === false ? '<span class="tag ok">公网地址</span>' : '')}</span></div>
|
||
${detail.reason ? `<p class="hint-inline">原因:${esc(detail.reason)}</p>` : ''}
|
||
${probes.length ? `<div class="tw" style="margin-top:8px"><table>
|
||
<thead><tr><th>STUN 服务器</th><th>回显公网 IP</th><th>回显端口</th></tr></thead>
|
||
<tbody>${probes.map(row).join('')}</tbody></table></div>
|
||
<p class="hint-inline">多次探测的端口一致 = 锥形 NAT;每个目标端口都不同 = 对称型 NAT。</p>`
|
||
: ''}
|
||
${used.length ? `<p class="hint-inline">本次成功通信的服务器:${used.map(esc).join('、')}</p>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
async function loadAccel() {
|
||
const box = $('#accel-box');
|
||
const r = await api('/api/accel', { method: 'POST', body: { op: 'get' } });
|
||
const d = r.data || {};
|
||
const on = !!d.enabled;
|
||
const devs = (d.devices || []);
|
||
const fv4 = d.family_v4 !== false, fv6 = d.family_v6 !== false;
|
||
const scopeCn = d.scope_cn || (fv4 && fv6 ? 'IPv4 + IPv6' : (fv4 ? '仅 IPv4' : (fv6 ? '仅 IPv6' : '未覆盖')));
|
||
const scopeNote = d.scope_note || {};
|
||
box.innerHTML = `
|
||
<div class="accel-box ${on ? 'on' : 'off'}">
|
||
<div class="accel-state">
|
||
<span class="dot ${on ? 'on' : 'off'}"></span>
|
||
<b>${on ? '软加速已开启' : '软加速已关闭'}</b>
|
||
${on ? '<span class="tag ok">运行中</span>' : '<span class="tag gray">未启用</span>'}
|
||
<span class="tag ${fv4 ? 'info' : 'gray'}">IPv4</span>
|
||
<span class="tag ${fv6 ? 'info' : 'gray'}">IPv6</span>
|
||
</div>
|
||
<div class="accel-meta">
|
||
<div class="kv"><b>作用范围</b><span><b>${esc(scopeCn)}</b>
|
||
<span style="color:var(--txt3);font-size:12px">
|
||
(flowtable 可同时承载双栈,实际是否卸载取决于规则里的 ip / ip6 匹配)</span></span></div>
|
||
${devs.length ? `<div class="kv"><b>卸载网卡</b><span class="mono">${devs.map(esc).join(' · ')}</span></div>` : ''}
|
||
${d.flowtable ? `<div class="kv"><b>flowtable</b><span class="mono">${esc(d.flowtable)}</span></div>` : ''}
|
||
${d.flows != null ? `<div class="kv"><b>已卸载流</b><span class="mono">${esc(d.flows)} 条</span></div>` : ''}
|
||
${d.reason ? `<div class="kv"><b>说明</b><span>${esc(d.reason)}</span></div>` : ''}
|
||
${d.hw_note ? `<div class="kv"><b>硬件卸载</b><span>${esc(d.hw_note)}</span></div>` : ''}
|
||
${r.msg_cn ? `<div class="kv"><b>状态</b><span>${esc(r.msg_cn)}</span></div>` : ''}
|
||
</div>
|
||
<div class="row" style="margin-top:12px;gap:16px;align-items:center">
|
||
<label class="switch"><input type="checkbox" id="accel-v4" ${fv4 ? 'checked' : ''}><i></i>加速 IPv4</label>
|
||
<label class="switch"><input type="checkbox" id="accel-v6" ${fv6 ? 'checked' : ''}><i></i>加速 IPv6</label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px;gap:8px">
|
||
<button id="accel-on" class="primary" ${on ? 'disabled' : ''}>一键开启软加速</button>
|
||
<button id="accel-off" class="ghost" ${on ? '' : 'disabled'}>关闭软加速</button>
|
||
<button id="accel-apply" class="ghost small" ${on ? '' : 'disabled'}>按所选协议族重新应用</button>
|
||
<button id="accel-ref" class="ghost small">刷新状态</button>
|
||
</div>
|
||
<div id="accel-scope-note" style="margin-top:12px">${renderAccelScope(scopeNote)}</div>
|
||
</div>`;
|
||
|
||
const set = async (op) => {
|
||
const b = $('#accel-' + (op === 'on' ? 'on' : 'off'));
|
||
if (b) { b.disabled = true; b.textContent = op === 'on' ? '开启中…' : '关闭中…'; }
|
||
const body = { op };
|
||
if (op === 'on') {
|
||
body.v4 = $('#accel-v4') ? $('#accel-v4').checked : true;
|
||
body.v6 = $('#accel-v6') ? $('#accel-v6').checked : true;
|
||
}
|
||
const rr = await api('/api/accel', { method: 'POST', body });
|
||
toast(rr.msg_cn || (rr.ok ? '操作完成' : '操作失败'), rr.ok ? 'ok' : 'err', 5000);
|
||
if (rr.data && rr.data.log) {
|
||
console.log('[accel]', rr.data.log);
|
||
}
|
||
loadAccel();
|
||
};
|
||
const onBtn = $('#accel-on'), offBtn = $('#accel-off'), refBtn = $('#accel-ref');
|
||
const apBtn = $('#accel-apply');
|
||
if (onBtn) onBtn.onclick = () => set('on');
|
||
if (offBtn) offBtn.onclick = () => set('off');
|
||
if (refBtn) refBtn.onclick = () => loadAccel();
|
||
if (apBtn) apBtn.onclick = () => set('on');
|
||
|
||
renderAccelImpact(d.impact || []);
|
||
}
|
||
|
||
/* 软加速的 IPv4 / IPv6 作用范围说明(#5) */
|
||
function renderAccelScope(note) {
|
||
if (!note || !note.conclusion) return '';
|
||
return `<div class="impact-info" style="border-left:3px solid var(--info,#3b82f6)">
|
||
<b>${esc(note.title || '软加速对 IPv4 / IPv6 的作用范围')}</b>
|
||
<div class="desc" style="margin:6px 0 8px">${esc(note.conclusion)}</div>
|
||
<div class="kv"><b>IPv4 规则</b><span class="mono" style="font-size:12px">${esc((note.v4 || {}).rule || '')}</span></div>
|
||
<div class="kv"><b>IPv6 规则</b><span class="mono" style="font-size:12px">${esc((note.v6 || {}).rule || '')}</span></div>
|
||
<ul style="font-size:13px;color:var(--txt2);line-height:1.9;margin:8px 0 0 18px">
|
||
${(note.points || []).map(p => `<li>${esc(p)}</li>`).join('')}
|
||
</ul>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAccelImpact(items) {
|
||
const el = $('#accel-impact'); if (!el) return;
|
||
if (!items.length) { el.innerHTML = '<p class="desc">暂无说明(后端未返回影响清单)。</p>'; return; }
|
||
el.innerHTML = `<div class="impact-list">${items.map(it => `
|
||
<div class="impact-item">
|
||
<span class="impact-dot ${it.level || 'info'}"></span>
|
||
<div><b>${esc(it.name)}</b><div class="desc" style="margin:2px 0 0">${esc(it.why)}</div></div>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
|
||
/* ============================ VLAN / IPTV ============================ */
|
||
async function viewVlan() {
|
||
const r = await api('/api/vlan');
|
||
const d = r.data || {};
|
||
const list = d.items || d.vlans || [];
|
||
const presets = d.presets || [];
|
||
const ifs = S.ifaces || [];
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>新增 / 编辑 VLAN</h3>
|
||
<p class="desc">在物理网卡上划分 802.1Q 子接口,常用于 IPTV 组播、访客隔离或多拨。</p>
|
||
<div class="row">
|
||
<label>父网卡<select id="vl-parent">
|
||
${ifs.map(i => `<option value="${esc(i.name)}">${esc(i.name)}${i.role ? ' — ' + esc(i.role) : ''}</option>`).join('') || '<option value="">(无可用网卡)</option>'}
|
||
</select></label>
|
||
<label>VLAN ID<input id="vl-vid" type="number" min="1" max="4094" placeholder="1 - 4094" value=""></label>
|
||
<label>别名<input id="vl-name" placeholder="例如 iptv"></label>
|
||
<label>用途<select id="vl-purpose">
|
||
<option value="iptv">IPTV 组播</option>
|
||
<option value="lan">LAN 扩展</option>
|
||
<option value="wan">WAN 多拨</option>
|
||
<option value="guest">访客隔离</option>
|
||
<option value="other">其他</option>
|
||
</select></label>
|
||
</div>
|
||
<div class="row" style="align-items:flex-end;gap:8px">
|
||
<button id="vl-add" class="primary">添加 VLAN</button>
|
||
<span class="desc" id="vl-msg" style="margin:0"></span>
|
||
</div>
|
||
${presets.length ? `<div class="desc" style="margin-top:10px">常用预设:
|
||
${presets.map(p => `<button class="ghost small vl-preset" data-p='${esc(JSON.stringify(p))}'>${esc(p.name || p.label)} (${esc(p.vid)})</button>`).join(' ')}</div>` : ''}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>已配置的 VLAN</h3>
|
||
<p class="desc">保存后需点击「保存并应用」;应用时才会真正创建子接口。</p>
|
||
<div id="vl-list">${list.length ? renderVlanRows(list) : '<p class="desc">暂无 VLAN 配置。</p>'}</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>IPTV 组播注意事项</h3>
|
||
<p class="desc">机顶盒通常通过 VLAN 获取组播电视流,需要:</p>
|
||
<div class="kv"><b>1</b><span>在光猫上开启 IPTV 绑定,并记录其 VLAN ID(常见 85 / 41 / 3961 等)。</span></div>
|
||
<div class="kv"><b>2</b><span>此处创建同 ID 的 VLAN 子接口,父网卡选连接光猫的那张网卡。</span></div>
|
||
<div class="kv"><b>3</b><span>网桥/交换机端口需开启 IGMP Snooping,避免组播泛洪。</span></div>
|
||
<div class="kv"><b>4</b><span>机顶盒接入 IPTV VLAN 所在的端口即可正常收看。</span></div>
|
||
</div>`;
|
||
|
||
const presetBtns = $$('#vl-list .vl-del');
|
||
presetBtns.forEach(b => b.onclick = () => delVlan(b.dataset.id));
|
||
$$('.vl-preset').forEach(b => b.onclick = () => {
|
||
const p = JSON.parse(b.dataset.p);
|
||
if ($('#vl-vid')) $('#vl-vid').value = p.vid || '';
|
||
if ($('#vl-name')) $('#vl-name').value = (p.name || '').replace(/\s*IPTV$/, '') || 'iptv';
|
||
if ($('#vl-purpose')) $('#vl-purpose').value = p.purpose || 'iptv';
|
||
});
|
||
|
||
$('#vl-add').onclick = addVlan;
|
||
}
|
||
|
||
function renderVlanRows(list) {
|
||
return list.map(v => `
|
||
<div class="vlan-row">
|
||
<div>
|
||
<b class="mono">${esc(v._iface || (v.parent + '.' + v.vid))}</b>
|
||
<span class="tag info">VLAN ${esc(v.vid)}</span>
|
||
${v.note ? `<span class="tag gray">${esc(v.note)}</span>` : ''}
|
||
${v._addr ? `<span class="mono" style="font-size:12px">${esc(v._addr)}</span>` : ''}
|
||
${v._present ? '<span class="tag ok">已创建</span>' : '<span class="tag gray">未创建</span>'}
|
||
</div>
|
||
<div>
|
||
<button class="ghost small vl-del" data-id="${esc(v._iface || (v.parent + '.' + v.vid))}">删除</button>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function addVlan() {
|
||
const parent = ($('#vl-parent') || {}).value || '';
|
||
const vid = parseInt(($('#vl-vid') || {}).value || '0', 10);
|
||
const name = (($('#vl-name') || {}).value || '').trim();
|
||
const purpose = ($('#vl-purpose') || {}).value || 'iptv';
|
||
if (!parent) { toast('请先选择父网卡', 'err'); return; }
|
||
if (!(vid >= 1 && vid <= 4094)) { toast('VLAN ID 必须在 1–4094 之间', 'err'); return; }
|
||
const r = await api('/api/vlan', { method: 'POST', body: { op: 'save', parent, vid, note: name || purpose } });
|
||
if (!r.ok) { toast(r.msg_cn || '保存失败', 'err', 5000); return; }
|
||
toast('已保存,点击「保存并应用」后生效', 'ok');
|
||
viewVlan();
|
||
}
|
||
|
||
async function delVlan(id) {
|
||
const r = await api('/api/vlan', { method: 'POST', body: { op: 'delete', id } });
|
||
if (!r.ok) { toast(r.msg_cn || '删除失败', 'err'); return; }
|
||
toast('已删除', 'ok');
|
||
viewVlan();
|
||
}
|
||
|
||
/* ============================ WOL 网络唤醒 ============================ */
|
||
async function viewWol() {
|
||
const r = await api('/api/wol');
|
||
const d = r.data || {};
|
||
const ifs = d.ifaces || [];
|
||
const saved = d.targets || [];
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>网卡唤醒能力</h3>
|
||
<p class="desc">每张网卡的 WOL(Wake-on-LAN)支持情况。唤醒需要目标设备已关机但仍带电。</p>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>网卡</th><th>状态</th><th>WOL 支持</th><th>当前设置</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
${ifs.length ? ifs.map(i => `<tr>
|
||
<td class="mono">${esc(i.name)}</td>
|
||
<td>${i.oper === 'UP' ? '<span class="tag ok">UP</span>' : '<span class="tag gray">DOWN</span>'}</td>
|
||
<td>${i.supports_wol ? '<span class="tag ok">支持</span>' : '<span class="tag gray">不支持</span>'}</td>
|
||
<td class="mono" style="font-size:12px">${esc(i.wol_mode || '—')}</td>
|
||
<td>${i.supports_wol
|
||
? `<button class="ghost small wol-en" data-if="${esc(i.name)}">${i.enabled ? '关闭 WOL' : '启用 WOL'}</button>`
|
||
: `<span class="desc" title="${esc(i.reason || '')}">${esc(i.reason || '—')}</span>`}</td>
|
||
</tr>`).join('') : '<tr><td colspan="5" class="desc">没有可用网卡</td></tr>'}
|
||
</tbody>
|
||
</table></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>唤醒指定设备</h3>
|
||
<p class="desc">输入目标设备的 MAC 地址,发送「魔术包」将其从关机/休眠中唤醒。</p>
|
||
<div class="row">
|
||
<label>MAC 地址<input id="wol-mac" placeholder="AA:BB:CC:DD:EE:FF" class="mono"></label>
|
||
<label>目标网段<select id="wol-net">
|
||
<option value="255.255.255.255">全网广播 255.255.255.255</option>
|
||
${(S.cfg.system && S.cfg.system.lan_address ? [S.cfg.system.lan_address] : []).map(a => {
|
||
const n = a.split('.').slice(0, 3).join('.') + '.255';
|
||
return `<option value="${esc(n)}" selected>LAN 广播 ${esc(n)}</option>`;
|
||
}).join('')}
|
||
<option value="192.168.1.255">192.168.1.255</option>
|
||
<option value="192.168.7.255">192.168.7.255</option>
|
||
</select></label>
|
||
<label>出口网卡<select id="wol-if">
|
||
<option value="">自动选择</option>
|
||
${ifs.map(i => `<option value="${esc(i.name)}">${esc(i.name)}</option>`).join('')}
|
||
</select></label>
|
||
</div>
|
||
<div class="row" style="align-items:flex-end;gap:8px">
|
||
<button id="wol-go" class="primary">发送唤醒包</button>
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="wol-save"><i></i>同时保存为常用设备</label>
|
||
</div>
|
||
<div id="wol-out" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>常用设备</h3>
|
||
<p class="desc">已保存的唤醒目标,可一键唤醒。</p>
|
||
<div id="wol-saved">${saved.length ? saved.map(t => `
|
||
<div class="vlan-row"><div>
|
||
<b>${esc(t.label || t.mac)}</b>
|
||
<span class="mono" style="font-size:12px">${esc(t.mac)}</span>
|
||
${t.iface ? `<span class="tag gray">${esc(t.iface)}</span>` : ''}
|
||
</div><div>
|
||
<button class="ghost small wol-wake" data-mac="${esc(t.mac)}" data-if="${esc(t.iface || '')}">唤醒</button>
|
||
<button class="ghost small wol-rm" data-mac="${esc(t.mac)}">删除</button>
|
||
</div></div>`).join('') : '<p class="desc">暂无保存的设备。</p>'}
|
||
</div>
|
||
</div>`;
|
||
|
||
$$('.wol-en').forEach(b => b.onclick = async () => {
|
||
const cur = ifs.find(i => i.name === b.dataset.if) || {};
|
||
const rr = await api('/api/wol', { method: 'POST', body: { op: 'iface', iface: b.dataset.if, enabled: !cur.enabled } });
|
||
toast(rr.msg_cn || (rr.ok ? '已启用' : '失败'), rr.ok ? 'ok' : 'err');
|
||
viewWol();
|
||
});
|
||
$('#wol-go').onclick = () => sendWol();
|
||
$$('.wol-wake').forEach(b => b.onclick = () => {
|
||
$('#wol-mac').value = b.dataset.mac;
|
||
if (b.dataset.if) $('#wol-if').value = b.dataset.if;
|
||
sendWol();
|
||
});
|
||
$$('.wol-rm').forEach(b => b.onclick = async () => {
|
||
const rest = saved.filter(t => t.mac !== b.dataset.mac);
|
||
const rr = await api('/api/wol', { method: 'POST', body: { op: 'save', devices: rest } });
|
||
toast(rr.msg_cn || '已删除', rr.ok ? 'ok' : 'err');
|
||
viewWol();
|
||
});
|
||
}
|
||
|
||
async function sendWol() {
|
||
const mac = ($('#wol-mac') || {}).value.trim();
|
||
const net = ($('#wol-net') || {}).value;
|
||
const iface = ($('#wol-if') || {}).value;
|
||
const save = ($('#wol-save') || {}).checked;
|
||
const out = $('#wol-out');
|
||
if (!/^([0-9a-f]{2}[:.-]){5}[0-9a-f]{2}$/i.test(mac.replace(/\s/g, ''))) {
|
||
out.innerHTML = '<div class="notice err">MAC 地址格式不正确,应为 AA:BB:CC:DD:EE:FF</div>';
|
||
return;
|
||
}
|
||
out.innerHTML = '<p class="desc">正在发送魔术包…</p>';
|
||
const body = { op: 'wake', mac, broadcast: net, iface, save: !!save };
|
||
if (save) body.label = mac;
|
||
const r = await api('/api/wol', { method: 'POST', body });
|
||
if (!r.ok) { out.innerHTML = `<div class="notice err">${esc(r.msg_cn || '发送失败')}</div>`; return; }
|
||
out.innerHTML = `<div class="notice ok">${esc(r.msg_cn || '魔术包已发送')}
|
||
${(r.data && r.data.sent || []).length ? '<div class="desc">已发往:' + r.data.sent.map(esc).join(' · ') + '</div>' : ''}</div>`;
|
||
if (save) setTimeout(viewWol, 800);
|
||
}
|
||
|
||
/* 网卡候选列表归一化:后端可能返回 ['ens18','ens19'] 或 [{name:'ens18'}...] */
|
||
function ifaceNames(list) {
|
||
return (list || []).map(i => (typeof i === 'string' ? i : (i && i.name) || ''))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
/* ============================ PPPoE 多拨 ============================ */
|
||
/* 多拨 = 在同一物理口上并发多条 PPPoE 会话。
|
||
爱快叫「多拨」,本质是 N 个 ppp 接口 + 源地址策略路由 + 加权负载均衡。 */
|
||
async function viewPppMulti() {
|
||
const r = await api('/api/pppoe-multi');
|
||
const d = r.data || {};
|
||
const sessions = d.sessions || [];
|
||
const ifs = ifaceNames(d.wan_ifaces);
|
||
const strat = d.strategies || [];
|
||
const online = d.online || 0;
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>PPPoE 多拨 <span class="tag ${online ? 'ok' : 'gray'}">${online}/${sessions.length} 在线</span></h3>
|
||
<p class="desc">在同一物理网口上并发多条 PPPoE 会话,把总带宽叠加起来(爱快「多拨」的等效实现)。
|
||
每条会话是一张独立的 ppp 接口,配独立的源地址策略路由 —— 这样从某条会话出去的回包才会从原路返回。</p>
|
||
<div class="accel-box" style="margin-top:12px">
|
||
<div class="impact-info"><b>⚠ 前提:运营商必须允许并发拨号</b>
|
||
<div class="desc">部分省份限制同一账号只能拨 1 条,多余会话会反复重拨并显示为「未连接」,这是运营商的限制,不是程序故障。
|
||
建议先用 1 条确认账号能正常拨通,再逐步增加会话数。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>拨号策略</h3>
|
||
<p class="desc">决定多条会话如何共同承载流量。</p>
|
||
<div class="row">
|
||
<label>WAN 物理网卡<select id="ppm-if">
|
||
${ifs.length ? ifs.map(i => `<option value="${esc(i)}"${i === d.wan_iface ? ' selected' : ''}>${esc(i)}</option>`).join('')
|
||
: '<option value="">(未检测到物理网卡)</option>'}
|
||
</select></label>
|
||
<label>负载策略<select id="ppm-strategy">
|
||
${strat.map(s => `<option value="${esc(s.id)}"${s.id === d.strategy ? ' selected' : ''}>${esc(s.name)}</option>`).join('')}
|
||
</select></label>
|
||
<label>会话数量<input id="ppm-count" type="number" min="1" max="8" value="${sessions.length || 1}" style="width:90px"></label>
|
||
</div>
|
||
<p class="desc" id="ppm-strategy-why"></p>
|
||
<div class="row" style="align-items:flex-end;gap:8px">
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="ppm-dup"><i></i>单账号多拨(所有会话用同一个账号)</label>
|
||
<button id="ppm-expand" class="ghost small">按会话数量生成</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>会话账号</h3>
|
||
<p class="desc">每条会话一个账号。若运营商允许同账号并发,勾选上面的「单账号多拨」即可全部填同一个账号。</p>
|
||
<div id="ppm-list">${ppmRenderSessions(sessions)}</div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button id="ppm-save" class="primary">保存配置</button>
|
||
</div>
|
||
<div id="ppm-out" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>应用与连接</h3>
|
||
<p class="desc">「应用并连接」会真正发起拨号并改变出网默认路由。请在确认账号无误后执行。</p>
|
||
<label class="switch" style="margin:6px 0"><input type="checkbox" id="ppm-confirm"><i></i>
|
||
我确认账号已填写正确,并了解该操作会改变当前网络的出网路由</label>
|
||
<div class="row" style="gap:8px">
|
||
<button id="ppm-apply" class="primary">应用并连接</button>
|
||
<button id="ppm-stop" class="ghost">断开全部会话</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>会话状态与排错</h3>
|
||
<p class="desc">逐条查看会话的接口、获得的地址与对端网关;点「日志」看拨号过程与原因分析。</p>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>#</th><th>接口</th><th>状态</th><th>本端地址</th><th>对端网关</th><th>路由表</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
${sessions.length ? sessions.map(s => `<tr>
|
||
<td>${s.idx}</td>
|
||
<td class="mono">${esc(s.iface)}</td>
|
||
<td>${s.running ? '<span class="tag ok">已连接</span>' : '<span class="tag gray">未连接</span>'}</td>
|
||
<td class="mono" style="font-size:12px">${esc(s.addr || '—')}</td>
|
||
<td class="mono" style="font-size:12px">${esc(s.peer || '—')}</td>
|
||
<td class="mono" style="font-size:12px">${s.table}</td>
|
||
<td style="white-space:nowrap">
|
||
<button class="ghost small ppm-act" data-idx="${s.idx}" data-op="session_restart">重拨</button>
|
||
<button class="ghost small ppm-act" data-idx="${s.idx}" data-op="session_stop">停止</button>
|
||
<button class="ghost small ppm-log" data-idx="${s.idx}">日志</button>
|
||
</td>
|
||
</tr>`).join('') : '<tr><td colspan="7" class="desc">尚未配置任何会话</td></tr>'}
|
||
</tbody>
|
||
</table></div>
|
||
<div id="ppm-logbox" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
${(d.notes || []).length ? `<div class="card">
|
||
<h3>使用须知</h3>
|
||
<ul class="desc" style="line-height:1.9">${d.notes.map(n => `<li>${esc(n)}</li>`).join('')}</ul>
|
||
</div>` : ''}`;
|
||
|
||
const upd = () => {
|
||
const sel = $('#ppm-strategy');
|
||
const s = strat.find(x => x.id === sel.value);
|
||
$('#ppm-strategy-why').textContent = s ? s.why : '';
|
||
};
|
||
$('#ppm-strategy').onchange = upd; upd();
|
||
|
||
$('#ppm-expand').onclick = () => {
|
||
const n = Math.max(1, Math.min(8, parseInt($('#ppm-count').value || '1', 10)));
|
||
const old = ppmReadSessions();
|
||
const dup = $('#ppm-dup').checked;
|
||
const out = [];
|
||
for (let i = 1; i <= n; i++) {
|
||
const prev = old[i - 1] || {};
|
||
out.push(dup && i > 1
|
||
? Object.assign({}, old[0] || {}, { weight: 1 })
|
||
: Object.assign({ user: '', has_password: false, weight: prev.weight || 1 }, prev));
|
||
}
|
||
$('#ppm-list').innerHTML = ppmRenderSessions(out.map((s, i) => Object.assign({}, s, { idx: i + 1 })));
|
||
};
|
||
|
||
$('#ppm-save').onclick = async () => {
|
||
const list = ppmReadSessions();
|
||
if (!list.length) { toast('请先生成至少 1 条会话', 'err'); return; }
|
||
const body = {
|
||
op: 'save', iface: $('#ppm-if').value, strategy: $('#ppm-strategy').value,
|
||
duplicate_account: $('#ppm-dup').checked, sessions: list,
|
||
};
|
||
const rr = await api('/api/pppoe-multi', { method: 'POST', body });
|
||
toast(rr.msg_cn || (rr.ok ? '已保存' : '保存失败'), rr.ok ? 'ok' : 'err');
|
||
if (rr.ok) setTimeout(viewPppMulti, 600);
|
||
else ppmOut(rr);
|
||
};
|
||
|
||
$('#ppm-apply').onclick = async () => {
|
||
if (!$('#ppm-confirm').checked) { toast('请先勾选确认项', 'err'); return; }
|
||
ppmOut({ msg_cn: '正在应用并逐条拨号,请稍候…', ok: true });
|
||
const rr = await api('/api/pppoe-multi', {
|
||
method: 'POST',
|
||
body: { op: 'apply', confirm: true },
|
||
});
|
||
toast(rr.msg_cn || (rr.ok ? '已应用' : '应用失败'), rr.ok ? 'ok' : 'err');
|
||
ppmOut(rr);
|
||
setTimeout(viewPppMulti, 1500);
|
||
};
|
||
|
||
$('#ppm-stop').onclick = async () => {
|
||
if (!window.confirm('确定断开全部多拨会话?出网将恢复为单一默认路由。')) return;
|
||
const rr = await api('/api/pppoe-multi', { method: 'POST', body: { op: 'stop' } });
|
||
toast(rr.msg_cn || (rr.ok ? '已断开' : '操作失败'), rr.ok ? 'ok' : 'err');
|
||
setTimeout(viewPppMulti, 800);
|
||
};
|
||
|
||
$$('.ppm-act').forEach(b => b.onclick = async () => {
|
||
const rr = await api('/api/pppoe-multi', {
|
||
method: 'POST',
|
||
body: { op: b.dataset.op, idx: parseInt(b.dataset.idx, 10) },
|
||
});
|
||
toast(rr.msg_cn || '', rr.ok ? 'ok' : 'err');
|
||
setTimeout(viewPppMulti, 900);
|
||
});
|
||
|
||
$$('.ppm-log').forEach(b => b.onclick = async () => {
|
||
const box = $('#ppm-logbox');
|
||
box.innerHTML = '<p class="desc">正在读取日志…</p>';
|
||
const rr = await api('/api/pppoe-multi/log', { method: 'POST', body: { idx: parseInt(b.dataset.idx, 10) } });
|
||
const dd = rr.data || {};
|
||
box.innerHTML = `
|
||
<h3 style="font-size:14px">会话 #${esc(String(dd.idx || b.dataset.idx))} 日志</h3>
|
||
${dd.hint ? `<div class="notice warn"><b>结论:</b>${esc(dd.hint)}</div>` : ''}
|
||
<pre class="term-pre" style="max-height:340px">${esc(dd.log || '(无日志)')}</pre>`;
|
||
});
|
||
}
|
||
|
||
function ppmRenderSessions(list) {
|
||
if (!list.length) list = [{ idx: 1, user: '', weight: 1 }];
|
||
return list.map((s, i) => `
|
||
<div class="vlan-row ppm-row">
|
||
<div style="flex:1;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<b style="min-width:52px">#${i + 1}</b>
|
||
<input class="ppm-user mono" placeholder="宽带账号(如 0791xxxxxxx)" value="${esc(s.user || '')}" style="min-width:220px">
|
||
<input class="ppm-pass mono" type="password"
|
||
placeholder="${s.has_password ? '••••••(留空表示不修改)' : '宽带密码'}" style="min-width:160px">
|
||
<label class="desc" style="display:flex;align-items:center;gap:4px">权重
|
||
<input class="ppm-weight" type="number" min="1" max="100" value="${esc(String(s.weight || 1))}" style="width:64px">
|
||
</label>
|
||
${s.running === true ? '<span class="tag ok">已连接</span>' : ''}
|
||
${s.addr ? `<span class="mono tag gray" style="font-size:11px">${esc(s.addr)}</span>` : ''}
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function ppmReadSessions() {
|
||
const users = $$('.ppm-user'), passes = $$('.ppm-pass'), weights = $$('.ppm-weight');
|
||
const out = [];
|
||
for (let i = 0; i < users.length; i++) {
|
||
const u = users[i].value.trim();
|
||
const p = passes[i].value;
|
||
const w = parseInt(weights[i].value || '1', 10);
|
||
if (!u && !p) continue; // 整行空 → 跳过
|
||
out.push({ user: u, password: p, weight: isNaN(w) ? 1 : w });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function ppmOut(r) {
|
||
const box = $('#ppm-out');
|
||
if (!box) return;
|
||
const d = r.data || {};
|
||
let html = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>`;
|
||
const bad = (d.failed || []);
|
||
if (bad.length) {
|
||
html += '<div class="table-wrap" style="margin-top:8px"><table><thead><tr><th>会话</th><th>原因</th></tr></thead><tbody>'
|
||
+ bad.map(f => `<tr><td>#${f.idx}</td><td class="mono" style="font-size:12px">${esc(f.err || '')}</td></tr>`).join('')
|
||
+ '</tbody></table></div>';
|
||
}
|
||
if (d.log) html += `<pre class="term-pre" style="max-height:260px">${esc(d.log)}</pre>`;
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
/* ============================ QoS 智能限速 ============================ */
|
||
async function viewQos() {
|
||
const r = await api('/api/qos');
|
||
const d = r.data || {};
|
||
const ips = d.ips || [];
|
||
const dscp = d.dscp || [];
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>智能限速 QoS
|
||
<span class="tag ${d.running ? 'ok' : 'gray'}">${d.running ? '运行中' : '未启用'}</span>
|
||
${ips.length ? '<span class="tag warn">按 IP 限速模式</span>' : '<span class="tag ok">CAKE 智能模式</span>'}
|
||
</h3>
|
||
<p class="desc">用 Linux 内核自带的 tc 流量控制复刻爱快 QoS:CAKE 负责公平分流与抗 bufferbloat,
|
||
HTB 负责按 IP 精确限速,nftables 负责 DSCP 优先级标记与连接数限制。</p>
|
||
${d.accel_conflict ? `<div class="notice err" style="margin-top:10px">
|
||
<b>检测到冲突:软加速(flowtable)正在运行。</b>
|
||
flowtable 会让「已建立连接」绕过 netfilter 钩子,导致 DSCP 打标与 IP 限速完全失效。
|
||
请先到「概览 → 网络状态 / 加速」关闭软加速,再启用 QoS。
|
||
</div>` : ''}
|
||
${!d.modules_ok ? `<div class="notice err" style="margin-top:10px">
|
||
缺少内核模块:${esc((d.missing_modules || []).join('、'))}。请确认使用 Debian 官方内核。
|
||
</div>` : ''}
|
||
<div class="accel-box" style="margin-top:12px">
|
||
<div class="impact-info"><b>工作方式</b>
|
||
<div class="desc">上行直接在 WAN 出口排队;下行必须先把入口流量重定向到 IFB 虚拟设备再排队
|
||
(这是 Linux 的固有约束,与爱快下行限速原理相同)。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>带宽与模式</h3>
|
||
<p class="desc">带宽建议填「实测速率的 85%~90%」,留一点余量让 CAKE 有排队空间,抗延迟效果最好。</p>
|
||
<div class="row">
|
||
<label>WAN 物理网卡<select id="qos-wan">
|
||
${ifaceNames(d.wan_ifaces).map(n => `<option value="${esc(n)}"${n === d.wan_iface ? ' selected' : ''}>${esc(n)}</option>`).join('')
|
||
|| `<option value="${esc(d.wan_iface || '')}">${esc(d.wan_iface || '(未检测到)')}</option>`}
|
||
</select></label>
|
||
<label>下行带宽 (Mbit/s)<input id="qos-down" type="number" min="0" max="100000" value="${esc(String(d.down_mbit || ''))}" placeholder="如 900" style="width:110px">
|
||
<span class="desc">0 = 不限速</span></label>
|
||
<label>上行带宽 (Mbit/s)<input id="qos-up" type="number" min="0" max="100000" value="${esc(String(d.up_mbit || ''))}" placeholder="如 90" style="width:110px">
|
||
<span class="desc">0 = 不限速</span></label>
|
||
<label>CAKE 队列模式<select id="qos-mode">
|
||
${(d.cake_modes || []).map(m => `<option value="${esc(m.id)}"${m.id === d.mode ? ' selected' : ''}>${esc(m.name)}</option>`).join('')}
|
||
</select></label>
|
||
</div>
|
||
<p class="desc" id="qos-mode-why"></p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>优先级规则(DSCP 打标)</h3>
|
||
<p class="desc">勾选后,匹配的流量会被标记更高的 DSCP,CAKE 会把它插到前面转发 —— 这就是「游戏优先」的实现方式。
|
||
注:加密流量(HTTPS/QUIC)只能按端口判断,无法准确识别具体应用。</p>
|
||
<div class="dep-grid">
|
||
${(d.presets || []).map(p => {
|
||
const on = dscp.some(x => x.preset === p.id);
|
||
return `<label class="dep-item" style="cursor:pointer">
|
||
<input type="checkbox" class="qos-pre" data-id="${esc(p.id)}" ${on ? 'checked' : ''}>
|
||
<div>
|
||
<b>${esc(p.name)}</b>
|
||
<div class="desc">端口 ${esc(p.ports)} → DSCP <span class="mono">${esc(p.dscp)}</span></div>
|
||
<div class="desc" style="font-size:11px">${esc(p.why)}</div>
|
||
</div>
|
||
</label>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>按内网 IP 精确限速(HTB)</h3>
|
||
<p class="desc">给指定设备设「保证带宽」与「峰值带宽」。保证带宽是该设备最低能拿到的速率,峰值是它在空闲时可借用的上限。
|
||
<b>填写了任何一条 IP 规则后,CAKE 智能模式会被 HTB 精确限速取代</b> —— 两者不能同时生效。</p>
|
||
<div id="qos-ips">${qosRenderIps(ips)}</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button id="qos-add-ip" class="ghost small">+ 添加 IP 限速</button>
|
||
<span class="desc">最多 64 条</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>连接数限制</h3>
|
||
<p class="desc">限制单个内网 IP 的并发连接数,抑制异常设备(中毒、P2P 泛滥)拖垮整台路由。</p>
|
||
<div class="row">
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="qos-cl" ${d.connlimit ? 'checked' : ''}><i></i>启用连接数限制</label>
|
||
<label>单 IP 上限<input id="qos-cl-max" type="number" min="10" max="20000" value="${esc(String(d.connlimit_max || 500))}" style="width:110px"></label>
|
||
<span class="desc">建议 300 以上,过低会影响浏览器/下载工具</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>保存与应用</h3>
|
||
<div class="row" style="gap:8px">
|
||
<button id="qos-save" class="primary">保存配置</button>
|
||
<button id="qos-on" class="primary" ${d.running ? 'disabled' : ''}>启用 QoS</button>
|
||
<button id="qos-off" class="ghost" ${d.running ? '' : 'disabled'}>停用 QoS</button>
|
||
<button id="qos-refresh" class="ghost small">刷新状态</button>
|
||
</div>
|
||
<label class="switch" style="margin:10px 0"><input type="checkbox" id="qos-confirm"><i></i>
|
||
我了解启用 QoS 会改变流量转发行为${d.accel_conflict ? '(且当前与软加速冲突)' : ''}</label>
|
||
<div id="qos-out" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>当前生效的规则</h3>
|
||
<p class="desc">直接读取内核里的 tc / nftables 状态,可用于确认规则是否真的挂上了。</p>
|
||
<pre class="term-pre" style="max-height:300px">${esc(qosLiveText(d.live))}</pre>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>影响与取舍</h3>
|
||
<div class="dep-grid">
|
||
${(d.impact || []).map(i => `<div class="impact-item impact-${esc(i.level)}">
|
||
<b>${esc(i.name)}</b><div class="desc">${esc(i.why)}</div></div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
|
||
const upd = () => {
|
||
const s = (d.cake_modes || []).find(x => x.id === $('#qos-mode').value);
|
||
$('#qos-mode-why').textContent = s ? s.why : '';
|
||
};
|
||
$('#qos-mode').onchange = upd; upd();
|
||
|
||
const collect = () => ({
|
||
op: 'save',
|
||
wan_iface: $('#qos-wan').value,
|
||
down_mbit: parseInt($('#qos-down').value || '0', 10) || 0,
|
||
up_mbit: parseInt($('#qos-up').value || '0', 10) || 0,
|
||
mode: $('#qos-mode').value,
|
||
dscp: $$('.qos-pre').filter(c => c.checked).map(c => ({ preset: c.dataset.id })),
|
||
connlimit: $('#qos-cl').checked,
|
||
connlimit_max: parseInt($('#qos-cl-max').value || '500', 10) || 500,
|
||
ips: qosReadIps(),
|
||
});
|
||
|
||
$('#qos-add-ip').onclick = () => {
|
||
const cur = qosReadIps();
|
||
cur.push({ ip: '', guar_mbit: 10, ceil_mbit: 50, prio: 2 });
|
||
$('#qos-ips').innerHTML = qosRenderIps(cur);
|
||
};
|
||
|
||
$('#qos-save').onclick = async () => {
|
||
const rr = await api('/api/qos', { method: 'POST', body: collect() });
|
||
toast(rr.msg_cn || (rr.ok ? '已保存' : '保存失败'), rr.ok ? 'ok' : 'err');
|
||
qosOut(rr);
|
||
if (rr.ok) setTimeout(viewQos, 700);
|
||
};
|
||
|
||
$('#qos-on').onclick = async () => {
|
||
if (!$('#qos-confirm').checked) { toast('请先勾选确认项', 'err'); return; }
|
||
qosOut({ msg_cn: '正在下发 QoS 规则…', ok: true });
|
||
const body = collect();
|
||
const rr = await api('/api/qos', {
|
||
method: 'POST',
|
||
body: { op: 'on', confirm: true, ignore_accel: d.accel_conflict === true && window.confirm('当前软加速与 QoS 冲突,是否仍要继续?') },
|
||
});
|
||
toast(rr.msg_cn || (rr.ok ? '已启用' : '启用失败'), rr.ok ? 'ok' : 'err');
|
||
qosOut(rr);
|
||
setTimeout(viewQos, 1000);
|
||
};
|
||
|
||
$('#qos-off').onclick = async () => {
|
||
if (!window.confirm('确定停用 QoS?所有 CAKE / HTB 规则将被移除。')) return;
|
||
const rr = await api('/api/qos', { method: 'POST', body: { op: 'off' } });
|
||
toast(rr.msg_cn || (rr.ok ? '已停用' : '操作失败'), rr.ok ? 'ok' : 'err');
|
||
setTimeout(viewQos, 800);
|
||
};
|
||
|
||
$('#qos-refresh').onclick = () => viewQos();
|
||
$$('.qos-rm-ip').forEach(b => b.onclick = () => {
|
||
const cur = qosReadIps().filter((_x, i) => String(i) !== b.dataset.i);
|
||
$('#qos-ips').innerHTML = qosRenderIps(cur);
|
||
});
|
||
}
|
||
|
||
function qosRenderIps(ips) {
|
||
if (!ips.length) return '<p class="desc">尚未添加 IP 限速规则 —— 当前使用 CAKE 智能模式,所有设备自动公平分流。</p>';
|
||
return ips.map((r, i) => `
|
||
<div class="vlan-row qos-ip-row">
|
||
<div style="flex:1;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<input class="qos-ip mono" placeholder="192.168.7.100" value="${esc(r.ip || '')}" style="min-width:150px">
|
||
<label class="desc" style="display:flex;align-items:center;gap:4px">保证
|
||
<input class="qos-guar" type="number" min="1" value="${esc(String(r.guar_mbit || 10))}" style="width:80px">M</label>
|
||
<label class="desc" style="display:flex;align-items:center;gap:4px">峰值
|
||
<input class="qos-ceil" type="number" min="1" value="${esc(String(r.ceil_mbit || 50))}" style="width:80px">M</label>
|
||
<label class="desc" style="display:flex;align-items:center;gap:4px">优先级
|
||
<input class="qos-prio" type="number" min="0" max="6" value="${esc(String(r.prio == null ? 2 : r.prio))}" style="width:56px"></label>
|
||
<span class="desc" style="font-size:11px">0 最高 / 6 最低</span>
|
||
</div>
|
||
<button class="ghost small qos-rm-ip" data-i="${i}">删除</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function qosReadIps() {
|
||
const ipEls = $$('.qos-ip'), gEls = $$('.qos-guar'), cEls = $$('.qos-ceil'), pEls = $$('.qos-prio');
|
||
const out = [];
|
||
for (let i = 0; i < ipEls.length; i++) {
|
||
const ip = ipEls[i].value.trim();
|
||
if (!ip) continue;
|
||
out.push({
|
||
ip,
|
||
guar_mbit: parseInt(gEls[i].value || '1', 10) || 1,
|
||
ceil_mbit: parseInt(cEls[i].value || '1', 10) || 1,
|
||
prio: (pEls[i] && parseInt(pEls[i].value, 10)) || 0,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function qosLiveText(live) {
|
||
live = live || {};
|
||
const parts = [];
|
||
parts.push('── 入口重定向 / 上行根队列 ──');
|
||
parts.push(((live.wan_root || []).concat(live.ingress || [])).join('\n') || '(无)');
|
||
parts.push('');
|
||
parts.push('── IFB 下行队列 ──');
|
||
parts.push((live.ifb || []).join('\n') || '(无)');
|
||
parts.push('');
|
||
parts.push('── nftables drouter_qos 表 ──');
|
||
parts.push(live.nft_ok ? (live.nft_dump || '(空)') : '(未创建)');
|
||
return parts.join('\n');
|
||
}
|
||
|
||
function qosOut(r) {
|
||
const box = $('#qos-out');
|
||
if (!box) return;
|
||
const d = r.data || {};
|
||
let html = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>`;
|
||
if (d.log) html += `<pre class="term-pre" style="max-height:280px">${esc(d.log)}</pre>`;
|
||
if (d.live) html += `<pre class="term-pre" style="max-height:220px">${esc(qosLiveText(d.live))}</pre>`;
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
/* ============================ DPI 应用识别 ============================ */
|
||
async function viewDpi() {
|
||
const r = await api('/api/dpi');
|
||
const d = r.data || {};
|
||
const srcs = d.sources || [];
|
||
const mirrors = d.mirrors || [];
|
||
const inst = d.installed || {};
|
||
const lastMirror = d.last_mirror || 'ghproxy';
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>应用识别 DPI <span class="tag ${srcs.some(s => s.present) ? 'ok' : 'gray'}">
|
||
${srcs.filter(s => s.present).length}/${srcs.length} 已安装</span></h3>
|
||
<p class="desc">按「应用/协议」识别流量(爱快的杀手锏)。Debian 上对应 nDPI / Suricata。
|
||
识别结果可交给 QoS 打标限速,或直接封禁某类应用。</p>
|
||
<div class="accel-box" style="margin-top:12px">
|
||
<div class="impact-info"><b>⚠ 加密流量的固有限制</b>
|
||
<div class="desc">HTTPS / QUIC 已加密,任何 DPI(包括爱快)都无法 100% 识别具体应用,
|
||
只能靠 SNI、端口、流量特征做推测。这是行业共同限制,不是实现缺陷。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>识别引擎状态</h3>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>组件</th><th>状态</th><th>路径 / 说明</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>nDPI Reader</td>
|
||
<td>${inst.ndpiReader && inst.ndpiReader.present ? '<span class="tag ok">已安装</span>' : '<span class="tag gray">未安装</span>'}</td>
|
||
<td class="mono" style="font-size:12px">${esc((inst.ndpiReader && inst.ndpiReader.path) || '—')}</td></tr>
|
||
<tr><td>Suricata</td>
|
||
<td>${inst.suricata && inst.suricata.present ? '<span class="tag ok">已安装</span>' : '<span class="tag gray">未安装</span>'}</td>
|
||
<td class="mono" style="font-size:12px">${esc((inst.suricata && inst.suricata.path) || '—')}</td></tr>
|
||
<tr><td>ntopng</td>
|
||
<td>${inst.ntopng && inst.ntopng.present ? '<span class="tag ok">已安装</span>' : '<span class="tag gray">未安装</span>'}</td>
|
||
<td class="mono" style="font-size:12px">${esc((inst.ntopng && inst.ntopng.path) || '—')}</td></tr>
|
||
<tr><td>xt_ndpi 内核模块</td>
|
||
<td>${inst.xt_ndpi_module && inst.xt_ndpi_module.present ? '<span class="tag ok">已加载</span>' : '<span class="tag gray">未加载</span>'}</td>
|
||
<td class="desc">提供 nftables 按应用匹配能力,需自行编译</td></tr>
|
||
<tr><td>APT 仓库 nDPI</td>
|
||
<td>${inst.apt_ndpi && inst.apt_ndpi.available ? '<span class="tag ok">可装</span>' : '<span class="tag gray">不可用</span>'}</td>
|
||
<td class="desc">可直接 apt install,无需编译</td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>规则库 / 源码更新</h3>
|
||
<p class="desc">nDPI 的协议识别规则托管在 GitHub。<b>国内直连 GitHub 通常超时或极慢</b>,
|
||
所以这里提供多个公共加速前缀 —— 先「测速」挑最快的,再更新。</p>
|
||
<div class="row">
|
||
<label>加速前缀<select id="dpi-mirror">
|
||
${mirrors.map(m => `<option value="${esc(m.key)}"${m.key === lastMirror ? ' selected' : ''}>${esc(m.name)}</option>`).join('')}
|
||
<option value="custom"${lastMirror === 'custom' ? ' selected' : ''}>自定义前缀…</option>
|
||
</select></label>
|
||
<label>自定义前缀<input id="dpi-custom" class="mono" placeholder="https://your-proxy.example.com/"
|
||
value="${esc(d.state && d.state.prefix && !mirrors.some(m => m.prefix === d.state.prefix) ? d.state.prefix : '')}" style="min-width:260px"></label>
|
||
</div>
|
||
<p class="desc" id="dpi-mirror-why"></p>
|
||
<div class="row" style="gap:8px;margin-top:6px">
|
||
<button id="dpi-test" class="ghost">测速并优选</button>
|
||
<button id="dpi-update" class="primary">更新规则库</button>
|
||
<button id="dpi-log" class="ghost small">查看日志</button>
|
||
</div>
|
||
<div id="dpi-out" style="margin-top:12px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>已安装的规则库</h3>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>库</th><th>状态</th><th>更新时间</th><th>大小</th><th>用途</th></tr></thead>
|
||
<tbody>
|
||
${srcs.map(s => `<tr>
|
||
<td><b>${esc(s.name)}</b><div class="desc mono" style="font-size:11px">${esc(s.repo)}</div></td>
|
||
<td>${s.present ? '<span class="tag ok">已安装</span>' : '<span class="tag gray">未安装</span>'}</td>
|
||
<td class="mono" style="font-size:12px">${esc(s.version || '—')}</td>
|
||
<td class="mono" style="font-size:12px">${esc(s.size_h || '—')}</td>
|
||
<td class="desc" style="font-size:12px">${esc(s.why)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table></div>
|
||
${d.last_update ? `<p class="desc" style="margin-top:8px">上次更新:${esc(d.last_update)}(前缀 ${esc(d.last_mirror || '—')})</p>` : ''}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>安装方式</h3>
|
||
<p class="desc">三种路径,按「省事 → 最新 → 最强」排列。选一个生成命令,复制到 Web 终端执行即可。</p>
|
||
<div class="row">
|
||
<select id="dpi-plan">
|
||
${(d.presets || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')}
|
||
</select>
|
||
<button id="dpi-plan-go" class="ghost">生成命令</button>
|
||
</div>
|
||
<p class="desc" id="dpi-plan-why"></p>
|
||
<div id="dpi-plan-out" style="margin-top:10px"></div>
|
||
</div>
|
||
|
||
${(d.notes || []).length ? `<div class="card">
|
||
<h3>使用须知</h3>
|
||
<ul class="desc" style="line-height:1.9">${d.notes.map(n => `<li>${esc(n)}</li>`).join('')}</ul>
|
||
</div>` : ''}`;
|
||
|
||
const updMirror = () => {
|
||
const k = $('#dpi-mirror').value;
|
||
const m = mirrors.find(x => x.key === k);
|
||
$('#dpi-mirror-why').textContent = m ? m.why : '自定义前缀:填写你自己搭建或信任的加速服务地址,需以 / 结尾。';
|
||
};
|
||
$('#dpi-mirror').onchange = updMirror; updMirror();
|
||
|
||
$('#dpi-test').onclick = async () => {
|
||
const box = $('#dpi-out');
|
||
box.innerHTML = '<p class="desc">正在逐个测试前缀连通性(每个最多 5 秒)…</p>';
|
||
const rr = await api('/api/dpi', {
|
||
method: 'POST',
|
||
body: { op: 'test', custom_prefix: $('#dpi-custom').value.trim() },
|
||
});
|
||
const res = (rr.data || {}).results || [];
|
||
box.innerHTML = `
|
||
<div class="notice ${rr.ok ? 'ok' : 'warn'}">${esc(rr.msg_cn || '')}</div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>前缀</th><th>结果</th><th>耗时</th><th>地址</th></tr></thead>
|
||
<tbody>${res.map(x => `<tr>
|
||
<td>${esc(x.name)}</td>
|
||
<td>${x.ok ? '<span class="tag ok">可用</span>' : `<span class="tag gray">${esc(x.err || '不可用')}</span>`}</td>
|
||
<td class="mono">${esc(String(x.secs))}s</td>
|
||
<td class="mono" style="font-size:11px">${esc(x.prefix || '(直连)')}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
<p class="desc">建议选最快且可用的那个,然后点「更新规则库」。</p>`;
|
||
if (rr.data && rr.data.best) {
|
||
const sel = $('#dpi-mirror');
|
||
if ([...sel.options].some(o => o.value === rr.data.best)) sel.value = rr.data.best;
|
||
updMirror();
|
||
}
|
||
};
|
||
|
||
$('#dpi-update').onclick = async () => {
|
||
const box = $('#dpi-out');
|
||
const mirror = $('#dpi-mirror').value;
|
||
box.innerHTML = '<p class="desc">正在下载并安装规则库,可能耗时较长(视网速而定)…</p>';
|
||
const rr = await api('/api/dpi', {
|
||
method: 'POST',
|
||
body: { op: 'update', mirror, custom_prefix: $('#dpi-custom').value.trim() },
|
||
});
|
||
const res = (rr.data || {}).results || [];
|
||
let html = `<div class="notice ${rr.ok ? 'ok' : 'err'}">${esc(rr.msg_cn || '')}</div>`;
|
||
if (res.length) {
|
||
html += '<div class="table-wrap"><table><thead><tr><th>库</th><th>结果</th><th>说明</th></tr></thead><tbody>'
|
||
+ res.map(x => `<tr><td>${esc(x.name)}</td>
|
||
<td>${x.ok ? '<span class="tag ok">成功</span>' : '<span class="tag warn">失败</span>'}</td>
|
||
<td class="desc" style="font-size:12px">${esc(x.ok ? ((x.files || 0) + ' 个文件,' + (x.size_kb || 0) + ' KB') : (x.detail || x.err || ''))}</td>
|
||
</tr>`).join('') + '</tbody></table></div>';
|
||
}
|
||
if ((rr.data || {}).log) html += `<pre class="term-pre" style="max-height:300px">${esc(rr.data.log)}</pre>`;
|
||
html += '<p class="desc">完整日志可用上方「查看日志」按钮获取,便于复制反馈。</p>';
|
||
box.innerHTML = html;
|
||
if (rr.ok) setTimeout(() => $('#dpi-log').click(), 1200);
|
||
};
|
||
|
||
$('#dpi-log').onclick = async () => {
|
||
const rr = await api('/api/dpi/log');
|
||
$('#dpi-out').innerHTML = `<pre class="term-pre" style="max-height:400px">${esc((rr.data || {}).log || '(暂无更新日志)')}</pre>
|
||
<button class="ghost small" id="dpi-copy-log">复制日志</button>`;
|
||
const cb = $('#dpi-copy-log');
|
||
if (cb) cb.onclick = () => {
|
||
navigator.clipboard.writeText((rr.data || {}).log || '');
|
||
toast('日志已复制', 'ok');
|
||
};
|
||
};
|
||
|
||
const updPlan = () => {
|
||
const p = (d.presets || []).find(x => x.id === $('#dpi-plan').value);
|
||
$('#dpi-plan-why').textContent = p ? p.why : '';
|
||
};
|
||
$('#dpi-plan').onchange = updPlan; updPlan();
|
||
|
||
$('#dpi-plan-go').onclick = async () => {
|
||
const which = $('#dpi-plan').value;
|
||
const rr = await api('/api/dpi', {
|
||
method: 'POST',
|
||
body: { op: 'plan', which, prefix: $('#dpi-custom').value.trim() || DPI_PREFIX_HINT },
|
||
});
|
||
const cmds = (rr.data || {}).cmds || [];
|
||
$('#dpi-plan-out').innerHTML = `<pre class="term-pre">${esc(cmds.join('\n'))}</pre>
|
||
<button class="ghost small" id="dpi-copy-cmd">复制命令</button>`;
|
||
const cb = $('#dpi-copy-cmd');
|
||
if (cb) cb.onclick = () => {
|
||
navigator.clipboard.writeText(cmds.join('\n'));
|
||
toast('命令已复制,可粘贴到 Web 终端执行', 'ok');
|
||
};
|
||
};
|
||
}
|
||
|
||
const DPI_PREFIX_HINT = 'https://ghproxy.com/';
|
||
|
||
/* ============================ 依赖自检 ============================ */
|
||
async function viewDepCheck() {
|
||
const v = $('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>运行环境依赖自检</h3>
|
||
<p class="desc">检测在一台全新安装的 <b>Debian 13 + XFCE 桌面</b> 上运行 Drouter 所需的
|
||
全部依赖、组件、软件与内核模块。绿色为已就绪,红色为缺失(必需),黄色为可选。</p>
|
||
<div class="row" style="align-items:center;gap:10px;flex-wrap:wrap">
|
||
<button id="dep-check" class="primary">开始检测</button>
|
||
<button id="dep-install" class="ghost" disabled>一键安装缺失的必需项</button>
|
||
<span class="desc" id="dep-sum" style="margin:0"></span>
|
||
</div>
|
||
<p class="desc" style="margin-top:8px">
|
||
<b>「一键安装」只装必需项</b>。可选组件(Samba / NFS / Docker / 桌面环境等)
|
||
需要你逐项点「安装」—— 它们装完会拉起常驻服务或占用大量空间,
|
||
不该因为一次点错就被全部装上。
|
||
</p>
|
||
<div class="row hidden" id="dep-filter" style="align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px">
|
||
<input id="dep-q" class="input" type="search" placeholder="搜索名称 / 命令 / 包名,如 xfs、f2fs、mkfs"
|
||
style="flex:1;min-width:220px" autocomplete="off">
|
||
<label class="desc" style="margin:0;display:flex;align-items:center;gap:4px;white-space:nowrap">
|
||
<input id="dep-only-miss" type="checkbox"> 只看缺失
|
||
</label>
|
||
</div>
|
||
<div class="row hidden" id="dep-groups" style="gap:6px;flex-wrap:wrap;margin-top:8px"></div>
|
||
</div>
|
||
<div id="dep-result"><p class="desc">点击「开始检测」开始。</p></div>
|
||
<div class="card hidden" id="dep-log-card">
|
||
<h3>安装日志</h3>
|
||
<p class="desc">如安装失败,可复制以下日志用于排查。</p>
|
||
<div class="code-wrap"><button class="ghost small copy-btn" data-copy="#dep-log">复制日志</button>
|
||
<pre id="dep-log" class="log-pre"></pre></div>
|
||
</div>`;
|
||
|
||
$('#dep-check').onclick = runDepCheck;
|
||
const inst = $('#dep-install');
|
||
// 必须包一层:直接把函数给 onclick 会把 MouseEvent 当成 keys 参数传进去
|
||
inst.onclick = () => runDepInstall();
|
||
$('#dep-q').oninput = renderDepList;
|
||
$('#dep-only-miss').onchange = renderDepList;
|
||
bindCopyButtons(v);
|
||
}
|
||
|
||
// 上一次检测的完整结果留在内存里,过滤 / 搜索只是重新渲染,不再打接口。
|
||
// DEP_REQ_MISSING:本次「缺失且必需」的 key 列表,一键安装只用这个列表,
|
||
// 避免把 Samba / NFS / Docker / 桌面环境这类可选大件一起装上。
|
||
let DEP_LAST = null;
|
||
let DEP_REQ_MISSING = [];
|
||
let DEP_FILTER = 'all';
|
||
|
||
function depMatch(x, q) {
|
||
if (!q) return true;
|
||
const hay = [x.name, x.key, x.target, x.pkg, x.note, x.group_name, x.where]
|
||
.filter(Boolean).join(' ').toLowerCase();
|
||
return hay.indexOf(q) >= 0;
|
||
}
|
||
|
||
function renderDepList() {
|
||
const d = DEP_LAST;
|
||
if (!d) return;
|
||
const box = $('#dep-result');
|
||
const items = d.items || [];
|
||
const q = (($('#dep-q') || {}).value || '').trim().toLowerCase();
|
||
const onlyMiss = !!(($('#dep-only-miss') || {}).checked);
|
||
|
||
const sel = items.filter(x => (DEP_FILTER === 'all' || x.group === DEP_FILTER)
|
||
&& depMatch(x, q) && (!onlyMiss || !x.ok));
|
||
|
||
const req = sel.filter(x => x.required);
|
||
const missReq = req.filter(x => !x.ok);
|
||
const opt = sel.filter(x => !x.required);
|
||
const missOpt = opt.filter(x => !x.ok);
|
||
|
||
const g = (title, arr, cls) => arr.length ? `
|
||
<div class="card">
|
||
<h3>${title} <span class="tag ${cls}">${arr.length}</span></h3>
|
||
<div class="dep-grid">${arr.map(renderDepItem).join('')}</div>
|
||
</div>` : '';
|
||
|
||
const missing = items.filter(x => !x.ok);
|
||
const total = items.length;
|
||
const shown = sel.length;
|
||
box.innerHTML =
|
||
(missing.length ? '' : '<div class="notice ok" style="margin-bottom:12px">全部依赖均已就绪,无需安装。</div>') +
|
||
(shown === 0
|
||
? `<div class="notice warn">当前筛选条件下没有匹配项(共 ${total} 项)。试试清空搜索或切换到「全部」。</div>`
|
||
: g('缺失(必需)', missReq, 'err') +
|
||
g('缺失(可选)', missOpt, 'warn') +
|
||
g('已就绪(必需)', req.filter(x => x.ok), 'ok') +
|
||
g('已就绪(可选)', opt.filter(x => x.ok), 'gray')) +
|
||
(shown !== total ? `<p class="desc" style="margin-top:8px">显示 ${shown} / ${total} 项</p>` : '');
|
||
bindDepInstallButtons(box);
|
||
}
|
||
|
||
async function runDepCheck() {
|
||
const btn = $('#dep-check');
|
||
btn.disabled = true; btn.textContent = '检测中…';
|
||
const r = await api('/api/deps/check');
|
||
btn.disabled = false; btn.textContent = '再次检测';
|
||
const box = $('#dep-result');
|
||
if (!r.ok) { box.innerHTML = `<div class="notice err">${esc(r.msg_cn || '检测失败')}</div>`; return; }
|
||
const d = r.data || {};
|
||
const items = d.items || [];
|
||
DEP_LAST = d;
|
||
DEP_FILTER = 'all';
|
||
|
||
const req = items.filter(x => x.required);
|
||
const missReq = req.filter(x => !x.ok);
|
||
const opt = items.filter(x => !x.required);
|
||
const missOpt = opt.filter(x => !x.ok);
|
||
$('#dep-sum').innerHTML = `必需 <b style="color:${missReq.length ? 'var(--err)' : 'var(--ok)'}">${req.length - missReq.length}/${req.length}</b>
|
||
· 可选 ${opt.length - missOpt.length}/${opt.length}
|
||
${d.extra && d.extra.os ? ` · ${esc(d.extra.os)}` : ''}`;
|
||
$('#dep-install').disabled = missReq.length === 0;
|
||
// 记下本次必需缺失项的 key:一键安装只能装这些,绝不能顺带把可选项也装上
|
||
DEP_REQ_MISSING = missReq.filter(x => x.pkg).map(x => x.key);
|
||
|
||
// 分组过滤按钮:光靠四段标题,70 项平铺时根本找不到想看的那一个
|
||
const groups = d.groups || [];
|
||
const gbox = $('#dep-groups');
|
||
if (groups.length) {
|
||
gbox.classList.remove('hidden');
|
||
const cnt = {};
|
||
items.forEach(x => { cnt[x.group] = (cnt[x.group] || 0) + 1; });
|
||
gbox.innerHTML =
|
||
`<button class="ghost small dep-grp on" data-grp="all">全部 <span class="tag gray">${items.length}</span></button>` +
|
||
groups.filter(g => cnt[g.id]).map(g =>
|
||
`<button class="ghost small dep-grp" data-grp="${esc(g.id)}">${esc(g.name)} <span class="tag gray">${cnt[g.id]}</span></button>`
|
||
).join('');
|
||
gbox.querySelectorAll('.dep-grp').forEach(b => {
|
||
b.onclick = () => {
|
||
DEP_FILTER = b.dataset.grp;
|
||
gbox.querySelectorAll('.dep-grp').forEach(x => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
renderDepList();
|
||
};
|
||
});
|
||
}
|
||
$('#dep-filter').classList.remove('hidden');
|
||
renderDepList();
|
||
}
|
||
|
||
function renderDepItem(x) {
|
||
const cls = x.ok ? 'ok' : (x.required ? 'err' : 'warn');
|
||
const ico = x.ok ? '✓' : (x.required ? '✕' : '!');
|
||
// 缺失且能装的给一个单独的安装按钮:可选项必须由用户逐项确认,
|
||
// 不能混进「一键安装」里(否则一次点错就把桌面环境 / Samba / Docker 全装上)
|
||
const btn = (!x.ok && x.pkg)
|
||
? `<button class="ghost small" data-dep-key="${esc(x.key)}">安装</button>` : '';
|
||
return `<div class="dep-item ${cls}">
|
||
<span class="dep-ico">${ico}</span>
|
||
<div class="dep-body">
|
||
<b>${esc(x.name)}</b>
|
||
<div class="desc">${esc(x.note || '')}</div>
|
||
<div class="mono dep-where">${esc(x.where || x.target || '')}${x.pkg ? ' · 包名 ' + esc(x.pkg) : ''}</div>
|
||
</div>
|
||
<span class="tag gray">${esc(x.group_name || '其它')}</span>
|
||
${x.required ? '<span class="tag err">必需</span>' : '<span class="tag gray">可选</span>'}
|
||
${btn}
|
||
</div>`;
|
||
}
|
||
|
||
// 渲染后把每个「安装」按钮接上事件
|
||
function bindDepInstallButtons(root) {
|
||
(root || document).querySelectorAll('[data-dep-key]').forEach(b => {
|
||
b.onclick = () => runDepInstall([b.dataset.depKey]);
|
||
});
|
||
}
|
||
|
||
async function runDepInstall(keys) {
|
||
const one = Array.isArray(keys) && keys.length > 0;
|
||
const btn = $('#dep-install');
|
||
if (!one && btn) { btn.disabled = true; btn.textContent = '安装中…(可能需要几分钟)'; }
|
||
toast(one ? '正在安装该组件…' : '正在安装缺失依赖,请稍候…', 'info', 5000);
|
||
const body = one ? { keys: keys } : { keys: DEP_REQ_MISSING };
|
||
const r = await api('/api/deps/install', { method: 'POST', body: body });
|
||
if (!one && btn) { btn.disabled = false; btn.textContent = '一键安装缺失的必需项'; }
|
||
btn.disabled = false; btn.textContent = '一键安装缺失项';
|
||
const box = $('#dep-result');
|
||
const d = r.data || {};
|
||
if (d.log) {
|
||
$('#dep-log-card').classList.remove('hidden');
|
||
$('#dep-log').textContent = d.log;
|
||
}
|
||
if (!r.ok) {
|
||
toast(r.msg_cn || '安装失败', 'err', 6000);
|
||
box.insertAdjacentHTML('afterbegin',
|
||
`<div class="notice err">安装失败:${esc(r.msg_cn || '')}。请查看下方「安装日志」。</div>`);
|
||
if (!$('#dep-log-card').classList.contains('hidden')) {
|
||
$('#dep-log-card').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
return;
|
||
}
|
||
const still = d.still_missing || [];
|
||
toast(still.length ? `安装完成,仍有 ${still.length} 项缺失` : '全部依赖已安装完成', still.length ? 'warn' : 'ok', 6000);
|
||
await runDepCheck();
|
||
const box2 = $('#dep-result');
|
||
if (box2) bindDepInstallButtons(box2);
|
||
const chk = $('#dep-check'); if (chk) chk.textContent = '再次检测';
|
||
}
|
||
|
||
/* ============================ Web 终端 + 文件管理器 ============================ */
|
||
/* #4 重做:真 PTY 交互终端。
|
||
前端只负责三件事:
|
||
① 按键 → 字节(每敲一个键立刻发送,不等回车);
|
||
② 回来的字节 → 屏幕(内置一个小型终端模拟器,处理 ANSI 光标与颜色);
|
||
③ 尺寸变化时上报 cols/rows,让 top / vi 这类全屏程序排版正确。
|
||
回显、行编辑、Ctrl+C、方向键历史全部由内核终端行规程负责,前端不再自己画假回显。
|
||
注意这是**本机 shell**(root / ajeef / drouter),不是到别的机器的 SSH ——
|
||
界面也就不再摆主机 / 端口 / 密码这些用不上的输入框,免得看着能连其实连不了。 */
|
||
|
||
const WS_MAX_SB = 600; // 回滚区最多保留多少行
|
||
const WS_WRITE_CHUNK = 1024; // 单次写入字节上限(粘贴长文本时分片发)
|
||
|
||
const WS = {
|
||
sid: null, cols: 0, rows: 0,
|
||
grid: null, cx: 0, cy: 0, key: '',
|
||
st: null, saved: null,
|
||
dirty: null, lineEls: [],
|
||
carry: '', // 跨帧拼接用的残字(半个代理对)
|
||
};
|
||
let WS_PUMP = null; // 读取轮询定时器
|
||
let WS_BUSY = false; // 防止 read 请求重叠
|
||
|
||
/* 16 色 ANSI 调色板(偏暗底终端的配色,保证在深色屏上可读) */
|
||
const ANSI_FG = ['#5c6b85', '#e06c62', '#7cc488', '#e2b34a', '#6fa4e8', '#b48ce8', '#5cc0d4', '#c9d4e4'];
|
||
const ANSI_FGB = ['#8b97ad', '#ff8b7f', '#9ce0ac', '#f2cd72', '#93c0f5', '#cfabf5', '#84dcee', '#eaf0fa'];
|
||
const ANSI_BG = ['#26314a', '#6b2b26', '#245239', '#5c4714', '#254063', '#453363', '#1c4a55', '#37455f'];
|
||
const ANSI_BGB = ['#3a4763', '#8a3a33', '#33684a', '#7a6220', '#345a86', '#5c4682', '#28636f', '#4b5b78'];
|
||
|
||
/* ---------------------------- 屏幕缓冲区 ---------------------------- */
|
||
function wsBlankRow(cols) {
|
||
const r = new Array(cols);
|
||
for (let x = 0; x < cols; x++) r[x] = { c: ' ', k: '' };
|
||
return r;
|
||
}
|
||
|
||
function wsGridInit(cols, rows) {
|
||
WS.cols = cols; WS.rows = rows;
|
||
WS.grid = [];
|
||
for (let y = 0; y < rows; y++) WS.grid.push(wsBlankRow(cols));
|
||
WS.cx = 0; WS.cy = 0;
|
||
WS.st = { fg: -1, bg: -1, bold: 0, dim: 0, it: 0, ul: 0, rev: 0 };
|
||
WS.key = '';
|
||
WS.saved = null;
|
||
WS.dirty = new Set();
|
||
WS.lineEls = [];
|
||
for (let y = 0; y < rows; y++) WS.dirty.add(y);
|
||
}
|
||
|
||
function wsStyleKey() {
|
||
const s = WS.st, p = [];
|
||
if (s.bold) p.push('b');
|
||
if (s.dim) p.push('d');
|
||
if (s.it) p.push('i');
|
||
if (s.ul) p.push('u');
|
||
if (s.rev) p.push('r');
|
||
if (s.fg >= 0) p.push('f' + s.fg);
|
||
if (s.bg >= 0) p.push('g' + s.bg);
|
||
return p.join('|');
|
||
}
|
||
|
||
function wsStyleAttr(k) {
|
||
if (!k) return { cls: '', style: '' };
|
||
let fg = '', bg = '', cls = [], rev = false;
|
||
k.split('|').forEach(t => {
|
||
if (t === 'b') cls.push('t-b');
|
||
else if (t === 'd') cls.push('t-dim');
|
||
else if (t === 'i') cls.push('t-i');
|
||
else if (t === 'u') cls.push('t-u');
|
||
else if (t === 'r') rev = true;
|
||
else if (t[0] === 'f') { const n = +t.slice(1); fg = n < 8 ? ANSI_FG[n] : ANSI_FGB[n - 8]; }
|
||
else if (t[0] === 'g') { const n = +t.slice(1); bg = n < 8 ? ANSI_BG[n] : ANSI_BGB[n - 8]; }
|
||
});
|
||
if (rev) { const t = fg; fg = bg || '#0d1526'; bg = t || '#c9d4e4'; }
|
||
let style = '';
|
||
if (fg) style += 'color:' + fg + ';';
|
||
if (bg) style += 'background:' + bg + ';';
|
||
return { cls: cls.join(' '), style: style };
|
||
}
|
||
|
||
function wsClearRow(y) {
|
||
const row = WS.grid[y];
|
||
for (let x = 0; x < WS.cols; x++) row[x] = { c: ' ', k: WS.key };
|
||
WS.dirty.add(y);
|
||
}
|
||
|
||
function wsScroll() {
|
||
const top = WS.grid.shift();
|
||
WS.grid.push(wsBlankRow(WS.cols));
|
||
wsScrollbackPush(top);
|
||
WS.cy = WS.rows - 1;
|
||
for (let y = 0; y < WS.rows; y++) WS.dirty.add(y);
|
||
}
|
||
|
||
function wsNewline() {
|
||
WS.cy++;
|
||
if (WS.cy >= WS.rows) wsScroll();
|
||
}
|
||
|
||
/* 字符显示宽度:中文 / 全角在真实终端里占两格。
|
||
以前一律按一格处理,光标列就和真实终端对不上 —— 输入中文时 readline
|
||
算的换行位置和屏幕画出来的不一致,看起来就是「字被切开了」。 */
|
||
function wsCharWidth(ch) {
|
||
if (!ch) return 0;
|
||
const c = ch.codePointAt(0);
|
||
if (c < 0x20) return 0; // 控制字符
|
||
if (c >= 0x1100 && c <= 0x115F) return 2; // 韩文初声
|
||
if (c >= 0x2E80 && c <= 0xA4CF) return 2; // CJK / 全角符号
|
||
if (c >= 0xAC00 && c <= 0xD7A3) return 2; // 韩文音节
|
||
if (c >= 0xF900 && c <= 0xFAFF) return 2; // CJK 兼容
|
||
if (c >= 0xFE30 && c <= 0xFE6F) return 2; // 全角标点
|
||
if (c >= 0xFF01 && c <= 0xFF60) return 2; // 全角 ASCII
|
||
if (c >= 0xFFE0 && c <= 0xFFE6) return 2;
|
||
if (c >= 0x20000 && c <= 0x3FFFD) return 2; // CJK 扩展 B~G
|
||
return 1;
|
||
}
|
||
|
||
function wsPut(ch) {
|
||
const w = wsCharWidth(ch);
|
||
if (!w) return;
|
||
// 宽字符放不下整行剩余宽度时先换行,别把半个字挤到下一行
|
||
if (WS.cx + w > WS.cols) { WS.cx = 0; wsNewline(); }
|
||
WS.grid[WS.cy][WS.cx] = { c: ch, k: WS.key };
|
||
if (w === 2 && WS.cx + 1 < WS.cols) {
|
||
// 第二格是占位,渲染时跳过(见 wsRowHtml)
|
||
WS.grid[WS.cy][WS.cx + 1] = { c: '', k: WS.key, pad: 1 };
|
||
}
|
||
WS.cx += w;
|
||
WS.dirty.add(WS.cy);
|
||
}
|
||
|
||
/* ---------------------------- ANSI 转义序列 ---------------------------- */
|
||
function wsSgr(p) {
|
||
const s = WS.st;
|
||
const list = p.length ? p : [0];
|
||
let i = 0;
|
||
while (i < list.length) {
|
||
const v = list[i];
|
||
if (v === 0) { s.fg = -1; s.bg = -1; s.bold = s.dim = s.it = s.ul = s.rev = 0; }
|
||
else if (v === 1) s.bold = 1;
|
||
else if (v === 2) s.dim = 1;
|
||
else if (v === 3) s.it = 1;
|
||
else if (v === 4) s.ul = 1;
|
||
else if (v === 7) s.rev = 1;
|
||
else if (v === 22) { s.bold = 0; s.dim = 0; }
|
||
else if (v === 23) s.it = 0;
|
||
else if (v === 24) s.ul = 0;
|
||
else if (v === 27) s.rev = 0;
|
||
else if (v >= 30 && v <= 37) s.fg = v - 30;
|
||
else if (v === 39) s.fg = -1;
|
||
else if (v >= 40 && v <= 47) s.bg = v - 40;
|
||
else if (v === 49) s.bg = -1;
|
||
else if (v >= 90 && v <= 97) s.fg = v - 90 + 8;
|
||
else if (v >= 100 && v <= 107) s.bg = v - 100 + 8;
|
||
else if (v === 38 || v === 48) {
|
||
// 256 色 / 真彩色:这里只取 16 色近似,其余跳过(排障可读性不受影响)
|
||
if (list[i + 1] === 5) {
|
||
const c = list[i + 2];
|
||
if (typeof c === 'number') { if (v === 38) s.fg = c < 16 ? c : 7; else s.bg = c < 16 ? c : 0; }
|
||
i += 2;
|
||
} else if (list[i + 1] === 2) { i += 4; }
|
||
}
|
||
i++;
|
||
}
|
||
WS.key = wsStyleKey();
|
||
}
|
||
|
||
function wsEraseLine(mode) {
|
||
const row = WS.grid[WS.cy];
|
||
const from = mode === 0 ? WS.cx : 0;
|
||
const to = mode === 1 ? WS.cx + 1 : WS.cols;
|
||
for (let x = from; x < to && x < WS.cols; x++) row[x] = { c: ' ', k: WS.key };
|
||
WS.dirty.add(WS.cy);
|
||
}
|
||
|
||
function wsEraseDisplay(mode) {
|
||
if (mode === 0) { wsEraseLine(0); for (let y = WS.cy + 1; y < WS.rows; y++) wsClearRow(y); }
|
||
else if (mode === 1) { wsEraseLine(1); for (let y = 0; y < WS.cy; y++) wsClearRow(y); }
|
||
else { for (let y = 0; y < WS.rows; y++) wsClearRow(y); }
|
||
}
|
||
|
||
function wsInsertLines(n) {
|
||
for (let i = 0; i < n; i++) { WS.grid.splice(WS.cy, 0, wsBlankRow(WS.cols)); WS.grid.pop(); }
|
||
for (let y = WS.cy; y < WS.rows; y++) WS.dirty.add(y);
|
||
}
|
||
function wsDeleteLines(n) {
|
||
for (let i = 0; i < n; i++) { WS.grid.splice(WS.cy, 1); WS.grid.push(wsBlankRow(WS.cols)); }
|
||
for (let y = WS.cy; y < WS.rows; y++) WS.dirty.add(y);
|
||
}
|
||
function wsInsertChars(n) {
|
||
const row = WS.grid[WS.cy], blanks = [];
|
||
for (let i = 0; i < n; i++) blanks.push({ c: ' ', k: WS.key });
|
||
row.splice(WS.cx, 0, ...blanks);
|
||
row.length = WS.cols;
|
||
WS.dirty.add(WS.cy);
|
||
}
|
||
function wsDeleteChars(n) {
|
||
const row = WS.grid[WS.cy];
|
||
row.splice(WS.cx, n);
|
||
while (row.length < WS.cols) row.push({ c: ' ', k: WS.key });
|
||
WS.dirty.add(WS.cy);
|
||
}
|
||
|
||
function wsCsi(params, final) {
|
||
const p = params.split(';').map(x => parseInt(x, 10));
|
||
const n1 = isNaN(p[0]) ? 1 : p[0];
|
||
const cxMax = WS.cols - 1, cyMax = WS.rows - 1;
|
||
switch (final) {
|
||
case 'A': WS.cy = Math.max(0, WS.cy - n1); break; // 光标上移
|
||
case 'B': WS.cy = Math.min(cyMax, WS.cy + n1); break; // 下移
|
||
case 'C': WS.cx = Math.min(cxMax, WS.cx + n1); break; // 右移
|
||
case 'D': WS.cx = Math.max(0, WS.cx - n1); break; // 左移
|
||
case 'E': WS.cy = Math.min(cyMax, WS.cy + n1); WS.cx = 0; break;
|
||
case 'F': WS.cy = Math.max(0, WS.cy - n1); WS.cx = 0; break;
|
||
case 'G': case '`': WS.cx = Math.max(0, Math.min(cxMax, (p[0] || 1) - 1)); break;
|
||
case 'H': case 'f':
|
||
WS.cy = Math.max(0, Math.min(cyMax, (p[0] || 1) - 1));
|
||
WS.cx = Math.max(0, Math.min(cxMax, (p[1] || 1) - 1));
|
||
break;
|
||
case 'J': wsEraseDisplay(p[0] || 0); break;
|
||
case 'K': wsEraseLine(p[0] || 0); break;
|
||
case 'L': wsInsertLines(n1); break;
|
||
case 'M': wsDeleteLines(n1); break;
|
||
case 'P': wsDeleteChars(n1); break;
|
||
case '@': wsInsertChars(n1); break;
|
||
case 'm': wsSgr(p); break;
|
||
case 's': WS.saved = { x: WS.cx, y: WS.cy }; break;
|
||
case 'u': if (WS.saved) { WS.cx = WS.saved.x; WS.cy = WS.saved.y; } break;
|
||
default: break; // h/l(隐藏光标、启用 Alternate Screen 等)不影响显示,忽略
|
||
}
|
||
}
|
||
|
||
/* 字节流 → 屏幕。状态机:0 普通 / 1 ESC / 2 CSI / 3 OSC / 4 字符集 / 5 OSC-ESC */
|
||
let WS_STATE = 0, WS_BUF = '';
|
||
|
||
function wsPutCh(ch) {
|
||
if (ch === '\n') { wsNewline(); return; }
|
||
if (ch === '\r') { WS.cx = 0; return; }
|
||
if (ch === '\b') { WS.cx = Math.max(0, WS.cx - 1); return; }
|
||
if (ch === '\t') { WS.cx = Math.min(WS.cols - 1, (Math.floor(WS.cx / 8) + 1) * 8); return; }
|
||
if (ch === '\x07') return; // 响铃:浏览器里别出声
|
||
if (ch < ' ') return; // 其余控制字符不显示在屏幕上
|
||
wsPut(ch);
|
||
}
|
||
|
||
/* 跨帧喂数据。注意三点:
|
||
1) 必须按码点遍历(Array.from),不能按 UTF-16 单元 —— 否则 emoji / CJK 扩展区
|
||
字符会被拆成两个半 surrogate,显示出来就是「断字」;
|
||
2) 服务端虽然已经保证不在半个 UTF-8 字符上切断,但 JSON 传输前后仍有拼接场景,
|
||
这里再兜一层:末尾若是不完整的代理对,留在缓冲里等下一帧;
|
||
3) 只累计脏行,真正画到 DOM 交给下一帧统一做(见 wsRequestRender)。 */
|
||
function wsFeed(text) {
|
||
if (!WS.grid || !text) return;
|
||
if (WS.carry) { text = WS.carry + text; WS.carry = ''; }
|
||
// 末尾是孤立高位代理 → 可能还有半个字符没到
|
||
const tail = text.charCodeAt(text.length - 1);
|
||
if (tail >= 0xD800 && tail <= 0xDBFF) {
|
||
WS.carry = text.slice(-1);
|
||
text = text.slice(0, -1);
|
||
}
|
||
const chars = Array.from(text);
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const ch = chars[i];
|
||
if (WS_STATE === 0) {
|
||
if (ch === '\x1b') { WS_STATE = 1; WS_BUF = ''; }
|
||
else wsPutCh(ch);
|
||
} else if (WS_STATE === 1) {
|
||
if (ch === '[') { WS_STATE = 2; WS_BUF = ''; }
|
||
else if (ch === ']') { WS_STATE = 3; WS_BUF = ''; }
|
||
else if (ch === '(' || ch === ')' || ch === '*' || ch === '+') { WS_STATE = 4; }
|
||
else if (ch === 'M') { WS.cy = Math.max(0, WS.cy - 1); WS_STATE = 0; } // 反向换行
|
||
else if (ch === '7') { WS.saved = { x: WS.cx, y: WS.cy }; WS_STATE = 0; }
|
||
else if (ch === '8') { if (WS.saved) { WS.cx = WS.saved.x; WS.cy = WS.saved.y; } WS_STATE = 0; }
|
||
else WS_STATE = 0;
|
||
} else if (WS_STATE === 2) {
|
||
const c = ch.charCodeAt(0);
|
||
if ((c >= 0x30 && c <= 0x3f) || (c >= 0x20 && c <= 0x2f)) WS_BUF += ch; // 参数 / 中间字节
|
||
else if (c >= 0x40 && c <= 0x7e) { wsCsi(WS_BUF, ch); WS_STATE = 0; } // 终止字节
|
||
else WS_STATE = 0;
|
||
} else if (WS_STATE === 3) {
|
||
// OSC(设置窗口标题等):到 BEL 或 ESC \ 结束,内容一律丢弃
|
||
if (ch === '\x07') WS_STATE = 0;
|
||
else if (ch === '\x1b') WS_STATE = 5;
|
||
} else if (WS_STATE === 5) WS_STATE = 0;
|
||
else WS_STATE = 0;
|
||
}
|
||
}
|
||
|
||
/* ---------------------------- 渲染 ---------------------------- */
|
||
function wsSpan(k, text) {
|
||
if (!text) return '';
|
||
if (!k) return esc(text);
|
||
const a = wsStyleAttr(k), at = [];
|
||
if (a.cls) at.push('class="' + a.cls + '"');
|
||
if (a.style) at.push('style="' + a.style + '"');
|
||
return '<span' + (at.length ? ' ' + at.join(' ') : '') + '>' + esc(text) + '</span>';
|
||
}
|
||
|
||
/* curX >= 0 时在该列画光标方块;其余行传 -1 */
|
||
function wsRowHtml(row, curX) {
|
||
let last = row.length - 1;
|
||
while (last >= 0 && row[last].c === ' ' && !row[last].k) last--;
|
||
if (curX >= 0 && curX > last) last = curX;
|
||
if (last < 0) return '';
|
||
let html = '', ck = null, buf = '';
|
||
const flush = () => { if (ck !== null) { html += wsSpan(ck, buf); buf = ''; ck = null; } };
|
||
for (let x = 0; x <= last; x++) {
|
||
const cell = row[x] || { c: ' ', k: '' };
|
||
if (cell.pad) continue; // 宽字符的第二格,不重复输出
|
||
if (x === curX) {
|
||
flush();
|
||
const a = wsStyleAttr(cell.k), at = [];
|
||
at.push('class="tcur' + (a.cls ? ' ' + a.cls : '') + '"');
|
||
if (a.style) at.push('style="' + a.style + '"');
|
||
html += '<span ' + at.join(' ') + '>' + esc(cell.c || ' ') + '</span>';
|
||
continue;
|
||
}
|
||
if (ck === null) { ck = cell.k; buf = cell.c; continue; }
|
||
if (cell.k === ck) { buf += cell.c; continue; }
|
||
flush(); ck = cell.k; buf = cell.c;
|
||
}
|
||
flush();
|
||
return html;
|
||
}
|
||
|
||
function wsScrollbackPush(row) {
|
||
const sb = $('#ws-scroll');
|
||
if (!sb) return;
|
||
const d = document.createElement('div');
|
||
d.className = 'tl';
|
||
d.innerHTML = wsRowHtml(row, -1);
|
||
if (sb.appendChild) sb.appendChild(d);
|
||
while ((sb.childElementCount || 0) > WS_MAX_SB && sb.removeChild) sb.removeChild(sb.firstChild);
|
||
}
|
||
|
||
/* 渲染合并到动画帧:一帧内可能连着收到好几段输出,如果每段都立刻改 innerHTML
|
||
并读 scrollHeight,浏览器会被迫连续同步重排 —— 输出一大就卡成几秒一跳。
|
||
这里只记「脏了」,真正画到 DOM 每帧最多一次。 */
|
||
let WS_RAF = null;
|
||
let WS_RAF_FORCE = false;
|
||
function wsRequestRender(force) {
|
||
if (force) WS_RAF_FORCE = true;
|
||
if (WS_RAF != null) return;
|
||
const run = () => {
|
||
WS_RAF = null;
|
||
wsRenderScreen();
|
||
wsAutoScroll(WS_RAF_FORCE);
|
||
WS_RAF_FORCE = false;
|
||
};
|
||
if (typeof requestAnimationFrame === 'function') WS_RAF = requestAnimationFrame(run);
|
||
else WS_RAF = setTimeout(run, 16);
|
||
}
|
||
|
||
function wsRenderScreen() {
|
||
const el = $('#ws-screen');
|
||
if (!el || !WS.grid) return;
|
||
if (WS.lineEls.length !== WS.rows) {
|
||
el.innerHTML = '';
|
||
WS.lineEls = [];
|
||
for (let y = 0; y < WS.rows; y++) {
|
||
const d = document.createElement('div');
|
||
d.className = 'tl';
|
||
if (el.appendChild) el.appendChild(d);
|
||
WS.lineEls.push(d);
|
||
}
|
||
for (let y = 0; y < WS.rows; y++) WS.dirty.add(y);
|
||
}
|
||
// 光标所在行每帧都要重画(光标可能只是挪了个位置,没有字符变化)
|
||
const cy = Math.max(0, Math.min(WS.rows - 1, WS.cy));
|
||
WS.dirty.add(cy);
|
||
if (typeof WS._cyPrev === 'number') WS.dirty.add(Math.max(0, Math.min(WS.rows - 1, WS._cyPrev)));
|
||
WS._cyPrev = cy;
|
||
WS.dirty.forEach(y => {
|
||
const d = WS.lineEls[y];
|
||
if (d) d.innerHTML = wsRowHtml(WS.grid[y], y === cy ? WS.cx : -1);
|
||
});
|
||
WS.dirty.clear();
|
||
}
|
||
|
||
function wsAutoScroll(force) {
|
||
const el = $('#ws-term');
|
||
if (!el) return;
|
||
const max = Number(el.scrollHeight) || 0;
|
||
const top = Number(el.scrollTop) || 0;
|
||
const h = Number(el.clientHeight) || max;
|
||
if (force || (max - top - h) < 40) {
|
||
try { el.scrollTop = max; } catch (e) { /* 少数环境是只读的,忽略 */ }
|
||
}
|
||
}
|
||
|
||
function wsNote(text) {
|
||
wsFeed(text + '\r\n');
|
||
wsRequestRender(true);
|
||
}
|
||
|
||
function wsClearScreen() {
|
||
wsGridInit(WS.cols || 100, WS.rows || 26);
|
||
WS.carry = '';
|
||
const sb = $('#ws-scroll');
|
||
if (sb) sb.innerHTML = '';
|
||
wsRenderScreen();
|
||
}
|
||
|
||
/* ---------------------------- 尺寸测量与同步 ---------------------------- */
|
||
function wsBox() {
|
||
const el = $('#ws-term');
|
||
if (!el) return null;
|
||
let w = Number(el.clientWidth) || 0;
|
||
let h = Number(el.clientHeight) || 0;
|
||
if (!w || !h) return null;
|
||
if (typeof getComputedStyle === 'function') {
|
||
const cs = getComputedStyle(el);
|
||
w -= (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
|
||
h -= (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
|
||
}
|
||
return (w > 60 && h > 60) ? { w: w, h: h } : null;
|
||
}
|
||
|
||
function wsMetrics() {
|
||
const el = $('#ws-term');
|
||
if (!el || !el.appendChild) return null;
|
||
const probe = document.createElement('div');
|
||
probe.className = 'tl ws-probe';
|
||
probe.textContent = 'M'.repeat(100);
|
||
probe.style.position = 'absolute';
|
||
probe.style.left = '-99999px';
|
||
probe.style.top = '0';
|
||
try {
|
||
el.appendChild(probe);
|
||
if (typeof probe.getBoundingClientRect !== 'function') return null;
|
||
const r = probe.getBoundingClientRect();
|
||
if (r.width < 1 || r.height < 1) return null;
|
||
return { cw: r.width / 100, lh: r.height };
|
||
} catch (e) {
|
||
return null;
|
||
} finally {
|
||
if (probe.remove) probe.remove();
|
||
}
|
||
}
|
||
|
||
function wsMeasure() {
|
||
const box = wsBox(), m = wsMetrics();
|
||
if (!box || !m) return { cols: WS.cols || 100, rows: WS.rows || 26 };
|
||
return {
|
||
cols: Math.max(20, Math.min(300, Math.floor(box.w / m.cw))),
|
||
rows: Math.max(6, Math.min(120, Math.floor(box.h / m.lh))),
|
||
};
|
||
}
|
||
|
||
function wsResizeGrid(cols, rows) {
|
||
const old = WS.grid, oc = WS.cols, or = WS.rows;
|
||
const ng = [];
|
||
for (let y = 0; y < rows; y++) {
|
||
const src = (old && y < or) ? old[y] : null;
|
||
const row = new Array(cols);
|
||
for (let x = 0; x < cols; x++) row[x] = (src && x < oc) ? src[x] : { c: ' ', k: '' };
|
||
ng.push(row);
|
||
}
|
||
WS.grid = ng; WS.cols = cols; WS.rows = rows;
|
||
WS.cx = Math.max(0, Math.min(cols - 1, WS.cx));
|
||
WS.cy = Math.max(0, Math.min(rows - 1, WS.cy));
|
||
WS.dirty = new Set();
|
||
WS.lineEls = [];
|
||
for (let y = 0; y < rows; y++) WS.dirty.add(y);
|
||
}
|
||
|
||
let WS_RS_T = null;
|
||
function wsOnResize() {
|
||
if (!WS.sid) return;
|
||
clearTimeout(WS_RS_T);
|
||
WS_RS_T = setTimeout(() => {
|
||
const m = wsMeasure();
|
||
if (m.cols === WS.cols && m.rows === WS.rows) return;
|
||
wsResizeGrid(m.cols, m.rows);
|
||
wsRenderScreen();
|
||
const sz = $('#ws-size'); if (sz) sz.textContent = m.cols + '×' + m.rows;
|
||
api('/api/webshell/resize', { method: 'POST', body: { sid: WS.sid, cols: m.cols, rows: m.rows } });
|
||
}, 250);
|
||
}
|
||
|
||
/* ---------------------------- 会话读写 ---------------------------- */
|
||
function wsStop() {
|
||
if (WS_PUMP) { clearTimeout(WS_PUMP); WS_PUMP = null; }
|
||
if (WS_RAF != null) {
|
||
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(WS_RAF);
|
||
else clearTimeout(WS_RAF);
|
||
WS_RAF = null;
|
||
}
|
||
}
|
||
|
||
function wsSchedule(ms) {
|
||
if (!WS.sid) return;
|
||
if (WS_PUMP) clearTimeout(WS_PUMP);
|
||
WS_PUMP = setTimeout(wsPump, ms);
|
||
}
|
||
|
||
function wsPump() {
|
||
WS_PUMP = null;
|
||
if (!WS.sid) return;
|
||
if (!$('#ws-screen')) return; // 已经离开终端页,别再空转
|
||
if (WS_BUSY) { wsSchedule(80); return; }
|
||
WS_BUSY = true;
|
||
// wait 让服务端在没有输出时挂起最多 1.2 秒,输出一到立刻返回 —— 既省掉
|
||
// 每秒十几次空轮询,又让命令的回显几乎零延迟出现
|
||
api('/api/webshell/read', { method: 'POST', body: { sid: WS.sid, wait: 1.2 } })
|
||
.then(r => {
|
||
if (!r.ok) {
|
||
if (r.code === 'NOSESS') wsLost('会话已失效,请重新连接');
|
||
else wsLost('读取失败:' + (r.msg_cn || '未知错误'));
|
||
return;
|
||
}
|
||
const d = r.data || {};
|
||
if (d.data) { wsFeed(d.data); wsRequestRender(); }
|
||
if (d.exit != null && d.exit >= 0) {
|
||
wsNote('\r\n[shell 已结束,退出码 ' + d.exit + ']');
|
||
wsStop();
|
||
return;
|
||
}
|
||
// 长轮询:有输出说明可能还有后续,立刻再取一次;空手而归说明 shell 空闲。
|
||
// 留 120ms 下限是给「守护还是旧版、不支持 wait」的情况兜底 ——
|
||
// 否则会变成 0 延迟空转,把管理服务打满。
|
||
wsSchedule(d.data ? 0 : 120);
|
||
})
|
||
.catch(() => wsSchedule(500))
|
||
.then(() => { WS_BUSY = false; });
|
||
}
|
||
|
||
function wsLost(msg) {
|
||
wsStop();
|
||
WS.sid = null;
|
||
const st = $('#ws-status');
|
||
if (st) st.innerHTML = '<span class="tag err">已断开</span>';
|
||
const c = $('#ws-connect'), d = $('#ws-disconnect');
|
||
if (c) c.disabled = false;
|
||
if (d) d.disabled = true;
|
||
wsNote('\r\n[连接中断] ' + msg);
|
||
toast(msg, 'err', 5000);
|
||
}
|
||
|
||
function wsWrite(data) {
|
||
if (!WS.sid || !data) return;
|
||
if (data.length <= WS_WRITE_CHUNK) { wsPostWrite(data); return; }
|
||
// 粘贴长文本时分片:一口气几万字节会让服务端写入阻塞,也会让 shell 逐行执行
|
||
let i = 0, n = 0;
|
||
for (; i < data.length; i += WS_WRITE_CHUNK) {
|
||
const part = data.slice(i, i + WS_WRITE_CHUNK);
|
||
setTimeout(part2 => wsPostWrite(part2), n * 25, part);
|
||
n++;
|
||
}
|
||
}
|
||
|
||
function wsPostWrite(data) {
|
||
api('/api/webshell/write', { method: 'POST', body: { sid: WS.sid, data: data } })
|
||
.then(r => {
|
||
if (!r.ok && r.code === 'NOSESS') { wsLost('会话已失效,请重新连接'); return; }
|
||
wsSchedule(15); // 刚输入完,马上去取回显,手感才跟手
|
||
})
|
||
.catch(() => { /* 轮询会兜住,不弹错误打扰输入 */ });
|
||
}
|
||
|
||
async function wsConnect() {
|
||
const user = ($('#ws-user') || {}).value || 'root';
|
||
const btn = $('#ws-connect');
|
||
if (btn) btn.disabled = true;
|
||
const st = $('#ws-status');
|
||
if (st) st.innerHTML = '<span class="tag info">正在连接…</span>';
|
||
const m = wsMeasure();
|
||
wsGridInit(m.cols, m.rows);
|
||
wsRenderScreen();
|
||
const r = await api('/api/webshell/connect', {
|
||
method: 'POST', body: { user: user, cols: m.cols, rows: m.rows },
|
||
});
|
||
if (btn) btn.disabled = false;
|
||
if (!r.ok) {
|
||
if (st) st.innerHTML = '<span class="tag err">连接失败</span>';
|
||
wsNote('连接失败:' + (r.msg_cn || '未知错误'));
|
||
if (r.code === 'NO_SHELLD') wsNote('请确认终端守护在运行:systemctl start drouter-shelld');
|
||
toast(r.msg_cn || '连接失败', 'err', 5000);
|
||
return;
|
||
}
|
||
WS.sid = (r.data || {}).sid;
|
||
const tt = $('#ws-title'); if (tt) tt.textContent = user + '@debian-primaryrouter';
|
||
const sz = $('#ws-size'); if (sz) sz.textContent = m.cols + '×' + m.rows;
|
||
if (st) st.innerHTML = '<span class="tag ok">已连接</span>';
|
||
const d = $('#ws-disconnect'); if (d) d.disabled = false;
|
||
wsSchedule(30);
|
||
const ta = $('#ws-key'); if (ta && ta.focus) ta.focus();
|
||
wsAutoScroll(true);
|
||
}
|
||
|
||
async function wsDisconnect(silent) {
|
||
const sid = WS.sid;
|
||
wsStop();
|
||
WS.sid = null;
|
||
const st = $('#ws-status');
|
||
if (st) st.innerHTML = '<span class="tag gray">已断开</span>';
|
||
const c = $('#ws-connect'), d = $('#ws-disconnect');
|
||
if (c) c.disabled = false;
|
||
if (d) d.disabled = true;
|
||
const tt = $('#ws-title'); if (tt) tt.textContent = '未连接';
|
||
if (sid) {
|
||
// 断开请求不 await 结果:就算服务端已经回收了会话,本地状态也必须立刻清干净
|
||
api('/api/webshell/disconnect', { method: 'POST', body: { sid: sid } });
|
||
if (!silent) wsNote('\r\n—— 已断开 ——');
|
||
}
|
||
}
|
||
|
||
/* ---------------------------- 键盘输入 ---------------------------- */
|
||
const WS_MAP = {
|
||
Enter: '\r', Backspace: '\x7f', Tab: '\t', Escape: '\x1b',
|
||
ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D',
|
||
Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', Insert: '\x1b[2~',
|
||
PageUp: '\x1b[5~', PageDown: '\x1b[6~',
|
||
F1: '\x1bOP', F2: '\x1bOQ', F3: '\x1bOR', F4: '\x1bOS',
|
||
};
|
||
|
||
function wsSelection() {
|
||
try {
|
||
if (typeof window !== 'undefined' && typeof window.getSelection === 'function') {
|
||
return String(window.getSelection() || '');
|
||
}
|
||
} catch (e) { /* 某些环境取不到,按没有选中处理 */ }
|
||
return '';
|
||
}
|
||
|
||
function wsKeyDown(e) {
|
||
if (!WS.sid) { e.preventDefault(); return; }
|
||
if (e.isComposing) return; // 输入法组词中,交给 compositionend
|
||
if (e.ctrlKey && !e.altKey && !e.metaKey) {
|
||
const k = String(e.key || '').toLowerCase();
|
||
if (k === 'v') return; // 粘贴交给浏览器(随后 input 事件会拿到文本)
|
||
if (k === 'c' && wsSelection()) return; // 有选中内容时 Ctrl+C 是复制,不是 SIGINT
|
||
if (/^[a-z]$/.test(k)) {
|
||
e.preventDefault();
|
||
wsWrite(String.fromCharCode(k.charCodeAt(0) - 96));
|
||
return;
|
||
}
|
||
}
|
||
if (e.altKey && !e.ctrlKey && !e.metaKey && String(e.key || '').length === 1) {
|
||
e.preventDefault(); wsWrite('\x1b' + e.key); return;
|
||
}
|
||
const m = WS_MAP[e.key];
|
||
if (m) { e.preventDefault(); wsWrite(m); return; }
|
||
}
|
||
|
||
/* ---------------------------- 页面装配 ---------------------------- */
|
||
async function viewWebShell() {
|
||
const v = $('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>Web 终端(本机 Shell)</h3>
|
||
<p class="desc">在浏览器里直接打开本机 shell。这是<b>实时交互终端</b>:每敲一个键立刻发到服务端,
|
||
回显由内核产生,所以 <span class="mono">top</span>、<span class="mono">vi</span>、
|
||
<span class="mono">passwd</span> 这类需要交互的程序都能正常用,Ctrl+C 中断、
|
||
方向键翻历史也都有效。</p>
|
||
<div class="row" style="align-items:center;gap:8px;flex-wrap:wrap">
|
||
<label>登录身份<select id="ws-user" style="width:190px">
|
||
<option value="root">root(超级用户)</option>
|
||
<option value="ajeef">ajeef(管理员)</option>
|
||
<option value="drouter">drouter(服务账号)</option>
|
||
</select></label>
|
||
<button id="ws-connect" class="primary">连接</button>
|
||
<button id="ws-disconnect" class="ghost" disabled>断开</button>
|
||
<button id="ws-clear" class="ghost small">清屏</button>
|
||
<button id="ws-copy" class="ghost small">复制可见内容</button>
|
||
<span class="desc" id="ws-status" style="margin:0"></span>
|
||
</div>
|
||
<p class="desc" style="margin-top:8px">
|
||
提示:这是<b>本机终端,不是 SSH 跳板</b>;root shell 权限等同物理控制台,
|
||
开启与输入都会写审计日志,闲置 30 分钟自动回收。
|
||
有选中文字时 Ctrl+C 仍是「复制」,否则是「中断当前命令」。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>终端输出</h3>
|
||
<div class="term-wrap">
|
||
<div class="term-bar">
|
||
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
|
||
<span class="term-title" id="ws-title">未连接</span>
|
||
<span class="tag gray" id="ws-size">—</span>
|
||
</div>
|
||
<div class="term-body term-screen" id="ws-term" tabindex="0">
|
||
<div id="ws-scroll"></div>
|
||
<div id="ws-screen"></div>
|
||
</div>
|
||
<textarea id="ws-key" class="ws-capture" spellcheck="false" autocomplete="off"
|
||
autocapitalize="off" autocorrect="off" aria-label="终端输入"></textarea>
|
||
</div>
|
||
<p class="desc">点击终端区域即可开始输入;窗口大小变化时会自动同步给服务端。</p>
|
||
</div>
|
||
|
||
<div class="card" id="fm-card">
|
||
<h3>文件管理器</h3>
|
||
<p class="desc">支持上传(SFTP/SCP)、下载、文件夹打包为 zip、重命名、删除、复制路径。删除会移入回收站。</p>
|
||
<div class="fm-toolbar">
|
||
<button id="fm-up" class="ghost small">↑ 上级</button>
|
||
<button id="fm-ref" class="ghost small">刷新</button>
|
||
<button id="fm-mkdir" class="ghost small">新建文件夹</button>
|
||
<div class="fm-path mono" id="fm-path">/root</div>
|
||
<label class="ghost small fm-upload">
|
||
上传文件<input type="file" id="fm-file" multiple hidden>
|
||
</label>
|
||
<span class="desc" id="fm-msg" style="margin:0"></span>
|
||
</div>
|
||
<div class="drop-zone" id="fm-drop">把文件拖到这里上传(或点击「上传文件」)</div>
|
||
<div id="fm-list"><p class="desc">正在载入…</p></div>
|
||
<div id="fm-progress"></div>
|
||
</div>
|
||
|
||
<div class="card hidden" id="fm-preview-card">
|
||
<h3>文件预览 <span class="desc" id="fm-prev-name" style="margin:0"></span>
|
||
<button class="ghost small copy-btn" data-copy="#fm-prev" style="float:right">复制内容</button></h3>
|
||
<pre id="fm-prev" class="log-pre"></pre>
|
||
</div>`;
|
||
|
||
WS.lineEls = [];
|
||
const m = wsMeasure();
|
||
wsGridInit(m.cols, m.rows);
|
||
const sz = $('#ws-size'); if (sz) sz.textContent = m.cols + '×' + m.rows;
|
||
if (WS.sid) {
|
||
// 从别的页面切回来:会话还在,直接把已有屏幕画出来继续轮询
|
||
wsRenderScreen();
|
||
wsAutoScroll(true);
|
||
wsSchedule(30);
|
||
const st = $('#ws-status');
|
||
if (st) st.innerHTML = '<span class="tag ok">已连接</span>';
|
||
const d = $('#ws-disconnect'); if (d) d.disabled = false;
|
||
const tt = $('#ws-title'); if (tt) tt.textContent = '已连接终端';
|
||
} else {
|
||
wsNote('未连接 —— 点击上方「连接」开始。');
|
||
wsNote('这是本机 shell,输入以 root / ajeef / drouter 身份执行。');
|
||
}
|
||
wsInit();
|
||
fmInit('/root');
|
||
bindCopyButtons(v);
|
||
}
|
||
|
||
function bindCopyButtons(root) {
|
||
(root || document).querySelectorAll('.copy-btn').forEach(b => {
|
||
b.onclick = () => {
|
||
const el = document.querySelector(b.dataset.copy);
|
||
if (!el) return;
|
||
const txt = el.textContent || '';
|
||
navigator.clipboard.writeText(txt).then(
|
||
() => toast('已复制到剪贴板', 'ok'),
|
||
() => { // 降级
|
||
const ta = document.createElement('textarea');
|
||
ta.value = txt; document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand('copy'); toast('已复制', 'ok'); } catch (e) { toast('复制失败', 'err'); }
|
||
ta.remove();
|
||
});
|
||
};
|
||
});
|
||
}
|
||
|
||
/* ---------- Web 终端:初始化 ---------- */
|
||
function wsInit() {
|
||
const c = $('#ws-connect'), d = $('#ws-disconnect');
|
||
if (c) c.onclick = wsConnect;
|
||
if (d) d.onclick = () => wsDisconnect(false);
|
||
if ($('#ws-clear')) $('#ws-clear').onclick = wsClearScreen;
|
||
if ($('#ws-copy')) $('#ws-copy').onclick = wsCopyAll;
|
||
wsBindInput();
|
||
}
|
||
|
||
function wsBindInput() {
|
||
const ta = $('#ws-key'), term = $('#ws-term');
|
||
if (!ta) return;
|
||
if (term && term.addEventListener) {
|
||
// 点终端区域就把焦点给隐藏输入框。
|
||
// 用 mouseup 而不是 mousedown:mousedown 时还没有选区,抢焦点会把用户
|
||
// 正在拖的选区清掉,表现为「终端里的文字选不中」。mouseup 时若已有选区
|
||
// 说明用户在复制,就别抢。
|
||
term.addEventListener('mouseup', () => {
|
||
if (wsSelection()) return;
|
||
if (ta.focus) ta.focus();
|
||
});
|
||
}
|
||
if (ta.addEventListener) {
|
||
ta.addEventListener('keydown', wsKeyDown);
|
||
// 普通字符 / 粘贴 / 输入法提交都走这里:把输入框内容取走发往服务端再清空
|
||
ta.addEventListener('input', e => {
|
||
if (e && e.isComposing) return;
|
||
const v = ta.value; ta.value = '';
|
||
if (v) wsWrite(v);
|
||
});
|
||
ta.addEventListener('compositionend', () => {
|
||
const v = ta.value; ta.value = '';
|
||
if (v) wsWrite(v);
|
||
});
|
||
}
|
||
}
|
||
|
||
function wsScreenText() {
|
||
const lines = [];
|
||
if (WS.grid) for (let y = 0; y < WS.rows; y++) {
|
||
const row = WS.grid[y];
|
||
let s = '';
|
||
for (let x = 0; x < WS.cols; x++) s += row[x].c;
|
||
lines.push(s.replace(/\s+$/, ''));
|
||
}
|
||
while (lines.length && !lines[lines.length - 1]) lines.pop();
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function wsCopyAll() {
|
||
const txt = wsScreenText();
|
||
if (!txt) { toast('屏幕是空的', 'warn'); return; }
|
||
const ta = document.createElement('textarea');
|
||
ta.value = txt;
|
||
if (document.body && document.body.appendChild) document.body.appendChild(ta);
|
||
if (ta.select) ta.select();
|
||
let done = false;
|
||
try { done = document.execCommand ? document.execCommand('copy') : false; } catch (e) { done = false; }
|
||
if (ta.remove) ta.remove();
|
||
toast(done ? '已复制可见内容' : '复制失败,请手动选中', done ? 'ok' : 'err');
|
||
}
|
||
|
||
// 关页面 / 刷新时主动断开,别把 root shell 留在服务端等 30 分钟超时
|
||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||
window.addEventListener('resize', wsOnResize);
|
||
window.addEventListener('beforeunload', () => { if (WS.sid) wsDisconnect(true); });
|
||
}
|
||
|
||
/* ============================ 通用 API 接口 ============================ */
|
||
async function viewApi() {
|
||
const v = $('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>对外通用 API 接口</h3>
|
||
<p class="desc">Drouter 提供统一的 JSON REST 接口,跨平台、跨语言、跨框架:
|
||
任意支持 HTTP 的环境(curl、Python、Node、Go、PHP、Java、.NET、Shell、
|
||
Postman、工单系统、IoT 网关、HomeAssistant…)都能直接接入,无需任何 SDK。</p>
|
||
<div class="row" style="gap:8px;flex-wrap:wrap">
|
||
<button id="api-load" class="primary">载入接口清单与范例</button>
|
||
<a id="api-openapi" class="ghost small" href="/api/openapi.json" target="_blank" rel="noopener">查看接口描述(JSON)</a>
|
||
<span class="desc" id="api-base" style="margin:0"></span>
|
||
</div>
|
||
<div class="kv"><b>响应格式</b><span class="mono">{"ok": true, "msg_cn": "…", "data": …}</span></div>
|
||
<div class="kv"><b>鉴权方式</b><span><span class="mono">POST /api/login</span> 取 token → 之后每个请求带 <span class="mono">X-Token</span> 头</span></div>
|
||
<div class="kv"><b>跨域支持</b><span>服务端已放开跨域,浏览器端也可直接调用</span></div>
|
||
</div>
|
||
<div id="api-body"><p class="desc">点击「载入接口清单与范例」查看。</p></div>`;
|
||
$('#api-load').onclick = loadApi;
|
||
loadApi();
|
||
}
|
||
|
||
async function loadApi() {
|
||
const box = $('#api-body');
|
||
box.innerHTML = '<p class="desc">载入中…</p>';
|
||
const [ex, desc] = await Promise.all([api('/api/examples'), api('/api/openapi')]);
|
||
if (!ex.ok) { box.innerHTML = `<div class="notice err">${esc(ex.msg_cn || '载入失败')}</div>`; return; }
|
||
const d = ex.data || {};
|
||
if ($('#api-base')) $('#api-base').innerHTML = `服务地址:<span class="mono">${esc(d.base || '')}</span>`;
|
||
const paths = (desc.ok && desc.data && desc.data.paths) || {};
|
||
const groups = {};
|
||
Object.entries(paths).forEach(([path, methods]) => {
|
||
Object.entries(methods).forEach(([m, node]) => {
|
||
const tag = (node.tags || ['通用'])[0];
|
||
(groups[tag] = groups[tag] || []).push({ path, m, sum: node.summary || '' });
|
||
});
|
||
});
|
||
const epHtml = Object.entries(groups).map(([tag, list]) => `
|
||
<div class="card">
|
||
<h3>${esc(tag)} <span class="tag gray">${list.length}</span></h3>
|
||
${list.map(x => `<div class="probe-row">
|
||
<span class="pk"><span class="tag ${x.m === 'get' ? 'info' : 'ok'}">${x.m.toUpperCase()}</span>
|
||
<span class="mono">${esc(x.path)}</span></span>
|
||
<span class="pv">${esc(x.sum)}</span>
|
||
</div>`).join('')}
|
||
</div>`).join('');
|
||
|
||
const exHtml = (d.examples || []).map((e, i) => `
|
||
<div class="card">
|
||
<h3>${esc(e.title)}
|
||
<button class="ghost small copy-btn" data-copy="#ex-${i}" style="float:right">复制代码</button></h3>
|
||
<div class="code-wrap">
|
||
<pre id="ex-${i}" class="log-pre">${esc(e.code)}</pre>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
box.innerHTML = `<div class="notice ok" style="margin-bottom:12px">${esc(d.note || '')}</div>`
|
||
+ epHtml + exHtml;
|
||
bindCopyButtons(box);
|
||
}
|
||
|
||
/* ---------- 文件管理器 ---------- */
|
||
let FM_PATH = '/root';
|
||
let FM_SEL = new Set();
|
||
|
||
async function fmInit(path) {
|
||
FM_PATH = path || FM_PATH;
|
||
const up = $('#fm-up'), ref = $('#fm-ref'), mk = $('#fm-mkdir');
|
||
if (!up) return;
|
||
up.onclick = () => fmList(FM_PARENT || '/');
|
||
ref.onclick = () => fmList(FM_PATH);
|
||
mk.onclick = fmMkdir;
|
||
const fileInput = $('#fm-file');
|
||
if (fileInput) fileInput.onchange = () => fmUpload(fileInput.files);
|
||
const drop = $('#fm-drop');
|
||
if (drop) {
|
||
drop.onclick = () => fileInput && fileInput.click();
|
||
['dragover', 'dragenter'].forEach(ev => drop.addEventListener(ev, e => {
|
||
e.preventDefault(); drop.classList.add('over');
|
||
}));
|
||
['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => {
|
||
e.preventDefault(); drop.classList.remove('over');
|
||
}));
|
||
drop.addEventListener('drop', e => {
|
||
if (e.dataTransfer && e.dataTransfer.files.length) fmUpload(e.dataTransfer.files);
|
||
});
|
||
}
|
||
fmList(FM_PATH);
|
||
}
|
||
|
||
let FM_PARENT = '/';
|
||
|
||
async function fmList(path) {
|
||
const box = $('#fm-list'); if (!box) return;
|
||
box.innerHTML = '<p class="desc">正在载入…</p>';
|
||
const r = await api('/api/files/list?path=' + encodeURIComponent(path));
|
||
if (!r.ok) { box.innerHTML = `<div class="notice err">${esc(r.msg_cn || '无法读取目录')}</div>`; return; }
|
||
const d = r.data || {};
|
||
FM_PATH = d.path || path;
|
||
FM_PARENT = d.parent || '/';
|
||
FM_SEL = new Set();
|
||
if ($('#fm-path')) $('#fm-path').textContent = FM_PATH;
|
||
const items = d.items || [];
|
||
const rows = items.map(it => `
|
||
<tr class="fm-row" data-path="${esc(it.path)}" data-dir="${it.dir ? 1 : 0}">
|
||
<td class="fm-chk"><input type="checkbox" class="fm-cb" data-path="${esc(it.path)}"></td>
|
||
<td class="fm-name">
|
||
${it.dir ? '📁' : (it.link ? '🔗' : '📄')}
|
||
<span class="fm-open" data-path="${esc(it.path)}" data-dir="${it.dir ? 1 : 0}">${esc(it.name)}</span>
|
||
</td>
|
||
<td>${it.dir ? '—' : fmtBytes(it.size)}</td>
|
||
<td>${fmtTime(it.mtime)}</td>
|
||
<td class="mono" style="font-size:12px">${esc(it.mode || '')}</td>
|
||
<td class="fm-acts">
|
||
${!it.dir ? `<button class="ghost small fm-dl" data-path="${esc(it.path)}">下载</button>` : `<button class="ghost small fm-zip" data-path="${esc(it.path)}">打包</button>`}
|
||
<button class="ghost small fm-ren" data-path="${esc(it.path)}" data-name="${esc(it.name)}">重命名</button>
|
||
<button class="ghost small fm-cp" data-path="${esc(it.path)}">复制路径</button>
|
||
<button class="ghost small fm-del" data-path="${esc(it.path)}">删除</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
const crumbs = (d.crumbs || []).map((c, i, a) =>
|
||
`<span class="fm-crumb" data-path="${esc(c.path)}">${esc(c.name)}</span>${i < a.length - 1 ? '<span class="fm-sep">/</span>' : ''}`).join('');
|
||
box.innerHTML = `
|
||
<div class="fm-crumbs">${crumbs}</div>
|
||
<div class="row" style="gap:8px;margin:8px 0">
|
||
<button class="ghost small" id="fm-zipsel" disabled>打包所选 zip</button>
|
||
<button class="ghost small" id="fm-delsel" disabled>删除所选</button>
|
||
<span class="desc" id="fm-selinfo" style="margin:0"></span>
|
||
</div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th></th><th>名称</th><th>大小</th><th>修改时间</th><th>权限</th><th>操作</th></tr></thead>
|
||
<tbody>${rows || '<tr><td colspan="6" class="desc">目录为空</td></tr>'}</tbody>
|
||
</table></div>`;
|
||
|
||
box.querySelectorAll('.fm-open').forEach(el => el.onclick = () => {
|
||
if (el.dataset.dir === '1') fmList(el.dataset.path);
|
||
else fmPreview(el.dataset.path);
|
||
});
|
||
box.querySelectorAll('.fm-dl').forEach(b => b.onclick = () => fmDownload(b.dataset.path));
|
||
box.querySelectorAll('.fm-zip').forEach(b => b.onclick = () => fmZip([b.dataset.path]));
|
||
box.querySelectorAll('.fm-ren').forEach(b => b.onclick = () => fmRename(b.dataset.path, b.dataset.name));
|
||
box.querySelectorAll('.fm-cp').forEach(b => b.onclick = () => {
|
||
navigator.clipboard.writeText(b.dataset.path).then(
|
||
() => toast('路径已复制:' + b.dataset.path, 'ok'),
|
||
() => toast('复制失败,请手动复制', 'err'));
|
||
});
|
||
box.querySelectorAll('.fm-del').forEach(b => b.onclick = () => fmDelete([b.dataset.path]));
|
||
box.querySelectorAll('.fm-crumb').forEach(c => c.onclick = () => fmList(c.dataset.path));
|
||
const cbs = box.querySelectorAll('.fm-cb');
|
||
const sync = () => {
|
||
FM_SEL = new Set();
|
||
cbs.forEach(cb => { if (cb.checked) FM_SEL.add(cb.dataset.path); });
|
||
$('#fm-zipsel').disabled = FM_SEL.size === 0;
|
||
$('#fm-delsel').disabled = FM_SEL.size === 0;
|
||
$('#fm-selinfo').textContent = FM_SEL.size ? `已选 ${FM_SEL.size} 项` : '';
|
||
};
|
||
cbs.forEach(cb => cb.onchange = sync);
|
||
$('#fm-zipsel').onclick = () => fmZip(Array.from(FM_SEL));
|
||
$('#fm-delsel').onclick = () => fmDelete(Array.from(FM_SEL));
|
||
}
|
||
|
||
function fmtTime(ts) {
|
||
if (!ts) return '—';
|
||
const d = new Date(ts * 1000);
|
||
const p = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
}
|
||
|
||
async function fmDownload(path) {
|
||
toast('正在准备下载…', 'info');
|
||
try {
|
||
const res = await fetch('/api/files/download?path=' + encodeURIComponent(path),
|
||
{ headers: S.token ? { 'X-Token': S.token } : {} });
|
||
if (!res.ok) { toast('下载失败', 'err'); return; }
|
||
const blob = await res.blob();
|
||
const name = path.split('/').pop() || 'download';
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob); a.download = name; a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
} catch (e) { toast('下载失败:' + e.message, 'err'); }
|
||
}
|
||
|
||
async function fmZip(paths) {
|
||
const msg = $('#fm-msg');
|
||
if (msg) msg.textContent = '正在打包…';
|
||
const r = await api('/api/files/zip', { method: 'POST', body: { paths } });
|
||
if (msg) msg.textContent = '';
|
||
if (!r.ok) { toast(r.msg_cn || '打包失败', 'err', 5000); return; }
|
||
toast(`${r.msg_cn}(${r.data.size_h})`, 'ok');
|
||
// 打包完直接下载
|
||
const f = r.data.file;
|
||
fmDownload(f);
|
||
}
|
||
|
||
async function fmDelete(paths) {
|
||
modal('确认删除', `<p>将把以下 ${paths.length} 项移入回收站(可在服务器 <span class="mono">/var/lib/drouter/trash</span> 找回):</p>
|
||
<pre class="log-pre" style="max-height:160px">${paths.map(esc).join('\n')}</pre>`, async () => {
|
||
const r = await api('/api/files/delete', { method: 'POST', body: { paths } });
|
||
if (!r.ok) { toast(r.msg_cn || '删除失败', 'err', 5000); return false; }
|
||
toast(r.msg_cn || '已删除', 'ok');
|
||
fmList(FM_PATH);
|
||
}, '移入回收站');
|
||
}
|
||
|
||
function fmRename(path, name) {
|
||
modal('重命名', `<label>新名称<input id="fm-new" value="${esc(name)}"></label>`, async () => {
|
||
const v = ($('#fm-new') || {}).value.trim();
|
||
if (!v) { toast('名称不能为空', 'err'); return false; }
|
||
const r = await api('/api/files/rename', { method: 'POST', body: { path, name: v } });
|
||
if (!r.ok) { toast(r.msg_cn || '重命名失败', 'err', 5000); return false; }
|
||
toast('已重命名', 'ok');
|
||
fmList(FM_PATH);
|
||
});
|
||
}
|
||
|
||
function fmMkdir() {
|
||
modal('新建文件夹', `<label>文件夹名称<input id="fm-newdir" placeholder="例如 backup"></label>`, async () => {
|
||
const v = ($('#fm-newdir') || {}).value.trim();
|
||
if (!v) { toast('名称不能为空', 'err'); return false; }
|
||
const r = await api('/api/files/mkdir', { method: 'POST', body: { parent: FM_PATH, name: v } });
|
||
if (!r.ok) { toast(r.msg_cn || '创建失败', 'err', 5000); return false; }
|
||
toast('已创建', 'ok');
|
||
fmList(FM_PATH);
|
||
});
|
||
}
|
||
|
||
async function fmPreview(path) {
|
||
const card = $('#fm-preview-card');
|
||
const r = await api('/api/files/read?path=' + encodeURIComponent(path));
|
||
if (!r.ok) { toast(r.msg_cn || '无法预览', 'warn', 5000); return; }
|
||
card.classList.remove('hidden');
|
||
$('#fm-prev-name').textContent = `(${r.data.name} · ${r.data.size_h}${r.data.truncated ? ' · 已截断' : ''})`;
|
||
$('#fm-prev').textContent = r.data.text || '';
|
||
card.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
function fmUpload(files) {
|
||
if (!files || !files.length) return;
|
||
const prog = $('#fm-progress');
|
||
prog.innerHTML = '';
|
||
Array.from(files).forEach(f => {
|
||
const row = document.createElement('div');
|
||
row.className = 'up-item';
|
||
row.innerHTML = `<div class="up-name">${esc(f.name)} <span class="desc">${fmtBytes(f.size)}</span></div>
|
||
<div class="bar"><i style="width:0%"></i></div><div class="up-st desc">等待…</div>`;
|
||
prog.appendChild(row);
|
||
const barEl = row.querySelector('i'), st = row.querySelector('.up-st');
|
||
// 小文件直接走 base64 API;大文件用分片写入
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
const b64 = String(reader.result).split(',')[1] || '';
|
||
st.textContent = '上传中…';
|
||
barEl.style.width = '40%';
|
||
const r = await api('/api/files/write', {
|
||
method: 'POST',
|
||
body: { path: FM_PATH, name: f.name, b64, size: f.size },
|
||
});
|
||
if (r.ok) { barEl.style.width = '100%'; st.innerHTML = '<span style="color:var(--ok)">完成</span>'; }
|
||
else { barEl.style.width = '100%'; barEl.style.background = 'var(--err)'; st.textContent = '失败:' + (r.msg_cn || ''); }
|
||
if (files[files.length - 1] === f) fmList(FM_PATH);
|
||
};
|
||
reader.onerror = () => { st.textContent = '读取本地文件失败'; };
|
||
reader.readAsDataURL(f);
|
||
});
|
||
}
|
||
|
||
/* ============================ 日志 ============================ */
|
||
function viewLog() {
|
||
const mods = ['all', 'system', 'config', 'network', 'dhcp', 'dns', 'firewall', 'ipv6', 'pppoe', 'web', 'user', 'power', 'service', 'pkg', 'helper'];
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>结构化日志(AI 可读)</h3>
|
||
<p class="desc">JSONL 格式,字段:ts / level / module / code / msg_cn / detail。
|
||
中文提示便于人工排查,原始信息保留便于 AI 分析。</p>
|
||
<div class="row">
|
||
<label style="flex:0 0 200px">模块<select id="lg-mod">${mods.map(m => `<option>${m}</option>`).join('')}</select></label>
|
||
<label style="flex:0 0 120px">条数<input id="lg-n" type="number" value="300"></label>
|
||
<button class="ghost fixed" id="lg-load">刷新日志</button>
|
||
<button class="ghost fixed" id="lg-export">导出 JSON</button>
|
||
<button class="ghost fixed" id="lg-journal">查看系统日志</button>
|
||
</div>
|
||
<div id="lg-out" style="margin-top:12px"><p class="desc">点击「刷新日志」载入。</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Web 操作审计</h3>
|
||
<p class="desc">记录谁在什么时间改了什么配置。</p>
|
||
<div id="au-out"><p class="desc">载入中…</p></div>
|
||
</div>`;
|
||
$('#lg-load').onclick = loadLogs;
|
||
$('#lg-mod').onchange = loadLogs;
|
||
$('#lg-export').onclick = async () => {
|
||
const r = await api(`/api/logs?module=${$('#lg-mod').value}&limit=5000`);
|
||
const blob = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'drouter-log-' + Date.now() + '.json';
|
||
a.click();
|
||
};
|
||
$('#lg-journal').onclick = async () => {
|
||
$('#lg-out').innerHTML = '<pre>读取中…</pre>';
|
||
const r = await api('/api/journal?limit=200');
|
||
$('#lg-out').innerHTML = `<pre>${esc(((r.data || {}).lines || []).join('\n') || '(无)')}</pre>`;
|
||
};
|
||
loadLogs();
|
||
api('/api/audit').then(r => {
|
||
const rows = r.data || [];
|
||
$('#au-out').innerHTML = rows.length ? `<table><thead><tr><th style="width:150px">时间</th><th style="width:90px">用户</th>
|
||
<th style="width:130px">操作</th><th>详情</th><th style="width:70px">结果</th></tr></thead><tbody>
|
||
${rows.map(x => `<tr><td class="mono">${esc(x.ts)}</td><td>${esc(x.user)}</td><td>${esc(x.action)}</td>
|
||
<td>${esc(x.detail)}</td><td>${x.ok ? '<span class="tag ok">成功</span>' : '<span class="tag err">失败</span>'}</td></tr>`).join('')}
|
||
</tbody></table>` : '<p class="desc">暂无审计记录。</p>';
|
||
});
|
||
}
|
||
|
||
async function loadLogs() {
|
||
const out = $('#lg-out');
|
||
out.innerHTML = '<pre>读取中…</pre>';
|
||
const r = await api(`/api/logs?module=${$('#lg-mod').value}&limit=${$('#lg-n').value}`);
|
||
const rows = (r.data || []).reverse();
|
||
if (!rows.length) { out.innerHTML = '<p class="desc">暂无日志记录。</p>'; return; }
|
||
out.innerHTML = `<div style="max-height:460px;overflow:auto;border:1px solid var(--line);border-radius:8px">
|
||
${rows.map(x => `<div class="logline">
|
||
<span class="lv ${esc(x.level)}">${esc(x.level)}</span>
|
||
<span class="ts">${esc(x.ts)}</span>
|
||
<span class="mo">${esc(x.module)}</span>
|
||
<span class="ms">${esc(x.msg_cn)}${x.detail ? ' <span style="color:var(--txt3)">· ' + esc(String(x.detail).slice(0, 200)) + '</span>' : ''}</span>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
|
||
/* ============================ 电源 ============================ */
|
||
function viewPower() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>安全操作</h3>
|
||
<p class="desc">正常重启与关机,会先停止服务再断电,对文件系统安全。</p>
|
||
<div class="row">
|
||
<button class="fixed" id="pw-reboot">安全重启</button>
|
||
<button class="fixed" id="pw-off">安全关机</button>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="border-color:#f0c8c3">
|
||
<h3 style="color:var(--err)">强制重启(高风险)</h3>
|
||
<p class="desc">直接触发内核 SysRq 立即重启,<b>不等待服务停止</b>,可能导致数据丢失。
|
||
仅在系统完全无响应时使用。</p>
|
||
<div class="row"><button class="danger fixed" id="pw-force">强制重启</button></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>定时重启(可选)</h3>
|
||
<p class="desc">按需配置,例如每天凌晨自动重启以释放内存。</p>
|
||
<div class="row">
|
||
<label style="flex:0 0 200px">执行时间(每日)<input id="pw-time" type="time" value="04:00"></label>
|
||
<button class="ghost fixed" id="pw-cron">生成定时任务说明</button>
|
||
</div>
|
||
<p class="hint-inline">为避免误操作,定时重启需在服务器上用 <span class="mono">systemd timer</span> 配置,此处仅提供模板。</p>
|
||
</div>`;
|
||
$('#pw-reboot').onclick = () => confirmPower('reboot', '确认重启', '安全重启');
|
||
$('#pw-off').onclick = () => confirmPower('poweroff', '确认关机', '安全关机');
|
||
$('#pw-force').onclick = () => confirmPower('force_reboot', '确认强制重启', '强制重启');
|
||
$('#pw-cron').onclick = () => modal('定时重启模板', `<p>在服务器上执行以下命令可配置每日 ${$('#pw-time').value} 重启:</p>
|
||
<pre>[Unit]
|
||
Description=Daily reboot
|
||
|
||
[Timer]
|
||
OnCalendar=*-*-* ${$('#pw-time').value}:00
|
||
Persistent=true
|
||
|
||
[Install]
|
||
WantedBy=timers.target</pre>
|
||
<p class="desc">保存为 /etc/systemd/system/drouter-reboot.timer 后执行 systemctl enable --now drouter-reboot.timer</p>`, null, '知道了');
|
||
}
|
||
|
||
function confirmPower(op, word, label) {
|
||
modal(label, `<p>确认要执行「${label}」吗?</p><p>请输入 <b>${word}</b> 以继续:</p>
|
||
<input id="cfm-in" placeholder="${word}">`, async () => {
|
||
if ($('#cfm-in').value.trim() !== word) { toast('确认文字不正确', 'err'); return false; }
|
||
const r = await api('/api/power', { method: 'POST', body: { op, confirm_text: word } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
});
|
||
}
|
||
|
||
/* ============================ 用户与密钥 ============================ */
|
||
async function viewUser() {
|
||
const r = await api('/api/users');
|
||
const users = r.data || [];
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>当前登录会话</h3>
|
||
<p class="desc">左下角的「退出登录」按钮已移除(避免与其它入口重复),登出操作统一放在这里。</p>
|
||
<div class="row" style="align-items:center">
|
||
<span class="kv" style="flex:1 1 200px"><b>登录账号</b><span class="mono">${esc(S.user || 'admin')}</span></span>
|
||
<button class="ghost fixed" id="us-logout">退出登录</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>系统用户(用于 SSH 登录)</h3>
|
||
<p class="desc">这些是 Debian 系统的真实用户,与 Web 管理台账号相互独立。
|
||
root 已禁止远程登录。如遇失联,可通过虚拟机平台的控制台,或物理机的 KVM/IPMI、本地显示器、VNC 等方式登录救回。</p>
|
||
<table><thead><tr><th>用户名</th><th>UID</th><th>家目录</th><th>状态</th><th>公钥数</th><th style="width:230px">操作</th></tr></thead>
|
||
<tbody>${users.map(u => `<tr>
|
||
<td><b>${esc(u.name)}</b></td><td>${u.uid}</td><td class="mono">${esc(u.home)}</td>
|
||
<td>${u.locked ? '<span class="tag warn">已锁定</span>' : '<span class="tag ok">正常</span>'}</td>
|
||
<td>${u.keys.length}</td>
|
||
<td>
|
||
<button class="small" data-pw="${esc(u.name)}">改密码</button>
|
||
<button class="small" data-lk="${esc(u.name)}" data-v="${u.locked ? 0 : 1}">${u.locked ? '解锁' : '锁定'}</button>
|
||
<button class="small danger" data-del="${esc(u.name)}">删除</button>
|
||
</td></tr>`).join('')}</tbody></table>
|
||
<div style="margin-top:14px"><button class="small" id="us-add">+ 新建系统用户</button></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>SSH 公钥登录</h3>
|
||
<p class="desc"><b>请上传公钥(.pub)</b>,不要上传私钥。私钥应保存在你的本地电脑上,绝不发送到服务器。
|
||
支持粘贴文本或选择 .pub 文件。</p>
|
||
<div class="row">
|
||
<label style="flex:0 0 200px">目标用户<select id="key-user">
|
||
${users.filter(u => u.uid >= 1000).map(u => `<option>${esc(u.name)}</option>`).join('')}</select></label>
|
||
<label style="flex:1 1 300px">公钥文件(.pub)<input type="file" id="key-file" accept=".pub,text/plain" style="padding:6px"></label>
|
||
</div>
|
||
<label>或直接粘贴公钥内容(以 ssh-rsa / ssh-ed25519 开头)
|
||
<textarea id="key-text" rows="4" placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@host"></textarea></label>
|
||
<div class="row">
|
||
<button class="primary fixed" id="key-add">写入公钥</button>
|
||
</div>
|
||
<div id="key-out" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>已授权公钥</h3>
|
||
<div id="keys-list">${users.filter(u => u.keys.length).map(u => `
|
||
<div style="margin-bottom:10px"><b>${esc(u.name)}</b>
|
||
<table><tbody>${u.keys.map(k => `<tr><td class="mono" style="max-width:520px;word-break:break-all">${esc(k.key)}</td>
|
||
<td style="width:80px"><button class="small danger" data-delkey="${esc(u.name)}" data-key="${esc(k.key)}">删除</button></td></tr>`).join('')}
|
||
</tbody></table></div>`).join('') || '<p class="desc">尚未配置任何公钥。</p>'}</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>SSH 服务端配置(只读)</h3>
|
||
<div class="kv"><b>root 远程登录</b><span>${'已禁止(PermitRootLogin no)'}</span></div>
|
||
<div class="kv"><b>监听端口</b><span>22</span></div>
|
||
<div class="kv"><b>公钥认证</b><span><span class="tag ok">已启用</span></span></div>
|
||
</div>`;
|
||
|
||
$('#us-add').onclick = () => modal('新建系统用户', `
|
||
<label>用户名<input id="cfm-in" placeholder="小写字母开头"></label>
|
||
<label>密码<input id="cfm-pw" type="password"></label>
|
||
<label class="switch"><input type="checkbox" id="cfm-sudo"><i></i>加入 sudo 组</label>`, async () => { const r2 = await api('/api/user', { method: 'POST', body: { op: 'add', name: $('#cfm-in').value.trim(), password: $('#cfm-pw').value, sudo: $('#cfm-sudo').checked } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
if (r2.ok) setTimeout(viewUser, 400);
|
||
}, '创建');
|
||
const loBtn = $('#us-logout');
|
||
if (loBtn) loBtn.onclick = () => logout(false);
|
||
$$('[data-pw]').forEach(b => b.onclick = () => modal(`修改 ${b.dataset.pw} 的密码`,
|
||
`<label>新密码<input id="cfm-pw" type="password"></label>`, async () => {
|
||
const r2 = await api('/api/user', { method: 'POST', body: { op: 'passwd', name: b.dataset.pw, password: $('#cfm-pw').value } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
}, '修改'));
|
||
$$('[data-lk]').forEach(b => b.onclick = async () => {
|
||
const r2 = await api('/api/user', { method: 'POST', body: { op: b.dataset.v === '1' ? 'lock' : 'unlock', name: b.dataset.lk } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); if (r2.ok) setTimeout(viewUser, 400);
|
||
});
|
||
$$('[data-del]').forEach(b => b.onclick = () => {
|
||
if (b.dataset.del === 'ajeef' || b.dataset.del === 'root') { toast('受保护用户不可删除', 'err'); return; }
|
||
modal('删除用户', `<p>确认删除用户 <b>${esc(b.dataset.del)}</b> 及其家目录?此操作不可恢复。</p>`,
|
||
async () => {
|
||
const r2 = await api('/api/user', { method: 'POST', body: { op: 'del', name: b.dataset.del } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); if (r2.ok) setTimeout(viewUser, 400);
|
||
});
|
||
});
|
||
$('#key-file').onchange = e => {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
if (/private|id_rsa$|id_ed25519$/i.test(f.name) && !f.name.endsWith('.pub')) {
|
||
toast('这看起来像私钥文件,请上传 .pub 公钥', 'err', 6000); e.target.value = ''; return;
|
||
}
|
||
const rd = new FileReader();
|
||
rd.onload = () => { $('#key-text').value = String(rd.result).trim(); toast('已读取公钥文件内容', 'ok'); };
|
||
rd.readAsText(f);
|
||
};
|
||
$('#key-add').onclick = async () => {
|
||
const txt = $('#key-text').value.trim();
|
||
if (!txt) { toast('请先粘贴或选择公钥内容', 'err'); return; }
|
||
if (/BEGIN .*PRIVATE KEY/.test(txt)) {
|
||
toast('检测到私钥内容!请勿上传私钥,只上传 .pub 公钥文件。', 'err', 9000); return;
|
||
}
|
||
const r2 = await api('/api/user', { method: 'POST', body: { op: 'key_add', name: $('#key-user').value, keys: txt.split('\n').filter(Boolean) } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err', 7000);
|
||
if (r2.ok) { $('#key-text').value = ''; setTimeout(viewUser, 500); }
|
||
};
|
||
$$('[data-delkey]').forEach(b => b.onclick = async () => {
|
||
const r2 = await api('/api/user', { method: 'POST', body: { op: 'key_del', name: b.dataset.delkey, key: b.dataset.key } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); if (r2.ok) setTimeout(viewUser, 400);
|
||
});
|
||
}
|
||
|
||
/* ============================ 系统设置 ============================ */
|
||
function viewSys() {
|
||
const sysCfg = $w('system');
|
||
$('#view').innerHTML = `
|
||
<div class="card" style="border-color:${S.buildMode ? '#f0e0a8' : '#bfe3c9'};background:${S.buildMode ? 'var(--warn-l)' : 'var(--ok-l)'}">
|
||
<h3 style="color:${S.buildMode ? 'var(--warn)' : 'var(--ok)'}">构建保护模式:${S.buildMode ? '已开启' : '已关闭'}</h3>
|
||
<p class="desc" style="color:inherit">
|
||
${S.buildMode
|
||
? '开启时,所有「应用」操作只会把配置写入磁盘,<b>不会启动服务、不会改变网络</b>。这是构建阶段的安全保险,可放心填完所有配置而不影响正在运行的局域网与 RealVNC 会话。'
|
||
: '已关闭。此后「保存并应用」会<b>真正生效</b>:DHCP 服务会启动并占用 53/67 端口,防火墙规则会立即加载。请确保这是你想要的。'}
|
||
</p>
|
||
<div class="row">
|
||
${S.buildMode
|
||
? `<button class="fixed" onclick="toggleBuildMode(false)">关闭保护,允许配置生效</button>`
|
||
: `<button class="ghost fixed" onclick="toggleBuildMode(true)">重新开启保护</button>`}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Web 管理账号</h3>
|
||
<p class="desc">此账号仅用于登录本管理台,与 Debian 系统用户完全独立。当前用户:<b>${esc(S.user)}</b></p>
|
||
<div class="row">
|
||
<label>原密码<input id="sy-old" type="password"></label>
|
||
<label>新密码(至少 8 位)<input id="sy-new" type="password"></label>
|
||
<label>确认新密码<input id="sy-new2" type="password"></label>
|
||
</div>
|
||
<div class="row"><button class="primary fixed" id="sy-pw">修改管理密码</button></div>
|
||
<div id="sy-out" class="msg"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Web 管理端口</h3>
|
||
<p class="desc">修改管理后台的 HTTPS 端口,避免与其他服务冲突。默认 <b>8443</b>。
|
||
修改后需要重启管理后台,然后用新地址重新访问(例如 <span class="mono">https://192.168.7.3:新端口/</span>)。</p>
|
||
<div class="row">
|
||
<label style="flex:0 0 200px">HTTPS 端口<input id="sy-port" type="number" min="1" max="65535"
|
||
value="${S.webPort || 8443}" placeholder="8443"></label>
|
||
<button class="fixed" id="sy-port-save">保存并重启后台</button>
|
||
<button class="ghost fixed" id="sy-port-reset">恢复默认 8443</button>
|
||
</div>
|
||
<div id="sy-port-out" class="msg"></div>
|
||
<p class="hint-inline">明文 HTTP 端口固定为 <span class="mono">8080</span>(仅建议本机调试使用)。
|
||
当前生效端口:<span class="mono" id="sy-port-cur">${S.webPort || 8443}</span></p>
|
||
</div>
|
||
<div class="card">
|
||
<h3>访问控制</h3>
|
||
<div class="kv"><b>管理端口</b><span class="mono">HTTPS ${S.webPort || 8443}(自签证书) / HTTP 8080</span></div>
|
||
<label>访问范围
|
||
<select id="sy-scope">
|
||
<option value="lan" ${(sysCfg.access_scope || 'lan') === 'lan' ? 'selected' : ''}>仅限局域网(推荐,WAN 侧不开放)</option>
|
||
<option value="all" ${sysCfg.access_scope === 'all' ? 'selected' : ''}>允许所有来源(含 WAN,风险较高)</option>
|
||
<option value="custom" ${sysCfg.access_scope === 'custom' ? 'selected' : ''}>自定义允许的网段</option>
|
||
</select>
|
||
</label>
|
||
<div id="sy-scope-custom" class="${(sysCfg.access_scope === 'custom') ? '' : 'hidden'}">
|
||
<label>允许访问的网段(逗号分隔,CIDR 格式)
|
||
<input id="sy-scope-net" value="${esc((sysCfg.access_nets || ['192.168.7.0/24']).join(','))}"
|
||
placeholder="192.168.7.0/24,10.0.0.0/8"></label>
|
||
</div>
|
||
<p class="hint-inline">访问范围仅影响管理后台的防火墙放行策略;改完请点击右上角「保存并应用」。</p>
|
||
<div class="kv"><b>会话超时</b><span>60 分钟</span></div>
|
||
<div class="kv"><b>登录失败锁定</b><span>连续 5 次失败后锁定 10 分钟</span></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>配置快照与回滚</h3>
|
||
<p class="desc">快照会<b>整体覆盖全部用户设置</b>:界面里的所有模块配置(DHCP/DNS、防火墙 IPv4·IPv6、WAN/LAN、IPv6 与 RA、UPnP、NTP、网卡角色、访问范围与 Web 端口等)连同底层配置文件一起打包。回滚时数据库与配置文件一并还原,不会出现「只恢复了一半」的情况。</p>
|
||
<div id="sy-scope-box" class="snap-scope"></div>
|
||
<div class="row">
|
||
<label style="flex:0 0 240px">快照备注<input id="sy-tag" placeholder="例如:切换前备份"></label>
|
||
<button class="ghost fixed" id="sy-snap">立即创建快照</button>
|
||
<button class="ghost fixed" id="sy-list">刷新快照列表</button>
|
||
</div>
|
||
<div id="sy-snapout" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>自动快照</h3>
|
||
<p class="desc">按设定的时间间隔自动为当前配置拍一份快照,并在超过保留期后自动清理旧快照。默认<b>开启</b>,可随时关闭。</p>
|
||
<div class="row" style="align-items:center">
|
||
<label class="switch"><input type="checkbox" id="as-en"><i></i>开启自动快照</label>
|
||
<span class="hint-inline" id="as-state"></span>
|
||
</div>
|
||
<div class="row">
|
||
<label>快照时间(小时一次)<input id="as-interval" type="number" min="1" max="168" value="6"></label>
|
||
<label>过期时间(天后自动删除)<input id="as-days" type="number" min="0" max="3650" value="7"></label>
|
||
<label>最多保留份数(0=不限)<input id="as-count" type="number" min="0" max="10000" value="30"></label>
|
||
</div>
|
||
<label>快照存放路径
|
||
<input id="as-path" class="mono" placeholder="/opt/drouter/snapshots"></label>
|
||
<p class="hint-inline">路径仅允许位于 <span class="mono">/opt · /srv · /var/backups · /mnt · /media · /home</span> 之下,避免误写系统关键目录。</p>
|
||
<div class="row" style="align-items:center">
|
||
<label class="switch"><input type="checkbox" id="as-manual"><i></i>手动快照不参与自动清理</label>
|
||
<label class="switch"><input type="checkbox" id="as-apply"><i></i>每次「保存并应用」前自动拍一张</label>
|
||
</div>
|
||
<div class="row">
|
||
<button class="primary fixed" id="as-save">保存自动快照策略</button>
|
||
<button class="ghost fixed" id="as-run">立即执行一次</button>
|
||
<button class="ghost fixed" id="as-prune">立即清理过期快照</button>
|
||
</div>
|
||
<div id="as-out" class="msg"></div>
|
||
<div class="kv"><b>下次自动快照</b><span id="as-next">—</span></div>
|
||
<div class="kv"><b>定时器状态</b><span id="as-timer">—</span></div>
|
||
<div class="kv"><b>快照占用</b><span id="as-disk">—</span></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>紧急救援通道</h3>
|
||
<p class="desc">当配置失误把网络"打崩"时(网络风暴、IP 冲突、路由环路、防火墙锁死、Web 管理页打不开等),
|
||
常规手段往往已经进不去路由器。<b>紧急救援通道</b>是一条完全独立于 WAN 口 / LAN 口的应急入口:
|
||
它会在网卡上额外绑定一组虚拟地址,因此<b>把网线插到任意一个网口都能访问</b>,
|
||
进入后可一键还原最近的快照并自动重启,让设备恢复到可用状态。</p>
|
||
<div class="row" style="align-items:center">
|
||
<label class="switch"><input type="checkbox" id="rs-en"><i></i>开启紧急救援通道</label>
|
||
<span class="hint-inline" id="rs-state"></span>
|
||
</div>
|
||
<div class="row">
|
||
<label>救援地址(虚拟 IP)<input id="rs-vip" class="mono" placeholder="169.254.0.1" value="169.254.0.1"></label>
|
||
<label style="flex:0 0 110px">掩码长度<input id="rs-mask" type="number" min="1" max="32" value="16"></label>
|
||
<label>救援端口<input id="rs-port" type="number" min="1" max="65535" value="8888"></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>备用救援地址(同网段直连用)<input id="rs-alt" class="mono" placeholder="10.99.99.1" value="10.99.99.1"></label>
|
||
</div>
|
||
<label>生效网卡(留空=自动应用到所有物理网卡,插任一口皆可访问)
|
||
<div id="rs-ifaces" class="chipbox"></div></label>
|
||
<div class="row" style="align-items:center">
|
||
<label class="switch"><input type="checkbox" id="rs-reboot"><i></i>一键还原后自动重启</label>
|
||
</div>
|
||
<p class="hint-inline" id="rs-hint">启用后访问方式:<span class="mono">http://169.254.0.1:8888/</span>(也支持 <span class="mono">?restore=1</span> 参数直达还原页)。</p>
|
||
<div class="row">
|
||
<button class="primary fixed" id="rs-save">保存救援通道设置</button>
|
||
<button class="ghost fixed" id="rs-test">连通性自检</button>
|
||
</div>
|
||
<div id="rs-out" class="msg"></div>
|
||
<div class="kv"><b>服务状态</b><span id="rs-svc">—</span></div>
|
||
<div class="kv"><b>虚拟网卡</b><span id="rs-vif">—</span></div>
|
||
<div id="rs-addrs" class="mono" style="margin-top:8px;font-size:12px;color:var(--muted)"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>网络后端迁移(高级)</h3>
|
||
<p class="desc">当前由 NetworkManager 管理网卡。如需切换为 systemd-networkd,
|
||
可先做干跑预览查看将写入的配置文件。<b>真正执行可能中断网络连接</b>——万一失联,可以通过
|
||
虚拟机平台的控制台,或者物理机的 KVM/IPMI、本地显示器、VNC 等方式重新登录救回。
|
||
确认自己能救回来之后,再执行迁移。</p>
|
||
<div class="row">
|
||
<button class="ghost fixed" id="sy-mig">生成迁移预览(不写入)</button>
|
||
</div>
|
||
<div id="sy-migout" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>配置文件位置</h3>
|
||
<table><thead><tr><th>内容</th><th>路径</th></tr></thead><tbody>
|
||
<tr><td>Web 配置数据库</td><td class="mono">/opt/drouter/data/drouter.db</td></tr>
|
||
<tr><td>配置快照</td><td class="mono">/opt/drouter/snapshots/</td></tr>
|
||
<tr><td>结构化日志</td><td class="mono">/var/log/drouter/*.jsonl</td></tr>
|
||
<tr><td>DHCP/DNS 配置</td><td class="mono">/etc/dnsmasq.d/drouter.conf</td></tr>
|
||
<tr><td>防火墙规则</td><td class="mono">/etc/nftables.d/drouter-v4.nft · drouter-v6.nft</td></tr>
|
||
<tr><td>PPPoE 配置</td><td class="mono">/etc/ppp/peers/drouter-wan</td></tr>
|
||
<tr><td>RA 配置</td><td class="mono">/etc/radvd.conf</td></tr>
|
||
</tbody></table>
|
||
</div>`;
|
||
$('#sy-pw').onclick = async () => {
|
||
const a = $('#sy-new').value, b = $('#sy-new2').value;
|
||
if (a !== b) { $('#sy-out').className = 'msg err'; $('#sy-out').textContent = '两次输入的新密码不一致'; return; }
|
||
const r = await api('/api/password', { method: 'POST', body: { old_password: $('#sy-old').value, new_password: a } });
|
||
$('#sy-out').className = 'msg ' + (r.ok ? 'ok' : 'err');
|
||
$('#sy-out').textContent = r.msg_cn;
|
||
if (r.ok) { $('#sy-old').value = $('#sy-new').value = $('#sy-new2').value = ''; }
|
||
};
|
||
$('#sy-snap').onclick = async () => {
|
||
const r = await api('/api/snapshot', { method: 'POST', body: { tag: $('#sy-tag').value || 'manual' } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
};
|
||
$('#sy-list').onclick = listSnapshots;
|
||
loadAutoSnapshot();
|
||
loadRescue();
|
||
// Web 端口修改
|
||
const portOut = $('#sy-port-out');
|
||
$('#sy-port-save').onclick = async () => {
|
||
const port = Number($('#sy-port').value);
|
||
if (!(port >= 1 && port <= 65535)) { portOut.className = 'msg err'; portOut.textContent = '端口必须是 1-65535 的数字'; return; }
|
||
if (!confirm(`确定把管理后台端口改为 ${port} 吗?改完后需要用 https://<本机IP>:${port}/ 重新访问。`)) return;
|
||
portOut.className = 'msg'; portOut.textContent = '正在保存并重启后台…';
|
||
const r = await api('/api/webport', { method: 'POST', body: { port } });
|
||
portOut.className = 'msg ' + (r.ok ? 'ok' : 'err');
|
||
portOut.textContent = r.msg_cn;
|
||
if (r.ok) {
|
||
S.webPort = port;
|
||
const cur = $('#sy-port-cur'); if (cur) cur.textContent = port;
|
||
toast('端口已改为 ' + port + ',请用新地址访问', 'ok', 8000);
|
||
}
|
||
};
|
||
$('#sy-port-reset').onclick = () => { $('#sy-port').value = 8443; toast('已填入默认端口,请点击「保存并重启后台」', 'ok'); };
|
||
// 访问范围
|
||
const scope = $('#sy-scope');
|
||
const bindScope = () => {
|
||
const v = scope.value;
|
||
S.cfg.system = Object.assign($w('system'), { access_scope: v });
|
||
const box = $('#sy-scope-custom');
|
||
if (box) box.classList.toggle('hidden', v !== 'custom');
|
||
setActionMsg('访问范围已改为「' + scope.options[scope.selectedIndex].text + '」,请点击保存并应用');
|
||
};
|
||
if (scope) { scope.onchange = bindScope; }
|
||
const sn = $('#sy-scope-net');
|
||
if (sn) sn.oninput = () => {
|
||
S.cfg.system = Object.assign($w('system'), {
|
||
access_nets: sn.value.split(',').map(x => x.trim()).filter(Boolean),
|
||
});
|
||
setActionMsg('允许网段已修改,请点击保存并应用');
|
||
};
|
||
$('#sy-mig').onclick = async () => {
|
||
$('#sy-migout').innerHTML = '<pre>生成中…</pre>';
|
||
const r = await api('/api/migrate/preview', { method: 'POST', body: { cfg: { lan: { iface: $w('system').lan_iface, address: $w('system').lan_address } } } });
|
||
const files = (r.data || {}).files || [];
|
||
$('#sy-migout').innerHTML = files.length
|
||
? files.map(f => `<div style="margin-bottom:10px"><b class="mono">${esc(f.name)}</b><pre>${esc(f.content)}</pre></div>`).join('')
|
||
: '<p class="desc">' + esc(r.msg_cn) + '</p>';
|
||
};
|
||
}
|
||
|
||
/* ---------- 配置快照:列表 / 下载 / 回滚 / 删除 ---------- */
|
||
async function listSnapshots() {
|
||
const r = await api('/api/snapshots');
|
||
const d = r.data || {};
|
||
const rows = (d.items || []) || [];
|
||
const root = d.root || '';
|
||
// 覆盖范围说明
|
||
const scopeBox = $('#sy-scope-box');
|
||
if (scopeBox && (d.scope || []).length) {
|
||
scopeBox.innerHTML = `<div class="snap-scope-t">快照包含以下内容:</div>` +
|
||
d.scope.map(g => `<div class="snap-g"><b>${esc(g.group)}</b><ul>${
|
||
(g.items || []).map(i => `<li>${esc(i)}</li>`).join('')}</ul></div>`).join('');
|
||
}
|
||
const disk = d.disk ? `磁盘:剩余 ${fmtBytes((d.disk.free_mb || 0) * 1024 * 1024)} / 共 ${fmtBytes((d.disk.total_mb || 0) * 1024 * 1024)}` : '';
|
||
const html = rows.length ? `
|
||
<p class="hint-inline">共 ${rows.length} 份快照,存放于 <span class="mono">${esc(root)}</span>。${esc(disk)}</p>
|
||
<table><thead><tr>
|
||
<th style="width:150px">时间</th><th>备注</th><th style="width:70px">大小</th>
|
||
<th style="width:70px">配置库</th><th style="width:200px">操作</th></tr></thead><tbody>
|
||
${rows.map(x => `<tr>
|
||
<td class="mono">${esc(x.ts)}</td>
|
||
<td>${esc(x.tag || '')}</td>
|
||
<td class="mono">${x.size_kb != null ? esc(x.size_kb) + ' KB' : '—'}</td>
|
||
<td>${x.db_included ? '<span class="tag-ok">已含</span>' : '<span class="tag-warn">旧版</span>'}</td>
|
||
<td>
|
||
<button class="small" data-dl="${esc(x.ts)}">下载</button>
|
||
<button class="small" data-rb="${esc(x.ts)}">回滚</button>
|
||
<button class="small danger" data-del="${esc(x.ts)}">删除</button>
|
||
</td></tr>`).join('')}</tbody></table>`
|
||
: '<p class="desc">暂无快照。</p>';
|
||
if (S.page === 'sys') { const b = $('#sy-snapout'); if (b) b.innerHTML = html; }
|
||
else modal('配置快照列表', html, null, '关闭');
|
||
|
||
$$('[data-dl]').forEach(b => b.onclick = async () => {
|
||
const ts = b.dataset.dl;
|
||
toast('正在打包快照 ' + ts + '…', 'ok', 2500);
|
||
try {
|
||
const res = await fetch('/api/snapshot/download?ts=' + encodeURIComponent(ts),
|
||
{ headers: S.token ? { 'X-Token': S.token } : {} });
|
||
if (!res.ok) {
|
||
let m = '下载失败';
|
||
try { m = (await res.json()).msg_cn || m; } catch (e) { }
|
||
toast(m, 'err', 6000); return;
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = 'drouter-snapshot-' + ts + '.tar.gz';
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||
toast('已开始下载快照 ' + ts, 'ok');
|
||
} catch (e) { toast('下载失败:' + e.message, 'err', 6000); }
|
||
});
|
||
$$('[data-rb]').forEach(b => b.onclick = () => modal('确认回滚',
|
||
`<p>将把配置整体恢复到快照 <b class="mono">${esc(b.dataset.rb)}</b> 的状态。</p>
|
||
<p class="desc">回滚会同时还原界面配置数据库与系统配置文件,并重启相关服务。回滚前系统会自动再拍一张 <span class="mono">before-rollback</span> 快照,万一不满意还能退回来。</p>
|
||
<p class="desc" style="color:var(--warn)">回滚过程中网络可能短暂中断。</p>`, async () => {
|
||
const r2 = await api('/api/rollback', { method: 'POST', body: { ts: b.dataset.rb } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err', 8000);
|
||
if (r2.ok) listSnapshots();
|
||
}, '确认回滚'));
|
||
$$('[data-del]').forEach(b => b.onclick = () => modal('删除快照',
|
||
`<p>确定删除快照 <b class="mono">${esc(b.dataset.del)}</b> 吗?此操作不可恢复。</p>`, async () => {
|
||
const r2 = await api('/api/snapshot/delete', { method: 'POST', body: { ts: b.dataset.del } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
if (r2.ok) listSnapshots();
|
||
}, '删除'));
|
||
}
|
||
|
||
/* ---------- 自动快照策略 ---------- */
|
||
async function loadAutoSnapshot() {
|
||
const r = await api('/api/snapshot/auto');
|
||
if (!r.ok) return;
|
||
const d = r.data || {}, c = d.config || {};
|
||
const setV = (id, v) => { const el = $(id); if (el) el.value = v; };
|
||
setV('#as-interval', c.interval_hours != null ? c.interval_hours : 6);
|
||
setV('#as-days', c.keep_days != null ? c.keep_days : 7);
|
||
setV('#as-count', c.keep_count != null ? c.keep_count : 30);
|
||
setV('#as-path', c.path || '/opt/drouter/snapshots');
|
||
const box = {
|
||
'#as-en': c.enabled !== false,
|
||
'#as-manual': c.keep_manual !== false,
|
||
'#as-apply': c.on_apply !== false,
|
||
};
|
||
Object.keys(box).forEach(id => { const el = $(id); if (el) el.checked = box[id]; });
|
||
const st = $('#as-state');
|
||
if (st) {
|
||
st.className = 'hint-inline';
|
||
st.innerHTML = c.enabled !== false
|
||
? '<span class="tag-ok">已开启</span> 自动快照正在按计划运行'
|
||
: '<span class="tag-warn">已关闭</span> 不会再自动创建快照';
|
||
}
|
||
const tm = $('#as-timer');
|
||
if (tm) tm.innerHTML = d.timer_active ? '<span class="tag-ok">运行中</span>' : '<span class="tag-warn">未运行</span>';
|
||
const nx = $('#as-next');
|
||
if (nx) nx.textContent = d.next_run ? fmtUnix(d.next_run) : '—';
|
||
const dk = $('#as-disk');
|
||
if (dk && d.disk) dk.textContent = `剩余 ${fmtBytes((d.disk.free_mb || 0) * 1048576)} / 共 ${fmtBytes((d.disk.total_mb || 0) * 1048576)}`;
|
||
|
||
$('#as-save').onclick = async () => {
|
||
const out = $('#as-out');
|
||
const body = {
|
||
op: 'set',
|
||
enabled: $('#as-en').checked,
|
||
interval_hours: Number($('#as-interval').value) || 6,
|
||
keep_days: Number($('#as-days').value) || 0,
|
||
keep_count: Number($('#as-count').value) || 0,
|
||
keep_manual: $('#as-manual').checked,
|
||
on_apply: $('#as-apply').checked,
|
||
path: ($('#as-path').value || '').trim(),
|
||
};
|
||
out.className = 'msg'; out.textContent = '正在保存…';
|
||
const r2 = await api('/api/snapshot/auto', { method: 'POST', body });
|
||
out.className = 'msg ' + (r2.ok ? 'ok' : 'err');
|
||
out.textContent = r2.msg_cn;
|
||
if (r2.ok) { toast(r2.msg_cn, 'ok'); loadAutoSnapshot(); }
|
||
};
|
||
$('#as-run').onclick = async () => {
|
||
const out = $('#as-out'); out.className = 'msg'; out.textContent = '正在创建快照…';
|
||
const r2 = await api('/api/snapshot/auto', { method: 'POST', body: { op: 'run_now' } });
|
||
out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); out.textContent = r2.msg_cn;
|
||
if (r2.ok) listSnapshots();
|
||
};
|
||
$('#as-prune').onclick = async () => {
|
||
const out = $('#as-out'); out.className = 'msg'; out.textContent = '正在清理…';
|
||
const r2 = await api('/api/snapshot/prune', {
|
||
method: 'POST', body: {
|
||
keep_days: Number($('#as-days').value) || 0,
|
||
keep_count: Number($('#as-count').value) || 0,
|
||
keep_manual: $('#as-manual').checked,
|
||
}
|
||
});
|
||
out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); out.textContent = r2.msg_cn;
|
||
if (r2.ok) listSnapshots();
|
||
};
|
||
}
|
||
|
||
/* ---------- 紧急救援通道 ---------- */
|
||
async function loadRescue() {
|
||
const r = await api('/api/rescue');
|
||
if (!r.ok) return;
|
||
const d = r.data || {};
|
||
const setV = (id, v) => { const el = $(id); if (el) el.value = v; };
|
||
const en = $('#rs-en');
|
||
if (en) en.checked = !!d.enabled;
|
||
setV('#rs-vip', d.vip || '169.254.0.1');
|
||
setV('#rs-mask', d.vip_mask || '16');
|
||
setV('#rs-port', d.port || 8888);
|
||
setV('#rs-alt', d.alt_vip || '10.99.99.1');
|
||
const rb = $('#rs-reboot'); if (rb) rb.checked = d.auto_reboot !== false;
|
||
// 网卡多选(留空 = 全部物理网卡)
|
||
const phys = d._phys_ifaces || [];
|
||
const chosen = d.ifaces || [];
|
||
const box = $('#rs-ifaces');
|
||
if (box) {
|
||
box.innerHTML = phys.map(n => `<label class="chip"><input type="checkbox" value="${esc(n)}"
|
||
${chosen.includes(n) ? 'checked' : ''}><span>${esc(n)}</span></label>`).join('')
|
||
|| '<span class="desc">未检测到物理网卡</span>';
|
||
}
|
||
const st = $('#rs-state');
|
||
if (st) st.innerHTML = d.enabled
|
||
? '<span class="tag-ok">已开启</span> 插任一口皆可访问救援页'
|
||
: '<span class="tag-warn">已关闭</span>(默认关闭,需要时再开)';
|
||
const sv = $('#rs-svc');
|
||
if (sv) sv.innerHTML = d._service_active ? '<span class="tag-ok">运行中</span>' : '<span class="tag-warn">未运行</span>';
|
||
const vf = $('#rs-vif');
|
||
if (vf) vf.innerHTML = d._vif_present ? '<span class="tag-ok">已创建</span>' : '<span class="tag-warn">未创建</span>';
|
||
const ad = $('#rs-addrs');
|
||
if (ad) ad.textContent = (d._addrs || []).length ? '当前地址:' + (d._addrs || []).join(' | ') : '';
|
||
updRescueHint();
|
||
|
||
$('#rs-save').onclick = async () => {
|
||
const out = $('#rs-out');
|
||
const wantOn = $('#rs-en').checked;
|
||
const body = {
|
||
op: 'set',
|
||
enabled: wantOn,
|
||
vip: $('#rs-vip').value.trim(),
|
||
vip_mask: $('#rs-mask').value,
|
||
port: Number($('#rs-port').value),
|
||
alt_vip: $('#rs-alt').value.trim(),
|
||
auto_reboot: $('#rs-reboot').checked,
|
||
ifaces: $$('#rs-ifaces input:checked').map(x => x.value),
|
||
confirm: true,
|
||
};
|
||
out.className = 'msg'; out.textContent = '正在保存并应用…';
|
||
const r2 = await api('/api/rescue', { method: 'POST', body });
|
||
out.className = 'msg ' + (r2.ok ? 'ok' : 'err');
|
||
out.textContent = r2.msg_cn;
|
||
if (r2.ok) { toast(r2.msg_cn, 'ok', 6000); loadRescue(); }
|
||
};
|
||
$('#rs-test').onclick = async () => {
|
||
const out = $('#rs-out'); out.className = 'msg'; out.textContent = '正在自检…';
|
||
const r2 = await api('/api/rescue', { method: 'POST', body: { op: 'test' } });
|
||
out.className = 'msg ' + (r2.ok ? 'ok' : 'err'); out.textContent = r2.msg_cn;
|
||
if (r2.ok) loadRescue();
|
||
};
|
||
const vipEl = $('#rs-vip'), portEl = $('#rs-port');
|
||
if (vipEl) vipEl.oninput = updRescueHint;
|
||
if (portEl) portEl.oninput = updRescueHint;
|
||
}
|
||
|
||
function updRescueHint() {
|
||
const el = $('#rs-hint'); if (!el) return;
|
||
const vip = ($('#rs-vip') || {}).value || '169.254.0.1';
|
||
const port = ($('#rs-port') || {}).value || 8888;
|
||
el.innerHTML = `启用后访问方式:<span class="mono">http://${esc(vip)}:${esc(port)}/</span>` +
|
||
`(也可用 <span class="mono">http://${esc(vip)}:${esc(port)}/?restore=1</span> 直达一键还原页)。` +
|
||
`此地址不占用 WAN/LAN,插任一口皆可访问。`;
|
||
}
|
||
|
||
function fmtUnix(v) {
|
||
const n = Number(v);
|
||
if (!n) return '—';
|
||
const ms = n > 1e12 ? n / 1000 : n;
|
||
const dt = new Date(ms);
|
||
if (isNaN(dt.getTime())) return String(v);
|
||
const p = x => String(x).padStart(2, '0');
|
||
return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}`;
|
||
}
|
||
|
||
/* ============================ 升级与保护 ============================ */
|
||
async function viewUpdate() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>RealVNC 保护状态</h3>
|
||
<p class="desc">本项目不会接管 VNC,只做状态展示与升级风险预警。</p>
|
||
<div id="up-vnc">读取中…</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>可升级软件包</h3>
|
||
<p class="desc">系统更新默认<b>只列出清单,不自动执行</b>。标记为高危的包可能影响桌面环境与 VNC,
|
||
升级前请先创建快照。</p>
|
||
<div class="row">
|
||
<button class="ghost fixed" id="pk-upd">更新软件源索引</button>
|
||
<button class="ghost fixed" id="pk-list">列出可升级包</button>
|
||
</div>
|
||
<div id="pk-out" style="margin-top:12px"><p class="desc">点击上方按钮开始。</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>版本锁定(apt-mark hold)</h3>
|
||
<p class="desc">本方案<b>不推荐</b>长期冻结大批系统包。仅当 RealVNC 官方明确要求固定某版本时,
|
||
对该单个包执行锁定。</p>
|
||
<div class="row">
|
||
<label style="flex:1 1 320px">包名(逗号分隔)<input id="pk-hold-n" placeholder="例如 xserver-xorg-core"></label>
|
||
<button class="ghost fixed" id="pk-hold">锁定</button>
|
||
<button class="ghost fixed" id="pk-unhold">解锁</button>
|
||
</div>
|
||
<div id="pk-holds" style="margin-top:10px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>升级后验证清单</h3>
|
||
<table><thead><tr><th>项目</th><th>验证方式</th></tr></thead><tbody>
|
||
<tr><td>图形会话</td><td>通过 RealVNC 连接 5900,确认桌面正常</td></tr>
|
||
<tr><td>管理台</td><td>刷新本页面,确认能正常登录</td></tr>
|
||
<tr><td>网络</td><td>概览页查看接口地址与默认路由是否正常</td></tr>
|
||
<tr><td>路由服务</td><td>概览页查看 dnsmasq / pppd 等服务状态</td></tr>
|
||
<tr><td>回滚预案</td><td>系统设置 → 快照列表,选择升级前的快照回滚</td></tr>
|
||
</tbody></table>
|
||
</div>`;
|
||
const r = await api('/api/upgrade/info');
|
||
const d = r.data || {};
|
||
$('#up-vnc').innerHTML = `
|
||
<div class="kv"><b>VNC 服务</b><span>${d.vnc_active ? '<span class="tag ok">运行中(5900 监听)</span>' : '<span class="tag err">未运行</span>'}</span></div>
|
||
<div class="kv"><b>已锁定包</b><span class="mono">${(d.holds || []).join(', ') || '无'}</span></div>
|
||
<div class="kv"><b>提示</b><span>${esc(d.notice || '')}</span></div>`;
|
||
$('#pk-holds').innerHTML = (d.holds || []).length
|
||
? `<table><tbody>${d.holds.map(h => `<tr><td class="mono">${esc(h)}</td></tr>`).join('')}</tbody></table>`
|
||
: '<p class="desc">当前没有任何被锁定的包。</p>';
|
||
$('#pk-upd').onclick = async () => {
|
||
$('#pk-out').innerHTML = '<pre>更新中,可能需要 1-2 分钟…</pre>';
|
||
const r2 = await api('/api/pkg', { method: 'POST', body: { op: 'update' } });
|
||
$('#pk-out').innerHTML = `<pre>${esc(r2.msg_cn)}\n\n${esc((r2.data || {}).out || '')}</pre>`;
|
||
};
|
||
$('#pk-list').onclick = async () => {
|
||
$('#pk-out').innerHTML = '<pre>查询中…</pre>';
|
||
const r2 = await api('/api/pkg', { method: 'POST', body: { op: 'list' } });
|
||
const dd = r2.data || {};
|
||
$('#pk-out').innerHTML = `
|
||
<div class="msg ${dd.risky && dd.risky.length ? 'warn' : 'ok'}">${esc(dd.notice || r2.msg_cn)}</div>
|
||
${(dd.risky || []).length ? `<div style="margin:10px 0"><b>高危包:</b>
|
||
${dd.risky.map(x => `<span class="tag warn" style="margin:2px">${esc(x)}</span>`).join('')}</div>` : ''}
|
||
<pre>${esc((dd.rows || []).join('\n') || '没有可升级的包')}</pre>`;
|
||
};
|
||
const holdOp = async op => {
|
||
const names = $('#pk-hold-n').value.split(',').map(x => x.trim()).filter(Boolean);
|
||
if (!names.length) { toast('请填写包名', 'err'); return; }
|
||
const r2 = await api('/api/pkg', { method: 'POST', body: { op, packages: names } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err');
|
||
if (r2.ok) setTimeout(viewUpdate, 500);
|
||
};
|
||
$('#pk-hold').onclick = () => holdOp('hold');
|
||
$('#pk-unhold').onclick = () => holdOp('unhold');
|
||
}
|
||
|
||
/* ============================================================
|
||
主题之家(#12)—— Web 内设计主题 / 离线预览 / ZIP 导入导出
|
||
============================================================ */
|
||
|
||
/* 可定制变量清单与前端镜像的中文说明。
|
||
后端 theme.py 的 THEME_VARS 是唯一权威来源;这里只是「首屏即时渲染」
|
||
用的副本,页面加载后会用 /api/theme?op=vars 的结果覆盖。 */
|
||
const TM_VARS = [
|
||
['--bg', '页面背景', 'base'], ['--bg2', '悬停背景', 'base'],
|
||
['--panel', '卡片背景', 'base'], ['--panel2', '表头背景', 'base'],
|
||
['--line', '边框', 'base'], ['--line2', '浅分隔线', 'base'],
|
||
['--txt', '主文字', 'base'], ['--txt2', '次要文字', 'base'], ['--txt3', '弱化文字', 'base'],
|
||
['--pri', '主题主色', 'color'], ['--pri-d', '主色加深', 'color'], ['--pri-l', '主色浅底', 'color'],
|
||
/* 主色本身若太浅(如暖橙),当链接/强调文字用会看不清;
|
||
--pri-text 是同一色调的「可读版本」,--pri 仍保留原色用于填充与按钮。 */
|
||
['--pri-text', '主色(文字用)', 'color'],
|
||
['--ok', '成功色', 'color'], ['--ok-l', '成功浅底', 'color'],
|
||
['--warn', '警告色', 'color'], ['--warn-l', '警告浅底', 'color'],
|
||
['--err', '错误色', 'color'], ['--err-l', '错误浅底', 'color'],
|
||
['--info', '信息色', 'color'], ['--info-l', '信息浅底', 'color'],
|
||
['--r', '卡片圆角', 'shape'], ['--sh', '卡片阴影', 'shape'], ['--grad', '主按钮渐变', 'shape'],
|
||
['--side', '侧栏宽度', 'layout'], ['--side-c', '侧栏折叠宽度', 'layout'],
|
||
];
|
||
const TM_CAT_CN = { base: '基础配色', color: '主色与状态色', shape: '圆角 / 阴影 / 渐变', layout: '布局尺寸' };
|
||
const TM_CAT_ORDER = ['base', 'color', 'shape', 'layout'];
|
||
/* 具名颜色白名单 —— 必须与后端 theme.py 的 _NAMED 完全一致。
|
||
缺少这一份,前端会把 red / transparent 这类合法取值判为非法,
|
||
导致「明明后端允许,界面却报错」的不一致体验。 */
|
||
const TM_NAMED = ['aqua', 'beige', 'black', 'blue', 'brown', 'chocolate', 'coral',
|
||
'crimson', 'currentcolor', 'cyan', 'darkgray', 'darkgreen', 'darkgrey', 'darkorange',
|
||
'darkred', 'dodgerblue', 'forestgreen', 'fuchsia', 'gold', 'goldenrod', 'gray', 'green',
|
||
'grey', 'indigo', 'ivory', 'khaki', 'lavender', 'lightgray', 'lightgrey', 'lime',
|
||
'magenta', 'maroon', 'midnightblue', 'navy', 'olive', 'orange', 'orchid', 'peru', 'pink',
|
||
'plum', 'purple', 'red', 'royalblue', 'salmon', 'seagreen', 'sienna', 'silver', 'skyblue',
|
||
'slategray', 'slategrey', 'steelblue', 'teal', 'tomato', 'transparent', 'turquoise',
|
||
'violet', 'wheat', 'white', 'whitesmoke', 'yellow'];
|
||
|
||
/* 内置基准值:主题不必写满全部变量,缺失的用这里的基准兜底 */
|
||
const TM_BASE = {
|
||
'--bg': '#eef2f9', '--bg2': '#f2f5fa', '--panel': '#ffffff', '--panel2': '#fafbfe',
|
||
'--line': '#e2e7f0', '--line2': '#eef1f7',
|
||
'--txt': '#161f30', '--txt2': '#556480', '--txt3': '#8894a8',
|
||
'--pri': '#1f6feb', '--pri-d': '#1858c4', '--pri-l': '#e8f0fe', '--pri-text': '#1f6feb',
|
||
'--ok': '#12813f', '--ok-l': '#e3f7ea', '--warn': '#9a6700', '--warn-l': '#fff8e1',
|
||
'--err': '#c0392b', '--err-l': '#fdecea', '--info': '#0b6e99', '--info-l': '#e5f4fb',
|
||
'--r': '12px', '--side': '236px', '--side-c': '68px',
|
||
'--sh': '0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
|
||
'--grad': 'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)',
|
||
};
|
||
|
||
/* ---------- 合法性预判:与后端 theme.py 规则一致,用于即时中文提示 ---------- */
|
||
function tmCheckValue(v, name) {
|
||
const s = String(v == null ? '' : v).trim();
|
||
if (!s) return '取值不能为空';
|
||
if (s.length > 220) return '取值过长(最多 220 字符)';
|
||
const low = s.toLowerCase();
|
||
for (const ch of [';', '{', '}', '<', '>', '@', '\\', '!', '`']) {
|
||
if (s.includes(ch)) return `包含非法字符「${ch}」`;
|
||
}
|
||
if (s.includes('/*') || s.includes('*/')) return '不允许出现注释符号';
|
||
if (/url\(|expression|import|javascript/i.test(s)) return '不允许使用外部引用(url / expression / import)';
|
||
if (low.startsWith('var(')) {
|
||
const m = /^var\(\s*(--[a-z0-9-]+)\s*(?:,[^)]*)?\)$/.exec(low);
|
||
if (!m) return 'var() 引用写法不正确';
|
||
if (!TM_VARS.some(x => x[0] === m[1])) return `不允许引用未开放的变量 ${m[1]}`;
|
||
return '';
|
||
}
|
||
if (s.startsWith('#')) {
|
||
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s)
|
||
? '' : '颜色格式不正确(支持 #rgb / #rrggbb / #rrggbbaa)';
|
||
}
|
||
if (low.startsWith('rgb') || low.startsWith('hsl')) {
|
||
const N255 = '(?:\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])';
|
||
const ALPHA = '(?:0|1|0?\\.\\d{1,3})';
|
||
const HUE = '(?:\\d|[1-9]\\d|[12]\\d{2}|3[0-5]\\d|360)(?:\\.\\d+)?(?:deg)?';
|
||
const PCT = '(?:\\d|[1-9]\\d|100)%';
|
||
return (new RegExp(`^rgba?\\(\\s*${N255}\\s*,\\s*${N255}\\s*,\\s*${N255}(\\s*,\\s*${ALPHA})?\\s*\\)$`).test(low)
|
||
|| new RegExp(`^hsla?\\(\\s*${HUE}\\s*,\\s*${PCT}\\s*,\\s*${PCT}(,\\s*${ALPHA})?\\s*\\)$`).test(low))
|
||
? '' : '颜色函数格式不正确(示例:rgba(31,111,235,.35),RGB 需在 0-255 内)';
|
||
}
|
||
if (/^\d+(\.\d+)?(px|rem|em|%|vh|vw|pt|ch)$/.test(low)) {
|
||
if (name && ['--r', '--side', '--side-c'].includes(name) && /(vh|vw)$/.test(low)) {
|
||
return '该变量不支持 vh / vw 单位';
|
||
}
|
||
return '';
|
||
}
|
||
if (low === '0' || TM_NAMED.indexOf(low) >= 0) return '';
|
||
if (/^(linear-gradient|radial-gradient)\(/i.test(s)) return '';
|
||
if (/^(?:(?:-?\d+(?:\.\d+)?(?:px|em|rem)?|rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-fA-F]{3,8}|inset|none|,|\s)+)$/i.test(s)) return '';
|
||
if (/^[a-z]+$/i.test(s)) return '不支持的具名取值,请用 #rrggbb 或 rgb() 形式';
|
||
return '取值格式无法识别';
|
||
}
|
||
|
||
function tmCheckCss(css) {
|
||
let s = String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||
if (!s.trim()) return '';
|
||
if (s.includes('@')) return '自定义 CSS 不允许使用 @ 规则(如 @import / @media)';
|
||
if (s.includes('\\')) return '自定义 CSS 不允许使用转义字符';
|
||
if (/javascript\s*:|expression\s*\(|behavior\s*:|url\s*\(/i.test(s)) return '自定义 CSS 不允许外部引用';
|
||
let rest = s.trim(), guard = 0;
|
||
while (rest) {
|
||
if (++guard > 200) return '自定义 CSS 结构过于复杂';
|
||
const m = /^([^{}]+)\{([^{}]*)\}/.exec(rest);
|
||
if (!m) return '自定义 CSS 语法不正确:应为「选择器 { 属性: 取值; }」形式';
|
||
if (!m[1].trim()) return '自定义 CSS 存在空选择器';
|
||
if (/[<>]/.test(m[1])) return '自定义 CSS 选择器包含非法字符';
|
||
for (const decl of m[2].split(';')) {
|
||
const d = decl.trim();
|
||
if (!d) continue;
|
||
if (!d.includes(':')) return `自定义 CSS 声明缺少冒号:${d.slice(0, 40)}`;
|
||
const prop = d.slice(0, d.indexOf(':')).trim().toLowerCase();
|
||
if (!/^(--[a-z0-9-]+|[a-z-]+)$/.test(prop)) return `属性名不合法:${prop.slice(0, 40)}`;
|
||
const val = d.slice(d.indexOf(':') + 1).trim();
|
||
if (!/^[0-9a-zA-Z#(),.\s%\-"/_]+$/.test(val)) return `取值含非法字符:${d.slice(0, 40)}`;
|
||
}
|
||
rest = rest.slice(m[0].length).trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/* #rrggbb → 可用于 <input type=color> 的 6 位色;不是 hex 则返回 fallback */
|
||
function tmToHex(v, fallback = '#888888') {
|
||
let s = String(v || '').trim().replace(/^#/, '');
|
||
if (/^[0-9a-fA-F]{6}$/.test(s)) return '#' + s.toLowerCase();
|
||
if (/^[0-9a-fA-F]{3}$/.test(s)) return '#' + s.split('').map(c => c + c).join('').toLowerCase();
|
||
return fallback;
|
||
}
|
||
|
||
/* 当前编辑中的主题对象(DOM → 对象) */
|
||
function tmCur() {
|
||
const e = S.themeEdit || {};
|
||
const vars = {};
|
||
TM_VARS.forEach(([k]) => {
|
||
const el = document.getElementById('tm-v-' + k.replace(/[^a-z0-9-]/gi, ''));
|
||
if (!el) return;
|
||
const on = document.getElementById('tm-o-' + k.replace(/[^a-z0-9-]/gi, ''));
|
||
if (on && !on.checked) return;
|
||
const v = String(el.value || '').trim();
|
||
if (v) vars[k] = v;
|
||
});
|
||
const gv = id => { const x = document.getElementById(id); return x ? String(x.value || '').trim() : ''; };
|
||
const cssEl = document.getElementById('tm-css');
|
||
return {
|
||
id: e.id || '',
|
||
name: gv('tm-name') || '我的主题',
|
||
author: gv('tm-author'),
|
||
description: gv('tm-desc'),
|
||
version: gv('tm-version') || '1.0',
|
||
dark: !!(document.getElementById('tm-dark') || {}).checked,
|
||
vars,
|
||
css: cssEl ? String(cssEl.value || '') : '',
|
||
};
|
||
}
|
||
|
||
/* 本地粗检(后端还会做一次权威校验) */
|
||
function tmLocalIssues(t) {
|
||
const out = [];
|
||
const nm = String(t.name || '').trim();
|
||
if (!nm) out.push('请填写主题名称');
|
||
else if (!/^[一-龥A-Za-z0-9 _.\-()]{1,48}$/.test(nm)) {
|
||
out.push('主题名称只能包含中文、字母、数字、空格与 - _ . ( )');
|
||
}
|
||
const keys = Object.keys(t.vars || {});
|
||
if (!keys.length) out.push('至少要改动 1 个变量');
|
||
for (const k of keys) {
|
||
const why = tmCheckValue(t.vars[k], k);
|
||
if (why) out.push(`变量 ${k} 取值不合法:${why}`);
|
||
}
|
||
const cw = tmCheckCss(t.css);
|
||
if (cw) out.push(cw);
|
||
return out;
|
||
}
|
||
|
||
function tmIssuesHTML(list) {
|
||
return list.length
|
||
? `<div class="tm-check bad"><b>共 ${list.length} 处问题,已阻止提交:</b><ul>${
|
||
list.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>`
|
||
: `<div class="tm-check ok">校验通过:主题结构、全部变量取值与自定义 CSS 均合法。</div>`;
|
||
}
|
||
|
||
/* 把主题渲染为 CSS 文本(与后端一致的输出格式,用于离线预览与「查看代码」) */
|
||
function tmToCss(t) {
|
||
const lines = [];
|
||
lines.push(`/* Drouter 主题:${t.name}${t.version ? ' v' + t.version : ''} */`);
|
||
lines.push(':root{');
|
||
Object.keys(t.vars || {}).sort().forEach(k => { lines.push(` ${k}:${t.vars[k]};`); });
|
||
lines.push('}');
|
||
lines.push(`:root{color-scheme:${t.dark ? 'dark' : 'light'}}`);
|
||
if (String(t.css || '').trim()) {
|
||
lines.push('', '/* ---- 作者自定义 CSS ---- */', String(t.css).trim());
|
||
}
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
/* 主题名 → 安全缩略色组(优先用主题自己的变量,缺失则回落到基准) */
|
||
function tmSwatch(t) {
|
||
const v = Object.assign({}, TM_BASE, t.vars || {});
|
||
return [v['--pri'], v['--bg'], v['--panel'], v['--txt']];
|
||
}
|
||
|
||
/* ---------- 离线预览:在一个隔离 iframe 里渲染一份「Drouter UI 缩影」 ----------
|
||
用 sandbox 隔离 + srcdoc,无需网络请求,也不影响当前页面;
|
||
主题 CSS 作为 :root 变量注入,因此能真实还原管理台的观感。 */
|
||
function tmPreviewDoc(t) {
|
||
const v = Object.assign({}, TM_BASE, t.vars || {});
|
||
const cssVars = Object.keys(v).map(k => `${k}:${v[k]};`).join('\n ');
|
||
const extra = String(t.css || '').trim();
|
||
const r = v['--r'] || '12px';
|
||
const grad = v['--grad'] || 'linear-gradient(135deg,#2b7bf3,#1f6feb)';
|
||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>主题离线预览</title>
|
||
<style>
|
||
:root{
|
||
${cssVars}
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
|
||
background:var(--bg);color:var(--txt);-webkit-font-smoothing:antialiased}
|
||
.tm-pv{display:flex;height:100vh;min-height:560px}
|
||
.tm-pv-side{flex:0 0 200px;background:var(--panel);border-right:1px solid var(--line);
|
||
padding:14px 12px;display:flex;flex-direction:column;gap:4px}
|
||
.tm-pv-brand{display:flex;align-items:center;gap:9px;margin-bottom:12px}
|
||
.tm-pv-brand i{width:26px;height:26px;border-radius:8px;display:block;background:${grad};flex:0 0 26px}
|
||
.tm-pv-brand b{font-size:15px;color:var(--txt)}
|
||
.tm-pv-g{font-size:11px;font-weight:600;color:hsl(210 72% 42%);margin:8px 0 2px}
|
||
.tm-pv-i{padding:6px 9px;border-radius:8px;font-size:12.5px;color:var(--txt2)}
|
||
.tm-pv-i.on{background:var(--pri-l);color:var(--pri);font-weight:600}
|
||
.tm-pv-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||
.tm-pv-top{height:52px;flex:0 0 52px;display:flex;align-items:center;justify-content:space-between;
|
||
padding:0 18px;background:var(--panel);border-bottom:1px solid var(--line)}
|
||
.tm-pv-top h4{margin:0;font-size:15px;color:var(--txt)}
|
||
.tm-pv-ck{font-size:12.5px;color:var(--txt3)}
|
||
.tm-pv-body{flex:1;overflow:auto;padding:16px 18px}
|
||
.tm-pv-card{background:var(--panel);border:1px solid var(--line);border-radius:${r};
|
||
padding:14px 16px;margin-bottom:12px;box-shadow:var(--sh)}
|
||
.tm-pv-card h5{margin:0 0 10px;font-size:13.5px;color:var(--txt)}
|
||
.tm-pv-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
||
.tm-pv-st{background:var(--panel);border:1px solid var(--line);border-radius:${r};padding:11px 13px}
|
||
.tm-pv-st .l{font-size:11px;color:var(--txt3);margin-bottom:4px}
|
||
.tm-pv-st .v{font-size:17px;font-weight:650;color:var(--txt)}
|
||
.tm-pv-st .b{height:6px;border-radius:3px;background:var(--line2);margin-top:6px;overflow:hidden}
|
||
.tm-pv-st .b i{display:block;height:100%;background:var(--pri)}
|
||
.tm-pv-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||
.tm-pv-btn{padding:6px 14px;border-radius:9px;font-size:12.5px;border:1px solid var(--line);
|
||
background:var(--panel);color:var(--txt)}
|
||
.tm-pv-btn.pri{background:${grad};color:#fff;border-color:transparent;font-weight:600}
|
||
.tm-pv-btn.dg{color:var(--err);border-color:var(--err)}
|
||
.tm-pv-tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
|
||
.tm-pv-tag{font-size:11px;padding:2px 7px;border-radius:5px}
|
||
.tm-pv-tag.ok{background:var(--ok-l);color:var(--ok)}
|
||
.tm-pv-tag.wn{background:var(--warn-l);color:var(--warn)}
|
||
.tm-pv-tag.er{background:var(--err-l);color:var(--err)}
|
||
.tm-pv-tag.if{background:var(--info-l);color:var(--info)}
|
||
table{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
|
||
th,td{text-align:left;padding:6px 9px;border-bottom:1px solid var(--line2)}
|
||
th{color:var(--txt2);background:var(--panel2);font-size:11px;font-weight:600}
|
||
td{color:var(--txt2)}
|
||
.tm-pv-note{font-size:11.5px;color:var(--txt3);margin-top:6px}
|
||
${extra}
|
||
</style></head>
|
||
<body>
|
||
<div class="tm-pv">
|
||
<aside class="tm-pv-side">
|
||
<div class="tm-pv-brand"><i></i><b>Drouter</b></div>
|
||
<div class="tm-pv-g">概览</div>
|
||
<div class="tm-pv-i on">系统概览</div>
|
||
<div class="tm-pv-i">网络状态 / 加速</div>
|
||
<div class="tm-pv-g">接口</div>
|
||
<div class="tm-pv-i">网卡与桥接</div>
|
||
<div class="tm-pv-i">WAN 口</div>
|
||
<div class="tm-pv-i">LAN 口</div>
|
||
<div class="tm-pv-g">安全</div>
|
||
<div class="tm-pv-i">防火墙 IPv4</div>
|
||
<div class="tm-pv-i">端口转发 / DMZ</div>
|
||
</aside>
|
||
<main class="tm-pv-main">
|
||
<header class="tm-pv-top">
|
||
<h4>系统概览</h4>
|
||
<span class="tm-pv-ck">2026年9月28日 星期一 · 农历八月</span>
|
||
</header>
|
||
<div class="tm-pv-body">
|
||
<div class="tm-pv-card">
|
||
<h5>实时状态</h5>
|
||
<div class="tm-pv-grid">
|
||
<div class="tm-pv-st"><div class="l">CPU 使用率</div><div class="v">18%</div>
|
||
<div class="b"><i style="width:18%"></i></div></div>
|
||
<div class="tm-pv-st"><div class="l">内存占用</div><div class="v">42%</div>
|
||
<div class="b"><i style="width:42%"></i></div></div>
|
||
<div class="tm-pv-st"><div class="l">下行速率</div><div class="v">86.4</div>
|
||
<div class="b"><i style="width:64%"></i></div></div>
|
||
<div class="tm-pv-st"><div class="l">在线终端</div><div class="v">12</div>
|
||
<div class="b"><i style="width:35%"></i></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="tm-pv-card">
|
||
<h5>操作与服务</h5>
|
||
<div class="tm-pv-row">
|
||
<button class="tm-pv-btn pri">保存并应用</button>
|
||
<button class="tm-pv-btn">仅保存</button>
|
||
<button class="tm-pv-btn dg">删除</button>
|
||
</div>
|
||
<div class="tm-pv-tags">
|
||
<span class="tm-pv-tag ok">运行中</span>
|
||
<span class="tm-pv-tag if">信息</span>
|
||
<span class="tm-pv-tag wn">警告</span>
|
||
<span class="tm-pv-tag er">错误</span>
|
||
</div>
|
||
<p class="tm-pv-note">这是离线预览 —— 与主界面使用同一套 CSS 变量,所见即所得。</p>
|
||
</div>
|
||
<div class="tm-pv-card">
|
||
<h5>连接简表</h5>
|
||
<table>
|
||
<thead><tr><th>源地址</th><th>目标</th><th>协议</th><th>状态</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>192.168.7.35</td><td>93.184.216.34:443</td><td>TCP</td><td>已建立</td></tr>
|
||
<tr><td>192.168.7.18</td><td>223.5.5.5:53</td><td>UDP</td><td>已建立</td></tr>
|
||
<tr><td>192.168.7.62</td><td>140.82.114.4:443</td><td>TCP</td><td>已建立</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</body></html>`;
|
||
}
|
||
|
||
function tmRefreshPreview() {
|
||
const t = tmCur();
|
||
const issues = tmLocalIssues(t);
|
||
const box = document.getElementById('tm-issues');
|
||
if (box) box.innerHTML = tmIssuesHTML(issues);
|
||
const frame = document.getElementById('tm-frame');
|
||
if (frame) frame.srcdoc = tmPreviewDoc(t);
|
||
const code = document.getElementById('tm-code');
|
||
if (code) code.textContent = tmToCss(t);
|
||
}
|
||
|
||
/* ---------- ZIP 导入导出(纯浏览器 + 后端 Base64 通道) ---------- */
|
||
function tmDownloadB64(b64, filename) {
|
||
try {
|
||
const bin = atob(b64);
|
||
const arr = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||
const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' }));
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = filename;
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500);
|
||
return true;
|
||
} catch (e) {
|
||
toast('下载失败:' + e.message, 'err');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function tmUpload(file) {
|
||
const tip = document.getElementById('tm-import-msg');
|
||
const show = (cls, txt) => { tip.className = 'tm-check ' + cls; tip.innerHTML = txt; };
|
||
if (!file) return;
|
||
const kb = Math.round(file.size / 1024);
|
||
if (file.size > 4 * 1024 * 1024) { show('bad', `文件过大(${kb} KB),主题包上限 4 MB`); return; }
|
||
if (!/\.(zip|drtheme)$/i.test(file.name)) {
|
||
show('bad', '请选择 .zip 主题包(由「导出」按钮生成的那种)');
|
||
return;
|
||
}
|
||
show('', '正在读取并校验…');
|
||
let b64;
|
||
try {
|
||
const dataUrl = await new Promise((res, rej) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => res(fr.result);
|
||
fr.onerror = () => rej(new Error('文件读取失败'));
|
||
fr.readAsDataURL(file);
|
||
});
|
||
b64 = String(dataUrl).split(',')[1] || '';
|
||
} catch (e) {
|
||
show('bad', '文件读取失败:' + esc(e.message)); return;
|
||
}
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'import', b64 } });
|
||
if (!r.ok) {
|
||
// 不规范的主题:给出中文原因,且绝不应用
|
||
show('bad', `<b>导入失败,主题未应用。</b><br>${esc(r.msg_cn)}`);
|
||
toast('主题校验未通过,已拒绝应用', 'err', 6000);
|
||
return;
|
||
}
|
||
show('ok', `<b>导入成功</b> —— ${esc(r.msg_cn)}`);
|
||
toast('主题导入成功', 'ok');
|
||
await tmReload();
|
||
const d = r.data || {};
|
||
if (d.id) tmApply(d.id, true);
|
||
}
|
||
|
||
/* ---------- 主题操作 ---------- */
|
||
async function tmApply(id, quiet) {
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'apply', id } });
|
||
if (!r.ok) { toast(r.msg_cn, 'err', 6000); return false; }
|
||
// 强制浏览器重新拉取 theme.css(绕开缓存)
|
||
const lk = document.getElementById('theme-css');
|
||
if (lk) lk.href = '/theme.css?v=' + Date.now();
|
||
toast(r.msg_cn, 'ok');
|
||
if (!quiet) await tmReload();
|
||
return true;
|
||
}
|
||
|
||
async function tmReload() {
|
||
const [lst, meta] = await Promise.all([
|
||
api('/api/theme?op=list'),
|
||
api('/api/theme?op=vars'),
|
||
]);
|
||
if (lst.ok) S.theme = lst.data || {};
|
||
if (meta.ok) {
|
||
S.themeMeta = meta.data || {};
|
||
const d = S.themeMeta.defaults || {};
|
||
Object.keys(d).forEach(k => { if (d[k]) TM_BASE[k] = d[k]; });
|
||
}
|
||
}
|
||
|
||
/* ---------- 主视图 ---------- */
|
||
async function viewTheme() {
|
||
const v = $('#view');
|
||
v.innerHTML = '<div class="card">正在读取主题列表…</div>';
|
||
await tmReload();
|
||
if (!S.theme || !S.theme.themes) {
|
||
v.innerHTML = `<div class="card"><h3 style="color:var(--err)">读取失败</h3>
|
||
<p class="desc">未能读取主题列表,请确认后端服务正常后重试。</p></div>`;
|
||
return;
|
||
}
|
||
if (!S.themeEdit) S.themeEdit = { id: '' };
|
||
tmRender();
|
||
}
|
||
|
||
function tmRender() {
|
||
const T = S.theme || {};
|
||
const themes = T.themes || [];
|
||
const active = T.active || 'default';
|
||
const cur = themes.find(x => x.id === active);
|
||
const e = S.themeEdit || { id: '' };
|
||
const v = $('#view');
|
||
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>主题之家
|
||
<span class="tag ${cur ? 'ok' : 'gray'}" style="float:right">
|
||
当前:${esc(cur ? cur.name : '未应用主题')}</span></h3>
|
||
<p class="desc">在这里挑选、设计并把玩界面外观。所有变更<b>只影响 Web 管理台外观</b>,
|
||
不会改动任何网络配置、防火墙规则或服务。</p>
|
||
<div class="tm-current">
|
||
<div class="tm-cur-swatch">${tmSwatch(cur || { vars: {} }).map(c =>
|
||
`<i style="background:${esc(c)}"></i>`).join('')}</div>
|
||
<div class="tm-cur-body">
|
||
<b>${esc(cur ? cur.name : '内置默认')}</b>
|
||
<p class="desc" style="margin:3px 0 0">${esc(cur && cur.description ? cur.description : '尚未应用自定义主题')}</p>
|
||
</div>
|
||
<div class="tm-cur-acts">
|
||
<button class="ghost small fixed" id="tm-reset">恢复默认主题</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-tabs">
|
||
<button class="tm-tab${S.themeTab === 'gallery' ? ' on' : ''}" data-tm-tab="gallery">主题广场</button>
|
||
<button class="tm-tab${S.themeTab === 'design' ? ' on' : ''}" data-tm-tab="design">主题设计器</button>
|
||
<button class="tm-tab${S.themeTab === 'import' ? ' on' : ''}" data-tm-tab="import">导入 / 导出</button>
|
||
</div>
|
||
|
||
<div id="tm-pane"></div>`;
|
||
|
||
$$('.tm-tab').forEach(b => b.onclick = () => { S.themeTab = b.dataset.tmTab; tmRender(); });
|
||
const rs = $('#tm-reset');
|
||
if (rs) rs.onclick = async () => {
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'reset' } });
|
||
if (r.ok) {
|
||
const lk = document.getElementById('theme-css');
|
||
if (lk) lk.href = '/theme.css?v=' + Date.now();
|
||
toast(r.msg_cn, 'ok'); await tmReload(); tmRender();
|
||
} else toast(r.msg_cn, 'err');
|
||
};
|
||
tmRenderPane();
|
||
}
|
||
|
||
function tmRenderPane() {
|
||
const T = S.theme || {};
|
||
const themes = T.themes || [];
|
||
const active = T.active || 'default';
|
||
const pane = $('#tm-pane');
|
||
if (!pane) return;
|
||
if (S.themeTab === 'gallery') {
|
||
pane.innerHTML = `
|
||
<div class="card">
|
||
<h3>全部主题(${themes.length})
|
||
<span class="desc" style="float:right;margin:0">共开放 ${TM_VARS.length} 个可定制变量</span></h3>
|
||
<p class="desc">内置主题不可修改或删除;想改的话点「以此为基础」复制到设计器里。</p>
|
||
<div class="tm-grid">
|
||
${themes.map(t => tmThemeCard(t, t.id === active)).join('')}
|
||
</div>
|
||
</div>`;
|
||
$$('[data-tm-act]').forEach(b => b.onclick = () => tmCardAct(b.dataset.tmAct, b.dataset.tmId));
|
||
return;
|
||
}
|
||
if (S.themeTab === 'import') {
|
||
pane.innerHTML = `
|
||
<div class="card">
|
||
<h3>导入主题包</h3>
|
||
<p class="desc">选择由「导出」生成的 <span class="mono">.zip</span> 主题包。
|
||
系统会逐项校验(结构、变量白名单、取值合法性、CSS 安全性),
|
||
<b>任何一项不通过都会给出中文原因并拒绝应用</b>,不会污染现有配置。</p>
|
||
<div class="tm-drop" id="tm-drop">
|
||
<p style="margin:0 0 10px">把主题包拖到这里,或</p>
|
||
<input type="file" id="tm-file" accept=".zip,.drtheme" style="display:none">
|
||
<button class="primary small" id="tm-pick">选择主题包</button>
|
||
<p class="desc" style="margin:10px 0 0">单个文件上限 4 MB,解压后上限 512 KB</p>
|
||
</div>
|
||
<div id="tm-import-msg" class="tm-check" style="display:none"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>导出当前主题</h3>
|
||
<p class="desc">把主题打包成独立的 <span class="mono">.zip</span>,可以分享给别人或备份。
|
||
包内含 <span class="mono">theme.json</span>(清单)、<span class="mono">theme.css</span>(渲染结果)
|
||
与 <span class="mono">README.txt</span>(安装说明)。</p>
|
||
<div class="row">
|
||
<label style="flex:1 1 260px">要导出的主题
|
||
<select id="tm-export-sel">
|
||
${themes.map(t => `<option value="${esc(t.id)}"${t.id === active ? ' selected' : ''}>${esc(t.name)}${t.builtin ? '(内置)' : ''}</option>`).join('')}
|
||
</select></label>
|
||
<button class="ghost fixed" id="tm-export">打包并下载</button>
|
||
</div>
|
||
<div id="tm-export-msg" class="tm-check" style="display:none"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>主题包格式说明</h3>
|
||
<p class="desc">如果你想手工写主题,包的结构非常简单:</p>
|
||
<pre>主题包.zip
|
||
├── theme.json ← 必需,UTF-8,见下方字段
|
||
├── theme.css ← 可选,导入时会被重新生成
|
||
└── README.txt ← 可选</pre>
|
||
<p class="desc" style="margin-top:10px"><span class="mono">theme.json</span> 字段:</p>
|
||
<pre>{
|
||
"name": "我的主题", // 必需,≤48 字
|
||
"description": "一句话说明", // 可选
|
||
"author": "作者名", // 可选
|
||
"version": "1.0", // 可选
|
||
"dark": false, // 可选,true = 深色主题
|
||
"vars": { // 必需,至少 1 个
|
||
"--pri": "#1f6feb",
|
||
"--bg": "#eef2f9"
|
||
},
|
||
"css": "" // 可选,追加的自定义 CSS
|
||
}</pre>
|
||
<p class="hint-inline">只有 <span class="mono">--bg / --panel / --pri / --txt …</span>
|
||
这 ${TM_VARS.length} 个变量会被接受。其它变量、<span class="mono">@import</span>、
|
||
<span class="mono">url()</span>、<span class="mono">javascript:</span>
|
||
一律拒绝并给出中文提示。</p>
|
||
</div>`;
|
||
const pick = $('#tm-pick'), file = $('#tm-file'), drop = $('#tm-drop');
|
||
pick.onclick = () => file.click();
|
||
file.onchange = () => tmUpload(file.files[0]);
|
||
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => {
|
||
e.preventDefault(); drop.classList.add('hot');
|
||
}));
|
||
['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => {
|
||
e.preventDefault(); drop.classList.remove('hot');
|
||
}));
|
||
drop.addEventListener('drop', e => {
|
||
if (e.dataTransfer.files && e.dataTransfer.files[0]) tmUpload(e.dataTransfer.files[0]);
|
||
});
|
||
const msg = $('#tm-import-msg');
|
||
msg.style.display = '';
|
||
$('#tm-export').onclick = tmExport;
|
||
$('#tm-export-msg').style.display = '';
|
||
return;
|
||
}
|
||
// design
|
||
const e = S.themeEdit || {};
|
||
pane.innerHTML = `
|
||
<div class="card">
|
||
<h3>${e.id ? '编辑主题' : '新建主题'}
|
||
${e.id ? `<span class="tag info" style="float:right">${esc(e.id)}</span>` : ''}</h3>
|
||
<p class="desc">左侧调整参数,右侧即时预览。<b>预览完全在本地完成</b>(沙箱 iframe),
|
||
不会影响真实界面;觉得满意再点「保存并应用」。</p>
|
||
<div class="row">
|
||
<label style="flex:1 1 200px">主题名称<input id="tm-name" value="${esc(e.name || '我的主题')}" maxlength="48"></label>
|
||
<label style="flex:1 1 140px">作者<input id="tm-author" value="${esc(e.author || '')}" maxlength="64" placeholder="选填"></label>
|
||
<label style="flex:1 1 120px">版本<input id="tm-version" value="${esc(e.version || '1.0')}" maxlength="24"></label>
|
||
</div>
|
||
<label>一句话描述<textarea id="tm-desc" rows="2" maxlength="300"
|
||
placeholder="选填,会显示在主题卡片上">${esc(e.description || '')}</textarea></label>
|
||
<label class="switch" style="display:inline-flex;margin-right:20px">
|
||
<input type="checkbox" id="tm-dark"${e.dark ? ' checked' : ''}><i></i>深色主题</label>
|
||
<div class="row" style="margin-top:4px">
|
||
<label style="flex:0 0 110px">一键配色主色
|
||
<input type="color" id="tm-seed" value="${esc(tmToHex((e.vars || {})['--pri'], '#1f6feb'))}"></label>
|
||
<button class="ghost fixed" id="tm-palette">按此主色生成整套配色</button>
|
||
<span class="desc" style="flex:1 1 200px;margin:0">会同时重算背景、文字、状态色与按钮渐变,保证对比度</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tm-split">
|
||
<div class="card">
|
||
<h3>变量调整</h3>
|
||
<p class="desc">勾选表示「包含在主题里」。取消勾选的变量不会写进主题,
|
||
应用到管理台时会回落到默认值。</p>
|
||
<div id="tm-vars">${tmVarsHTML(e)}</div>
|
||
<h3 style="margin-top:18px">自定义 CSS(高级)</h3>
|
||
<p class="desc">只接受「选择器 { 属性: 取值; }」形式;禁止
|
||
<span class="mono">@import / @media</span>、<span class="mono">url()</span>、
|
||
<span class="mono">javascript:</span> 等外部引用。</p>
|
||
<textarea id="tm-css" rows="6" spellcheck="false"
|
||
placeholder=".card{border-width:2px} .stat .val{font-size:22px}">${esc(e.css || '')}</textarea>
|
||
</div>
|
||
<div class="card">
|
||
<h3>离线预览</h3>
|
||
<p class="desc">下方是隔离沙箱中的真实渲染效果,随左侧改动实时更新。</p>
|
||
<iframe id="tm-frame" class="tm-frame" sandbox="allow-same-origin"
|
||
title="主题离线预览"></iframe>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="ghost small fixed" id="tm-frame-new">在新窗口打开预览</button>
|
||
<button class="ghost small fixed" id="tm-frame-re">重新渲染预览</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>校验与操作</h3>
|
||
<div id="tm-issues" class="tm-check"></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="ghost fixed" id="tm-check">校验主题</button>
|
||
<button class="ghost fixed" id="tm-save">仅保存</button>
|
||
<button class="primary fixed" id="tm-save-apply">保存并应用</button>
|
||
<button class="ghost fixed" id="tm-export-cur">导出为 ZIP</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>主题代码</h3>
|
||
<p class="desc">最终写入 <span class="mono">theme.css</span> 的完整内容,可直接复制到别处使用。</p>
|
||
<div class="code-wrap">
|
||
<button class="code-copy" id="tm-copy-css">复制</button>
|
||
<pre id="tm-code" class="term-pre"></pre>
|
||
</div>
|
||
</div>`;
|
||
tmBindDesign();
|
||
tmRefreshPreview();
|
||
}
|
||
|
||
function tmVarsHTML(e) {
|
||
const vars = e.vars || {};
|
||
return TM_CAT_ORDER.map(cat => {
|
||
const items = TM_VARS.filter(x => x[2] === cat);
|
||
return `<div class="tm-cat">
|
||
<div class="tm-cat-t">${esc(TM_CAT_CN[cat] || cat)}</div>
|
||
${items.map(([k, cn]) => {
|
||
const key = k.replace(/[^a-z0-9-]/gi, '');
|
||
const val = vars[k] || TM_BASE[k] || '';
|
||
const isColor = !['--r', '--sh', '--grad', '--side', '--side-c'].includes(k);
|
||
return `<div class="tm-vrow">
|
||
<label class="switch" style="margin:0;flex:0 0 auto">
|
||
<input type="checkbox" id="tm-o-${key}"${vars[k] ? ' checked' : ''}><i></i></label>
|
||
<span class="tm-vname">${esc(cn)}<br><span class="mono" style="font-size:11px;color:var(--txt3)">${esc(k)}</span></span>
|
||
${isColor ? `<input type="color" class="tm-color" data-for="${key}"
|
||
value="${esc(tmToHex(val, '#cccccc'))}">` : ''}
|
||
<input type="text" id="tm-v-${key}" class="tm-vtext mono" value="${esc(val)}"
|
||
spellcheck="false" autocomplete="off">
|
||
<button class="ghost small tm-vreset" data-reset="${key}"
|
||
title="回到默认">↺</button>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function tmBindDesign() {
|
||
$$('.tm-color').forEach(c => c.oninput = () => {
|
||
const el = document.getElementById('tm-v-' + c.dataset.for);
|
||
if (el) { el.value = c.value; tmSyncSwatch(c.dataset.for, c.value); tmRefreshPreview(); }
|
||
});
|
||
$$('.tm-vtext').forEach(t => t.oninput = () => {
|
||
const cb = document.getElementById('tm-o-' + t.id.replace('tm-v-', ''));
|
||
if (cb) cb.checked = true;
|
||
const sw = document.querySelector('.tm-color[data-for="' + t.id.replace('tm-v-', '') + '"]');
|
||
if (sw) sw.value = tmToHex(t.value, sw.value);
|
||
tmRefreshPreview();
|
||
});
|
||
$$('[data-reset]').forEach(b => b.onclick = () => {
|
||
const el = document.getElementById('tm-v-' + b.dataset.reset);
|
||
const sw = document.querySelector('.tm-color[data-for="' + b.dataset.reset + '"]');
|
||
if (el) el.value = TM_BASE['--' + b.dataset.reset.replace(/^/, '').replace(/^/, '')] || el.value;
|
||
const key = '--' + b.dataset.reset;
|
||
if (el) el.value = TM_BASE[key] || el.value;
|
||
if (sw) sw.value = tmToHex(el.value, sw.value);
|
||
tmRefreshPreview();
|
||
});
|
||
$$('.tm-vrow input[type=checkbox]').forEach(c => c.onchange = () => tmRefreshPreview());
|
||
const dk = document.getElementById('tm-dark');
|
||
if (dk) dk.onchange = () => tmRefreshPreview();
|
||
const cssEl = document.getElementById('tm-css');
|
||
if (cssEl) cssEl.oninput = debounce(() => tmRefreshPreview(), 250);
|
||
['tm-name', 'tm-desc', 'tm-version', 'tm-author'].forEach(id => {
|
||
const x = document.getElementById(id);
|
||
if (x) x.oninput = debounce(() => tmRefreshPreview(), 300);
|
||
});
|
||
$('#tm-frame-re').onclick = () => tmRefreshPreview();
|
||
$('#tm-frame-new').onclick = () => {
|
||
const t = tmCur();
|
||
const w = window.open('', '_blank');
|
||
if (w) { w.document.write(tmPreviewDoc(t)); w.document.close(); }
|
||
else toast('浏览器拦截了弹出窗口,请允许后再试', 'warn');
|
||
};
|
||
$('#tm-check').onclick = () => {
|
||
const issues = tmLocalIssues(tmCur());
|
||
$('#tm-issues').innerHTML = tmIssuesHTML(issues);
|
||
toast(issues.length ? `发现 ${issues.length} 处问题` : '校验通过', issues.length ? 'err' : 'ok');
|
||
};
|
||
$('#tm-save').onclick = () => tmSave(false);
|
||
$('#tm-save-apply').onclick = () => tmSave(true);
|
||
$('#tm-export-cur').onclick = () => tmExportDraft();
|
||
$('#tm-copy-css').onclick = () => {
|
||
const txt = document.getElementById('tm-code').textContent;
|
||
navigator.clipboard.writeText(txt).then(
|
||
() => toast('CSS 已复制到剪贴板', 'ok'),
|
||
() => toast('复制失败,请手动选择文本', 'err'));
|
||
};
|
||
}
|
||
|
||
function tmSyncSwatch(key, val) {
|
||
const sw = document.querySelector('.tm-color[data-for="' + key + '"]');
|
||
if (sw) sw.value = tmToHex(val, sw.value);
|
||
}
|
||
|
||
async function tmSave(applyAfter) {
|
||
const t = tmCur();
|
||
const issues = tmLocalIssues(t);
|
||
$('#tm-issues').innerHTML = tmIssuesHTML(issues);
|
||
if (issues.length) {
|
||
toast(`有 ${issues.length} 处问题,未保存`, 'err', 6000);
|
||
return;
|
||
}
|
||
const payload = Object.assign({}, t, { id: (S.themeEdit || {}).id || '' });
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'save', theme: payload } });
|
||
if (!r.ok) {
|
||
$('#tm-issues').innerHTML = `<div class="tm-check bad"><b>后端拒绝了保存:</b>
|
||
<br>${esc(r.msg_cn)}</div>`;
|
||
toast('保存失败:' + r.msg_cn, 'err', 6000);
|
||
return;
|
||
}
|
||
S.themeEdit = Object.assign({}, t, { id: r.data.id });
|
||
toast(r.msg_cn, 'ok');
|
||
await tmReload();
|
||
if (applyAfter) await tmApply(r.data.id, false);
|
||
tmRender();
|
||
}
|
||
|
||
async function tmExportDraft() {
|
||
const issues = tmLocalIssues(tmCur());
|
||
$('#tm-issues').innerHTML = tmIssuesHTML(issues);
|
||
if (issues.length) { toast('请先修正问题再导出', 'err'); return; }
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'preview', theme: tmCur() } });
|
||
if (!r.ok) { toast('打包失败:' + r.msg_cn, 'err', 6000); return; }
|
||
// 草稿未落盘,导出在浏览器侧合成 zip 内容需要主题对象,这里走一次临时保存
|
||
const s = await api('/api/theme/op', { method: 'POST', body: { op: 'save', theme: tmCur() } });
|
||
if (!s.ok) { toast('打包前保存失败:' + s.msg_cn, 'err', 6000); return; }
|
||
S.themeEdit = Object.assign({}, tmCur(), { id: s.data.id });
|
||
await tmDoExport(s.data.id);
|
||
await tmReload();
|
||
}
|
||
|
||
async function tmExport() {
|
||
const id = $('#tm-export-sel').value;
|
||
await tmDoExport(id);
|
||
}
|
||
|
||
async function tmDoExport(id) {
|
||
const msg = $('#tm-export-msg') || $('#tm-issues');
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'export', id } });
|
||
if (!r.ok) {
|
||
if (msg) { msg.className = 'tm-check bad'; msg.innerHTML = esc(r.msg_cn); }
|
||
toast('导出失败:' + r.msg_cn, 'err', 6000);
|
||
return;
|
||
}
|
||
tmDownloadB64(r.data.b64, r.data.filename);
|
||
if (msg) {
|
||
msg.className = 'tm-check ok';
|
||
msg.innerHTML = `已生成 <span class="mono">${esc(r.data.filename)}</span>(${Math.max(1, Math.round(r.data.size / 1024))} KB),
|
||
浏览器应已开始下载;没弹窗的话请检查浏览器的下载拦截设置。`;
|
||
}
|
||
toast('主题包已导出', 'ok');
|
||
}
|
||
|
||
function tmThemeCard(t, on) {
|
||
const sw = tmSwatch(t);
|
||
return `<div class="tm-card${on ? ' on' : ''}">
|
||
<div class="tm-card-top" style="--sw0:${esc(sw[0])};--sw1:${esc(sw[1])};--sw2:${esc(sw[2])};--sw3:${esc(sw[3])}">
|
||
<div class="tm-card-mock">
|
||
<div class="tm-mk-side"><i></i><span></span><span></span></div>
|
||
<div class="tm-mk-main"><div class="tm-mk-bar"></div><div class="tm-mk-body"></div></div>
|
||
</div>
|
||
${on ? '<span class="tm-card-use">使用中</span>' : ''}
|
||
</div>
|
||
<div class="tm-card-info">
|
||
<b>${esc(t.name)}</b>
|
||
<span class="tag ${t.dark ? 'gray' : 'info'}">${t.dark ? '深色' : '浅色'}</span>
|
||
${t.builtin ? '<span class="tag gray">内置</span>' : ''}
|
||
<p class="desc" style="margin:4px 0 0">${esc(t.description || '(无描述)')}</p>
|
||
<p class="desc" style="margin:2px 0 0;font-size:11.5px">
|
||
${esc(t.author ? '作者:' + t.author : '未署名')} · ${t.vars_count || 0} 个变量
|
||
${t.has_css ? ' · 含自定义 CSS' : ''}</p>
|
||
</div>
|
||
<div class="tm-card-acts">
|
||
${on ? '' : `<button class="primary small" data-tm-act="apply" data-tm-id="${esc(t.id)}">应用</button>`}
|
||
<button class="ghost small" data-tm-act="fork" data-tm-id="${esc(t.id)}">以此为基础</button>
|
||
<button class="ghost small" data-tm-act="export" data-tm-id="${esc(t.id)}">导出</button>
|
||
${t.builtin ? '' : `<button class="ghost small danger" data-tm-act="del" data-tm-id="${esc(t.id)}">删除</button>`}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function tmCardAct(act, id) {
|
||
const t = (S.theme.themes || []).find(x => x.id === id);
|
||
if (act === 'apply') { await tmApply(id, false); tmRender(); return; }
|
||
if (act === 'export') { await tmDoExport(id); return; }
|
||
if (act === 'fork') {
|
||
const r = await api('/api/theme?op=get&id=' + encodeURIComponent(id));
|
||
if (!r.ok) { toast('读取主题失败:' + r.msg_cn, 'err'); return; }
|
||
const d = r.data || {};
|
||
S.themeEdit = {
|
||
id: '', name: (d.name || '我的主题') + ' 副本',
|
||
author: '', description: d.description || '', version: '1.0',
|
||
dark: !!d.dark, vars: Object.assign({}, d.vars || {}), css: d.css || '',
|
||
};
|
||
S.themeTab = 'design';
|
||
tmRender();
|
||
toast('已载入到设计器,改好后保存即为新主题', 'ok');
|
||
return;
|
||
}
|
||
if (act === 'del') {
|
||
modal('删除主题', `<p>确定要删除主题「<b>${esc(t ? t.name : id)}</b>」吗?</p>
|
||
<p class="desc">删除后无法恢复。若你还想保留,建议先点「导出」备份。</p>`,
|
||
async () => {
|
||
const r = await api('/api/theme/op', { method: 'POST', body: { op: 'delete', id } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 5000);
|
||
if (r.ok) { await tmReload(); tmRender(); }
|
||
}, '删除');
|
||
}
|
||
}
|
||
|
||
/* ============================ 通用弹窗 ============================ */
|
||
function modal(title, html, onOk, okText = '确定') {
|
||
const m = $('#modal');
|
||
$('#modal-box').innerHTML = `<h3>${esc(title)}</h3><div class="modal-body">${html}</div>
|
||
<div class="acts"><button id="md-cancel" class="ghost">取消</button>
|
||
${onOk ? `<button id="md-ok" class="primary">${esc(okText)}</button>` : ''}</div>`;
|
||
m.classList.remove('hidden');
|
||
const close = () => m.classList.add('hidden');
|
||
$('#md-cancel').onclick = close;
|
||
m.onclick = e => { if (e.target === m) close(); };
|
||
if (onOk) $('#md-ok').onclick = async () => {
|
||
const keep = await onOk();
|
||
if (keep !== false) close();
|
||
};
|
||
}
|
||
|
||
/* ============================ 顶栏时钟 / 农历(#12) ============================
|
||
需求:顶栏不再显示重复的「主机名 · 内存 21% · 运行 22小时18分」,
|
||
改为时钟 + 农历 + 年月日 + 星期。运行时间保留在概览页里。
|
||
农历用经典的「1900-2100 压缩表」算法,纯本地计算,不依赖网络。 */
|
||
|
||
// 农历数据表(1900-2100):每年一个 16 进制数
|
||
// 低 4 位 = 闰月月份(0 表示无闰月);中间 12 位 = 每月大小月(1 大 30 天 / 0 小 29 天);
|
||
// 最高位 = 闰月大小(1=30 天 / 0=29 天)。逐行 10 个,便于核对。
|
||
const lunarInfo = [
|
||
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
|
||
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
|
||
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
|
||
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
|
||
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
|
||
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
|
||
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
|
||
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,
|
||
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
|
||
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,
|
||
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
|
||
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
|
||
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
|
||
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
|
||
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,
|
||
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0,
|
||
0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,
|
||
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,
|
||
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160,
|
||
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252,
|
||
0x0d520,
|
||
];
|
||
const LUNAR_MONTH = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
|
||
const LUNAR_DAY_PRE = ['初', '十', '廿', '卅'];
|
||
const LUNAR_DAY_SUF = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||
const GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
|
||
const ZHI = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
|
||
const ZODIAC = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
|
||
const WEEK_CN = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||
const SOLAR_TERM = ['小寒', '大寒', '立春', '雨水', '惊蛰', '春分', '清明', '谷雨', '立夏', '小满',
|
||
'芒种', '夏至', '小暑', '大暑', '立秋', '处暑', '白露', '秋分', '寒露', '霜降', '立冬', '小雪',
|
||
'大雪', '冬至'];
|
||
|
||
function lunarLeapMonth(y) { return lunarInfo[y - 1900] & 0xf; }
|
||
function lunarLeapDays(y) {
|
||
return lunarLeapMonth(y) ? ((lunarInfo[y - 1900] & 0x10000) ? 30 : 29) : 0;
|
||
}
|
||
function lunarMonthDays(y, m) { return (lunarInfo[y - 1900] & (0x10000 >> m)) ? 30 : 29; }
|
||
function lunarYearDays(y) {
|
||
let sum = 348;
|
||
for (let i = 0x8000; i > 0x8; i >>= 1) sum += (lunarInfo[y - 1900] & i) ? 1 : 0;
|
||
return sum + lunarLeapDays(y);
|
||
}
|
||
|
||
/* 公历 → 农历(经典压缩表算法,1900-2100,已用 2024/2025/2026 春节与中秋逐日校验) */
|
||
function solarToLunar(dt) {
|
||
const base = new Date(1900, 0, 31);
|
||
let offset = Math.floor((new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) - base) / 86400000);
|
||
let temp = 0, i;
|
||
for (i = 1900; i < 2101 && offset > 0; i++) { temp = lunarYearDays(i); offset -= temp; }
|
||
if (offset < 0) { offset += temp; i--; }
|
||
const lunarYear = i;
|
||
const leap = lunarLeapMonth(i);
|
||
let isLeap = false;
|
||
for (i = 1; i < 13 && offset > 0; i++) {
|
||
if (leap > 0 && i === leap + 1 && isLeap === false) {
|
||
--i; isLeap = true; temp = lunarLeapDays(lunarYear);
|
||
} else {
|
||
temp = lunarMonthDays(lunarYear, i);
|
||
}
|
||
if (isLeap === true && i === leap + 1) isLeap = false;
|
||
offset -= temp;
|
||
}
|
||
if (offset === 0 && leap > 0 && i === leap + 1) {
|
||
if (isLeap === true) isLeap = false; else { isLeap = true; --i; }
|
||
}
|
||
if (offset < 0) { offset += temp; --i; }
|
||
return {
|
||
lunarYear, month: i, day: offset + 1, isLeap,
|
||
ganZhi: GAN[(lunarYear - 4) % 10] + ZHI[(lunarYear - 4) % 12],
|
||
zodiac: ZODIAC[(lunarYear - 4) % 12],
|
||
};
|
||
}
|
||
|
||
function lunarDayCn(d) {
|
||
if (d === 10) return '初十';
|
||
if (d === 20) return '二十';
|
||
if (d === 30) return '三十';
|
||
return LUNAR_DAY_PRE[Math.floor(d / 10)] + LUNAR_DAY_SUF[d % 10];
|
||
}
|
||
|
||
function lunarText(date) {
|
||
try {
|
||
const l = solarToLunar(date);
|
||
// 初一显示月份,其余显示日
|
||
const md = (l.day === 1)
|
||
? `${l.isLeap ? '闰' : ''}${LUNAR_MONTH[l.month - 1]}月`
|
||
: `${LUNAR_MONTH[l.month - 1]}月${lunarDayCn(l.day)}`;
|
||
return `${l.ganZhi}${l.zodiac}年 ${md}`;
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
/* 节气:使用「寿星公式」(1900-2100 通用,误差 0 天,已用 2024/2025/2026 立春·冬至·夏至校验) */
|
||
const SOLAR_TERM_MIN = [0, 21208, 42467, 63836, 85337, 107014, 128867, 150921, 173149,
|
||
195551, 218072, 240693, 263343, 285989, 308563, 331033, 353350, 375494, 397447,
|
||
419210, 440795, 462224, 483532, 504758];
|
||
|
||
function solarTermDate(y, n) {
|
||
// n: 0=小寒 … 23=冬至;返回该节气的公历日期
|
||
const minutes = 525948.76 * (y - 1900) + SOLAR_TERM_MIN[n];
|
||
const base = Date.UTC(1900, 0, 6, 2, 5, 0);
|
||
const d = new Date(base + minutes * 60000);
|
||
return { y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate() };
|
||
}
|
||
|
||
function solarTermText(date) {
|
||
try {
|
||
const y = date.getFullYear(), m = date.getMonth() + 1, d = date.getDate();
|
||
const i0 = (m - 1) * 2;
|
||
const a = solarTermDate(y, i0), b = solarTermDate(y, i0 + 1);
|
||
if (a.y === y && a.m === m && a.d === d) return SOLAR_TERM[i0];
|
||
if (b.y === y && b.m === m && b.d === d) return SOLAR_TERM[i0 + 1];
|
||
return '';
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
let CLOCK_24H = localStorage.getItem('drouter_clock24') !== '0';
|
||
let CLOCK_TIMER = null;
|
||
|
||
function tickClock() {
|
||
const now = new Date();
|
||
const h = now.getHours(), mi = String(now.getMinutes()).padStart(2, '0'),
|
||
s = String(now.getSeconds()).padStart(2, '0');
|
||
let timeStr;
|
||
if (CLOCK_24H) {
|
||
timeStr = `${String(h).padStart(2, '0')}:${mi}:${s}`;
|
||
} else {
|
||
const ap = h < 12 ? '上午' : '下午';
|
||
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||
timeStr = `${ap} ${h12}:${mi}:${s}`;
|
||
}
|
||
const t = document.getElementById('clock-time');
|
||
const dEl = document.getElementById('clock-date');
|
||
const lEl = document.getElementById('clock-lunar');
|
||
if (t) t.textContent = timeStr;
|
||
if (dEl) {
|
||
const term = solarTermText(now);
|
||
dEl.textContent = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${WEEK_CN[now.getDay()]}`
|
||
+ (term ? ` · ${term}` : '');
|
||
}
|
||
if (lEl) lEl.textContent = lunarText(now);
|
||
}
|
||
|
||
function startClock() {
|
||
if (CLOCK_TIMER) clearInterval(CLOCK_TIMER);
|
||
tickClock();
|
||
CLOCK_TIMER = setInterval(tickClock, 1000);
|
||
const box = document.getElementById('clock-box');
|
||
if (box) box.onclick = () => {
|
||
CLOCK_24H = !CLOCK_24H;
|
||
try { localStorage.setItem('drouter_clock24', CLOCK_24H ? '1' : '0'); } catch (e) { /* ignore */ }
|
||
tickClock();
|
||
toast('已切换为 ' + (CLOCK_24H ? '24 小时制' : '12 小时制'), 'ok', 1500);
|
||
};
|
||
}
|
||
|
||
/* ============================ 顶部与守护 ============================ */
|
||
async function loadAll() {
|
||
let cfg, si, svc, ifc, bm, wp;
|
||
try {
|
||
[cfg, si, svc, ifc, bm, wp] = await Promise.all([
|
||
api('/api/config'), api('/api/sysinfo'), api('/api/services'),
|
||
api('/api/ifaces'), api('/api/buildmode', { method: 'GET' }),
|
||
api('/api/webport')]);
|
||
} catch (e) {
|
||
cfg = si = svc = ifc = bm = wp = { ok: false, msg_cn: '加载失败:' + e.message };
|
||
}
|
||
if (cfg.ok) S.cfg = cfg.data || {};
|
||
if (bm && bm.ok) S.buildMode = !!((bm.data || {}).enabled);
|
||
if (wp && wp.ok) S.webPort = (wp.data || {}).port || 8443;
|
||
S.ifaces = ((ifc && ifc.data) || []).filter(x => !x.is_virtual && x.name !== 'lo');
|
||
// 顶栏已改为时钟 / 农历,不再显示重复的主机名与内存占用(#12)
|
||
const v = document.getElementById('vnc-dot');
|
||
if (v) {
|
||
v.innerHTML = S.buildMode
|
||
? '<span class="dot off"></span>构建保护模式'
|
||
: '<span class="dot on"></span>可生效模式';
|
||
}
|
||
// 构建模式全局横幅
|
||
const tip = $('#newiface-tip');
|
||
if (tip) {
|
||
if (S.buildMode) {
|
||
tip.classList.remove('hidden');
|
||
tip.innerHTML = `<div><b>构建保护模式已开启</b> —— 所有「应用」只会写入磁盘,不会启动服务、不会改变网络。
|
||
这样可以安全地把配置准备好,等你添加好 WAN 口并确认无误后,再一次性切换。</div>
|
||
<button class="ghost small" onclick="toggleBuildMode(false)">我准备好执行切换</button>`;
|
||
} else {
|
||
tip.classList.add('hidden');
|
||
}
|
||
}
|
||
// 任一请求未通过鉴权,直接打回登录页,避免页面卡在「正在载入…」
|
||
const anyUnauth = [cfg, si, svc, ifc, bm, wp].some(r => r && r.code === 'UNAUTH');
|
||
if (anyUnauth) { logout(true); throw new Error('UNAUTH'); }
|
||
}
|
||
|
||
function boot() {
|
||
renderNav();
|
||
startClock();
|
||
loadAll().then(() => go('dash')).catch(e => {
|
||
// 鉴权失效已在 loadAll 内处理(打回登录页);其余错误给出可见提示,避免卡在「正在载入…」
|
||
if (e && e.message === 'UNAUTH') return;
|
||
go('dash');
|
||
toast('初始化未完全成功,部分数据可能未加载:' + (e && e.message || e), 'warn');
|
||
});
|
||
// 轻量心跳:仅用于检测后端是否在线(顶栏不再展示内存,避免与概览重复)
|
||
setInterval(async () => {
|
||
const r = await api('/api/health');
|
||
if (!r.ok) toast('与后端连接中断,请检查管理服务是否在运行', 'err', 8000);
|
||
}, 60000);
|
||
}
|
||
|
||
/* ============================ 保存 / 应用 ============================ */
|
||
|
||
function pageModules() { return PAGE_MODULES[S.page] || null; }
|
||
|
||
function currentModule() {
|
||
const m = pageModules();
|
||
return m && m.save.length ? m.save[0] : null;
|
||
}
|
||
|
||
/* 端口转发:保存前把编辑中的规则同步进配置,并同时应用防火墙模块 */
|
||
/* 访问控制 / 家长时间组(#8):不走 /api/config,用专属 /api/acl 接口 */
|
||
async function saveAcl(live) {
|
||
const d = S.acl || {};
|
||
const r = await api('/api/acl', { method: 'POST', body: {
|
||
op: 'save', live: !!live, enable: !!d.enable,
|
||
time_groups: d.time_groups || [], groups: d.groups || [], rules: d.rules || [],
|
||
} });
|
||
return r;
|
||
}
|
||
|
||
/* 内网文件共享 SMB/NFS(#9):同样走专属 /api/share 接口 */
|
||
async function saveShare(live) {
|
||
const d = sb();
|
||
const r = await api('/api/share', { method: 'POST', body: {
|
||
op: 'save', live: !!live, enable_smb: !!d.enable_smb, enable_nfs: !!d.enable_nfs,
|
||
samba: d.samba || {}, nfs: d.nfs || {},
|
||
} });
|
||
return r;
|
||
}
|
||
|
||
/* ================== Docker / Docker Compose 面板(#10) ================== */
|
||
const DK_STATE_TAG = { running: 'ok', exited: 'gray', created: 'info', paused: 'warn',
|
||
restarting: 'warn', removing: 'err', dead: 'err' };
|
||
const DK_STATE_CN = { running: '运行中', exited: '已退出', created: '已创建', paused: '已暂停',
|
||
restarting: '重启中', removing: '删除中', dead: '异常' };
|
||
|
||
/* "12.3MiB / 1GiB" → {used, total}(字节),用于画占比条 */
|
||
function dkMemParts(s) {
|
||
const toB = t => {
|
||
const m = /^([\d.]+)\s*([KMGT]?i?B)?$/i.exec(String(t || '').trim());
|
||
if (!m) return 0;
|
||
const u = (m[2] || 'B').toUpperCase().replace('I', '');
|
||
const k = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 }[u] || 1;
|
||
return parseFloat(m[1]) * k;
|
||
};
|
||
const p = String(s || '').split('/');
|
||
return { used: toB(p[0]), total: toB(p[1]) };
|
||
}
|
||
|
||
function dkBar(perc, kind) {
|
||
const n = Math.max(0, Math.min(100, parseFloat(String(perc || '0').replace('%', '')) || 0));
|
||
return `<span class="dk-bar"><i style="width:${n}%;background:var(--${kind})"></i></span>
|
||
<span class="mono" style="font-size:11.5px">${n.toFixed(1)}%</span>`;
|
||
}
|
||
|
||
function dkContainerRow(c) {
|
||
const st = c.stat || {};
|
||
const tag = DK_STATE_TAG[c.state] || 'gray';
|
||
const ports = (c.ports || '').split(',').map(x => x.trim()).filter(Boolean);
|
||
return `<div class="dk-trow dk-crow">
|
||
<span>
|
||
<b>${esc(c.name)}</b>
|
||
<span class="tag ${tag}" style="margin-left:6px">${esc(DK_STATE_CN[c.state] || c.state)}</span>
|
||
<br><span class="mono" style="font-size:11px;color:var(--txt3)">${esc(c.image)} · ${esc(c.id)}</span>
|
||
${ports.length ? `<br><span class="mono" style="font-size:11px;color:var(--txt2)">${esc(ports.join(' '))}</span>` : ''}
|
||
</span>
|
||
<span class="dk-metric">${st.cpu ? dkBar(st.cpu, 'ok') : '<span class="mono">—</span>'}</span>
|
||
<span class="dk-metric">${st.mem_perc ? dkBar(st.mem_perc, 'info') : '<span class="mono">—</span>'}
|
||
<br><span class="mono" style="font-size:11px;color:var(--txt3)">${esc(st.mem || '')}</span></span>
|
||
<span class="mono" style="font-size:11.5px">${esc(st.net || '—')}<br>
|
||
<span style="color:var(--txt3)">${esc(st.block || '')}</span></span>
|
||
<span class="dk-acts">
|
||
${c.state === 'running'
|
||
? `<button class="ghost small" data-dk="stop" data-id="${esc(c.name)}">停止</button>
|
||
<button class="ghost small" data-dk="restart" data-id="${esc(c.name)}">重启</button>`
|
||
: `<button class="ghost small" data-dk="start" data-id="${esc(c.name)}">启动</button>`}
|
||
<button class="ghost small" data-dk="logs" data-id="${esc(c.name)}">日志</button>
|
||
<button class="ghost small" data-dk="inspect" data-id="${esc(c.name)}">详情</button>
|
||
${c.state !== 'running'
|
||
? `<button class="ghost small danger" data-dk="rm" data-id="${esc(c.name)}">删除</button>` : ''}
|
||
</span>
|
||
</div>`;
|
||
}
|
||
|
||
async function viewDocker() {
|
||
$('#view').innerHTML = `<div class="card"><h3>Docker 管理面板</h3>
|
||
<p class="desc">正在读取 Docker 引擎状态与容器列表…</p></div>`;
|
||
const r = await api('/api/docker');
|
||
if (!r.ok) {
|
||
$('#view').innerHTML = `<div class="card"><h3 style="color:var(--err)">读取失败</h3>
|
||
<p class="desc">${esc(r.msg_cn || '无法读取 Docker 状态')}</p></div>`;
|
||
return;
|
||
}
|
||
S.docker = r.data || {};
|
||
renderDocker();
|
||
}
|
||
|
||
function renderDocker() {
|
||
const D = S.docker || {};
|
||
const pk = D.pkgs || {};
|
||
const info = D.info || {};
|
||
const svc = D.services || {};
|
||
const conts = D.containers || [];
|
||
const imgs = D.images || [];
|
||
const nets = D.networks || [];
|
||
const vols = D.volumes || [];
|
||
const stacks = D.stacks || [];
|
||
const dOn = !!((pk.docker || {}).installed);
|
||
const cOn = !!((pk.compose || {}).installed);
|
||
const running = conts.filter(x => x.state === 'running').length;
|
||
const svcTags = Object.entries(svc).map(([n, st]) =>
|
||
`<span class="tag ${st === 'active' ? 'ok' : 'gray'}">${esc(n)} ${esc(st)}</span>`).join(' ');
|
||
|
||
const installBlock = (!dOn || !cOn) ? `
|
||
<div class="card">
|
||
<h3>安装 Docker</h3>
|
||
<p class="desc">检测到 Docker 或 Compose 尚未安装。执行下面的命令即可安装 Debian 13 官方源的版本:</p>
|
||
<p class="mono" style="background:var(--panel2);padding:10px;border-radius:8px">${esc(pk.install_cmd || 'apt-get install -y docker.io docker-compose')}</p>
|
||
<p class="hint-inline">安装后执行 <span class="mono">systemctl enable --now docker</span> 启动。drouter 不会自动安装,避免影响你现有环境。</p>
|
||
</div>` : '';
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>Docker 管理面板
|
||
<span class="tag ${running ? 'ok' : 'gray'}" style="float:right">容器 ${running} / ${conts.length} 运行中</span>
|
||
<span class="tag ${dOn ? 'ok' : 'warn'}" style="float:right;margin-right:6px">引擎 ${dOn ? '已安装' : '未安装'}</span>
|
||
<span class="tag ${cOn ? 'ok' : 'warn'}" style="float:right;margin-right:6px">Compose ${cOn ? '已安装' : '未安装'}</span></h3>
|
||
<p class="desc">${esc(D.note || '')}</p>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:0 18px">
|
||
<div class="kv"><b>服务状态</b><span>${svcTags || '—'}</span></div>
|
||
<div class="kv"><b>引擎版本</b><span class="mono">${esc(info.version || '—')}</span></div>
|
||
<div class="kv"><b>镜像 / 容器</b><span class="mono">${esc((info.images || '0') + ' 镜像 · ' + (info.containers || '0') + ' 容器')}</span></div>
|
||
<div class="kv"><b>存储驱动</b><span class="mono">${esc(info.driver || '—')}</span></div>
|
||
<div class="kv"><b>数据目录</b><span class="mono">${esc(info.root || '—')}</span></div>
|
||
<div class="kv"><b>Compose 命令</b><span class="mono">${esc(D.compose_cmd || 'docker compose')}</span></div>
|
||
</div>
|
||
${info.error ? `<p class="hint-inline" style="color:var(--warn)">提示:${esc(info.error)}</p>` : ''}
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="ghost small fixed" id="dk-refresh">刷新</button>
|
||
<button class="ghost small fixed" id="dk-prune-containers">清理已停止容器</button>
|
||
<button class="ghost small fixed" id="dk-prune-images">清理悬空镜像</button>
|
||
<button class="ghost small fixed" id="dk-prune-volumes">清理无用数据卷</button>
|
||
<button class="ghost small fixed" onclick="go('dcfg')">引擎配置 daemon.json ⚙</button>
|
||
</div>
|
||
</div>
|
||
|
||
${installBlock}
|
||
|
||
<div class="card">
|
||
<h3>容器
|
||
<span class="desc" style="float:right;margin:0">每 5 秒自动刷新资源占用</span></h3>
|
||
<p class="desc">CPU / 内存为 <span class="mono">docker stats</span> 的实时快照。</p>
|
||
<div id="dk-conts">${conts.length ? `<div class="dk-table">
|
||
<div class="dk-thead"><span>容器</span><span>CPU</span><span>内存</span><span>网络 / 磁盘 IO</span><span>操作</span></div>
|
||
${conts.map(dkContainerRow).join('')}</div>`
|
||
: '<p class="hint-inline">当前没有容器。可在下方 Compose 项目中创建,或先在 Web 终端里运行 docker run。</p>'}</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>镜像(${imgs.length})</h3>
|
||
${imgs.length ? `
|
||
<div class="dk-table">
|
||
<div class="dk-thead"><span>仓库:标签</span><span>镜像 ID</span><span>大小</span><span>创建时间</span></div>
|
||
${imgs.map(i => `<div class="dk-trow">
|
||
<span class="mono">${esc(i.repo)}:${esc(i.tag)}</span>
|
||
<span class="mono">${esc((i.id || '').slice(0, 12))}</span>
|
||
<span class="mono">${esc(i.size)}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(String(i.created || '').replace(' +0800 CST', ''))}</span>
|
||
</div>`).join('')}
|
||
</div>` : '<p class="hint-inline">暂无镜像。</p>'}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>网络与数据卷</h3>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 22px">
|
||
<div>
|
||
<h4 style="margin:0 0 6px;font-size:13px">网络(${nets.length})</h4>
|
||
${nets.length ? nets.map(n => `<div class="kv"><b class="mono">${esc(n.name)}</b>
|
||
<span class="mono">${esc(n.driver)}${n.scope ? ' · ' + esc(n.scope) : ''}</span></div>`).join('')
|
||
: '<p class="hint-inline">暂无网络</p>'}
|
||
</div>
|
||
<div>
|
||
<h4 style="margin:0 0 6px;font-size:13px">数据卷(${vols.length})</h4>
|
||
${vols.length ? vols.map(v => `<div class="kv"><b class="mono">${esc(v.name)}</b>
|
||
<span class="mono" style="font-size:11px">${esc(v.driver)}</span></div>`).join('')
|
||
: '<p class="hint-inline">暂无数据卷</p>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Compose 项目(${stacks.length})</h3>
|
||
<p class="desc">项目统一放在 <span class="mono">${esc(D.stack_dir || '/etc/drouter/docker/stacks')}</span> 下,
|
||
每个项目一个子目录,可一键启动 / 停止 / 重建。</p>
|
||
<div id="dk-stacks">${dkStacksHTML(stacks)}</div>
|
||
<div class="row" style="margin-top:12px">
|
||
<label style="flex:1 1 220px">项目名(小写字母/数字/-/_)<input id="dk-st-name" placeholder="my-app"></label>
|
||
<button class="ghost small fixed" id="dk-st-new">新建 / 编辑</button>
|
||
<button class="ghost small fixed" id="dk-st-load">载入已有</button>
|
||
</div>
|
||
<label style="display:block;margin-top:10px">docker-compose.yml 内容
|
||
<textarea id="dk-st-yaml" rows="12" spellcheck="false"
|
||
style="font-family:ui-monospace,Consolas,monospace;font-size:12.5px"
|
||
placeholder="services: web: image: nginx:alpine ports: - "8080:80""></textarea></label>
|
||
<div class="row" style="margin-top:8px">
|
||
<button class="primary small fixed" id="dk-st-save">保存项目</button>
|
||
<button class="ghost small fixed" id="dk-st-up">启动(up -d)</button>
|
||
<button class="ghost small fixed" id="dk-st-down">停止(down)</button>
|
||
<button class="ghost small fixed" id="dk-st-restart">重启</button>
|
||
<button class="ghost small fixed" id="dk-st-pull">拉取镜像</button>
|
||
<button class="ghost small fixed" id="dk-st-logs">查看状态</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>docker run → docker-compose.yml 转换</h3>
|
||
<p class="desc">把任意 <span class="mono">docker run</span> 命令粘贴进来,自动生成等价的
|
||
compose 文件(本地解析,不联网、不执行命令)。支持多行续行、引号、新版空格写法。</p>
|
||
<label style="display:block">docker run 命令
|
||
<textarea id="dk-cv-in" rows="7" spellcheck="false"
|
||
style="font-family:ui-monospace,Consolas,monospace;font-size:12.5px"
|
||
placeholder="docker run -d --name web -p 8080:80 -v /data:/usr/share/nginx/html nginx:alpine"></textarea></label>
|
||
<div class="row" style="margin-top:8px;align-items:flex-end">
|
||
<label>服务名(可留空自动取)<input id="dk-cv-name" placeholder="web"></label>
|
||
<label>版本号(留空=现代格式)<input id="dk-cv-ver" placeholder="留空推荐"></label>
|
||
<button class="primary small fixed" id="dk-cv-go">转换</button>
|
||
<button class="ghost small fixed" id="dk-cv-tostack">转为项目并填入上方</button>
|
||
</div>
|
||
<div id="dk-cv-msg"></div>
|
||
<div id="dk-cv-out"></div>
|
||
</div>
|
||
|
||
<div class="card" id="dk-out-card" style="display:none">
|
||
<h3 id="dk-out-title">输出</h3>
|
||
<pre id="dk-out" class="mono" style="max-height:460px;overflow:auto;background:var(--panel2);
|
||
padding:12px;border-radius:8px;font-size:12px;line-height:1.6;white-space:pre-wrap"></pre>
|
||
</div>
|
||
`;
|
||
dkBind();
|
||
dkAutoRefresh();
|
||
}
|
||
|
||
function dkStacksHTML(stacks) {
|
||
if (!stacks.length) return '<p class="hint-inline">还没有项目。用下面的编辑框创建一个。</p>';
|
||
return stacks.map(s => `<div class="dk-trow">
|
||
<span><b class="mono">${esc(s.name)}</b>
|
||
<br><span class="mono" style="font-size:11px;color:var(--txt3)">${esc(s.file)} · ${esc(s.mtime)}</span></span>
|
||
<span class="dk-acts">
|
||
<button class="ghost small" data-dkst="load" data-name="${esc(s.name)}">编辑</button>
|
||
<button class="ghost small" data-dkst="up" data-name="${esc(s.name)}">启动</button>
|
||
<button class="ghost small" data-dkst="down" data-name="${esc(s.name)}">停止</button>
|
||
<button class="ghost small danger" data-dkst="delete" data-name="${esc(s.name)}">删除</button>
|
||
</span></div>`).join('');
|
||
}
|
||
|
||
let DK_TIMER = null;
|
||
function dkAutoRefresh() {
|
||
if (DK_TIMER) clearInterval(DK_TIMER);
|
||
DK_TIMER = setInterval(async () => {
|
||
if (S.page !== 'docker') { clearInterval(DK_TIMER); DK_TIMER = null; return; }
|
||
const r = await api('/api/docker');
|
||
if (!r.ok) return;
|
||
S.docker = r.data || {};
|
||
// 只更新容器区,避免打断用户正在输入的文本框
|
||
const box = $('#dk-conts');
|
||
if (!box) return;
|
||
const conts = (S.docker || {}).containers || [];
|
||
box.innerHTML = conts.length ? `<div class="dk-table">
|
||
<div class="dk-thead"><span>容器</span><span>CPU</span><span>内存</span><span>网络 / 磁盘 IO</span><span>操作</span></div>
|
||
${conts.map(dkContainerRow).join('')}</div>`
|
||
: '<p class="hint-inline">当前没有容器。</p>';
|
||
dkBindRows();
|
||
}, 5000);
|
||
}
|
||
|
||
function dkShow(title, text) {
|
||
const c = $('#dk-out-card');
|
||
if (!c) return;
|
||
$('#dk-out-title').textContent = title;
|
||
$('#dk-out').textContent = text || '(无输出)';
|
||
c.style.display = '';
|
||
c.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
|
||
async function dkOp(body, okMsg) {
|
||
const r = await api('/api/docker', { method: 'POST', body });
|
||
toast(r.msg_cn || (r.ok ? '完成' : '失败'), r.ok ? 'ok' : 'err', 8000);
|
||
return r;
|
||
}
|
||
|
||
async function dkReload() {
|
||
const r = await api('/api/docker');
|
||
if (r.ok) { S.docker = r.data || {}; renderDocker(); }
|
||
}
|
||
|
||
function dkBindRows() {
|
||
$$('#dk-conts [data-dk]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const op = b.dataset.dk, id = b.dataset.id;
|
||
if (op === 'logs') {
|
||
const r = await api('/api/docker', { method: 'POST', body: { op: 'logs', id, tail: 300 } });
|
||
dkShow('容器日志 · ' + id, r.ok ? (r.data || {}).text : (r.msg_cn || '读取失败'));
|
||
toast(r.msg_cn || '', r.ok ? 'ok' : 'err'); return;
|
||
}
|
||
if (op === 'inspect') {
|
||
const r = await api('/api/docker', { method: 'POST', body: { op: 'inspect', id } });
|
||
dkShow('容器详情 · ' + id, r.ok ? (r.data || {}).text : (r.msg_cn || '读取失败'));
|
||
toast(r.msg_cn || '', r.ok ? 'ok' : 'err'); return;
|
||
}
|
||
if (op === 'rm') {
|
||
modal('删除容器', `<p>将删除容器 <b class="mono">${esc(id)}</b>。若容器未运行才会成功,
|
||
数据卷不会被删除。</p><p>确认继续?</p>`,
|
||
async () => { await dkOp({ op: 'rm', id }, ''); dkReload(); }, '确认删除');
|
||
return;
|
||
}
|
||
const cn = { start: '启动', stop: '停止', restart: '重启' }[op] || op;
|
||
if (op === 'stop') {
|
||
modal('停止容器', `<p>将停止容器 <b class="mono">${esc(id)}</b>,其提供的服务会中断。</p><p>确认继续?</p>`,
|
||
async () => { await dkOp({ op, id }, ''); dkReload(); }, '确认停止');
|
||
return;
|
||
}
|
||
const r = await dkOp({ op, id }, cn);
|
||
if (r.ok) dkReload();
|
||
};
|
||
});
|
||
}
|
||
|
||
function dkBind() {
|
||
dkBindRows();
|
||
const on = (sel, fn) => { const e = $(sel); if (e) e.onclick = fn; };
|
||
|
||
on('#dk-refresh', () => dkReload());
|
||
on('#dk-prune-containers', () => modal('清理已停止容器',
|
||
'<p>会删除所有已停止的容器。不会影响正在运行的容器与镜像。</p><p>确认继续?</p>',
|
||
async () => { await dkOp({ op: 'prune', what: 'containers' }); dkReload(); }, '清理'));
|
||
on('#dk-prune-images', () => modal('清理悬空镜像',
|
||
'<p>会删除未被任何容器引用的悬空镜像(dangling)。</p><p>确认继续?</p>',
|
||
async () => { await dkOp({ op: 'prune', what: 'images' }); dkReload(); }, '清理'));
|
||
on('#dk-prune-volumes', () => modal('清理无用数据卷',
|
||
'<p>会删除没有被容器使用的数据卷,<b>其中的数据会永久丢失</b>。</p><p>确认继续?</p>',
|
||
async () => { await dkOp({ op: 'prune', what: 'volumes' }); dkReload(); }, '确认清理'));
|
||
|
||
$$('#dk-stacks [data-dkst]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const act = b.dataset.dkst, name = b.dataset.name;
|
||
const mod = S.page;
|
||
if (act === 'load') {
|
||
const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_get', name } });
|
||
if (!r.ok) { toast(r.msg_cn, 'err'); return; }
|
||
$('#dk-st-name').value = name;
|
||
$('#dk-st-yaml').value = (r.data || {}).content || '';
|
||
toast('已载入项目 ' + name, 'ok');
|
||
return;
|
||
}
|
||
if (act === 'delete') {
|
||
modal('删除项目', `<p>会删除项目 <b class="mono">${esc(name)}</b> 的配置文件目录。
|
||
正在运行的容器请先「停止」。</p><p>请输入项目名以确认删除:</p>
|
||
<input id="dk-del-name" placeholder="${esc(name)}" style="width:100%;margin-top:6px">`,
|
||
async () => {
|
||
const inp = $('#dk-del-name');
|
||
const v = inp ? inp.value.trim() : '';
|
||
await dkOp({ op: 'stack_delete', name, confirm: v });
|
||
dkReload();
|
||
}, '确认删除');
|
||
return;
|
||
}
|
||
const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_' + act, name } });
|
||
if (r.ok) {
|
||
dkShow(`项目 ${name} · ${act}`, (r.data || {}).text || r.msg_cn);
|
||
}
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
dkReload();
|
||
void mod;
|
||
};
|
||
});
|
||
|
||
const stackName = () => ($('#dk-st-name').value || '').trim();
|
||
const stackYaml = () => $('#dk-st-yaml').value || '';
|
||
|
||
on('#dk-st-new', () => { $('#dk-st-name').value = ''; $('#dk-st-yaml').value = ''; });
|
||
on('#dk-st-save', async () => {
|
||
const r = await dkOp({ op: 'stack_save', name: stackName(), content: stackYaml() });
|
||
if (r.ok) dkReload();
|
||
});
|
||
['up', 'down', 'restart', 'pull'].forEach(k => {
|
||
on('#dk-st-' + k, async () => {
|
||
const n = stackName();
|
||
if (!n) { toast('请先填写项目名', 'warn'); return; }
|
||
const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_' + k, name: n } });
|
||
if (r.ok) dkShow(`项目 ${n} · ${k}`, (r.data || {}).text || r.msg_cn);
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
dkReload();
|
||
});
|
||
});
|
||
on('#dk-st-logs', async () => {
|
||
const n = stackName();
|
||
if (!n) { toast('请先填写项目名', 'warn'); return; }
|
||
const r = await api('/api/docker', { method: 'POST', body: { op: 'stack_ps', name: n } });
|
||
dkShow(`项目 ${n} 状态`, r.ok ? ((r.data || {}).text || '(无容器)') : r.msg_cn);
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
});
|
||
|
||
const doConvert = async (toStack) => {
|
||
const cmd = $('#dk-cv-in').value || '';
|
||
const box = $('#dk-cv-msg'); const out = $('#dk-cv-out');
|
||
if (!cmd.trim()) { toast('请输入 docker run 命令', 'warn'); return null; }
|
||
const r = await api('/api/docker', { method: 'POST', body: {
|
||
op: 'convert', cmd, service: $('#dk-cv-name').value.trim(),
|
||
version: $('#dk-cv-ver').value.trim() } });
|
||
const d = r.data || {};
|
||
if (!r.ok || !d.ok) {
|
||
box.innerHTML = `<p class="hint-inline" style="color:var(--err)">${esc(d.msg_cn || r.msg_cn || '转换失败')}</p>`;
|
||
out.innerHTML = '';
|
||
toast(d.msg_cn || r.msg_cn || '转换失败', 'err');
|
||
return null;
|
||
}
|
||
const warn = (d.warnings || []).length
|
||
? `<p class="hint-inline" style="color:var(--warn)">提示:${d.warnings.map(esc).join(';')}</p>` : '';
|
||
box.innerHTML = `<p class="hint-inline" style="color:var(--ok)">${esc(d.msg_cn || '解析成功')}
|
||
· 镜像 <span class="mono">${esc(d.image)}</span> · 服务名 <span class="mono">${esc(d.service)}</span></p>` + warn;
|
||
out.innerHTML = `<div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:6px">
|
||
<button class="ghost small" id="dk-cv-copy">复制 YAML</button>
|
||
${toStack ? '<button class="ghost small" id="dk-cv-fill">填入上方项目</button>' : ''}</div>
|
||
<pre class="mono" style="max-height:360px;overflow:auto;background:var(--panel2);padding:12px;
|
||
border-radius:8px;font-size:12px;line-height:1.6;white-space:pre-wrap">${esc(d.yaml || '')}</pre>`;
|
||
const cp = $('#dk-cv-copy');
|
||
if (cp) cp.onclick = () => {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = d.yaml || '';
|
||
document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand('copy'); toast('已复制到剪贴板', 'ok'); }
|
||
catch (e) { toast('复制失败,请手动选中', 'warn'); }
|
||
document.body.removeChild(ta);
|
||
};
|
||
const fl = $('#dk-cv-fill');
|
||
if (fl) fl.onclick = () => {
|
||
$('#dk-st-name').value = d.service || '';
|
||
$('#dk-st-yaml').value = d.yaml || '';
|
||
toast('已填入下方 Compose 项目编辑器', 'ok');
|
||
const el = $('#dk-st-yaml');
|
||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
};
|
||
return d;
|
||
};
|
||
on('#dk-cv-go', () => doConvert(false));
|
||
on('#dk-cv-tostack', () => doConvert(true));
|
||
}
|
||
|
||
/* ============ Docker 引擎配置 daemon.json(#5) ============
|
||
页面只做三件事:把表单攒成一个 op=preview 发给后端拿真实代码、把代码显示出来、
|
||
把代码写进 /etc/docker/daemon.json。预览一律以后端为准 —— 前端再抄一份合并
|
||
逻辑迟早会和后端不一致,用户看到的代码和写进去的代码必须对得上。 */
|
||
let DCFG = null;
|
||
let DCFG_PV_TIMER = null;
|
||
const DCFG_BAK_KEEP_TXT = '5';
|
||
|
||
function dcfgForm(op) {
|
||
const q = id => document.getElementById(id);
|
||
const mirrors = [];
|
||
$$('#dc-mirrors [data-dcm]').forEach(cb => { if (cb.checked) mirrors.push(cb.dataset.dcm); });
|
||
return {
|
||
op: op || 'preview',
|
||
ipv6: q('dc-v6-on') ? !!q('dc-v6-on').checked : false,
|
||
fixed_cidr_v6: q('dc-v6-cidr') ? q('dc-v6-cidr').value.trim() : '',
|
||
experimental: q('dc-v6-exp') ? !!q('dc-v6-exp').checked : false,
|
||
mirrors: mirrors,
|
||
mirror_custom: q('dc-mirror-custom') ? q('dc-mirror-custom').value.trim() : '',
|
||
log_rotate: q('dc-log-on') ? !!q('dc-log-on').checked : false,
|
||
log_max_size: q('dc-log-size') ? q('dc-log-size').value.trim() : '10m',
|
||
log_max_file: q('dc-log-file') ? Number(q('dc-log-file').value) : 3,
|
||
live_restore: q('dc-live') ? !!q('dc-live').checked : false,
|
||
bip: q('dc-bip') ? q('dc-bip').value.trim() : '',
|
||
};
|
||
}
|
||
|
||
async function dcfgPreview() {
|
||
const box = document.getElementById('dc-code-pre');
|
||
if (!box) return;
|
||
const r = await api('/api/dcfg', { method: 'POST', body: dcfgForm('preview') });
|
||
if (!r.ok) { box.textContent = '(预览失败:' + (r.msg_cn || '未知错误') + ')'; return; }
|
||
const d = r.data || {};
|
||
box.textContent = d.preview || '';
|
||
const w = document.getElementById('dc-code-warn');
|
||
if (w) {
|
||
const errs = d.errs || [], warns = d.warns || [];
|
||
w.innerHTML = errs.length
|
||
? `<p class="hint-inline" style="color:var(--err)">✗ ${errs.map(esc).join(';')}(这样保存会被拒绝)</p>`
|
||
: (warns.length
|
||
? `<p class="hint-inline" style="color:var(--warn)">⚠ ${warns.map(esc).join(';')}</p>` : '');
|
||
}
|
||
}
|
||
|
||
function dcfgSchedulePreview() {
|
||
clearTimeout(DCFG_PV_TIMER);
|
||
DCFG_PV_TIMER = setTimeout(dcfgPreview, 250);
|
||
}
|
||
|
||
async function viewDcfg() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>Docker 引擎配置(daemon.json)
|
||
<button class="ghost small" id="dc-reload" style="float:right">重新读取</button></h3>
|
||
<p class="desc">Docker 的所有引擎级设置都写在
|
||
<span class="mono">/etc/docker/daemon.json</span> 里。这一页把它变成可勾选的表单:
|
||
<b>IPv6 一键开关</b>、<b>国内镜像源切换与测速</b>,顺带把容器日志滚动、
|
||
live-restore、默认网桥网段也纳管进来。</p>
|
||
<div id="dc-state"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>IPv6 一键开关</h3>
|
||
<div id="dc-v6"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>镜像源(国内加速)</h3>
|
||
<div id="dc-mirror"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>日志滚动与其他</h3>
|
||
<div id="dc-misc"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>配置代码(就是将要写入的 daemon.json)
|
||
<button class="ghost small" id="dc-copy" style="float:right">复制</button></h3>
|
||
<p class="desc">下面这段代码随上面的选项实时变化,和点「保存」后真正落盘的内容
|
||
<b>逐字节一致</b>。你在文件里自己写的、本页不管的键(例如
|
||
<span class="mono">data-root</span>)会原样保留在最后。</p>
|
||
<div id="dc-code-warn"></div>
|
||
<pre class="mono" id="dc-code-pre" style="max-height:420px;overflow:auto;background:var(--panel2);
|
||
padding:12px;border-radius:8px;font-size:12px;line-height:1.6;white-space:pre-wrap">(读取中…)</pre>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="primary" id="dc-save">保存到 daemon.json</button>
|
||
<button class="ghost" id="dc-save-restart">保存并重启 Docker</button>
|
||
</div>
|
||
<div id="dc-bak" style="margin-top:12px"></div>
|
||
<div id="dc-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>`;
|
||
$('#dc-reload').onclick = dcfgLoad;
|
||
$('#dc-save').onclick = () => dcfgSave(false);
|
||
$('#dc-save-restart').onclick = () => dcfgSave(true);
|
||
$('#dc-copy').onclick = () => {
|
||
const t = ($('#dc-code-pre') || {}).textContent || '';
|
||
const ta = document.createElement('textarea');
|
||
ta.value = t; document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand('copy'); toast('已复制到剪贴板', 'ok'); }
|
||
catch (e) { toast('复制失败,请手动选中', 'warn'); }
|
||
document.body.removeChild(ta);
|
||
};
|
||
dcfgLoad();
|
||
}
|
||
|
||
async function dcfgLoad() {
|
||
const r = await api('/api/dcfg', { method: 'POST', body: { op: 'status' } });
|
||
if (!r.ok) {
|
||
$('#dc-state').innerHTML = `<div class="notice err">${esc(r.msg_cn || '读取失败')}</div>`;
|
||
return;
|
||
}
|
||
DCFG = r.data;
|
||
dcfgRender();
|
||
dcfgPreview();
|
||
}
|
||
|
||
function dcfgRender() {
|
||
const d = DCFG || {};
|
||
const c = d.cfg || {};
|
||
const inst = !!d.installed;
|
||
const on = d.active === 'active';
|
||
|
||
// ---------- 状态 ----------
|
||
$('#dc-state').innerHTML = `
|
||
${inst ? '' : `<div class="notice warn" style="margin-bottom:10px">
|
||
这台机器上<b>还没有安装 Docker</b>(<span class="mono">docker</span> 命令不存在)。
|
||
这不影响你在这里预先把配置写好 —— 文件会照常保存到
|
||
<span class="mono">/etc/docker/daemon.json</span>,将来装好 Docker 一启动就直接生效。
|
||
要现在装,请去「系统 → 依赖自检与安装」或直接
|
||
<span class="mono">apt-get install -y docker.io docker-compose</span>。</div>`}
|
||
${d.parse_error ? `<div class="notice err" style="margin-bottom:10px">
|
||
现有的 daemon.json 无法解析:${esc(d.parse_error)}<br>
|
||
为安全起见本页<b>不会覆盖它</b>,请先手工修正或把文件挪走。</div>` : ''}
|
||
${(d.unmanaged || []).length ? `<div class="notice warn" style="margin-bottom:10px">
|
||
现有文件里有 <b>${(d.unmanaged || []).length}</b> 个本页不管的键:
|
||
<span class="mono">${(d.unmanaged || []).map(esc).join('、')}</span>。
|
||
保存时它们会<b>原样保留</b>,不会被清掉。</div>` : ''}
|
||
${d.build_mode ? '<div class="notice warn" style="margin-bottom:10px">当前处于<b>构建保护模式</b>:可以写配置文件,但不会重启 Docker。</div>' : ''}
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:0 18px">
|
||
<div class="kv"><b>Docker 引擎</b><span>
|
||
<span class="tag ${inst ? 'ok' : 'warn'}">${inst ? '已安装' : '未安装'}</span>
|
||
<span class="tag ${on ? 'ok' : 'gray'}" style="margin-left:6px">${on ? '运行中' : esc(d.active || '—')}</span></span></div>
|
||
<div class="kv"><b>版本</b><span class="mono">${esc(d.version || '—')}</span></div>
|
||
<div class="kv"><b>开机自启</b><span class="mono">${esc(d.enabled || '—')}</span></div>
|
||
<div class="kv"><b>配置文件</b><span class="mono">${esc(d.file || '—')}</span></div>
|
||
<div class="kv"><b>文件是否存在</b><span class="mono">${d.exists ? '存在' : '不存在(保存时创建)'}</span></div>
|
||
</div>`;
|
||
|
||
// ---------- IPv6 ----------
|
||
const v6on = !!c.ipv6;
|
||
$('#dc-v6').innerHTML = `
|
||
<div class="dep-item">
|
||
<span class="dep-ico" style="background:var(--info)">6</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>让容器网络支持 IPv6</b>
|
||
<div class="desc" style="margin:6px 0 0">打开后 Docker 会给默认网桥分配一个 IPv6 网段,
|
||
容器就能拿到 v6 地址、也能用 v6 互相访问。默认<b>关闭</b> —— 因为开了不等于能上 v6 外网,
|
||
还得上游路由器配合。</div>
|
||
<div style="margin:8px 0 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
<label class="switch"><input type="checkbox" id="dc-v6-on"${v6on ? ' checked' : ''}><i></i></label>
|
||
<span class="tag ${v6on ? 'ok' : 'gray'}">${v6on ? '开启' : '关闭'}</span>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label style="flex:1 1 260px">容器 IPv6 网段(fixed-cidr-v6)
|
||
<input id="dc-v6-cidr" value="${esc(c.fixed_cidr_v6 || '')}"
|
||
placeholder="留空=保存时自动生成一个 fd00::/8 私有段"></label>
|
||
<button class="ghost small" id="dc-v6-ula">随机生成一个私有段</button>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<label class="switch"><input type="checkbox" id="dc-v6-exp"${c.experimental ? ' checked' : ''}><i></i>
|
||
<span>同时写入 <span class="mono">"experimental": true</span></span></label>
|
||
</div>
|
||
<details style="margin-top:8px">
|
||
<summary class="desc" style="cursor:pointer;color:var(--pri)">打开/关闭会带来什么</summary>
|
||
<ul class="desc" style="margin:6px 0 0;padding-left:20px;line-height:1.85">
|
||
<li>会同时写入 <span class="mono">ip6tables: true</span> —— 没有它 Docker 不会为容器
|
||
生成 IPv6 的防火墙/NAT 规则,等于白开</li>
|
||
<li>这里默认用 <span class="mono">fd00::/8</span> 私有段(ULA)。
|
||
容器拿到私有 v6 后,<b>能互通但上不了 v6 外网</b>,除非你在防火墙 IPv6 页
|
||
做了 NAT66 或上游下发了前缀委派并配好路由</li>
|
||
<li>用公网段(2408: / 2001: 这类)时,还需要上游路由器做 NDP 代理,
|
||
否则包出得去回不来,家用环境多半搞不定</li>
|
||
<li><span class="mono">experimental</span> 只有 Docker 24 / 25 需要它来解锁
|
||
ip6tables;26 起 ip6tables 已转正。装的是 26 及以上就别勾,
|
||
个别版本见到这一项会直接拒绝启动</li>
|
||
<li>改完必须<b>重启 Docker</b> 才生效,而且已有的容器要重建才会拿到 v6 地址</li>
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ---------- 镜像源 ----------
|
||
const sel = c.mirrors || [];
|
||
$('#dc-mirror').innerHTML = `
|
||
<p class="desc">镜像源就是「Docker Hub 的国内代理」:拉镜像时先问它要,能省下大量等待。
|
||
可以同时勾多个 —— Docker 会<b>按顺序</b>试,前一个失败就换下一个。</p>
|
||
<div id="dc-mirrors" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:2px 18px">
|
||
${(d.mirrors || []).map(m => {
|
||
const isOn = sel.indexOf(m.key) >= 0;
|
||
const ph = (m.url || '').indexOf('<') >= 0;
|
||
return `<label class="switch" style="padding:6px 0">
|
||
<input type="checkbox" data-dcm="${esc(m.key)}"${isOn && !ph ? ' checked' : ''}${ph ? ' disabled' : ''}><i></i>
|
||
<span>${esc(m.name)}${ph ? '(占位,请用下面的自定义源)' : ''}</span></label>
|
||
<div class="desc" style="margin:-2px 0 6px;font-size:12px">${esc(m.note || '')}</div>`;
|
||
}).join('')}
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label style="flex:1 1 320px">自定义镜像源
|
||
<input id="dc-mirror-custom" value="${esc(c.mirror_custom || '')}"
|
||
placeholder="https://xxxx.mirror.aliyuncs.com"></label>
|
||
<button class="ghost small" id="dc-mt">一键测速</button>
|
||
</div>
|
||
<p class="desc">测速从<b>这台路由器自己</b>发起,反映的是 Docker 拉镜像时的真实链路
|
||
(已加 <span class="mono">--noproxy</span>,不会被环境里的代理掩盖)。
|
||
HTTP <span class="mono">200</span> 或 <span class="mono">401</span> 都表示通 ——
|
||
Registry 的探针本来就要求带凭证,回 401 是正常应答。</p>
|
||
<div id="dc-mt-out" style="margin-top:8px"></div>`;
|
||
|
||
// ---------- 其他 ----------
|
||
$('#dc-misc').innerHTML = `
|
||
<div class="dep-item">
|
||
<span class="dep-ico" style="background:var(--info)">L</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>容器日志滚动(强烈建议开)</b>
|
||
<div class="desc" style="margin:6px 0 0">限制每个容器日志文件的大小和份数。
|
||
不开的话,一个死循环打印日志的容器能在几小时内把小硬盘写满 ——
|
||
这是「路由器磁盘莫名爆满」最常见的原因。</div>
|
||
<div style="margin:8px 0 0;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||
<label class="switch"><input type="checkbox" id="dc-log-on"${c.log_rotate === false ? '' : ' checked'}><i></i></label>
|
||
<label style="flex:0 0 130px">单文件上限<input id="dc-log-size" value="${esc(c.log_max_size || '10m')}" placeholder="10m"></label>
|
||
<label style="flex:0 0 110px">保留份数<input id="dc-log-file" type="number" min="1" max="20" value="${Number(c.log_max_file || 3)}"></label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="dep-item" style="margin-top:10px">
|
||
<span class="dep-ico" style="background:var(--info)">R</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>live-restore(重启 dockerd 时保住容器)</b>
|
||
<div class="desc" style="margin:6px 0 0">开启后,重启 / 升级 Docker 守护进程时
|
||
<b>正在运行的容器不会被一起杀掉</b>。对一台跑着常驻服务的路由器来说很值。</div>
|
||
<div style="margin:8px 0 0">
|
||
<label class="switch"><input type="checkbox" id="dc-live"${c.live_restore === false ? '' : ' checked'}><i></i></label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="dep-item" style="margin-top:10px">
|
||
<span class="dep-ico" style="background:var(--info)">B</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>默认网桥网段(bip)</b>
|
||
<div class="desc" style="margin:6px 0 0">Docker 默认用 <span class="mono">172.17.0.0/16</span>。
|
||
如果你的局域网或 VPN 正好也是这个段,容器网络就会和它打架,
|
||
表现为「装了 Docker 之后某些内网地址访问不了」。留空表示不动,用 Docker 默认值。</div>
|
||
<div style="margin:8px 0 0">
|
||
<label style="flex:1 1 260px"><input id="dc-bip" value="${esc(c.bip || '')}" placeholder="留空=用 Docker 默认 172.17.0.1/16"></label>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ---------- 备份还原 ----------
|
||
const bks = d.backups || [];
|
||
$('#dc-bak').innerHTML = `
|
||
<div class="row" style="align-items:center">
|
||
<span class="desc" style="margin:0">每次保存都会自动备份旧文件(保留最近 ${DCFG_BAK_KEEP_TXT} 份,
|
||
目录 <span class="mono">${esc(d.bak_dir || '')}</span>)</span>
|
||
${bks.length ? `<select id="dc-bak-sel" style="width:auto">
|
||
${bks.map(b => `<option value="${esc(b.file)}">${esc(b.mtime)} · ${esc(fmtBytes(b.size))}</option>`).join('')}
|
||
</select>
|
||
<button class="ghost small" id="dc-bak-go">还原这份备份</button>` : '<span class="desc">(暂无备份)</span>'}
|
||
</div>`;
|
||
|
||
dcfgBind();
|
||
const out = $('#dc-out');
|
||
if (out) { out.classList.add('hidden'); out.innerHTML = ''; }
|
||
}
|
||
|
||
function dcfgBind() {
|
||
['dc-v6-on', 'dc-v6-exp', 'dc-log-on', 'dc-live'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('change', dcfgSchedulePreview);
|
||
});
|
||
['dc-v6-cidr', 'dc-mirror-custom', 'dc-log-size', 'dc-log-file', 'dc-bip'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', dcfgSchedulePreview);
|
||
});
|
||
$$('#dc-mirrors [data-dcm]').forEach(cb => {
|
||
cb.addEventListener('change', dcfgSchedulePreview);
|
||
});
|
||
const ula = document.getElementById('dc-v6-ula');
|
||
if (ula) ula.onclick = async () => {
|
||
const r = await api('/api/dcfg', { method: 'POST', body: { op: 'ula' } });
|
||
if (!r.ok) { toast(r.msg_cn || '生成失败', 'err'); return; }
|
||
const el = document.getElementById('dc-v6-cidr');
|
||
if (el) { el.value = (r.data || {}).ula || ''; dcfgPreview(); }
|
||
};
|
||
const mt = document.getElementById('dc-mt');
|
||
if (mt) mt.onclick = async () => {
|
||
const box = $('#dc-mt-out');
|
||
box.innerHTML = '<p class="desc">正在逐个探测,最多约一分钟…</p>';
|
||
const body = dcfgForm('mirror_test');
|
||
const r = await api('/api/dcfg', { method: 'POST', body });
|
||
if (!r.ok) { box.innerHTML = `<p class="hint-inline" style="color:var(--err)">${esc(r.msg_cn)}</p>`; return; }
|
||
const rows = ((r.data || {}).results) || [];
|
||
box.innerHTML = `<div class="dk-table">
|
||
<div class="dk-thead"><span>镜像源</span><span>结果</span><span>耗时</span></div>
|
||
${rows.map(t => `<div class="dk-trow">
|
||
<span><b>${esc(t.name)}</b><br><span class="mono" style="font-size:11px;color:var(--txt3)">${esc(t.url)}</span></span>
|
||
<span class="tag ${t.ok ? 'ok' : (t.skipped ? 'gray' : 'err')}">${esc(t.reason || (t.ok ? '可用' : '不可用'))}
|
||
${t.code ? ' · ' + esc(t.code) : ''}</span>
|
||
<span class="mono" style="font-size:11.5px">${t.ms ? esc(t.ms + ' ms') : '—'}</span>
|
||
</div>`).join('')}</div>
|
||
<p class="desc">先把最快的那个勾上(可以多选几个当备用),再回上面点保存。</p>`;
|
||
};
|
||
const bg = document.getElementById('dc-bak-go');
|
||
if (bg) bg.onclick = () => {
|
||
const sel = document.getElementById('dc-bak-sel');
|
||
const f = sel ? sel.value : '';
|
||
if (!f) { toast('请先选择一份备份', 'warn'); return; }
|
||
modal('还原 daemon.json', `<p>会用备份 <span class="mono">${esc(f)}</span> 覆盖当前的
|
||
<span class="mono">/etc/docker/daemon.json</span>。</p>
|
||
<p>当前文件会先另存一份新备份,不会丢。</p><p>确认继续?</p>`,
|
||
async () => {
|
||
const r = await api('/api/dcfg', { method: 'POST', body: { op: 'restore', file: f } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
if (r.ok) dcfgLoad();
|
||
}, '确认还原');
|
||
};
|
||
}
|
||
|
||
async function dcfgSave(thenRestart) {
|
||
const r = await api('/api/dcfg', { method: 'POST', body: dcfgForm('save') });
|
||
const box = $('#dc-out');
|
||
box.classList.remove('hidden');
|
||
box.innerHTML = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>`;
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
if (!r.ok) return;
|
||
const d = r.data || {};
|
||
if (d.content) {
|
||
const pre = $('#dc-code-pre');
|
||
if (pre) pre.textContent = d.content; // 用服务端真实写入的内容校准预览
|
||
}
|
||
dcfgLoad();
|
||
if (thenRestart) dcfgAskRestart();
|
||
}
|
||
|
||
function dcfgAskRestart() {
|
||
const d = DCFG || {};
|
||
if (!d.installed) {
|
||
toast('这台机器上没有 Docker,无需重启。配置已保存,装好 Docker 后自动生效', 'warn', 8000);
|
||
return;
|
||
}
|
||
if (d.build_mode) {
|
||
toast('当前处于构建保护模式:配置已写入 /etc/docker/daemon.json,'
|
||
+ '但不会重启 Docker(避免影响正在运行的局域网与 RealVNC 会话)', 'warn', 9000);
|
||
return;
|
||
}
|
||
modal('重启 Docker 使配置生效', `<p>重启会<b>中断所有正在运行的容器</b>(通常几十秒内恢复),
|
||
并且会重建 <span class="mono">docker0</span> 网桥、重新插入 iptables / nftables 链。</p>
|
||
<p>对一台正在跑 easytier / cloudflared / AdGuardHome 这类常驻服务的路由器来说,
|
||
这意味着相关服务会有一次短暂中断。</p>
|
||
<p>如果容器没有设 <span class="mono">restart: unless-stopped</span>,
|
||
重启后需要你手工拉起来。</p><p>确认继续?</p>`,
|
||
async () => {
|
||
const r = await api('/api/dcfg', { method: 'POST', body: { op: 'restart', confirm: true } });
|
||
const box = $('#dc-out');
|
||
box.classList.remove('hidden');
|
||
box.innerHTML = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>`;
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) dcfgLoad();
|
||
}, '确认重启 Docker');
|
||
}
|
||
|
||
/* ============ 打印服务 CUPS / USB RAW 直通(#7) ============
|
||
两种模式抢的是同一个 USB 打印机,所以必须互斥。页面把「当前实际状态」和
|
||
「推荐值」分开显示:默认值只是建议,改不改由用户点保存决定。 */
|
||
let PRINT = null;
|
||
|
||
async function viewPrint() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>打印服务
|
||
<button class="ghost small" id="pt-reload" style="float:right">重新读取</button></h3>
|
||
<p class="desc">把这台路由器变成打印服务器,让局域网里的电脑、手机直接打。
|
||
两种模式<b>互斥二选一</b> —— 它们抢的是同一个 USB 打印机。</p>
|
||
<div id="pt-state"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>工作模式</h3>
|
||
<div id="pt-mode"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>CUPS 模式设置</h3>
|
||
<div id="pt-cups"><p class="desc">正在读取…</p></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="primary" id="pt-save">保存并生效</button>
|
||
</div>
|
||
<div id="pt-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>打印队列</h3>
|
||
<div id="pt-queues"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>添加队列</h3>
|
||
<div id="pt-add"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>USB 打印机与可接入设备</h3>
|
||
<div id="pt-usb"><p class="desc">正在读取…</p></div>
|
||
</div>`;
|
||
$('#pt-reload').onclick = printLoad;
|
||
$('#pt-save').onclick = printSave;
|
||
printLoad();
|
||
}
|
||
|
||
async function printLoad() {
|
||
const r = await api('/api/print', { method: 'POST', body: { op: 'status' } });
|
||
if (!r.ok) {
|
||
$('#pt-state').innerHTML = `<div class="notice err">${esc(r.msg_cn || '读取失败')}</div>`;
|
||
return;
|
||
}
|
||
PRINT = r.data;
|
||
printRender();
|
||
}
|
||
|
||
function printSvcTag(s) {
|
||
const on = (s || {}).active === 'active';
|
||
return `<span class="tag ${on ? 'ok' : 'gray'}">${on ? '运行中' : '已停止'}</span>
|
||
<span class="mono" style="font-size:11px;color:var(--txt3)">${esc((s || {}).enabled || '')}</span>`;
|
||
}
|
||
|
||
function printRender() {
|
||
const d = PRINT || {};
|
||
const cfg = d.cfg || {};
|
||
const c = cfg.cups || {};
|
||
const raw = cfg.raw || {};
|
||
const mode = cfg.mode || 'cups';
|
||
const inst = !!d.installed;
|
||
|
||
// ---------- 状态 ----------
|
||
$('#pt-state').innerHTML = `
|
||
${inst ? '' : `<div class="notice warn" style="margin-bottom:10px">
|
||
CUPS 尚未安装。请先到「系统 → 依赖自检与安装」安装,或执行
|
||
<span class="mono">apt-get install -y ${esc((d.need_install || []).join(' '))}</span>。</div>`}
|
||
${d.build_mode ? '<div class="notice warn" style="margin-bottom:10px">当前处于<b>构建保护模式</b>:保存只会写配置,不会启停打印服务。</div>' : ''}
|
||
${inst && (d.listen || '').indexOf('127.0.0.1') >= 0 && mode === 'cups'
|
||
? `<div class="notice warn" style="margin-bottom:10px">
|
||
CUPS 现在<b>只监听 127.0.0.1:631</b> —— 局域网里的设备连不上。
|
||
在下面把「允许局域网访问」打开并保存即可。</div>` : ''}
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:0 18px">
|
||
<div class="kv"><b>CUPS</b><span>${inst ? printSvcTag(d.cups) : '<span class="tag warn">未安装</span>'}</span></div>
|
||
<div class="kv"><b>cups-browsed</b><span>${printSvcTag(d.browsed)}</span></div>
|
||
<div class="kv"><b>RAW 直通</b><span>${printSvcTag(d.raw)}</span></div>
|
||
<div class="kv"><b>CUPS 版本</b><span class="mono">${esc(d.cups_version || '—')}</span></div>
|
||
<div class="kv"><b>631 实际监听</b><span class="mono">${esc(d.listen || '未监听')}</span></div>
|
||
<div class="kv"><b>avahi 发现服务</b><span>
|
||
<span class="tag ${d.avahi_active ? 'ok' : 'warn'}">${d.avahi_active ? '运行中' : '未运行'}</span></span></div>
|
||
<div class="kv"><b>管理页面</b><span class="mono">${esc(d.cups_url || '')}</span></div>
|
||
</div>`;
|
||
|
||
// ---------- 模式 ----------
|
||
const modes = [
|
||
{ k: 'cups', n: 'CUPS 打印服务器', d: '功能最全:免驱打印、AirPrint / Mopria、队列管理、Web 管理界面' },
|
||
{ k: 'raw', n: 'USB RAW 直通', d: '最省资源:把 USB 打印机原样映射成 TCP 9100,驱动全在客户端' },
|
||
{ k: 'off', n: '关闭打印服务', d: '停掉所有打印相关服务,631 / 9100 都不再监听' },
|
||
];
|
||
$('#pt-mode').innerHTML = `
|
||
<div class="dep-grid">
|
||
${modes.map(m => `<label class="dep-item" style="cursor:pointer">
|
||
<span class="dep-ico" style="background:${mode === m.k ? 'var(--ok)' : 'var(--txt3)'}">
|
||
${mode === m.k ? '✓' : '·'}</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>${esc(m.n)}</b>
|
||
<div class="desc" style="margin:4px 0 0">${esc(m.d)}</div>
|
||
<div style="margin:6px 0 0">
|
||
<input type="radio" name="pt-mode" value="${esc(m.k)}"${mode === m.k ? ' checked' : ''}>
|
||
<span class="desc" style="margin:0">${mode === m.k ? '当前模式' : '切换到这个模式'}</span>
|
||
</div>
|
||
</div></label>`).join('')}
|
||
</div>
|
||
${(d.usb_printers || []).length === 0 && mode === 'raw'
|
||
? `<div class="notice warn" style="margin-top:10px">当前<b>没有检测到 USB 打印机</b>,
|
||
RAW 直通模式没有设备可用。这是一台虚拟机 —— 需要先把 USB 打印机在 PVE 里
|
||
直通给这台虚拟机,插上之后设备节点才会出现。</div>` : ''}
|
||
<details style="margin-top:10px">
|
||
<summary class="desc" style="cursor:pointer;color:var(--pri)">两种模式到底差在哪</summary>
|
||
<ul class="desc" style="margin:6px 0 0;padding-left:20px;line-height:1.85">
|
||
<li><b>CUPS 模式</b>:手机和电脑<b>不用装驱动</b>,靠 IPP Everywhere / AirPrint /
|
||
Mopria 直接发现并打印;还能把网络上已有的打印机收进来再共享出去</li>
|
||
<li><b>RAW 直通模式</b>:路由器<b>完全不碰打印数据</b>,客户端装自己的原厂驱动。
|
||
老式打印机、驱动装不上的机型用它最省心,也最省路由器资源</li>
|
||
<li>两者<b>不能同时开</b>:CUPS 侧靠 ipp-usb(libusb)拿设备,RAW 侧靠内核
|
||
usblp 模块的 <span class="mono">/dev/usb/lp0</span>,抢同一个设备的结果是谁都打不出来</li>
|
||
<li>切换模式时本页会自动把另一边停干净,不用你手工去 systemctl</li>
|
||
</ul>
|
||
</details>`;
|
||
|
||
// ---------- CUPS 设置 ----------
|
||
const sw = (id, on) =>
|
||
`<label class="switch"><input type="checkbox" id="${id}"${on ? ' checked' : ''}><i></i></label>`;
|
||
const items = {};
|
||
(d.items || []).forEach(it => { items[it.key] = it; });
|
||
const card = (key, ctrl) => {
|
||
const it = items[key] || { name: key, why: '', impact: [] };
|
||
return `<div class="dep-item">
|
||
<span class="dep-ico" style="background:var(--info)">i</span>
|
||
<div class="dep-body" style="flex:1">
|
||
<b>${esc(it.name)}</b>
|
||
<div class="desc" style="margin:6px 0 0">${esc(it.why)}</div>
|
||
<div style="margin:8px 0 0">${ctrl}</div>
|
||
${(it.impact || []).length ? `<details style="margin-top:8px">
|
||
<summary class="desc" style="cursor:pointer;color:var(--pri)">打开/关闭会带来什么</summary>
|
||
<ul class="desc" style="margin:6px 0 0;padding-left:20px;line-height:1.85">
|
||
${it.impact.map(x => `<li>${esc(x)}</li>`).join('')}</ul></details>` : ''}
|
||
</div></div>`;
|
||
};
|
||
$('#pt-cups').innerHTML = `
|
||
<div class="dep-grid">
|
||
${card('listen', `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
<select id="pt-listen" style="width:auto">
|
||
<option value="lan"${c.listen === 'lan' ? ' selected' : ''}>允许局域网访问(推荐)</option>
|
||
<option value="local"${c.listen === 'local' ? ' selected' : ''}>只听本机 127.0.0.1</option>
|
||
</select>
|
||
<span class="tag ${c.listen === 'lan' ? 'ok' : 'gray'}">${
|
||
c.listen === 'lan' ? '开放到直连网段' : '仅本机'}</span></div>`)}
|
||
${card('share', `<div style="display:flex;align-items:center;gap:10px">
|
||
${sw('pt-share', !!c.share)}
|
||
<span class="desc" style="margin:0">共享后其他设备才能发现这台打印机</span></div>`)}
|
||
${card('web_iface', `<div style="display:flex;align-items:center;gap:10px">
|
||
${sw('pt-web', !!c.web_iface)}</div>`)}
|
||
${card('remote_admin', `<div style="display:flex;align-items:center;gap:10px">
|
||
${sw('pt-radmin', !!c.remote_admin)}
|
||
<span class="tag ${c.remote_admin ? 'warn' : 'gray'}">${c.remote_admin ? '已开放' : '默认关闭'}</span></div>`)}
|
||
${card('browsed', `<div style="display:flex;align-items:center;gap:10px">
|
||
${sw('pt-browsed', !!c.browsed)}</div>`)}
|
||
</div>
|
||
<div class="row" style="margin-top:12px">
|
||
<label style="flex:0 0 220px">RAW 直通设备
|
||
<input id="pt-raw-dev" value="${esc(raw.device || '/dev/usb/lp0')}" placeholder="/dev/usb/lp0"></label>
|
||
<label style="flex:0 0 130px">监听端口
|
||
<input id="pt-raw-port" type="number" min="1" max="65535" value="${Number(raw.port || 9100)}"></label>
|
||
<label style="flex:0 0 170px">绑定地址
|
||
<input id="pt-raw-bind" value="${esc(raw.bind || '0.0.0.0')}" placeholder="0.0.0.0"></label>
|
||
</div>
|
||
<p class="desc">上面三项只在 <b>RAW 直通模式</b>下生效。用系统自带的
|
||
<span class="mono">socat</span> 实现,不需要额外装包。</p>
|
||
${d.socat ? '' : '<div class="notice warn">系统里没有 <b>socat</b>,RAW 直通模式无法工作。请到「依赖自检与安装」安装。</div>'}`;
|
||
|
||
// ---------- 队列 ----------
|
||
const qs = d.queues || [];
|
||
$('#pt-queues').innerHTML = qs.length ? `<div class="dk-table">
|
||
<div class="dk-thead"><span>队列</span><span>设备地址</span><span>状态</span><span>操作</span></div>
|
||
${qs.map(q => `<div class="dk-trow">
|
||
<span><b>${esc(q.name)}</b>
|
||
${q.default ? '<span class="tag ok" style="margin-left:6px">默认</span>' : ''}
|
||
${q.not_saved_yet
|
||
? '<span class="tag warn" style="margin-left:6px" title="CUPS 还没把它写进 printers.conf,重启 CUPS 或重启机器后才会固定下来">未落盘</span>' : ''}
|
||
<br><span class="desc" style="margin:0;font-size:12px">${esc(q.info || q.make_model || '')}</span></span>
|
||
<span class="mono" style="font-size:11px">${esc(q.uri || '—')}</span>
|
||
<span>
|
||
<span class="tag ${q.shared ? 'ok' : 'warn'}">${q.shared ? '已共享' : '未共享'}</span>
|
||
<span class="tag ${q.accepting ? 'ok' : 'gray'}">${q.accepting ? '接受任务' : '已暂停'}</span>
|
||
<br><span class="desc" style="margin:0;font-size:12px">${esc(q.state || '')}</span></span>
|
||
<span class="dk-acts">
|
||
<button class="ghost small" data-ptq="share" data-n="${esc(q.name)}" data-v="${q.shared ? '0' : '1'}">
|
||
${q.shared ? '取消共享' : '共享'}</button>
|
||
<button class="ghost small" data-ptq="default" data-n="${esc(q.name)}">设为默认</button>
|
||
<button class="ghost small" data-ptq="test" data-n="${esc(q.name)}">测试页</button>
|
||
<button class="ghost small" data-ptq="${q.accepting ? 'reject' : 'accept'}" data-n="${esc(q.name)}">
|
||
${q.accepting ? '暂停' : '恢复'}</button>
|
||
<button class="ghost small danger" data-ptq="del" data-n="${esc(q.name)}">删除</button>
|
||
</span></div>`).join('')}</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="ghost small" id="pt-jobs">查看打印任务</button>
|
||
</div>
|
||
<div id="pt-jobs-out"></div>`
|
||
: '<p class="hint-inline">还没有任何打印队列。可以在下面添加一个,'
|
||
+ '或者打开 cups-browsed 让它自动发现网络上的打印机。</p>';
|
||
|
||
// ---------- 添加队列 ----------
|
||
const devs = (d.devices || []).filter(x => x.uri && !x.uri.startsWith('beh'));
|
||
$('#pt-add').innerHTML = `
|
||
<div class="row">
|
||
<label style="flex:0 0 220px">队列名(英文数字)
|
||
<input id="pt-new-name" placeholder="HP-LaserJet"></label>
|
||
<label style="flex:1 1 320px">设备地址
|
||
<input id="pt-new-uri" list="pt-uri-list" placeholder="ipp://192.168.7.231/ipp/print">
|
||
<datalist id="pt-uri-list">
|
||
${devs.map(x => `<option value="${esc(x.uri)}">${esc(x.kind)} · ${esc(x.uri)}</option>`).join('')}
|
||
</datalist></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label style="flex:1 1 320px">驱动
|
||
<select id="pt-new-drv">
|
||
${(d.drivers || []).map(x => `<option value="${esc(x.key)}">${esc(x.name)}</option>`).join('')}
|
||
</select></label>
|
||
<label style="flex:1 1 200px">描述<input id="pt-new-info" placeholder="客厅打印机"></label>
|
||
<label style="flex:1 1 200px">位置<input id="pt-new-loc" placeholder="书房"></label>
|
||
</div>
|
||
<p class="desc">${(d.drivers || []).map(x => esc(x.note)).join(' ') || ''}</p>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="primary" id="pt-add-go">添加队列</button>
|
||
</div>`;
|
||
|
||
// ---------- USB 与设备 ----------
|
||
const usbs = d.usb_printers || [];
|
||
$('#pt-usb').innerHTML = `
|
||
${usbs.length ? `<p class="hint-inline" style="color:var(--ok)">检测到 ${usbs.length} 台 USB 打印机:</p>
|
||
${usbs.map(u => `<div class="kv"><b>${esc(u.name)}</b>
|
||
<span class="mono">${esc(u.node || '')} ${u.vid ? '· ' + esc(u.vid + ':' + u.pid) : ''}</span></div>`).join('')}`
|
||
: `<div class="notice warn">没有检测到 USB 打印机。
|
||
这是一台 <b>PVE 虚拟机</b> —— 需要先把 USB 打印机在 PVE 的硬件设置里
|
||
直通(USB Host Device)给这台虚拟机,插好之后这里才会出现设备节点。
|
||
${d.usblp_loaded ? '' : '(内核的 usblp 模块当前未加载,插上设备后会自动加载)'}</div>`}
|
||
<p class="desc" style="margin-top:12px">网络上发现的设备(<span class="mono">lpinfo -v</span>):</p>
|
||
${(d.devices || []).length ? `<div class="dk-table">
|
||
<div class="dk-thead"><span>类型</span><span>地址</span></div>
|
||
${(d.devices || []).map(x => `<div class="dk-trow">
|
||
<span class="mono" style="font-size:11.5px">${esc(x.kind)}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(x.uri)}</span></div>`).join('')}</div>`
|
||
: '<p class="hint-inline">(无)</p>'}`;
|
||
|
||
printBind();
|
||
const out = $('#pt-out');
|
||
if (out) { out.classList.add('hidden'); out.innerHTML = ''; }
|
||
}
|
||
|
||
function printBind() {
|
||
$$('[data-ptq]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const act = b.dataset.ptq, n = b.dataset.n;
|
||
if (act === 'del') {
|
||
modal('删除打印队列', `<p>将删除队列 <span class="mono">${esc(n)}</span>。</p>
|
||
<p>队列里未完成的任务会一起清掉。确认继续?</p>`, async () => {
|
||
const r = await api('/api/print', { method: 'POST',
|
||
body: { op: 'queue_del', name: n, confirm: n } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000);
|
||
if (r.ok) printLoad();
|
||
}, '确认删除');
|
||
return;
|
||
}
|
||
if (act === 'test') {
|
||
const r = await api('/api/print', { method: 'POST', body: { op: 'testpage', name: n } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
return;
|
||
}
|
||
const body = { op: 'queue_set', name: n };
|
||
if (act === 'share') body.share = b.dataset.v === '1';
|
||
if (act === 'accept') body.accept = true;
|
||
if (act === 'reject') body.accept = false;
|
||
if (act === 'default') body.default = true;
|
||
const r = await api('/api/print', { method: 'POST', body });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000);
|
||
if (r.ok) printLoad();
|
||
};
|
||
});
|
||
const add = $('#pt-add-go');
|
||
if (add) add.onclick = async () => {
|
||
const name = ($('#pt-new-name').value || '').trim();
|
||
const uri = ($('#pt-new-uri').value || '').trim();
|
||
if (!name) { toast('请填写队列名', 'warn'); return; }
|
||
if (!uri) { toast('请填写设备地址', 'warn'); return; }
|
||
const r = await api('/api/print', { method: 'POST', body: {
|
||
op: 'queue_add', name, uri,
|
||
driver: $('#pt-new-drv').value,
|
||
info: ($('#pt-new-info').value || '').trim(),
|
||
location: ($('#pt-new-loc').value || '').trim(),
|
||
share: true } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
if (r.ok) printLoad();
|
||
};
|
||
const jobs = $('#pt-jobs');
|
||
if (jobs) jobs.onclick = async () => {
|
||
const r = await api('/api/print', { method: 'POST', body: { op: 'jobs' } });
|
||
const box = $('#pt-jobs-out');
|
||
if (!r.ok) { box.innerHTML = `<p class="hint-inline" style="color:var(--err)">${esc(r.msg_cn)}</p>`; return; }
|
||
const js = (r.data || {}).jobs || [];
|
||
box.innerHTML = js.length ? `<div class="dk-table" style="margin-top:10px">
|
||
<div class="dk-thead"><span>任务</span><span>用户</span><span>大小</span><span>操作</span></div>
|
||
${js.map(j => `<div class="dk-trow">
|
||
<span class="mono" style="font-size:11.5px">${esc(j.id)}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(j.user)}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(j.size)}</span>
|
||
<span><button class="ghost small" data-ptj="${esc(j.id)}">取消</button></span></div>`).join('')}</div>`
|
||
: '<p class="hint-inline">当前没有打印任务。</p>';
|
||
$$('[data-ptj]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const r2 = await api('/api/print', { method: 'POST', body: { op: 'job_cancel', id: b.dataset.ptj } });
|
||
toast(r2.msg_cn, r2.ok ? 'ok' : 'err', 6000);
|
||
if (r2.ok) jobs.click();
|
||
};
|
||
});
|
||
};
|
||
}
|
||
|
||
async function printSave() {
|
||
const q = id => document.getElementById(id);
|
||
const mode = ($$('input[name="pt-mode"]').find(x => x.checked) || {}).value || 'cups';
|
||
const r = await api('/api/print', { method: 'POST', body: {
|
||
op: 'save', mode,
|
||
cups: {
|
||
listen: q('pt-listen') ? q('pt-listen').value : undefined,
|
||
share: q('pt-share') ? q('pt-share').checked : undefined,
|
||
web_iface: q('pt-web') ? q('pt-web').checked : undefined,
|
||
remote_admin: q('pt-radmin') ? q('pt-radmin').checked : undefined,
|
||
browsed: q('pt-browsed') ? q('pt-browsed').checked : undefined,
|
||
},
|
||
raw: {
|
||
device: q('pt-raw-dev') ? q('pt-raw-dev').value.trim() : undefined,
|
||
port: q('pt-raw-port') ? Number(q('pt-raw-port').value) : undefined,
|
||
bind: q('pt-raw-bind') ? q('pt-raw-bind').value.trim() : undefined,
|
||
},
|
||
} });
|
||
const box = $('#pt-out');
|
||
box.classList.remove('hidden');
|
||
const errs = ((r.data || {}).errors) || [];
|
||
box.innerHTML = `<div class="notice ${r.ok && !errs.length ? 'ok' : (r.ok ? 'warn' : 'err')}">
|
||
${esc(r.msg_cn || '')}</div>
|
||
${errs.length ? `<ul class="desc" style="margin:8px 0 0;padding-left:20px;line-height:1.8">
|
||
${errs.map(e => `<li>${esc(e)}</li>`).join('')}</ul>` : ''}`;
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) printLoad();
|
||
}
|
||
|
||
/* ============ AC / AP 管理中心(OpenSOHO,#10) ============ */
|
||
let OH = null;
|
||
let OH_PROBE = null;
|
||
|
||
async function viewOpensoho() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>AC / AP 管理中心
|
||
<button class="ghost small" id="oh-reload" style="float:right">重新读取</button></h3>
|
||
<p class="desc">用 OpenSOHO 集中管理家里的 OpenWRT 无线 AP:Wi-Fi、VLAN、PoE 一次配好统一下发。
|
||
AP 侧装 <span class="mono">openwisp-config</span> 并用下面的共享密钥注册进来。</p>
|
||
<div id="oh-state"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>安装</h3>
|
||
<div id="oh-install"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>共享密钥</h3>
|
||
<div id="oh-secret"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>基本设置</h3>
|
||
<div id="oh-cfg"><p class="desc">正在读取…</p></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="primary" id="oh-save">保存并生效</button>
|
||
</div>
|
||
<div id="oh-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>AP 与客户端概况
|
||
<button class="ghost small" id="oh-probe" style="float:right">读取</button></h3>
|
||
<div id="oh-stats"><p class="desc">点「读取」从 OpenSOHO 拉一次实时数据。</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>卸载</h3>
|
||
<div id="oh-uninstall"><p class="desc">正在读取…</p></div>
|
||
</div>`;
|
||
$('#oh-reload').onclick = ohLoad;
|
||
$('#oh-save').onclick = ohSave;
|
||
$('#oh-probe').onclick = ohProbe;
|
||
ohLoad();
|
||
}
|
||
|
||
async function ohLoad() {
|
||
const r = await api('/api/opensoho', { method: 'POST', body: { op: 'status' } });
|
||
if (!r.ok) {
|
||
$('#oh-state').innerHTML = `<div class="notice err">${esc(r.msg_cn || '读取失败')}</div>`;
|
||
return;
|
||
}
|
||
OH = r.data;
|
||
OH_PROBE = null;
|
||
ohRender();
|
||
}
|
||
|
||
function ohSvcTag(s) {
|
||
const on = (s || {}).active === 'active';
|
||
return `<span class="tag ${on ? 'ok' : 'gray'}">${on ? '运行中' : '已停止'}</span>
|
||
<span class="mono" style="font-size:11px;color:var(--txt3)">${esc((s || {}).enabled || '')}</span>`;
|
||
}
|
||
|
||
function ohRender() {
|
||
const d = OH || {};
|
||
const c = d.cfg || {};
|
||
const inst = !!d.installed;
|
||
const ip = d.lan_ip || '127.0.0.1';
|
||
|
||
// ---------- 状态 ----------
|
||
$('#oh-state').innerHTML = `
|
||
${inst ? '' : `<div class="notice warn" style="margin-bottom:10px">
|
||
OpenSOHO 还没装。它不是 apt 包,请在下面「安装」里装。</div>`}
|
||
${d.build_mode ? '<div class="notice warn" style="margin-bottom:10px">当前处于<b>构建保护模式</b>:安装与保存只会写盘,不会真的启动服务。</div>' : ''}
|
||
${inst && d.healthy === false && (d.service || {}).active === 'active'
|
||
? `<div class="notice warn" style="margin-bottom:10px">
|
||
systemd 说服务在跑,但 <span class="mono">/api/health</span> 没应答(${esc(d.health_msg || '')})。
|
||
多半是启动后立刻退出了,看 <span class="mono">journalctl -u opensoho</span>。</div>` : ''}
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:0 18px">
|
||
<div class="kv"><b>OpenSOHO</b><span>${inst ? '<span class="tag ok">已安装</span>' : '<span class="tag warn">未安装</span>'}</span></div>
|
||
<div class="kv"><b>版本</b><span class="mono">${esc(d.version || '—')}</span></div>
|
||
<div class="kv"><b>服务</b><span>${ohSvcTag(d.service)}</span></div>
|
||
<div class="kv"><b>健康检查</b><span>
|
||
<span class="tag ${d.healthy ? 'ok' : 'gray'}">${d.healthy ? '正常' : '无应答'}</span></span></div>
|
||
<div class="kv"><b>实际监听</b><span class="mono">${(d.listen || []).length ? esc(d.listen.join('、')) : '未监听'}</span></div>
|
||
<div class="kv"><b>控制台</b><span>${d.console_url && inst
|
||
? `<a href="${esc(d.console_url)}" target="_blank" rel="noopener" class="mono">${esc(d.console_url)}</a>` : '—'}</span></div>
|
||
</div>
|
||
<p class="desc" style="margin:10px 0 0">${esc(d.note || '')}</p>`;
|
||
|
||
// ---------- 安装 ----------
|
||
const cands = d.local_candidates || [];
|
||
$('#oh-install').innerHTML = `
|
||
<div class="row">
|
||
<button class="primary" id="oh-inst-net"${inst ? '' : ''}>${inst ? '重新安装 / 升级' : '在线安装(最新版)'}</button>
|
||
</div>
|
||
<div style="margin-top:14px">
|
||
<label class="desc" style="display:block;margin-bottom:4px">
|
||
从本机已有文件安装(机器访问不了 GitHub 时用这个)</label>
|
||
<div class="row">
|
||
<select id="oh-local" style="flex:1;min-width:200px">
|
||
<option value="">— 手动输入路径 —</option>
|
||
${cands.map(p => `<option value="${esc(p)}">${esc(p)}</option>`).join('')}
|
||
</select>
|
||
<input id="oh-local-path" class="mono" placeholder="/tmp/opensoho 或 /tmp/opensoho_xxx.zip"
|
||
style="flex:1;min-width:220px">
|
||
<button class="ghost" id="oh-inst-local">安装</button>
|
||
</div>
|
||
<p class="desc" style="margin:6px 0 0">
|
||
先在「Web 终端 / 文件」把二进制或 zip 传到 <span class="mono">/tmp</span>,
|
||
再在上面选中它。升级走同一条路径,数据目录会原样保留,已注册的 AP 不受影响。</p>
|
||
</div>`;
|
||
$('#oh-inst-net').onclick = () => ohInstall('');
|
||
$('#oh-inst-local').onclick = () => {
|
||
const sel = document.getElementById('oh-local').value;
|
||
const typed = (document.getElementById('oh-local-path').value || '').trim();
|
||
const path = typed || sel;
|
||
if (!path) { toast('请先选择一个文件或填写路径', 'err'); return; }
|
||
ohInstall(path);
|
||
};
|
||
|
||
// ---------- 共享密钥 ----------
|
||
$('#oh-secret').innerHTML = inst ? `
|
||
<div class="row">
|
||
<input class="mono" id="oh-secret-val" readonly value="${esc(d.secret || '')}"
|
||
style="flex:1;min-width:240px;font-size:13px">
|
||
<button class="ghost" id="oh-secret-copy">复制</button>
|
||
<button class="ghost" id="oh-secret-regen">重新生成</button>
|
||
</div>
|
||
${d.secret_weak ? '<div class="notice warn" style="margin-top:10px">当前共享密钥偏短,建议重新生成一个。</div>' : ''}
|
||
<p class="desc" style="margin:8px 0 0">
|
||
在 AP 的 OpenWRT 上装 <span class="mono">openwisp-config</span>,把控制器地址填
|
||
<span class="mono">http://${esc(ip)}:${esc(String(c.port || 8090))}</span>、共享密钥填上面这串。
|
||
重新生成后,<b>已注册的 AP 都要重填一次</b>。</p>
|
||
${d.admin_email ? `<p class="desc" style="margin:6px 0 0">
|
||
控制台管理员:<span class="mono">${esc(d.admin_email)}</span>
|
||
密码 <span class="mono">${esc(d.admin_password || '—')}</span>(进控制台用它登录)</p>` : ''}
|
||
<p class="desc" style="margin:6px 0 0">配置静态加密:
|
||
<span class="tag ${d.enc_ok ? 'ok' : 'warn'}">${d.enc_ok ? '已启用' : '未启用'}</span>
|
||
Wi-Fi 密码等敏感项在磁盘上是${d.enc_ok ? '加密' : '明文'}存放的。</p>` :
|
||
'<p class="desc">安装后这里会显示 AP 注册用的共享密钥。</p>';
|
||
if (inst) {
|
||
const cp = document.getElementById('oh-secret-copy');
|
||
if (cp) cp.onclick = () => {
|
||
navigator.clipboard.writeText(d.secret || '').then(() => toast('已复制', 'ok'), () => toast('复制失败', 'err'));
|
||
};
|
||
const rg = document.getElementById('oh-secret-regen');
|
||
if (rg) rg.onclick = ohRegenSecret;
|
||
}
|
||
|
||
// ---------- 基本设置 ----------
|
||
$('#oh-cfg').innerHTML = inst ? `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:0 18px">
|
||
<div class="kv"><b>监听地址</b><span>
|
||
<select id="oh-bind">
|
||
${(d.bind_choices || []).map(b => `<option value="${esc(b)}"${b === (c.bind || '0.0.0.0') ? ' selected' : ''}>${esc(b)}</option>`).join('')}
|
||
</select></span></div>
|
||
<div class="kv"><b>端口</b><span>
|
||
<input id="oh-port" type="number" min="1024" max="65535" value="${esc(String(c.port || 8090))}"
|
||
style="width:110px"></span></div>
|
||
</div>
|
||
<div class="dep-grid" style="margin-top:12px">
|
||
<label class="dep-item"><input type="checkbox" id="oh-newdev"${c.enable_new_devices !== false ? ' checked' : ''}>
|
||
<div class="dep-body"><b>自动接管新 AP</b>
|
||
<div class="desc" style="margin:2px 0 0">关掉 = 只监控不下发配置</div></div></label>
|
||
<label class="dep-item"><input type="checkbox" id="oh-enc"${c.encryption !== false ? ' checked' : ''}${inst ? ' disabled' : ''}>
|
||
<div class="dep-body"><b>配置静态加密</b>
|
||
<div class="desc" style="margin:2px 0 0">加密存放 Wi-Fi 密码等敏感项${inst ? '(库已建好,装完后不可再切换)' : ''}</div></div></label>
|
||
<label class="dep-item"><input type="checkbox" id="oh-auto"${c.autostart !== false ? ' checked' : ''}>
|
||
<div class="dep-body"><b>开机自启</b>
|
||
<div class="desc" style="margin:2px 0 0">随系统一起启动</div></div></label>
|
||
</div>
|
||
<p class="desc" style="margin:10px 0 0">改监听地址或端口会重建 systemd 单元并重启服务,
|
||
已注册的 AP 需要用新地址重新注册。</p>` :
|
||
'<p class="desc">安装后可在这里改监听地址、端口与几个开关。</p>';
|
||
|
||
// ---------- 卸载 ----------
|
||
$('#oh-uninstall').innerHTML = inst ? `
|
||
<div class="row">
|
||
<label class="desc" style="margin:0"><input type="checkbox" id="oh-purge">
|
||
同时删除数据目录(AP 清单与配置)</label>
|
||
<button class="danger" id="oh-uninst">卸载 OpenSOHO</button>
|
||
</div>
|
||
<p class="desc" style="margin:6px 0 0">默认保留数据目录,下次重装直接接着用。</p>` :
|
||
'<p class="desc">尚未安装。</p>';
|
||
const ub = document.getElementById('oh-uninst');
|
||
if (ub) ub.onclick = ohUninstall;
|
||
|
||
// ---------- 概况 ----------
|
||
ohRenderStats();
|
||
}
|
||
|
||
function ohRenderStats() {
|
||
const box = document.getElementById('oh-stats');
|
||
if (!box) return;
|
||
const d = OH_PROBE;
|
||
if (!d) {
|
||
box.innerHTML = '<p class="desc">点右上角「读取」从 OpenSOHO 拉一次实时数据。</p>';
|
||
return;
|
||
}
|
||
const stats = d.stats || [];
|
||
const devs = d.devices || [];
|
||
box.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0 18px">
|
||
${stats.map(s => `<div class="kv"><b>${esc(s.name)}</b><span class="mono">${
|
||
s.count === null ? '<span class="tag warn">读取失败</span>' : esc(String(s.count))
|
||
}</span></div>`).join('')}
|
||
</div>
|
||
${devs.length ? `<div style="margin-top:14px;overflow-x:auto">
|
||
<table class="tbl"><thead><tr>
|
||
<th>名称</th><th>MAC</th><th>地址</th><th>型号</th><th>状态</th>
|
||
</tr></thead><tbody>
|
||
${devs.map(x => `<tr>
|
||
<td>${esc(x.name || '—')}</td>
|
||
<td class="mono">${esc(x.mac || '—')}</td>
|
||
<td class="mono">${esc(x.ip || '—')}</td>
|
||
<td>${esc(x.model || '—')}</td>
|
||
<td><span class="tag ${x.enabled ? 'ok' : 'gray'}">${x.enabled ? '已启用' : '已停用'}</span></td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>` : '<p class="desc" style="margin-top:12px">还没有 AP 注册进来。</p>'}`;
|
||
}
|
||
|
||
async function ohInstall(path) {
|
||
const r = await api('/api/opensoho', { method: 'POST', body: { op: 'install', local_path: path || '' } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 12000);
|
||
if (r.ok) ohLoad();
|
||
}
|
||
|
||
async function ohUninstall() {
|
||
const purge = (document.getElementById('oh-purge') || {}).checked;
|
||
if (!window.confirm(purge
|
||
? '确定卸载 OpenSOHO 并删除数据目录?AP 清单与配置将一并清除,不可恢复。'
|
||
: '确定卸载 OpenSOHO?数据目录会保留,下次重装可直接使用。')) return;
|
||
const r = await api('/api/opensoho', { method: 'POST', body: { op: 'uninstall', purge } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) ohLoad();
|
||
}
|
||
|
||
async function ohSave() {
|
||
const q = id => document.getElementById(id);
|
||
const r = await api('/api/opensoho', { method: 'POST', body: {
|
||
op: 'save',
|
||
cfg: {
|
||
bind: q('oh-bind') ? q('oh-bind').value : undefined,
|
||
port: q('oh-port') ? Number(q('oh-port').value) : undefined,
|
||
enable_new_devices: q('oh-newdev') ? q('oh-newdev').checked : undefined,
|
||
encryption: q('oh-enc') ? q('oh-enc').checked : undefined,
|
||
autostart: q('oh-auto') ? q('oh-auto').checked : undefined,
|
||
},
|
||
} });
|
||
const box = $('#oh-out');
|
||
box.classList.remove('hidden');
|
||
box.innerHTML = `<div class="notice ${r.ok ? 'ok' : 'err'}">${esc(r.msg_cn || '')}</div>`;
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) ohLoad();
|
||
}
|
||
|
||
async function ohRegenSecret() {
|
||
if (!window.confirm('重新生成共享密钥后,已注册的 AP 都要用新密钥重新注册一次。确定继续?')) return;
|
||
const r = await api('/api/opensoho', { method: 'POST', body: { op: 'secret' } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) ohLoad();
|
||
}
|
||
|
||
async function ohProbe() {
|
||
const r = await api('/api/opensoho', { method: 'POST', body: { op: 'probe' } });
|
||
if (!r.ok) {
|
||
toast(r.msg_cn || '读取失败', 'err', 9000);
|
||
return;
|
||
}
|
||
OH_PROBE = r.data;
|
||
ohRenderStats();
|
||
toast(r.msg_cn, 'ok');
|
||
}
|
||
|
||
/* ============ CA 证书管理 + SSL 测试(#11) ============ */
|
||
let CA_DATA = null;
|
||
let CA_TEST = null;
|
||
|
||
async function viewCa() {
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>管理后台正在用的证书
|
||
<button class="ghost small" id="ca-reload" style="float:right">重新读取</button></h3>
|
||
<p class="desc">浏览器打开 <span class="mono">https://本机:端口</span> 时看到的就是这张。
|
||
默认是一张自签证书 —— 能加密,但浏览器不认识它,所以会提示「不安全」。
|
||
想消除提示,在下面建一个自己的 CA,用它签一张服务器证书,再把根证书装进你的设备。</p>
|
||
<div id="ca-web"><p class="desc">正在读取…</p></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="ghost" id="ca-restore">重新生成一张默认自签证书</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>证书库</h3>
|
||
<p class="desc">CA 证书用来签发;服务器证书可以直接部署给管理后台;
|
||
证书签名请求(CSR)是拿去给公共证书机构签名的半成品。</p>
|
||
<div id="ca-list"><p class="desc">正在读取…</p></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>① 创建自己的 CA</h3>
|
||
<p class="desc">CA 是「发证机构」。建好之后,你签的每一张证书都属于同一个信任体系 ——
|
||
只要把这张 CA 装进手机 / 电脑一次,之后签的所有证书都不再提示不安全。</p>
|
||
<div class="row">
|
||
<label>名称(显示用)<input id="ca-nc-name" placeholder="家里的根证书" style="flex:1;min-width:160px"></label>
|
||
<label>证书主体 CN<input id="ca-nc-cn" value="drouter-ca" style="flex:1;min-width:160px"></label>
|
||
<label style="flex:0 0 150px">有效期(天)<input id="ca-nc-days" type="number" min="1" max="36500" value="3650"></label>
|
||
<label style="flex:0 0 200px">私钥算法<select id="ca-nc-key"></select></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="primary" id="ca-nc-go">创建 CA</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>② 用 CA 签一张服务器证书</h3>
|
||
<p class="desc">签好的证书会带上你填的域名和 IP。浏览器访问时,地址必须出现在
|
||
<b>SAN(使用者可选名称)</b>里,否则照样报「名称不匹配」—— 这是最常被忽略的一步。</p>
|
||
<div class="row">
|
||
<label>用哪张 CA 签<select id="ca-sg-ca" style="flex:1;min-width:200px"></select></label>
|
||
<label>证书主体 CN<input id="ca-sg-cn" placeholder="drouter.lan" style="flex:1;min-width:160px"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label style="flex:1">域名(多个用逗号分隔)
|
||
<input id="ca-sg-dns" placeholder="drouter.lan,router.home" ></label>
|
||
<label style="flex:1">IP(多个用逗号分隔)
|
||
<input id="ca-sg-ip" class="mono"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label style="flex:0 0 150px">有效期(天)<input id="ca-sg-days" type="number" min="1" max="36500" value="825"></label>
|
||
<label style="flex:0 0 200px">私钥算法<select id="ca-sg-key"></select></label>
|
||
<button class="primary" id="ca-sg-go">签发</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>③ 导入已有证书</h3>
|
||
<p class="desc">从证书机构买的、别处签好的,直接粘进来(或填这台机器上的文件路径)。
|
||
证书和私钥可以粘在同一个框里,会自动拆开。</p>
|
||
<div class="row">
|
||
<label style="flex:0 0 170px">类型<select id="ca-im-kind">
|
||
<option value="server">服务器证书</option>
|
||
<option value="ca">CA 证书</option>
|
||
</select></label>
|
||
<label style="flex:1">补到哪条记录(可选,用来补全 CSR)
|
||
<select id="ca-im-id" style="width:100%"></select></label>
|
||
</div>
|
||
<label class="desc" style="display:block;margin:10px 0 4px">证书 PEM(可含私钥)</label>
|
||
<textarea id="ca-im-crt" rows="5" class="mono" style="width:100%;font-size:12px"
|
||
placeholder="-----BEGIN CERTIFICATE----- … -----END CERTIFICATE-----"></textarea>
|
||
<div class="row" style="margin-top:10px">
|
||
<label style="flex:1">或用这台机器上的文件
|
||
<input id="ca-im-path" class="mono" placeholder="/tmp/fullchain.pem"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="primary" id="ca-im-go">导入</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>④ 生成证书签名请求(CSR)</h3>
|
||
<p class="desc">如果你有正式域名并想用公共证书机构签发的证书:在这里生成 CSR,
|
||
把它提交给证书机构,签回来的证书回到上面「导入」里补全即可。</p>
|
||
<div class="row">
|
||
<label>域名 CN<input id="ca-cs-cn" placeholder="router.example.com" style="flex:1;min-width:180px"></label>
|
||
<label style="flex:1">其它域名(逗号分隔)<input id="ca-cs-dns"></label>
|
||
<label style="flex:0 0 200px">私钥算法<select id="ca-cs-key"></select></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="primary" id="ca-cs-go">生成 CSR</button>
|
||
</div>
|
||
<div id="ca-cs-out" class="hidden" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>SSL / TLS 体检</h3>
|
||
<p class="desc">对任意主机的任意端口做一次真实握手,看协议版本、加密套件、证书链和校验结论。
|
||
默认测本机管理后台;也可以测局域网里其它设备、或者外网站点。</p>
|
||
<div class="row">
|
||
<label style="flex:1;min-width:170px">主机<input id="ca-ts-host" placeholder="127.0.0.1"></label>
|
||
<label style="flex:0 0 110px">端口<input id="ca-ts-port" type="number" min="1" max="65535" value="8443"></label>
|
||
<label style="flex:1;min-width:150px">SNI(留空=同主机)<input id="ca-ts-sni" placeholder="可留空"></label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label class="desc" style="margin:0"><input type="checkbox" id="ca-ts-insecure">
|
||
不看校验结果,只要握手能通就行</label>
|
||
<button class="primary" id="ca-ts-go">开始测试</button>
|
||
<button class="ghost" id="ca-ts-self">测本机管理后台</button>
|
||
</div>
|
||
<div id="ca-ts-out" style="margin-top:12px"><p class="desc">填好主机点「开始测试」。</p></div>
|
||
</div>`;
|
||
$('#ca-reload').onclick = caLoad;
|
||
$('#ca-restore').onclick = caRestore;
|
||
$('#ca-nc-go').onclick = caNewCA;
|
||
$('#ca-sg-go').onclick = caSign;
|
||
$('#ca-im-go').onclick = caImport;
|
||
$('#ca-cs-go').onclick = caGenCsr;
|
||
$('#ca-ts-go').onclick = () => caSslTest(false);
|
||
$('#ca-ts-self').onclick = () => caSslTest(true);
|
||
caLoad();
|
||
}
|
||
|
||
async function caLoad() {
|
||
const r = await api('/api/ca', { method: 'POST', body: { op: 'status' } });
|
||
if (!r.ok) {
|
||
$('#ca-web').innerHTML = `<div class="notice err">${esc(r.msg_cn || '读取失败')}</div>`;
|
||
return;
|
||
}
|
||
CA_DATA = r.data || {};
|
||
caRender();
|
||
}
|
||
|
||
function caDaysTag(days) {
|
||
// 到期阈值由后端下发,前端不另写一份 —— 免得改了后端这边悄悄走偏
|
||
const warn = (CA_DATA || {}).warn_days || 30;
|
||
if (days === null || days === undefined) return '<span class="tag gray">未知</span>';
|
||
if (days < 0) return `<span class="tag err">已过期 ${esc(String(-days))} 天</span>`;
|
||
if (days <= warn) return `<span class="tag warn">剩 ${esc(String(days))} 天</span>`;
|
||
return `<span class="tag ok">剩 ${esc(String(days))} 天</span>`;
|
||
}
|
||
|
||
function caRender() {
|
||
const d = CA_DATA || {};
|
||
const w = d.web || {};
|
||
const items = d.items || [];
|
||
|
||
// ---------- 正在用的证书 ----------
|
||
$('#ca-web').innerHTML = `
|
||
${d.build_mode ? '<div class="notice warn" style="margin-bottom:10px">当前处于<b>构建保护模式</b>:部署只会写盘,不会重启 Web 服务。</div>' : ''}
|
||
${(d.index_broken) ? '<div class="notice err" style="margin-bottom:10px">证书库索引文件损坏,列表可能不完整。新建证书会重建它。</div>' : ''}
|
||
${w.installed ? '' : '<div class="notice err" style="margin-bottom:10px">'
|
||
+ '管理后台现在没有可用的证书:' + esc(w.err || '证书文件缺失或无法解析') + '。</div>'}
|
||
${(w.days_left !== null && w.days_left !== undefined && w.days_left <= (d.warn_days || 30))
|
||
? `<div class="notice ${w.days_left < 0 ? 'err' : 'warn'}" style="margin-bottom:10px">
|
||
这张证书${w.days_left < 0 ? '已经过期' : '快到期了'}(剩 ${esc(String(w.days_left))} 天)。
|
||
建议现在换一张,否则浏览器会彻底拒绝连接。</div>` : ''}
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 18px">
|
||
<div class="kv"><b>证书主题</b><span class="mono">${esc(w.subject || '—')}</span></div>
|
||
<div class="kv"><b>颁发者</b><span class="mono">${esc(w.issuer || '—')}</span></div>
|
||
<div class="kv"><b>有效期</b><span class="mono">${esc(w.not_before || '—')} → ${esc(w.not_after || '—')}</span></div>
|
||
<div class="kv"><b>剩余</b><span>${caDaysTag(w.days_left)}</span></div>
|
||
<div class="kv"><b>类型</b><span>
|
||
${w.self_signed ? '<span class="tag warn">自签(浏览器会提示)</span>'
|
||
: '<span class="tag ok">由 CA 签发</span>'}</span></div>
|
||
<div class="kv"><b>算法</b><span class="mono">${esc(w.sig_alg || w.key_alg || '—')}</span></div>
|
||
<div class="kv"><b>SHA256 指纹</b><span class="mono" style="font-size:11px">${esc(w.fingerprint || '—')}</span></div>
|
||
<div class="kv"><b>在证书库里</b><span>${w.in_lib
|
||
? `<span class="tag ok">是(${esc(w.in_lib)})</span>`
|
||
: '<span class="tag gray">否(自动生成,未入库)</span>'}</span></div>
|
||
</div>
|
||
${(w.san || []).length ? `<p class="desc" style="margin:10px 0 0">
|
||
覆盖的名称:<span class="mono">${esc((w.san || []).join('、'))}</span></p>` : ''}
|
||
${(d.last) ? `<div class="notice ${d.last.ok ? 'ok' : 'err'}" style="margin-top:12px">
|
||
上次部署(${esc(d.last.ts || '')}):${esc(d.last.msg || '')}</div>` : ''}`;
|
||
|
||
// ---------- 证书库 ----------
|
||
if (!items.length) {
|
||
$('#ca-list').innerHTML = '<p class="desc">证书库是空的。先在下面「① 创建自己的 CA」建一张。</p>';
|
||
} else {
|
||
$('#ca-list').innerHTML = `
|
||
<div style="overflow-x:auto">
|
||
<table class="tbl"><thead><tr>
|
||
<th>名称</th><th>类型</th><th>主题 / CN</th><th>有效期至</th><th>剩余</th>
|
||
<th>私钥</th><th>状态</th><th style="white-space:nowrap">操作</th>
|
||
</tr></thead><tbody>
|
||
${items.map(x => `<tr>
|
||
<td>${esc(x.name || '—')}</td>
|
||
<td><span class="tag ${x.kind === 'ca' ? 'info' : (x.kind === 'csr' ? 'gray' : 'ok')}">${esc(x.kind_cn || x.kind || '')}</span></td>
|
||
<td class="mono" style="font-size:11px">${esc(x.subject || x.err || '—')}</td>
|
||
<td class="mono" style="font-size:11px">${esc(x.not_after || '—')}</td>
|
||
<td>${caDaysTag(x.days_left)}</td>
|
||
<td>${x.has_key ? '<span class="tag ok">有</span>' : '<span class="tag gray">无</span>'}</td>
|
||
<td>${x.in_use ? '<span class="tag ok">正在用</span>'
|
||
: (x.can_sign ? '<span class="tag info">可签发</span>'
|
||
: (x.has_csr ? '<span class="tag gray">待签回</span>' : ''))}</td>
|
||
<td style="white-space:nowrap">
|
||
${x.kind === 'server' && x.has_key && !x.in_use
|
||
? `<button class="ghost small" data-ca-deploy="${esc(x.id)}">部署</button>` : ''}
|
||
<button class="ghost small" data-ca-del="${esc(x.id)}">删除</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div>`;
|
||
document.querySelectorAll('#ca-list [data-ca-deploy]').forEach(b => {
|
||
b.onclick = () => caDeploy(b.getAttribute('data-ca-deploy'));
|
||
});
|
||
document.querySelectorAll('#ca-list [data-ca-del]').forEach(b => {
|
||
b.onclick = () => caDelete(b.getAttribute('data-ca-del'));
|
||
});
|
||
}
|
||
|
||
// ---------- 下拉:CA 列表 / 密钥算法 / 待补全的 CSR ----------
|
||
const cas = items.filter(x => x.kind === 'ca' && x.has_key);
|
||
const csrs = items.filter(x => x.kind === 'csr');
|
||
const kts = d.key_types || [];
|
||
['ca-nc-key', 'ca-sg-key', 'ca-cs-key'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.innerHTML = kts.map(k => `<option value="${esc(k.v)}"${k.v === 'rsa:2048' ? ' selected' : ''}>${esc(k.n)}</option>`).join('')
|
||
|| '<option value="rsa:2048">RSA 2048</option>';
|
||
});
|
||
const selCa = document.getElementById('ca-sg-ca');
|
||
if (selCa) {
|
||
selCa.innerHTML = cas.length
|
||
? cas.map(x => `<option value="${esc(x.id)}">${esc(x.name)}(${esc(x.subject || x.id)})</option>`).join('')
|
||
: '<option value="">— 还没有 CA,先创建一张 —</option>';
|
||
}
|
||
const selId = document.getElementById('ca-im-id');
|
||
if (selId) {
|
||
selId.innerHTML = '<option value="">— 新建一条记录 —</option>'
|
||
+ csrs.map(x => `<option value="${esc(x.id)}">补全:${esc(x.name)}</option>`).join('');
|
||
}
|
||
const ipIn = document.getElementById('ca-sg-ip');
|
||
if (ipIn && !ipIn.value) ipIn.value = (d.default_ips || []).join(',');
|
||
const hostIn = document.getElementById('ca-ts-host');
|
||
if (hostIn && !hostIn.value) hostIn.value = '127.0.0.1';
|
||
const portIn = document.getElementById('ca-ts-port');
|
||
if (portIn && !portIn.value) portIn.value = String(d.web_port || 8443);
|
||
}
|
||
|
||
async function caNewCA() {
|
||
const q = id => document.getElementById(id);
|
||
const r = await api('/api/ca', { method: 'POST', body: {
|
||
op: 'new_ca',
|
||
name: (q('ca-nc-name') || {}).value || '',
|
||
cn: (q('ca-nc-cn') || {}).value || '',
|
||
days: Number((q('ca-nc-days') || {}).value) || 3650,
|
||
key_type: (q('ca-nc-key') || {}).value || 'rsa:2048',
|
||
} });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) caLoad();
|
||
}
|
||
|
||
async function caSign() {
|
||
const q = id => document.getElementById(id);
|
||
const caId = (q('ca-sg-ca') || {}).value || '';
|
||
if (!caId) { toast('请先创建一张 CA 证书', 'err'); return; }
|
||
const split = s => (s || '').split(/[,,\s]+/).map(x => x.trim()).filter(Boolean);
|
||
const r = await api('/api/ca', { method: 'POST', body: {
|
||
op: 'sign', ca_id: caId,
|
||
cn: (q('ca-sg-cn') || {}).value || '',
|
||
san_dns: split((q('ca-sg-dns') || {}).value),
|
||
san_ip: split((q('ca-sg-ip') || {}).value),
|
||
days: Number((q('ca-sg-days') || {}).value) || 825,
|
||
key_type: (q('ca-sg-key') || {}).value || 'rsa:2048',
|
||
} });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) caLoad();
|
||
}
|
||
|
||
async function caImport() {
|
||
const q = id => document.getElementById(id);
|
||
const r = await api('/api/ca', { method: 'POST', body: {
|
||
op: 'import',
|
||
kind: (q('ca-im-kind') || {}).value || 'server',
|
||
complete_id: (q('ca-im-id') || {}).value || '',
|
||
cert: (q('ca-im-crt') || {}).value || '',
|
||
path: (q('ca-im-path') || {}).value || '',
|
||
} });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 10000);
|
||
if (r.ok) caLoad();
|
||
}
|
||
|
||
async function caGenCsr() {
|
||
const q = id => document.getElementById(id);
|
||
const cn = ((q('ca-cs-cn') || {}).value || '').trim();
|
||
if (!cn) { toast('请填写域名', 'err'); return; }
|
||
const r = await api('/api/ca', { method: 'POST', body: {
|
||
op: 'csr', cn,
|
||
san_dns: (q('ca-cs-dns') || {}).value.split(/[,,\s]+/).map(x => x.trim()).filter(Boolean),
|
||
key_type: (q('ca-cs-key') || {}).value || 'rsa:2048',
|
||
} });
|
||
const box = $('#ca-cs-out');
|
||
box.classList.remove('hidden');
|
||
if (!r.ok) {
|
||
box.innerHTML = `<div class="notice err">${esc(r.msg_cn || '')}</div>`;
|
||
toast(r.msg_cn, 'err', 9000);
|
||
return;
|
||
}
|
||
box.innerHTML = `<div class="notice ok">${esc(r.msg_cn)}</div>
|
||
<label class="desc" style="display:block;margin:10px 0 4px">把它交给证书机构(点一下全选复制)</label>
|
||
<textarea id="ca-cs-text" rows="8" class="mono" readonly
|
||
style="width:100%;font-size:12px">${esc((r.data || {}).csr || '')}</textarea>
|
||
<div class="row" style="margin-top:8px">
|
||
<button class="ghost" id="ca-cs-copy">复制 CSR</button></div>`;
|
||
const cp = document.getElementById('ca-cs-copy');
|
||
if (cp) cp.onclick = () => {
|
||
navigator.clipboard.writeText((r.data || {}).csr || '')
|
||
.then(() => toast('已复制', 'ok'), () => toast('复制失败', 'err'));
|
||
};
|
||
caLoad();
|
||
}
|
||
|
||
// 换证书要重启 drouter-web,而 helper 与它同在一个 systemd cgroup —— 重启会
|
||
// 把这次 HTTP 响应一起带走。这不是失败:结果已经写到 /etc/drouter/ca/
|
||
// last-deploy.json,刷新回来在本页顶部就能看到。所以这里要把「连接断了」
|
||
// 和「真的失败了」分开处理,否则用户会以为换证书的路上出了事。
|
||
function caRestarting(r) {
|
||
return !r.ok && (r.code === 'NET' || r.code === 'PARSE');
|
||
}
|
||
|
||
function caAfterRestart() {
|
||
toast('Web 服务正在重启,10 秒后自动刷新(需要重新登录一次)', 'ok', 14000);
|
||
setTimeout(() => location.reload(), 10000);
|
||
}
|
||
|
||
async function caDeploy(id) {
|
||
if (!window.confirm(
|
||
'把这张证书部署给管理后台?\n\n'
|
||
+ '· Web 服务会重启,你需要重新登录一次\n'
|
||
+ '· 换上后如果握手不通过,会自动还原原来的证书\n'
|
||
+ '· 自签证书浏览器仍会提示,那是正常的(加密是生效的)')) return;
|
||
const r = await api('/api/ca', { method: 'POST', body: { op: 'deploy', id } });
|
||
if (caRestarting(r)) { caAfterRestart(); return; }
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 14000);
|
||
if (r.ok) setTimeout(caLoad, 1500);
|
||
}
|
||
|
||
async function caDelete(id) {
|
||
if (!window.confirm('确定删除这张证书?它的私钥会一起删除,不可恢复。')) return;
|
||
const r = await api('/api/ca', { method: 'POST', body: { op: 'delete', id } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
if (r.ok) caLoad();
|
||
}
|
||
|
||
async function caRestore() {
|
||
if (!window.confirm(
|
||
'重新生成一张默认自签证书并立即部署?\n\n'
|
||
+ '它会带上本机所有 IP 与 localhost,有效期十年。'
|
||
+ 'Web 服务会重启,你需要重新登录。')) return;
|
||
const r = await api('/api/ca', { method: 'POST', body: { op: 'restore', cn: 'drouter.local' } });
|
||
if (caRestarting(r)) { caAfterRestart(); return; }
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 14000);
|
||
if (r.ok) setTimeout(caLoad, 1500);
|
||
}
|
||
|
||
async function caSslTest(selfMode) {
|
||
const q = id => document.getElementById(id);
|
||
const d = CA_DATA || {};
|
||
if (selfMode) {
|
||
const h = document.getElementById('ca-ts-host');
|
||
const p = document.getElementById('ca-ts-port');
|
||
if (h) h.value = '127.0.0.1';
|
||
if (p) p.value = String(d.web_port || 8443);
|
||
}
|
||
const host = ((q('ca-ts-host') || {}).value || '').trim();
|
||
if (!host) { toast('请填写要测试的主机', 'err'); return; }
|
||
$('#ca-ts-out').innerHTML = '<p class="desc">正在握手…</p>';
|
||
const r = await api('/api/ca', { method: 'POST', body: {
|
||
op: 'ssltest', host,
|
||
port: Number((q('ca-ts-port') || {}).value) || 443,
|
||
sni: (q('ca-ts-sni') || {}).value || '',
|
||
insecure: !!(q('ca-ts-insecure') || {}).checked,
|
||
} });
|
||
if (!r.ok) {
|
||
$('#ca-ts-out').innerHTML = `<div class="notice err">${esc(r.msg_cn || '测试失败')}</div>`;
|
||
return;
|
||
}
|
||
CA_TEST = r.data || {};
|
||
caRenderTest();
|
||
}
|
||
|
||
function caRenderTest() {
|
||
const t = CA_TEST || {};
|
||
const box = $('#ca-ts-out');
|
||
if (!box) return;
|
||
if (!t.ok) {
|
||
box.innerHTML = `<div class="notice err">${esc(t.host || '')}:${esc(String(t.port || ''))}
|
||
—— ${esc(t.why || '握手失败')}</div>`;
|
||
return;
|
||
}
|
||
const chain = t.chain || [];
|
||
box.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:0 18px">
|
||
<div class="kv"><b>握手</b><span><span class="tag ok">成功</span></span></div>
|
||
<div class="kv"><b>协议</b><span class="mono">${esc(t.proto || '—')}</span></div>
|
||
<div class="kv"><b>加密套件</b><span class="mono" style="font-size:11px">${esc(t.cipher || '—')}</span></div>
|
||
<div class="kv"><b>耗时</b><span class="mono">${esc(String(t.ms || 0))} ms</span></div>
|
||
</div>
|
||
<div class="dep-grid" style="margin-top:12px">
|
||
${(t.checks || []).map(c => `
|
||
<div class="dep-item">
|
||
<span class="tag ${c.ok ? 'ok' : 'err'}">${c.ok ? '通过' : '注意'}</span>
|
||
<div class="dep-body"><b>${esc(c.n || '')}</b>
|
||
<div class="desc" style="margin:2px 0 0">${esc(c.d || '')}</div></div>
|
||
</div>`).join('')}
|
||
</div>
|
||
${chain.length ? `<div style="margin-top:14px;overflow-x:auto">
|
||
<table class="tbl"><thead><tr>
|
||
<th>#</th><th>主题</th><th>颁发者</th><th>有效期至</th><th>剩余</th><th>签名算法</th>
|
||
</tr></thead><tbody>
|
||
${chain.map(c => `<tr>
|
||
<td>${esc(String(c.level))}</td>
|
||
<td class="mono" style="font-size:11px">${esc(c.subject || '—')}</td>
|
||
<td class="mono" style="font-size:11px">${esc(c.issuer || '—')}</td>
|
||
<td class="mono" style="font-size:11px">${esc(c.not_after || '—')}</td>
|
||
<td>${caDaysTag(c.days_left)}</td>
|
||
<td class="mono" style="font-size:11px">${esc(c.sig_alg || '—')}</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>
|
||
<p class="desc" style="margin:8px 0 0">叶子证书 SHA256:
|
||
<span class="mono" style="font-size:11px">${esc((chain[0] || {}).fingerprint || '—')}</span></p>
|
||
</div>` : ''}`;
|
||
}
|
||
|
||
/* ============ 连接跟踪与流量日志 —— 统一日志系统(#11) ============ */
|
||
const UL_LEVEL_TAG = { emerg: 'err', alert: 'err', crit: 'err', err: 'err',
|
||
warn: 'warn', notice: 'info', info: 'gray', debug: 'gray' };
|
||
const UL_SRC_CN = { fw: '防火墙', conntrack: '连接跟踪', flow: '当前连接',
|
||
wan: 'WAN 接入', ddns: '动态域名', app: '应用日志', system: '系统日志' };
|
||
const UL_SRC_ICON = { fw: '⛨', conntrack: '⇄', flow: '≡', wan: '⇅',
|
||
ddns: '⌂', app: '✦', system: '⛁' };
|
||
const UL_PRESET_SINCE = [
|
||
{ v: '5 min ago', n: '最近 5 分钟' },
|
||
{ v: '30 min ago', n: '最近 30 分钟' },
|
||
{ v: '2 hours ago', n: '最近 2 小时' },
|
||
{ v: '12 hours ago', n: '最近 12 小时' },
|
||
{ v: '24 hours ago', n: '最近 1 天' },
|
||
{ v: '7 days ago', n: '最近 7 天' },
|
||
];
|
||
|
||
function ulState() {
|
||
if (!S.ulog) {
|
||
S.ulog = { view: 'log', since: '30 min ago', level: '', src: '', proto: '',
|
||
action: '', q: '', limit: 400, items: [], stat: {}, meta: {},
|
||
conf: {}, sources: [], levels: [], archive: {},
|
||
// 实时连接事件订阅默认关:它会让服务端阻塞若干秒等 conntrack 事件。
|
||
// 想看实时新建连接时再手动打开(#7:默认查询不该替它买单 2 秒)。
|
||
live: false, liveSec: 3 };
|
||
}
|
||
return S.ulog;
|
||
}
|
||
|
||
function ulQuery() {
|
||
const u = ulState();
|
||
return { op: 'query', view: u.view, since: u.since, level: u.level,
|
||
src: u.src, proto: u.proto, action: u.action, q: u.q, limit: u.limit,
|
||
live: !!u.live, live_sec: u.liveSec || 3 };
|
||
}
|
||
|
||
async function viewFlowLog() {
|
||
$('#view').innerHTML = `<div class="card"><h3>连接与流日志</h3>
|
||
<p class="desc">正在采集各源日志…(防火墙 / 连接跟踪 / WAN / DDNS / 应用 / 系统)</p></div>`;
|
||
const u = ulState();
|
||
if (!u.sources.length) {
|
||
const c = await api('/api/ulog/conf');
|
||
if (c.ok) {
|
||
u.conf = (c.data || {}).conf || {};
|
||
u.sources = (c.data || {}).sources || [];
|
||
u.levels = (c.data || {}).levels || [];
|
||
u.archive = (c.data || {}).archive || {};
|
||
u.caps = (c.data || {}).caps || {};
|
||
u.conntrack = (c.data || {}).conntrack || {};
|
||
u.fwLog = (c.data || {}).fw_log;
|
||
}
|
||
}
|
||
await ulLoad();
|
||
}
|
||
|
||
async function ulLoad() {
|
||
const u = ulState();
|
||
if (u.view === 'flow') {
|
||
const r = await api('/api/ulog/flow', { method: 'POST', body: {
|
||
proto: u.proto, q: u.q, limit: 500 } });
|
||
if (!r.ok) { toast(r.msg_cn || '读取连接表失败', 'err'); return; }
|
||
u.flow = r.data || {};
|
||
renderFlowLog();
|
||
return;
|
||
}
|
||
const r = await api('/api/ulog', { method: 'POST', body: ulQuery() });
|
||
if (!r.ok) { toast(r.msg_cn || '读取日志失败', 'err'); return; }
|
||
const d = r.data || {};
|
||
u.items = d.items || [];
|
||
u.stat = d.stat || {};
|
||
u.meta = d.meta || {};
|
||
u.conf = d.conf || u.conf;
|
||
u.sources = d.sources || u.sources;
|
||
u.levels = d.levels || u.levels;
|
||
u.archive = d.archive || u.archive;
|
||
renderFlowLog();
|
||
}
|
||
|
||
function ulStatChips(stat, map) {
|
||
const e = Object.entries(stat || {});
|
||
if (!e.length) return '<span class="mono" style="color:var(--txt3)">—</span>';
|
||
return e.sort((a, b) => b[1] - a[1]).map(([k, v]) =>
|
||
`<span class="ul-chip"><b>${esc((map || {})[k] || k)}</b> ${v}</span>`).join(' ');
|
||
}
|
||
|
||
function renderFlowLog() {
|
||
const u = ulState();
|
||
const isFlow = u.view === 'flow';
|
||
const srcOpt = u.sources.map(s =>
|
||
`<option value="${esc(s.v)}" ${u.src === s.v ? 'selected' : ''}>${esc(s.n)}</option>`).join('');
|
||
const lvOpt = u.levels.map(l =>
|
||
`<option value="${esc(l.v)}" ${u.level === l.v ? 'selected' : ''}>${esc(l.n)}(${esc(l.v)})</option>`).join('');
|
||
const sinceOpt = UL_PRESET_SINCE.map(p =>
|
||
`<option value="${esc(p.v)}" ${u.since === p.v ? 'selected' : ''}>${esc(p.n)}</option>`).join('');
|
||
|
||
const items = u.items || [];
|
||
const st = u.stat || {};
|
||
|
||
$('#view').innerHTML = `
|
||
<div class="card">
|
||
<h3>连接与流日志
|
||
<span class="tag ${u.conf.enabled ? 'ok' : 'gray'}" style="float:right">
|
||
统一日志 ${u.conf.enabled ? '已开启' : '已关闭'}</span></h3>
|
||
<p class="desc">把防火墙、连接跟踪、WAN 接入、DDNS、应用与系统日志
|
||
<b>规范化为同一种记录结构</b>,可用同一套条件检索、统计与导出。
|
||
归档文件:<span class="mono">${esc((u.archive || {}).path || '/var/log/drouter/ulog.jsonl')}</span>
|
||
(${(u.archive || {}).rows || 0} 条 · ${fmtBytes((u.archive || {}).size || 0)})。</p>
|
||
<div class="row" style="align-items:center;gap:8px;flex-wrap:wrap">
|
||
<div class="seg" style="flex:0 0 auto">
|
||
<button data-ulview="log" class="${isFlow ? '' : 'on'}">统一日志</button>
|
||
<button data-ulview="flow" class="${isFlow ? 'on' : ''}">当前连接</button>
|
||
</div>
|
||
<button class="ghost small fixed" id="ul-refresh">刷新</button>
|
||
${isFlow ? '' : `<label class="switch" title="打开后会订阅 conntrack 实时事件流,
|
||
查询会多等几秒以捕捉这段时间内新建的连接。默认关闭以保证查询速度。">
|
||
<input type="checkbox" id="ul-live" ${u.live ? 'checked' : ''}><i></i>
|
||
抓实时连接事件(${u.liveSec || 3} 秒)</label>`}
|
||
<span class="desc" id="ul-meta" style="margin:0"></span>
|
||
</div>
|
||
${u.fwLog === false && !isFlow ? `<p class="hint-inline" style="color:var(--warn)">
|
||
防火墙日志开关当前为关闭:规则里没有 log 语句,因此「防火墙」源不会有新记录。
|
||
可到「防火墙 IPv4 / IPv6」页打开「记录被拒绝的包」。</p>` : ''}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>筛选与检索</h3>
|
||
<div class="row">
|
||
<label>时间范围<select id="ul-since" ${isFlow ? 'disabled' : ''}>${sinceOpt}</select></label>
|
||
<label>日志源<select id="ul-src">
|
||
<option value="">全部来源</option>${srcOpt}</select></label>
|
||
<label>最低级别<select id="ul-level" ${isFlow ? 'disabled' : ''}>
|
||
<option value="">不限</option>${lvOpt}</select></label>
|
||
<label>协议<select id="ul-proto">
|
||
<option value="">全部</option>
|
||
${['TCP', 'UDP', 'ICMP', 'ICMPv6'].map(p =>
|
||
`<option value="${p}" ${u.proto === p ? 'selected' : ''}>${p}</option>`).join('')}
|
||
</select></label>
|
||
<label>动作<input id="ul-action" value="${esc(u.action)}"
|
||
placeholder="DROP / ACCEPT / NEW…" ${isFlow ? 'disabled' : ''}></label>
|
||
<label style="flex:2 1 260px">关键字
|
||
<input id="ul-q" value="${esc(u.q)}" placeholder="IP、端口、中文描述…"></label>
|
||
<label style="flex:0 0 120px">条数
|
||
<input id="ul-limit" type="number" min="20" max="3000" value="${u.limit}"></label>
|
||
<button class="primary small fixed" id="ul-go">查询</button>
|
||
<button class="ghost small fixed" id="ul-reset">重置</button>
|
||
</div>
|
||
<p class="hint-inline">「最低级别」按严重程度过滤:选「警告」会同时显示错误与警告。
|
||
关键字对中文描述与原始行同时匹配。</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>分布统计</h3>
|
||
<div class="ul-stats">
|
||
<div><b>按来源</b><div>${ulStatChips(st.by_src, UL_SRC_CN)}</div></div>
|
||
<div><b>按级别</b><div>${ulStatChips(st.by_level)}</div></div>
|
||
<div><b>按动作</b><div>${ulStatChips(st.by_action)}</div></div>
|
||
<div><b>按协议</b><div>${ulStatChips(st.by_proto)}</div></div>
|
||
<div><b>来源地址 TOP</b><div>${(st.top_src_ip || []).map(x =>
|
||
`<span class="ul-chip mono">${esc(x.v)} ${x.n}</span>`).join(' ') || '<span class="mono" style="color:var(--txt3)">—</span>'}</div></div>
|
||
<div><b>目的端口 TOP</b><div>${(st.top_dst_port || []).map(x =>
|
||
`<span class="ul-chip mono">:${esc(x.v)} ${x.n}</span>`).join(' ') || '<span class="mono" style="color:var(--txt3)">—</span>'}</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
${isFlow ? ulFlowTable(u) : ulLogTable(items)}
|
||
|
||
<div class="card">
|
||
<h3>保留策略与导出</h3>
|
||
<p class="desc">归档会按下面的策略自动清理(由后台定时任务执行)。开启归档后,
|
||
即使系统 journal 轮转,历史连接与流量日志仍可追溯。</p>
|
||
<div id="ul-daemon" class="hint-inline"></div>
|
||
<div class="row">
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="ul-enabled"
|
||
${u.conf.enabled ? 'checked' : ''}><i></i>启用统一日志</label>
|
||
<label class="switch" style="margin:0"><input type="checkbox" id="ul-archive"
|
||
${u.conf.archive ? 'checked' : ''}><i></i>归档到磁盘</label>
|
||
<label>保留天数<input id="ul-keep-days" type="number" min="1" max="365"
|
||
value="${u.conf.keep_days || 7}" style="width:100px"></label>
|
||
<label>最大条数<input id="ul-keep-rows" type="number" min="1000"
|
||
value="${u.conf.keep_rows || 200000}" style="width:130px"></label>
|
||
<label>归档最低级别<select id="ul-minlevel">
|
||
${(u.levels || []).map(l => `<option value="${esc(l.v)}"
|
||
${u.conf.min_level === l.v ? 'selected' : ''}>${esc(l.n)}</option>`).join('')}
|
||
</select></label>
|
||
</div>
|
||
<h4 style="margin:14px 0 6px;font-size:13px">采集源开关</h4>
|
||
<div class="row" id="ul-srcs">
|
||
${(u.sources || []).map(s => `<label class="switch" style="margin:0;flex:0 0 auto">
|
||
<input type="checkbox" data-ulsrc="${esc(s.v)}"
|
||
${((u.conf.sources || {})[s.v]) ? 'checked' : ''}><i></i>${esc(s.n)}</label>`).join('')}
|
||
</div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="primary small fixed" id="ul-save">保存设置</button>
|
||
<button class="ghost small fixed" id="ul-prune">立即清理</button>
|
||
<button class="ghost small fixed" id="ul-export-json">导出 JSON</button>
|
||
<button class="ghost small fixed" id="ul-export-csv">导出 CSV</button>
|
||
<button class="ghost small fixed danger" id="ul-clear">清空归档</button>
|
||
</div>
|
||
<div id="ul-out"></div>
|
||
</div>
|
||
`;
|
||
ulBind();
|
||
ulLoadDaemon();
|
||
const m = u.meta || {};
|
||
const el = $('#ul-meta');
|
||
if (el) {
|
||
el.textContent = isFlow
|
||
? `连接表 ${((u.flow || {}).stat || {}).total || 0} 条` +
|
||
(((u.flow || {}).cap || {}).max
|
||
? ` · 内核表 ${((u.flow || {}).cap || {}).count || 0}/${((u.flow || {}).cap || {}).max}` : '')
|
||
: `采集 ${m.collected || 0} 条 · 命中 ${m.matched || 0} 条 · 显示 ${m.shown || 0} 条`;
|
||
}
|
||
}
|
||
|
||
function ulLogTable(items) {
|
||
if (!items.length) {
|
||
return '<div class="card"><h3>日志明细</h3><p class="hint-inline">'
|
||
+ '当前条件下没有日志。可放宽时间范围或取消筛选条件;'
|
||
+ '若「防火墙」源为空,请检查防火墙日志开关。</p></div>';
|
||
}
|
||
return `<div class="card">
|
||
<h3>日志明细(${items.length})</h3>
|
||
<div class="ul-table">
|
||
<div class="ul-thead"><span>时间</span><span>来源</span><span>级别</span>
|
||
<span>动作 / 协议</span><span>连接</span><span>描述</span></div>
|
||
${items.map(ulRow).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function ulRow(r) {
|
||
const lv = UL_LEVEL_TAG[r.level] || 'gray';
|
||
return `<div class="ul-trow" data-ulraw="${esc((r.raw || '').slice(0, 400))}">
|
||
<span class="mono" style="font-size:11.5px">${esc((r.ts || '').replace('T', ' '))}</span>
|
||
<span><span class="ul-ico">${UL_SRC_ICON[r.src] || '•'}</span>
|
||
${esc(UL_SRC_CN[r.src] || r.src)}</span>
|
||
<span><span class="tag ${lv}">${esc(r.level_cn || r.level)}</span></span>
|
||
<span class="mono" style="font-size:11.5px">${esc(r.action || '—')}
|
||
${r.proto ? '<br>' + esc(r.proto) : ''}</span>
|
||
<span class="mono" style="font-size:11.5px">${r.saddr
|
||
? esc(r.saddr + (r.sport ? ':' + r.sport : '')) + '<br>→ ' +
|
||
esc(r.daddr + (r.dport ? ':' + r.dport : '')) : '—'}</span>
|
||
<span>${esc(r.msg_cn || '')}${r.iface ? `<br><span class="mono"
|
||
style="font-size:11px;color:var(--txt3)">${esc(r.iface)}</span>` : ''}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function ulFlowTable(u) {
|
||
const f = u.flow || {};
|
||
const rows = f.rows || [];
|
||
const st = f.stat || {};
|
||
if (!rows.length) {
|
||
return `<div class="card"><h3>当前连接</h3>
|
||
<p class="hint-inline">没有读到连接记录${f.err ? ':' + esc(f.err) : ''}。
|
||
若系统未安装 <span class="mono">conntrack</span> 工具或未开启连接跟踪,此页会为空。</p></div>`;
|
||
}
|
||
return `<div class="card">
|
||
<h3>当前连接(${rows.length})
|
||
<span class="tag gray" style="float:right">TCP ${st.tcp || 0} · UDP ${st.udp || 0}
|
||
· ICMP ${st.icmp || 0} · 其他 ${st.other || 0}</span></h3>
|
||
<p class="desc">${esc(f.note || '')}</p>
|
||
<div class="ul-table">
|
||
<div class="ul-thead"><span>协议</span><span>状态</span>
|
||
<span>源地址</span><span>目的地址</span><span>已传输</span><span>方向</span></div>
|
||
${rows.map(r => `<div class="ul-trow">
|
||
<span class="mono">${esc(r.proto)}</span>
|
||
<span><span class="tag ${r.extra && r.extra.state === 'ESTABLISHED' ? 'ok'
|
||
: (r.extra && r.extra.state === 'UNREPLIED' ? 'warn' : 'gray')}">
|
||
${esc((r.extra || {}).state_cn || (r.extra || {}).state || '—')}</span></span>
|
||
<span class="mono" style="font-size:11.5px">${esc(r.saddr)}${r.sport ? ':' + esc(r.sport) : ''}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(r.daddr)}${r.dport ? ':' + esc(r.dport) : ''}</span>
|
||
<span class="mono" style="font-size:11.5px">${r.extra && r.extra.bytes
|
||
? esc(fmtBytes(r.extra.bytes)) : '—'}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(r.action || '')}</span>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function ulBind() {
|
||
const u = ulState(); const on = (sel, ev, fn) => { const e = $(sel); if (e) e[ev] = fn; };
|
||
|
||
$$('[data-ulview]').forEach(b => {
|
||
b.onclick = () => { u.view = b.dataset.ulview; ulLoad(); };
|
||
});
|
||
on('#ul-refresh', 'onclick', () => ulLoad());
|
||
// 实时事件订阅:勾选后立刻按新设置重查一次(它会让查询多等几秒)
|
||
on('#ul-live', 'onchange', e => {
|
||
u.live = !!(e && e.target && e.target.checked);
|
||
ulLoad();
|
||
});
|
||
on('#ul-go', 'onclick', () => {
|
||
u.since = ($('#ul-since') || {}).value || u.since;
|
||
u.src = ($('#ul-src') || {}).value || '';
|
||
u.level = ($('#ul-level') || {}).value || '';
|
||
u.proto = ($('#ul-proto') || {}).value || '';
|
||
u.action = ($('#ul-action') || {}).value || '';
|
||
u.q = ($('#ul-q') || {}).value || '';
|
||
const lim = parseInt(($('#ul-limit') || {}).value, 10);
|
||
u.limit = (isFinite(lim) && lim >= 20) ? Math.min(lim, 3000) : 400;
|
||
ulLoad();
|
||
});
|
||
on('#ul-reset', 'onclick', () => {
|
||
Object.assign(u, { since: '30 min ago', src: '', level: '', proto: '',
|
||
action: '', q: '', limit: 400 });
|
||
ulLoad();
|
||
});
|
||
|
||
// 点行展开原始内容
|
||
$$('.ul-trow').forEach(tr => {
|
||
tr.onclick = () => {
|
||
const raw = tr.dataset.ulraw || '';
|
||
if (!raw) return;
|
||
modal('原始日志内容', `<pre class="mono" style="max-height:420px;overflow:auto;
|
||
background:var(--panel2);padding:12px;border-radius:8px;font-size:12px;
|
||
white-space:pre-wrap">${esc(raw)}</pre>`, () => {}, '关闭', true);
|
||
};
|
||
});
|
||
|
||
on('#ul-save', 'onclick', async () => {
|
||
const srcs = {};
|
||
$$('#ul-srcs [data-ulsrc]').forEach(cb => { srcs[cb.dataset.ulsrc] = !!cb.checked; });
|
||
const r = await api('/api/ulog', { method: 'POST', body: {
|
||
op: 'save_conf',
|
||
enabled: !!($('#ul-enabled') || {}).checked,
|
||
archive: !!($('#ul-archive') || {}).checked,
|
||
keep_days: parseInt(($('#ul-keep-days') || {}).value, 10) || 7,
|
||
keep_rows: parseInt(($('#ul-keep-rows') || {}).value, 10) || 200000,
|
||
min_level: ($('#ul-minlevel') || {}).value || 'debug',
|
||
sources: srcs,
|
||
} });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
if (r.ok) { u.conf = (r.data || {}).conf || u.conf; ulLoad(); }
|
||
});
|
||
|
||
on('#ul-prune', 'onclick', async () => {
|
||
const r = await api('/api/ulog', { method: 'POST', body: { op: 'prune' } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
if (r.ok) ulLoad();
|
||
});
|
||
|
||
const doExport = async (fmt) => {
|
||
const r = await api('/api/ulog', { method: 'POST',
|
||
body: Object.assign(ulQuery(), { op: 'export', format: fmt }) });
|
||
if (!r.ok) { toast(r.msg_cn, 'err'); return; }
|
||
const d = r.data || {};
|
||
const name = 'drouter-log-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')
|
||
+ (fmt === 'csv' ? '.csv' : '.jsonl');
|
||
ulDownload(d.text || '', name);
|
||
toast(r.msg_cn || ('已导出 ' + (d.rows || 0) + ' 条'), 'ok', 7000);
|
||
const out = $('#ul-out');
|
||
if (out) {
|
||
out.innerHTML = `<p class="hint-inline">已导出 <b>${d.rows || 0}</b> 条记录,
|
||
文件名 <span class="mono">${esc(name)}</span>。</p>`;
|
||
}
|
||
};
|
||
on('#ul-export-json', 'onclick', () => doExport('json'));
|
||
on('#ul-export-csv', 'onclick', () => doExport('csv'));
|
||
|
||
on('#ul-clear', 'onclick', () => {
|
||
modal('清空日志归档', `<p>会删除 <span class="mono">/var/log/drouter/ulog.jsonl</span>,
|
||
历史连接与流量日志将<b>永久丢失</b>(不影响系统 journal)。</p><p>确认继续?</p>`,
|
||
async () => {
|
||
const r = await api('/api/ulog', { method: 'POST', body: { op: 'clear_archive' } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
if (r.ok) ulLoad();
|
||
}, '确认清空');
|
||
});
|
||
}
|
||
|
||
async function ulLoadDaemon() {
|
||
const box = $('#ul-daemon');
|
||
if (!box) return;
|
||
const r = await api('/api/ulog/daemon');
|
||
if (!r.ok) { box.innerHTML = ''; return; }
|
||
const d = r.data || {};
|
||
const t = (d.units || {})['drouter-logd.timer'] || {};
|
||
const last = d.last || {};
|
||
const on = t.active === 'active';
|
||
box.innerHTML = `后台采集:<span class="tag ${on ? 'ok' : 'gray'}">
|
||
${on ? '运行中' : '未运行'}</span>
|
||
<span class="mono" style="font-size:11.5px">${esc(d.next || '')}</span>
|
||
${last.ts ? ` 最近一次:<span class="mono" style="font-size:11.5px">${esc(last.ts)}</span>
|
||
${esc(last.msg_cn || '')}` : ''}`;
|
||
}
|
||
|
||
function ulDownload(text, filename) { try {
|
||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = filename;
|
||
document.body.appendChild(a); a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||
} catch (e) {
|
||
toast('导出失败:' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
function preSaveSync() {
|
||
if (S.page === 'portfwd') {
|
||
syncPfRules();
|
||
return ['portfwd', 'nft_v4', 'nft_v6'];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
$('#ab-save').onclick = async () => {
|
||
const mod = currentModule();
|
||
if (S.page === 'acl') {
|
||
const r = await saveAcl(false);
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000);
|
||
setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
return;
|
||
}
|
||
if (S.page === 'nfs') {
|
||
const r = await saveShare(false);
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 7000);
|
||
setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
return;
|
||
}
|
||
preSaveSync(); // 端口转发:先把编辑中的规则同步回配置
|
||
const pm = pageModules();
|
||
if (!pm || !pm.save.length) { toast('当前页面的操作按钮就在页面内部,无需全局保存', 'warn'); return; }
|
||
let allOk = true, msgs = [];
|
||
for (const m of pm.save) {
|
||
const r = await api('/api/config', { method: 'POST', body: { module: m, data: S.cfg[m] || {} } });
|
||
msgs.push(r.msg_cn); if (!r.ok) allOk = false;
|
||
}
|
||
const tail = pm.apply.length ? '' : '(该页面的设置没有独立配置文件,'
|
||
+ '会作为上下文随防火墙 / DHCP 等模块一起生效)';
|
||
toast(msgs.join(';') + tail, allOk ? 'ok' : 'err');
|
||
setActionMsg(msgs.join(';') + tail, allOk ? 'ok' : 'err');
|
||
};
|
||
|
||
$('#ab-apply').onclick = async () => {
|
||
if (S.page === 'acl') {
|
||
const doAcl = async (live) => {
|
||
setActionMsg('正在校验并应用访问控制规则…');
|
||
const r = await saveAcl(live);
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
const r2 = await api('/api/acl');
|
||
if (r2.ok) { S.acl = r2.data || S.acl; renderAcl(); }
|
||
};
|
||
if (S.buildMode) {
|
||
modal('构建保护模式已开启', `<p>当前处于 <b>构建保护模式</b>,规则只会写入
|
||
<span class="mono">/etc/drouter/generated/acl.nft</span>,<b>不会加载到内核</b>,
|
||
以确保不影响正在运行的局域网与 RealVNC 会话。</p>
|
||
<p>确认要把当前规则写入磁盘(暂不生效)?</p>`, () => doAcl(false), '写入磁盘');
|
||
} else {
|
||
modal('应用访问控制规则', `<p>规则会立即加载到内核(表 <span class="mono">inet drouter_acl</span>)。</p>
|
||
<p>如果规则写得不严谨,可能阻断目标设备的正常上网,但不影响路由器本身与其他设备。</p>
|
||
<p>确认继续?</p>`, () => doAcl(true), '确认应用');
|
||
}
|
||
return;
|
||
}
|
||
if (S.page === 'nfs') {
|
||
const doSh = async (live) => {
|
||
setActionMsg('正在校验并应用共享配置…');
|
||
const r = await saveShare(live);
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 9000);
|
||
setActionMsg(r.msg_cn, r.ok ? 'ok' : 'err');
|
||
const r2 = await api('/api/share');
|
||
if (r2.ok) { S.shareData = r2.data; S.share = (r2.data || {}).cfg || S.share; renderShare(); }
|
||
};
|
||
if (S.buildMode) {
|
||
modal('构建保护模式已开启', `<p>当前处于 <b>构建保护模式</b>,配置只会写入
|
||
<span class="mono">/etc/samba/drouter.conf</span> 与 <span class="mono">/etc/exports</span>,
|
||
<b>不会重启 smbd / nfs-server</b>,以确保不影响正在运行的局域网与 RealVNC 会话。</p>
|
||
<p>确认要把当前配置写入磁盘(暂不生效)?</p>`, () => doSh(false), '写入磁盘');
|
||
} else {
|
||
modal('应用文件共享配置', `<p>会写入配置文件并重启对应的共享服务:</p>
|
||
<ul style="font-size:13px;color:var(--txt2);line-height:1.9">
|
||
<li>启用 SMB 时:重启 <span class="mono">smbd</span> / <span class="mono">nmbd</span></li>
|
||
<li>启用 NFS 时:重启 <span class="mono">nfs-server</span> 并重载导出表</li>
|
||
</ul>
|
||
<p>已经挂载的客户端可能会短暂断开,一般会自动重连。</p>
|
||
<p>确认继续?</p>`, () => doSh(true), '确认应用');
|
||
}
|
||
return;
|
||
}
|
||
const pm = pageModules();
|
||
if (!pm) { toast('当前页面的「应用」按钮就在页面内部,无需全局应用', 'warn'); return; }
|
||
|
||
const doApply = async (live) => {
|
||
setActionMsg('正在校验并应用…');
|
||
preSaveSync(); // 端口转发:先把编辑中的规则同步回配置
|
||
let ok = true, msgs = [];
|
||
// 1) 先入库:纯配置模块(system / portfwd)只有这一步
|
||
for (const m of pm.save) {
|
||
const r = await api('/api/config', { method: 'POST', body: { module: m, data: S.cfg[m] || {} } });
|
||
if (!r.ok) { msgs.push(r.msg_cn); ok = false; }
|
||
}
|
||
// 2) 再渲染生效:只有真正有配置文件的模块才走 /api/apply
|
||
for (const m of pm.apply) {
|
||
const r = await api('/api/apply', { method: 'POST', body: { module: m, data: S.cfg[m] || {}, live } });
|
||
msgs.push(r.msg_cn); if (!r.ok) ok = false;
|
||
}
|
||
if (!pm.apply.length) {
|
||
msgs.push('设置已保存(该页面没有独立的配置文件需要渲染,'
|
||
+ '其设置会作为上下文随防火墙 / DHCP 等模块一起生效)');
|
||
}
|
||
toast(msgs.join(';'), ok ? 'ok' : 'err', 8000);
|
||
setActionMsg(msgs.join(';'), ok ? 'ok' : 'err');
|
||
loadAll();
|
||
};
|
||
|
||
const highRisk = ['iface', 'lan', 'wan'].includes(S.page);
|
||
const buildMode = S.buildMode;
|
||
|
||
if (buildMode) {
|
||
modal('构建保护模式已开启', `<p>当前处于 <b>构建保护模式</b>,配置只会写入磁盘,
|
||
<b>不会启动服务、不会改变网络</b>,以确保不影响正在运行的局域网与 RealVNC 会话。</p>
|
||
<p>确认要把当前页面的配置写入磁盘(暂不生效)?</p>`, () => doApply(false), '写入磁盘');
|
||
return;
|
||
}
|
||
if (highRisk) {
|
||
modal('高危操作确认', `<p>当前页面的配置变更会<b>真正生效</b>,可能影响网络连接,包括 RealVNC 远程会话。</p>
|
||
<p>系统会在应用前自动创建配置快照,失败时自动回滚。</p>
|
||
<p>确认继续?</p>`, () => doApply(true), '确认应用');
|
||
} else {
|
||
doApply(true);
|
||
}
|
||
};
|
||
|
||
/* 构建保护模式开关 */
|
||
async function toggleBuildMode(enable) {
|
||
const doIt = async () => {
|
||
const r = await api('/api/buildmode', { method: 'POST', body: { op: enable ? 'on' : 'off', confirm: !enable } });
|
||
toast(r.msg_cn, r.ok ? 'ok' : 'err', 8000);
|
||
if (r.ok) { S.buildMode = enable; loadAll(); go(S.page); }
|
||
};
|
||
if (!enable) {
|
||
modal('关闭构建保护模式', `<p>关闭后,所有「保存并应用」操作将<b>真正生效</b>:</p>
|
||
<ul style="font-size:13px;color:var(--txt2);line-height:1.9">
|
||
<li>DHCP 服务会启动并占用 <span class="mono">53/67</span> 端口</li>
|
||
<li>防火墙规则会立即加载</li>
|
||
<li>网络类改动会导致连接中断</li>
|
||
</ul>
|
||
<p>请确认你已经准备好执行切换。</p>
|
||
<p>输入 <b>确认关闭保护</b> 以继续:</p>
|
||
<input id="cfm-in" placeholder="确认关闭保护">`, async () => {
|
||
if ($('#cfm-in').value.trim() !== '确认关闭保护') { toast('确认文字不正确', 'err'); return false; }
|
||
await doIt();
|
||
}, '关闭保护');
|
||
} else { await doIt(); }
|
||
}
|
||
|
||
/* ============================ 入口 ============================ */
|
||
$('#lg-btn').onclick = doLogin;
|
||
$('#lg-pass').onkeydown = e => { if (e.key === 'Enter') doLogin(); };
|
||
/* 左下角「退出登录」与右上角「刷新数据」已按要求移除(#12):
|
||
退出可在左侧用户区或会话过期后自动跳回;刷新由手动 F5 或各页自带刷新按钮完成。 */
|
||
|
||
/* 侧边栏:宽屏折叠成图标栏,窄屏变成抽屉(手机适配 #14) */
|
||
const SIDE_KEY = 'drouter_side_collapsed';
|
||
const isNarrow = () => !!(typeof window !== 'undefined' && window.matchMedia
|
||
&& window.matchMedia('(max-width:760px)').matches);
|
||
if (localStorage.getItem(SIDE_KEY) === '1') $('#app').classList.add('side-collapsed');
|
||
|
||
/* 关闭手机端抽屉。做成具名函数是因为 go() 里也要调用(选完菜单自动收起)。 */
|
||
function closeDrawer() {
|
||
const el = $('#app');
|
||
if (el) el.classList.remove('m-drawer');
|
||
}
|
||
|
||
$('#btn-side').onclick = () => {
|
||
// 窄屏走抽屉,不写折叠状态——否则回到宽屏时会莫名是折叠的
|
||
if (isNarrow()) { $('#app').classList.toggle('m-drawer'); return; }
|
||
const c = $('#app').classList.toggle('side-collapsed');
|
||
try { localStorage.setItem(SIDE_KEY, c ? '1' : '0'); } catch (e) { /* ignore */ }
|
||
};
|
||
|
||
/* 右上角「刷新」:重新执行当前页面的视图函数,等于原地重进一次。
|
||
图标转一圈给反馈;失败时仍由 go() 的统一错误卡片兜住。 */
|
||
const btnRefresh = $('#btn-refresh');
|
||
if (btnRefresh) {
|
||
btnRefresh.onclick = () => {
|
||
const k = S.page;
|
||
if (!k) return;
|
||
btnRefresh.classList.add('spinning');
|
||
// 先移除动画类再加,保证连续点击也能看到旋转
|
||
setTimeout(() => btnRefresh.classList.remove('spinning'), 700);
|
||
go(k);
|
||
const p = PAGES.find(x => x.k === k) || {};
|
||
toast('已刷新「' + (p.t || k) + '」', 'ok', 1500);
|
||
};
|
||
}
|
||
|
||
/* 左下角「退出系统」:带一次确认,避免误点把正在配置的内容弄丢 */
|
||
const btnLogout = $('#btn-logout');
|
||
if (btnLogout) {
|
||
btnLogout.onclick = () => {
|
||
modal(
|
||
'退出系统',
|
||
'<p class="desc">确定要退出管理后台吗?未保存的配置改动会丢失。</p>',
|
||
() => { logout(false); },
|
||
'退出'
|
||
);
|
||
};
|
||
}
|
||
|
||
/* 抽屉遮罩:点击关闭;Esc 也关闭 */
|
||
const mMask = $('#m-mask');
|
||
if (mMask) mMask.onclick = closeDrawer;
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeDrawer();
|
||
});
|
||
/* 从窄屏拉回宽屏时清掉抽屉状态,否则遮罩会一直挡住页面 */
|
||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||
const mq = window.matchMedia('(max-width:760px)');
|
||
const onW = () => { if (!mq.matches) closeDrawer(); };
|
||
if (mq.addEventListener) mq.addEventListener('change', onW);
|
||
else if (mq.addListener) mq.addListener(onW);
|
||
}
|
||
|
||
/* 表格横向滚动(#14):渲染后统一给 <table> 包一层 .tw。
|
||
全站有 20 多处表格渲染点(还含 ajax 异步刷新的),逐个改不现实,
|
||
这里用 MutationObserver 兜住「直接赋值 innerHTML」和「异步回填」两种路径。 */
|
||
function wrapTables(root) {
|
||
if (!root || typeof root.querySelectorAll !== 'function') return;
|
||
root.querySelectorAll('table').forEach(t => {
|
||
const p = t.parentNode;
|
||
if (!p) return;
|
||
if (p.classList && p.classList.contains('tw')) return; // 已包过
|
||
const w = document.createElement('div');
|
||
w.className = 'tw';
|
||
p.insertBefore(w, t);
|
||
w.appendChild(t);
|
||
});
|
||
}
|
||
(function watchTables() {
|
||
const v = $('#view');
|
||
if (!v || typeof MutationObserver === 'undefined') return;
|
||
let busy = false;
|
||
new MutationObserver(() => {
|
||
if (busy) return;
|
||
busy = true;
|
||
try { wrapTables(v); } catch (e) { /* 忽略:包裹失败只是少了横向滚动 */ }
|
||
busy = false;
|
||
}).observe(v, { childList: true, subtree: true });
|
||
})();
|
||
|
||
/* 菜单搜索:输入时自动展开匹配分组 */
|
||
$('#nav-filter').oninput = debounce(e => {
|
||
S.navKw = e.target.value;
|
||
renderNav();
|
||
}, 140);
|
||
$('#nav-filter').onkeydown = e => {
|
||
if (e.key === 'Escape') { e.target.value = ''; S.navKw = ''; renderNav(); }
|
||
};
|
||
|
||
if (S.token) {
|
||
api('/api/me').then(r => {
|
||
if (r.ok) {
|
||
S.user = r.data.user;
|
||
$('#login').classList.add('hidden');
|
||
$('#app').classList.remove('hidden');
|
||
boot();
|
||
} else { logout(true); }
|
||
});
|
||
}
|