Files
liuzhuohua b7ca9c89e8 feat(i18n/layout): 全站中英双语 + 四轮排版修复收官 + 修 boot_id 重启检测
v1.0.10 两件大事:

1. 界面中英双语(英文界面无中文残留)
   - 新增 web/i18n.js(约 4200 条词条),零依赖零构建
   - 后端下发中文数据走 bt4() 查表翻译;独立模块(upstream/netdetail/
     realtime/update)纳入翻译并注册切语言重绘钩子
   - 后端「状态/聚合」接口只回稳定枚举+原始数值,不下发中文文案
   - 顺带修一个真实 XSS:顶层 const 不挂 window,独立脚本 esc 静默退化成零转义

2. 修掉「文字竖着排/错位」(多分辨率反复暴露)
   - 单元格 min-content 只有 1 字宽 → word-break:keep-all + 表头 nowrap
   - 依赖卡片正文被标签/按钮挤成 27px → .dep-body{flex:1 1 200px} + flex-wrap
   - 裸 flex:1(下限为0)给正文 → 一律改 flex:1 1 <px>
   - .dep-item 横向容器直接塞块级子项 → 退回块级
   - 关键陷阱:auto-fill 布局屏越宽列数越多单卡越窄,窄屏反而测不出来,
     真机审计推进到「逐视图点按钮 + 1920/2560 宽视口 + 中英双语」
   - 守卫 t-layout.py 扩到 35 条,12 处反向验证全红

3. 修真缺陷:_link_since() 重启检测被误改成 if False:(注释还写着「已修」),
   uptime 归零后相减得负数 → 恢复比对 boot_id

附带:新增 25 个检查器(t-*.py)、排版审计工具链(shot-*/ocr-*/live-*)、
版本号兜底值同步到 1.0.10、.gitignore 收纳 i18n/排版排查的一次性脚本。
2026-10-10 04:50:32 +08:00

186 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================ 上游链路详情 ============================
概览页的「上游链路」卡片:IPv4 / IPv6 协议详情 + DHCPv6 统计。
独立于 app.js —— 这块要 ethtool 级的外部命令开销和独立的刷新节奏,
塞进 690KB 的 app.js 会让首屏多背一份重量。
⚠️ 一条重要的诚实性约束:
**「已连接时长」内核不提供**(/sys/class/net/<n>/ 没有该节点,
`ip -s link` 也没有)。所以这里显示「无法获取」而**不编一个数字** ——
用户会拿「已连接 5h55m」当判断依据(是不是要重播?),不准的数字
比没有更糟。
------------------------------------------------------------------------ */
(function () {
'use strict';
if (window.__drouterUpstream) return;
window.__drouterUpstream = true;
const $ = s => document.querySelector(s);
const esc = window.__drouterEsc || (x => {
if (x == null) return '';
if (typeof x === 'object') { try { x = JSON.stringify(x); } catch (e) { /* 循环引用 */ } }
return String(x).replace(/[&<>"']/g,
c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
});
let TIMER = null;
/* DHCPv6 有效期的秒 → 「1h 4m 20s」 */
function lifeFmt(s) {
s = Math.floor(Number(s) || 0);
if (s <= 0) return t('已过期');
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
let out = '';
if (d) out += d + ` ${t('天')} `;
if (h || d) out += h + t(' 小时 ');
out += m + ` ${t('分')} ` + sec + ` ${t('秒')}`;
return out.trim();
}
/* 已连接时长 → 「3d 5h 20m」
⚠️ 后端用 uptime 差值算(内核没这个字段),带 trusted 标记:
trusted=false 说明起点是「drouter 第一次记录到它 up」的时刻,
不一定是接口插上的时刻 —— 这时**必须如实说明**,不能装作精确。 */
function linkUp(u) {
if (u.uptime_s == null) {
return `<span class="tag gray">${t('正在累计')}</span>`;
}
if (!u.uptime_s) {
return `<span class="tag gray">${t('正在累计')}</span>`;
}
const s = Math.floor(u.uptime_s);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
let txt = '';
if (d) txt += d + ` ${t('天')} `;
if (h || d) txt += h + t(' 小时 ');
txt += m + ` ${t('分')} ` + sec + ` ${t('秒')}`;
return `<span class="mono">${esc(txt.trim())}</span>` + (
u.uptime_trusted ? ''
: `<span style="color:var(--txt3);font-size:12px"> ${t('(从面板首次记录该接口在线时开始计,非插线时刻)')}</span>`);
}
function kv(k, v, hint) {
return `<div class="kv"><b>${esc(k)}</b><span>${v}${hint
? `<span style="color:var(--txt3);font-size:12px"> ${esc(hint)}</span>` : ''}</span></div>`;
}
function linkRow(u) {
// 网桥 / MAC:用户排障时最常问的两句「走的是哪个口」「MAC 是多少」
const dev = u.bridge
? `${t('网桥')} <span class="mono">${esc(u.bridge)}</span>`
: `${t('物理接口')} <span class="mono">${esc(u.name)}</span>`;
return kv(t('设备'), dev
+ ' MAC <span class="mono">' + esc(u.mac || '—') + '</span>');
}
function v6Block(u, d6) {
const addrs = (u.addrs || []).map(a =>
`<span class="mono">${esc(a)}</span>`).join('<br>');
let h = `
<div class="card up-card">
<h3>${t('ups.v6')}<span class="tag info">${esc(bt('UPS_PROTO', u.proto_cn, 'en') || u.proto_cn || '—')}</span>
<span class="tag ${u.valid_life ? 'ok' : 'gray'}">
${u.valid_life ? t('地址有效') : t('有效期未知')}</span></h3>
${kv(t('ups.addr'), addrs || '<span class="mono">—</span>')}
${u.gw ? kv(t('ups.gw'), `<span class="mono">${esc(u.gw)}</span>`) : ''}
${kv(t('剩余有效期'), u.valid_life
? `<span class="mono">${esc(lifeFmt(u.valid_life))}</span>`
: `<span class="tag gray">${t('内核不提供该信息')}</span>`,
u.prefer_life ? t('首选期 ') + esc(lifeFmt(u.prefer_life)) : '')}
${kv(t('ups.connected'), linkUp(u))}
${linkRow(u)}
</div>`;
// DHCPv6 统计
if (d6 && d6.length) {
const rows = d6.map(x => {
const bad = /failures|discarded|dropped|error/.test(x.key);
return `<div class="eth-row${bad ? ' eth-warn' : ''}">
<span class="eth-k">${esc(x.label || x.key)}</span>
<span class="eth-v mono ${bad && x.value > 0 ? 'cnt-bad' : ''}">${esc(x.value)}</span>
</div>`;
}).join('');
h += `
<div class="card up-card">
<h3>${t('ups.dhcp6Stat')}<span class="tag gray">${d6.length} ${t('项')}</span></h3>
<p class="desc">${t('来自内核')} <code>/proc/net/snmp6</code> ${t('的 dhcp6s')} ${t('计数器, 累计值(本次开机以来)。「发送失败」「丢弃的包」非')} 0 ${t('值得留意。')}</p>
<div class="eth-grid">${rows}</div>
</div>`;
} else {
h += `
<div class="card up-card">
<h3>DHCPv6 ${t('统计')}</h3>
<div class="eth-box eth-off">
<span class="tag gray">${t('ups.dhcp6Off')}</span>
<span class="eth-reason">${t('ups.dhcp6OffTip')}</span>
</div>
</div>`;
}
return h;
}
function v4Block(u) {
return `
<div class="card up-card">
<h3>${t('ups.v4')}<span class="tag info">${esc(bt('UPS_PROTO', u.proto_cn, 'en') || u.proto_cn || t('静态地址'))}</span>
<span class="tag ok">${t('已连接')}</span></h3>
${kv(t('ups.addr'), `<span class="mono">${esc(u.addr || '—')}</span>`)}
${u.gw ? kv(t('ups.gw'), `<span class="mono">${esc(u.gw)}</span>`) : ''}
${kv('DNS', `<span class="mono">${esc(u.dns || '—')}</span>`,
u.dns && u.dns.includes('127.0.0.1') ? t('本机缓存服务(dnsmasq)') : '')}
${kv(t('ups.connected'), linkUp(u))}
${linkRow(u)}
</div>`;
}
async function render() {
const host = $('#upstream-box');
if (!host) return;
let d = null;
try {
const r = await api('/api/upstream');
if (r && r.ok) d = r.data;
} catch (e) { /* 下面统一处理 */ }
if (!d || (!d.v4 || !d.v4.length) && (!d.v6 || !d.v6.length)) {
host.innerHTML = `<div class="card"><h3>${t('ups.title')}</h3>
<div class="eth-box eth-off">
<span class="tag gray">${t('ups.noUpstream')}</span>
<span class="eth-reason">${t('ups.noUpstreamTip')}</span>
</div></div>`;
return;
}
let h = '';
(d.v4 || []).forEach(u => { h += v4Block(u); });
(d.v6 || []).forEach(u => { h += v6Block(u, d.dhcp6); });
host.innerHTML = h;
}
/* 10 秒刷新一次:上游信息变化很慢(地址租期是小时级),
而每个请求都要 fork 多个 ip 命令。5 秒太密,30 秒又太钝。 */
function start() {
if (TIMER) clearInterval(TIMER);
TIMER = setInterval(() => {
// 离开概览页就停 —— 否则一直在 fork ip/resolvectl
const b = $('#upstream-box');
if (!b) { stop(); return; }
render();
}, 10000);
}
function stop() { if (TIMER) { clearInterval(TIMER); TIMER = null; } }
window.__drouterUpstreamStop = stop;
window.drouterRenderUpstream = () => { render(); start(); };
if (window.i18n) window.i18n.register('upstream',
() => { render(); start(); });
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => render());
}
})();