feat(panel, PANEL_ENHANCE_PLAN 批1-B): 前端五页——首页仪表盘/音频电平/电源/进程服务/系统补强/网络健壮性/PIN 卡
- 新增 #/home 默认路由:单请求 /api/overview + 5s 节拍(电平 1s 子节拍独立), 卡片:设备信息(boot/默认启动)/电量/音频(rms·peak dBFS 双条电平表 -60..0)/WiFi+DNS/ 在跑App与服务健康/存储/性能小条;任一源 available:false 该卡显示「—」。 - 音频页:rms(青绿)+peak(黄) 双条电平表 + deci-dB 数值 + 近30采样 sparkline,轮询提速 1s;无流占位。 - 新增 #/power:电池卡 + 功耗估算卡(est_mw + confidence 徽标 estimated/derived/measured) + 子系统分解堆叠条(screen/wifi/bt/audio/cpu/baseline mW+占比)。 - 新增 #/proc:svcd 服务表(restart 计數>=3 高亮/来源 board|tf) + ams App 表(crashes/launches/last_kind), 行点开经 /api/files 列 MeowOS/logs/<pkg>/ 读最新 stdout_*.log 尾部(崩溃尾巴);全只读,无 restart/kill。 - 系统页补强:电池行、当前运行模式(后端已有 boot.mode/current 则显示) vs 默认启动并列(不一致高亮)、 重启原因/升级状态(后端未暴露时显示「—」)。 - 网络页健壮性(281 缺陷):status 瞬态失败不再整页踢「不可用」占位,改顶部内联错误条 + 5s 自动重试, 数据区保留上次成功内容。 - 设置页 PIN 卡:显示已设/未设/锁定状态,「修改 PIN」(旧+新双输入)与「清空 PIN」(旧 PIN 确认), 走 /api/auth/login 校验旧 PIN + PUT /api/settings/panel.pin。 - 首页置顶导航新增「首页」,默认路由 #/ 或 #/home;侧栏新增「电源」「进程·服务」入口。 验证:headless Chromium --dump-dom 静态样例数据(参照 281),9 页 JS 0 错误, 双主题(md3/miuix × dark/light)通过;proc stdout 尾部支持 5s 重渲染后重开。 仅改 patch_v2/meowpanel/web/。
This commit is contained in:
1 parent
d12164305d
commit
7ba0fde3e6
3 files changed
+768
-34
No files matched your search
@@ -21,6 +21,10 @@
|
|||||||
<span class="brand-name">MeowPanel</span>
|
<span class="brand-name">MeowPanel</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav" id="nav">
|
<nav class="nav" id="nav">
|
||||||
|
<button class="nav-item" data-route="home" title="首页仪表盘(系统总览)">
|
||||||
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 10.5V20h13v-9.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 20v-5h5v5" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="nav-label">首页</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-route="perf" title="实时性能">
|
<button class="nav-item" data-route="perf" title="实时性能">
|
||||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M3 13h4l2-5 3 9 2-4h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M3 13h4l2-5 3 9 2-4h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
<span class="nav-label">性能</span>
|
<span class="nav-label">性能</span>
|
||||||
@@ -33,6 +37,10 @@
|
|||||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 10v4h3l5 4V6l-5 4H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 10v4h3l5 4V6l-5 4H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||||
<span class="nav-label">音频</span>
|
<span class="nav-label">音频</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-route="power" title="电源 / 功耗(meow_powermgr)">
|
||||||
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="nav-label">电源</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-route="net" title="网络管理(meow_netmgrd)">
|
<button class="nav-item" data-route="net" title="网络管理(meow_netmgrd)">
|
||||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M2.5 9a15 15 0 0 1 19 0M5.5 12.5a10 10 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></svg></span>
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M2.5 9a15 15 0 0 1 19 0M5.5 12.5a10 10 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></svg></span>
|
||||||
<span class="nav-label">网络</span>
|
<span class="nav-label">网络</span>
|
||||||
@@ -61,6 +69,10 @@
|
|||||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="8.5" cy="11" r="1.2" fill="currentColor"/><path d="M12 11l3.5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="8.5" cy="11" r="1.2" fill="currentColor"/><path d="M12 11l3.5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>
|
||||||
<span class="nav-label">屏幕</span>
|
<span class="nav-label">屏幕</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-route="proc" title="进程 / 服务(meow_svcd + meow_amsd)">
|
||||||
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="9" cy="6" r="2.2" fill="var(--bg)"/><circle cx="15" cy="12" r="2.2" fill="var(--bg)"/><circle cx="7" cy="18" r="2.2" fill="var(--bg)"/></svg></span>
|
||||||
|
<span class="nav-label">进程·服务</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-route="system" title="系统信息 / 升级">
|
<button class="nav-item" data-route="system" title="系统信息 / 升级">
|
||||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 8h6M9 12h6M9 16h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 8h6M9 12h6M9 16h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||||
<span class="nav-label">系统</span>
|
<span class="nav-label">系统</span>
|
||||||
@@ -82,7 +94,7 @@
|
|||||||
<button class="icon-btn menu-btn" id="menu-btn" title="展开/收起导航" aria-label="菜单">
|
<button class="icon-btn menu-btn" id="menu-btn" title="展开/收起导航" aria-label="菜单">
|
||||||
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="topbar-title" id="page-title">实时性能</h1>
|
<h1 class="topbar-title" id="page-title">首页仪表盘</h1>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<span class="conn" id="conn-dot" data-state="unknown" title="后端连接">
|
<span class="conn" id="conn-dot" data-state="unknown" title="后端连接">
|
||||||
<span class="conn-dot"></span><span class="conn-text" id="conn-text">检测中…</span>
|
<span class="conn-dot"></span><span class="conn-text" id="conn-text">检测中…</span>
|
||||||
@@ -141,6 +153,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改 / 清空面板 PIN 对话框(设置页「面板 PIN」卡) -->
|
||||||
|
<div id="pindlg" class="modal-scrim" hidden>
|
||||||
|
<div class="modal">
|
||||||
|
<h2 class="modal-title" id="pindlg-title">修改 PIN</h2>
|
||||||
|
<p class="modal-body" id="pindlg-body"></p>
|
||||||
|
<input type="password" id="pindlg-old" class="login-input" placeholder="当前 PIN" inputmode="numeric" autocomplete="off" maxlength="4" hidden>
|
||||||
|
<input type="password" id="pindlg-new" class="login-input" placeholder="新 4 位 PIN" inputmode="numeric" autocomplete="off" maxlength="4" hidden>
|
||||||
|
<input type="password" id="pindlg-new2" class="login-input" placeholder="再次输入新 PIN" inputmode="numeric" autocomplete="off" maxlength="4" hidden>
|
||||||
|
<p class="login-err" id="pindlg-err" hidden></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" id="pindlg-cancel">取消</button>
|
||||||
|
<button class="btn btn-primary" id="pindlg-ok">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast" hidden></div>
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
|
||||||
<script src="meowpanel.js"></script>
|
<script src="meowpanel.js"></script>
|
||||||
|
|||||||
@@ -381,3 +381,83 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
|
|||||||
.topbar{ padding:0 10px; gap:8px; }
|
.topbar{ padding:0 10px; gap:8px; }
|
||||||
.seg-btn{ padding:6px 9px; }
|
.seg-btn{ padding:6px 9px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 批1-B:首页仪表盘 / 电平表 / 电源 / 进程 / PIN / 网络横幅 ================= */
|
||||||
|
|
||||||
|
/* 首页卡片网格 */
|
||||||
|
.home-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:16px; }
|
||||||
|
.home-grid .card{ margin-bottom:0; }
|
||||||
|
.home-sub{ color:var(--on-surface-variant); font-size:12px; margin-top:4px; }
|
||||||
|
.home-dash{ font-size:20px; font-weight:700; }
|
||||||
|
.home-unit{ font-size:12px; font-weight:400; color:var(--on-surface-variant); margin-left:4px; }
|
||||||
|
.home-bigval{ font-size:30px; font-weight:700; line-height:1.1; }
|
||||||
|
.home-bigunit{ font-size:14px; font-weight:400; color:var(--on-surface-variant); margin-left:4px; }
|
||||||
|
.home-chip{ display:inline-block; padding:1px 8px; border-radius:999px; font-size:11px; vertical-align:middle; }
|
||||||
|
.home-chip.ok{ background:rgba(76,175,80,.18); color:var(--success); }
|
||||||
|
.home-chip.warn{ background:rgba(255,179,0,.18); color:var(--warning); }
|
||||||
|
.home-chip.bad{ background:rgba(207,102,121,.18); color:var(--error); }
|
||||||
|
.home-chip.mut{ background:var(--surface-variant); color:var(--on-surface-variant); }
|
||||||
|
.home-card-grid{ display:grid; grid-template-columns:1fr 1fr; gap:6px 16px; }
|
||||||
|
.home-kv{ display:flex; justify-content:space-between; gap:10px; font-size:13px; padding:3px 0; }
|
||||||
|
.home-kv .k{ color:var(--on-surface-variant); }
|
||||||
|
.home-kv .v{ text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.dash-strip{ display:flex; align-items:center; gap:8px; margin-top:8px; }
|
||||||
|
|
||||||
|
/* 电平表(rms 青绿 / peak 黄,刻度 -60..0 dBFS) */
|
||||||
|
.lvl{ margin-top:8px; }
|
||||||
|
.lvl-row{ display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
||||||
|
.lvl-lbl{ width:44px; color:var(--on-surface-variant); font-size:12px; font-weight:600; }
|
||||||
|
.lvl-meter{ flex:1; height:14px; border-radius:99px; background:var(--surface-dim); overflow:hidden; border:var(--outline-w) solid var(--outline-variant); }
|
||||||
|
.lvl-fill{ height:100%; border-radius:99px; transition:width .18s ease; }
|
||||||
|
.lvl-fill.rms{ background:#00bfa5; }
|
||||||
|
.lvl-fill.peak{ background:#ffb300; }
|
||||||
|
.lvl-val{ width:58px; text-align:right; font-family:var(--mono); font-size:12px; color:var(--on-surface-variant); }
|
||||||
|
.lvl-scale{ display:flex; justify-content:space-between; color:var(--on-surface-variant); font-size:10px; padding:2px 68px 0 54px; }
|
||||||
|
.lvl-spark{ display:block; width:100%; height:46px; margin-top:8px; }
|
||||||
|
.lvl-spark .grid{ stroke:var(--outline-variant); stroke-width:1; }
|
||||||
|
.lvl-note{ color:var(--on-surface-variant); font-size:12px; margin-top:6px; }
|
||||||
|
|
||||||
|
/* 置信度徽标(measured/derived/estimated) */
|
||||||
|
.conf-badge{ display:inline-flex; align-items:center; gap:5px; padding:3px 12px; border-radius:999px; font-size:12px; font-weight:600; }
|
||||||
|
.conf-badge.measured{ background:rgba(76,175,80,.18); color:var(--success); }
|
||||||
|
.conf-badge.derived{ background:rgba(74,139,245,.18); color:var(--primary); }
|
||||||
|
.conf-badge.estimated{ background:rgba(255,179,0,.18); color:var(--warning); }
|
||||||
|
.conf-badge.none{ background:var(--surface-variant); color:var(--on-surface-variant); }
|
||||||
|
|
||||||
|
/* 子系统分解堆叠条 */
|
||||||
|
.stack{ display:flex; height:24px; border-radius:99px; overflow:hidden; background:var(--surface-dim); border:var(--outline-w) solid var(--outline-variant); margin-top:10px; }
|
||||||
|
.stack > span{ height:100%; }
|
||||||
|
.stack-legend{ display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:12px; font-size:12px; }
|
||||||
|
.stack-legend .sw{ display:inline-block; width:12px; height:12px; border-radius:3px; margin-right:5px; vertical-align:middle; }
|
||||||
|
.stack-legend .seg{ display:inline-flex; align-items:center; }
|
||||||
|
.up-strip{ display:flex; align-items:center; gap:10px; }
|
||||||
|
|
||||||
|
/* 电池展示 */
|
||||||
|
.batt{ display:flex; align-items:center; gap:14px; }
|
||||||
|
.batt-icon{ position:relative; width:40px; height:20px; border:2px solid var(--on-surface-variant); border-radius:4px; overflow:hidden; flex:0 0 40px; }
|
||||||
|
.batt-icon::after{ content:""; position:absolute; right:-5px; top:5px; width:3px; height:8px; background:var(--on-surface-variant); border-radius:0 2px 2px 0; }
|
||||||
|
.batt-icon .lvl-fill{ background:var(--success); }
|
||||||
|
.batt-icon .lvl-fill.warn{ background:var(--warning); }
|
||||||
|
.batt-icon .lvl-fill.bad{ background:var(--error); }
|
||||||
|
.batt-icon .lvl-fill.charge{ background:var(--primary); }
|
||||||
|
|
||||||
|
/* 进程/服务页高亮与行内日志 */
|
||||||
|
.restart-hi{ color:var(--error); font-weight:700; }
|
||||||
|
.crash-hi{ color:var(--error); font-weight:700; }
|
||||||
|
.proc-row{ cursor:pointer; }
|
||||||
|
.proc-tail{ margin-top:8px; }
|
||||||
|
.proc-badge{ display:inline-block; padding:1px 7px; border-radius:999px; font-size:11px; font-family:var(--mono); background:var(--surface-variant); color:var(--on-surface-variant); margin-right:4px; }
|
||||||
|
.proc-badge.board{ background:rgba(74,139,245,.18); color:var(--primary); }
|
||||||
|
.proc-badge.tf{ background:rgba(255,179,0,.18); color:var(--warning); }
|
||||||
|
|
||||||
|
/* 网络页内联错误横幅(不再整页踢「不可用」占位) */
|
||||||
|
.page-banner{ display:flex; align-items:center; gap:12px; padding:10px 14px; border-radius:var(--radius-m); background:rgba(207,102,121,.13); color:var(--error); margin-bottom:14px; border:var(--outline-w) solid var(--outline-variant); font-size:13px; }
|
||||||
|
.page-banner .ico{ font-size:16px; }
|
||||||
|
.page-banner button{ margin-left:auto; }
|
||||||
|
|
||||||
|
/* 设置页 PIN 卡 */
|
||||||
|
.pin-status{ font-size:13px; }
|
||||||
|
.pin-status .dot{ display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||||||
|
.pin-status .dot.set{ background:var(--success); }
|
||||||
|
.pin-status .dot.unset{ background:var(--warning); }
|
||||||
|
.pin-status .dot.locked{ background:var(--error); }
|
||||||
@@ -35,6 +35,86 @@
|
|||||||
function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; }
|
function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; }
|
||||||
function pct(p) { return Math.max(0, Math.min(100, p)); }
|
function pct(p) { return Math.max(0, Math.min(100, p)); }
|
||||||
|
|
||||||
|
/* ---------------- 音频电平表 / 仪表盘工具(deci-dB → dBFS;-990 = -inf) ---------------- */
|
||||||
|
function deciToDb(v) { return (v == null || v <= -990) ? null : v / 10; }
|
||||||
|
function fmtDb(v) {
|
||||||
|
const db = deciToDb(v);
|
||||||
|
if (db == null) return '−∞';
|
||||||
|
return (db > 0 ? '+' : '') + db.toFixed(1);
|
||||||
|
}
|
||||||
|
function dbPct(v) { // -60..0 dBFS 映射到 0..100% 条宽
|
||||||
|
const db = deciToDb(v);
|
||||||
|
if (db == null) return 0;
|
||||||
|
return Math.max(0, Math.min(100, (db + 60) / 60 * 100));
|
||||||
|
}
|
||||||
|
function isAvail(b) { return !!(b && b.available !== false); }
|
||||||
|
const sparkRms = [], sparkPeak = [];
|
||||||
|
const SPARK_MAX = 30;
|
||||||
|
function sparklineHtml() {
|
||||||
|
return '<svg class="lvl-spark" viewBox="0 0 100 46" preserveAspectRatio="none" aria-hidden="true">' +
|
||||||
|
'<line class="grid" x1="0" y1="46" x2="100" y2="46"/>' +
|
||||||
|
'<line class="grid" x1="0" y1="23" x2="100" y2="23"/>' +
|
||||||
|
'<polyline class="spk-rms" points="" fill="none" stroke="#00bfa5" stroke-width="1.6" vector-effect="non-scaling-stroke"/>' +
|
||||||
|
'<polyline class="spk-peak" points="" fill="none" stroke="#ffb300" stroke-width="1.6" vector-effect="non-scaling-stroke"/>' +
|
||||||
|
'</svg>';
|
||||||
|
}
|
||||||
|
function sparklinePoints(arr) {
|
||||||
|
const n = arr.length;
|
||||||
|
if (!n) return '';
|
||||||
|
return arr.map((v, i) => {
|
||||||
|
const x = (i / (SPARK_MAX - 1)) * 100;
|
||||||
|
return x.toFixed(1) + ',' + (46 - Math.max(0, Math.min(100, v)) / 100 * 40).toFixed(1);
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
function pushSpark(v, arr) { // deci-dB → 0..100 百分比入队(<=30 点)
|
||||||
|
arr.push(dbPct(v));
|
||||||
|
if (arr.length > SPARK_MAX) arr.shift();
|
||||||
|
}
|
||||||
|
function drawSpark(rootEl) {
|
||||||
|
const svg = rootEl && rootEl.querySelector('.lvl-spark');
|
||||||
|
if (!svg) return;
|
||||||
|
const r = svg.querySelector('.spk-rms'), p = svg.querySelector('.spk-peak');
|
||||||
|
if (r) r.setAttribute('points', sparklinePoints(sparkRms));
|
||||||
|
if (p) p.setAttribute('points', sparklinePoints(sparkPeak));
|
||||||
|
}
|
||||||
|
function resetSpark() { sparkRms.length = 0; sparkPeak.length = 0; }
|
||||||
|
function levelMeterHtml() {
|
||||||
|
return '<div class="lvl">' +
|
||||||
|
'<div class="lvl-row"><span class="lvl-lbl">RMS</span>' +
|
||||||
|
'<div class="lvl-meter"><div class="lvl-fill rms" style="width:0%"></div></div>' +
|
||||||
|
'<span class="lvl-val" data-val="rms">−∞</span></div>' +
|
||||||
|
'<div class="lvl-row"><span class="lvl-lbl">PEAK</span>' +
|
||||||
|
'<div class="lvl-meter"><div class="lvl-fill peak" style="width:0%"></div></div>' +
|
||||||
|
'<span class="lvl-val" data-val="peak">−∞</span></div>' +
|
||||||
|
'<div class="lvl-scale"><span>-60</span><span>-40</span><span>-20</span><span>0 dBFS</span></div>' +
|
||||||
|
sparklineHtml() + '</div>';
|
||||||
|
}
|
||||||
|
function updateLevelMeter(rootEl, d) {
|
||||||
|
if (!rootEl) return;
|
||||||
|
const rms = (d && d.available) ? d.rms : null;
|
||||||
|
const peak = (d && d.available) ? d.peak : null;
|
||||||
|
const fr = rootEl.querySelector('.lvl-fill.rms');
|
||||||
|
const fp = rootEl.querySelector('.lvl-fill.peak');
|
||||||
|
const vr = rootEl.querySelector('.lvl-val[data-val="rms"]');
|
||||||
|
const vp = rootEl.querySelector('.lvl-val[data-val="peak"]');
|
||||||
|
if (fr) fr.style.width = dbPct(rms) + '%';
|
||||||
|
if (fp) fp.style.width = dbPct(peak) + '%';
|
||||||
|
if (vr) vr.textContent = fmtDb(rms);
|
||||||
|
if (vp) vp.textContent = fmtDb(peak);
|
||||||
|
pushSpark(rms, sparkRms); pushSpark(peak, sparkPeak);
|
||||||
|
drawSpark(rootEl);
|
||||||
|
}
|
||||||
|
function homeKv(k, v) {
|
||||||
|
return '<div class="home-kv"><span class="k">' + esc(k) + '</span><span class="v">' + v + '</span></div>';
|
||||||
|
}
|
||||||
|
function homeUnavailable() { return '<div class="home-sub">— 数据不可用</div>'; }
|
||||||
|
function homeCard(title, body) { return '<div class="card"><h3 class="card-title">' + esc(title) + '</h3>' + body + '</div>'; }
|
||||||
|
function modePill(m) {
|
||||||
|
if (!m) return '<span class="home-chip mut">—</span>';
|
||||||
|
const cls = (m === 'meow') ? 'ok' : ((m === 'hiby') ? 'warn' : 'mut');
|
||||||
|
return '<span class="home-chip ' + cls + '">' + esc(m) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 全局状态 ---------------- */
|
/* ---------------- 全局状态 ---------------- */
|
||||||
const state = {
|
const state = {
|
||||||
style: 'md3', mode: 'dark', // 已解析的风格/明暗(应用到 <html> data-style/data-mode)
|
style: 'md3', mode: 'dark', // 已解析的风格/明暗(应用到 <html> data-style/data-mode)
|
||||||
@@ -419,7 +499,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 2b. 音频页(meow_audiod 状态快照,5s 轮询,只读) ---------------- */
|
/* ---------------- 2b. 音频页(meow_audiod 状态快照,1s 轮询,只读;含 rms/peak 电平可视化) ----------------
|
||||||
|
* 电平:rms(青绿) + peak(黄) 双条 dBFS 表(-60..0 刻度)+ deci-dB 数值 + 近 30 采样 sparkline(客户端自累积)。
|
||||||
|
* 短流时显示「无流」;audiod 不在 → available:false。 */
|
||||||
let audioTimer = null;
|
let audioTimer = null;
|
||||||
function stopAudio() { if (audioTimer) { clearInterval(audioTimer); audioTimer = null; } }
|
function stopAudio() { if (audioTimer) { clearInterval(audioTimer); audioTimer = null; } }
|
||||||
function audioPortName(p) { // audiod numid=9 路由口语义(AUDIOD_DESIGN §5)
|
function audioPortName(p) { // audiod numid=9 路由口语义(AUDIOD_DESIGN §5)
|
||||||
@@ -428,7 +510,7 @@
|
|||||||
return m[p] || ('端口 ' + p);
|
return m[p] || ('端口 ' + p);
|
||||||
}
|
}
|
||||||
function fmtFrames(n) { return (n == null) ? '—' : Number(n).toLocaleString(); }
|
function fmtFrames(n) { return (n == null) ? '—' : Number(n).toLocaleString(); }
|
||||||
function renderAudioData(page, d) {
|
function audioFlowHtml(d) {
|
||||||
let h = '';
|
let h = '';
|
||||||
if (!d || !d.available) {
|
if (!d || !d.available) {
|
||||||
h += '<div class="card"><h3 class="card-title">音频服务(meow_audiod)</h3>' +
|
h += '<div class="card"><h3 class="card-title">音频服务(meow_audiod)</h3>' +
|
||||||
@@ -456,22 +538,454 @@
|
|||||||
row('音量', (d.vol != null && d.vol >= 0 ? esc(d.vol) + ' %' : '—')) +
|
row('音量', (d.vol != null && d.vol >= 0 ? esc(d.vol) + ' %' : '—')) +
|
||||||
'</tbody></table></div>';
|
'</tbody></table></div>';
|
||||||
}
|
}
|
||||||
h += '<p class="muted">每 5 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。</p>';
|
return h;
|
||||||
page.innerHTML = h;
|
|
||||||
}
|
}
|
||||||
async function fetchAudioOnce() {
|
async function fetchAudioOnce() {
|
||||||
const page = $('#audio-page');
|
const flowEl = $('#audio-flow');
|
||||||
if (!page || state.route !== 'audio') return;
|
if (!flowEl || state.route !== 'audio') return;
|
||||||
let d;
|
let d;
|
||||||
try { d = await api('GET', '/api/audio'); }
|
try { d = await api('GET', '/api/audio'); }
|
||||||
catch (e) { if (page && state.route === 'audio') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
catch (e) {
|
||||||
if (state.route === 'audio') renderAudioData(page, d);
|
if (flowEl && state.route === 'audio') flowEl.innerHTML = '<div class="card"><div class="empty">加载失败:' + esc(errText(e)) + '</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.route !== 'audio') return;
|
||||||
|
flowEl.innerHTML = audioFlowHtml(d);
|
||||||
|
updateLevelMeter($('#audio-level'), d);
|
||||||
|
const note = $('#audio-level-note');
|
||||||
|
if (note) {
|
||||||
|
if (!d || !d.available) note.innerHTML = 'audiod 不可用 · 每 1 秒自动刷新 · 只读展示。';
|
||||||
|
else if (!d.active) note.innerHTML = '当前无流 · 电平保持 −∞ · 每 1 秒自动刷新。';
|
||||||
|
else note.innerHTML = '每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function renderAudio() {
|
function renderAudio() {
|
||||||
$('#content').innerHTML = '<div class="page" id="audio-page"><p class="placeholder">加载音频状态…</p></div>';
|
resetSpark(); // 进入页面重置 sparkline 累积
|
||||||
stopAudio();
|
stopAudio();
|
||||||
|
$('#content').innerHTML =
|
||||||
|
'<div class="page" id="audio-page">' +
|
||||||
|
'<div id="audio-flow"><p class="placeholder">加载音频状态…</p></div>' +
|
||||||
|
'<div class="card"><h3 class="card-title">电平(dBFS)</h3>' +
|
||||||
|
'<div id="audio-level">' + levelMeterHtml() + '</div>' +
|
||||||
|
'<p class="muted" id="audio-level-note">每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
fetchAudioOnce();
|
fetchAudioOnce();
|
||||||
audioTimer = setInterval(fetchAudioOnce, 5000);
|
audioTimer = setInterval(fetchAudioOnce, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 2d. 首页仪表盘(#/home,默认路由;/api/overview 聚合,5s 节拍 + 电平 1s 子节拍) ----------------
|
||||||
|
* 单请求 /api/overview 供全部卡片;另低频补充 /api/system(boot/默认启动,静态)+ /api/net/dns(DNS 上游/缓存)。
|
||||||
|
* 任一源 available:false / 缺失 → 该卡显示「—」,绝不拖垮其它卡。 */
|
||||||
|
let homeTimer = null, homeLevelTimer = null;
|
||||||
|
let homeData = null, homeDns = null, homeSys = null;
|
||||||
|
function stopHome() {
|
||||||
|
if (homeTimer) { clearInterval(homeTimer); homeTimer = null; }
|
||||||
|
if (homeLevelTimer) { clearInterval(homeLevelTimer); homeLevelTimer = null; }
|
||||||
|
}
|
||||||
|
/* 设备信息卡:版本/hostname/内核/架构/运行模式/默认启动(boot 来自 /api/system 一次性拉取) */
|
||||||
|
function deviceCard() {
|
||||||
|
const p = (homeData && homeData.perf && homeData.perf.system);
|
||||||
|
const s = homeSys;
|
||||||
|
const ver = (s && s.version) || (p && p.version) || '—';
|
||||||
|
const host = (s && s.hostname) || (p && p.hostname) || '—';
|
||||||
|
const ker = (s && s.kernel) || (p && p.kernel) || '—';
|
||||||
|
const arch = (s && s.arch) || (p && p.arch) || '—';
|
||||||
|
const bootD = (s && s.boot && s.boot.default) || '—';
|
||||||
|
const cur = (s && s.boot) ? (s.boot.mode || s.boot.current || null) : null;
|
||||||
|
let rows = '';
|
||||||
|
rows += homeKv('版本', esc(ver));
|
||||||
|
rows += homeKv('主机名', esc(host));
|
||||||
|
rows += homeKv('内核', esc(ker));
|
||||||
|
rows += homeKv('架构', esc(arch));
|
||||||
|
rows += homeKv('运行模式', modePill(cur));
|
||||||
|
rows += homeKv('默认启动', modePill(bootD));
|
||||||
|
return homeCard('设备信息', '<div class="home-card-grid">' + rows + '</div>');
|
||||||
|
}
|
||||||
|
function batteryCard() {
|
||||||
|
const pw = homeData && homeData.power;
|
||||||
|
if (!isAvail(pw)) return homeCard('电量 / 充电', homeUnavailable());
|
||||||
|
const b = pw.battery || {};
|
||||||
|
const pct = (b.pct != null) ? b.pct : null;
|
||||||
|
const state = b.state || '—';
|
||||||
|
const charging = (state === 'Charging' || state === 'Full');
|
||||||
|
const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : ''));
|
||||||
|
let inner = '<div class="batt"><span class="batt-icon"><span class="lvl-fill ' + fillCls + '" style="width:' + (pct == null ? 0 : pct) + '%"></span></span>' +
|
||||||
|
'<span class="home-bigval">' + (pct == null ? '—' : pct) + '</span><span class="home-bigunit">%</span>' +
|
||||||
|
'<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(state) + '</span></div>';
|
||||||
|
inner += '<div class="home-card-grid">' +
|
||||||
|
homeKv('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') +
|
||||||
|
homeKv('健康', b.health ? esc(b.health) : '—') +
|
||||||
|
homeKv('温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
|
||||||
|
(b.est_minutes_left != null ? homeKv('续航估算', b.est_minutes_left + ' 分钟') :
|
||||||
|
(b.est_minutes_full != null ? homeKv('充满估算', b.est_minutes_full + ' 分钟') : '')) +
|
||||||
|
'</div>';
|
||||||
|
return homeCard('电量 / 充电', inner);
|
||||||
|
}
|
||||||
|
function audioCard() {
|
||||||
|
const ad = homeData && homeData.audio;
|
||||||
|
if (!isAvail(ad)) return homeCard('音频', homeUnavailable());
|
||||||
|
let body = '';
|
||||||
|
if (ad.active) {
|
||||||
|
body += '<div class="home-card-grid">' +
|
||||||
|
homeKv('流', esc(ad.app || '—') + ' · ' + esc(ad.rate) + 'Hz ' + esc(ad.fmt || '')) +
|
||||||
|
homeKv('输出口', audioPortName(ad.port)) +
|
||||||
|
homeKv('音量', (ad.vol != null && ad.vol >= 0 ? esc(ad.vol) + ' %' : '—')) +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
|
body += '<div class="home-sub">当前无活跃音频流。</div>';
|
||||||
|
}
|
||||||
|
body += '<div id="home-audio-level">' + levelMeterHtml() + '</div>' +
|
||||||
|
'<p class="muted" id="home-audio-note">' + (ad.active ? '电平 · 1s 子节拍' : '无流') + '</p>';
|
||||||
|
return homeCard('音频', body);
|
||||||
|
}
|
||||||
|
function netCard() {
|
||||||
|
const nd = homeData && homeData.net;
|
||||||
|
if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable());
|
||||||
|
const w = nd.wifi || {};
|
||||||
|
const dn = homeDns;
|
||||||
|
let rows = '';
|
||||||
|
rows += homeKv('WiFi', w.connected ? ('<span class="home-chip ok">已连接</span> ' + esc(w.ssid || '')) : '<span class="home-chip warn">未连接</span>');
|
||||||
|
if (w.connected) {
|
||||||
|
rows += homeKv('IP', esc(w.ip || '—'));
|
||||||
|
rows += homeKv('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—'));
|
||||||
|
}
|
||||||
|
if (dn) {
|
||||||
|
rows += homeKv('DNS 上游', esc(dn.upstream || '—'));
|
||||||
|
rows += homeKv('DNS 缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0) + (dn.enabled ? '' : '(停用)'));
|
||||||
|
} else {
|
||||||
|
rows += homeKv('DNS', '<span class="home-chip mut">—</span>');
|
||||||
|
}
|
||||||
|
return homeCard('WiFi / DNS', '<div class="home-card-grid">' + rows + '</div>');
|
||||||
|
}
|
||||||
|
function healthCard() {
|
||||||
|
const ams = homeData && homeData.ams;
|
||||||
|
const svcd = homeData && homeData.svcd;
|
||||||
|
let appTxt = '—', svcBad = 0, svcHi = 0, svcTxt = '—';
|
||||||
|
if (isAvail(ams)) {
|
||||||
|
const apps = ams.apps || [];
|
||||||
|
const run = apps.filter(a => a.state === 'fg' || a.state === 'bg').length;
|
||||||
|
appTxt = run + ' 在跑 / ' + (ams.count || apps.length) + ' 已装';
|
||||||
|
}
|
||||||
|
let svcHtml;
|
||||||
|
if (isAvail(svcd)) {
|
||||||
|
const svcs = svcd.services || [];
|
||||||
|
svcBad = svcs.filter(s => s.state === 'failed').length;
|
||||||
|
svcHi = svcs.filter(s => (s.restart || 0) >= 3).length;
|
||||||
|
svcTxt = svcs.filter(s => s.state === 'running').length + ' 运行中';
|
||||||
|
svcHtml = '';
|
||||||
|
if (svcBad) svcHtml += '<span class="home-chip bad">' + svcBad + ' FAILED</span> ';
|
||||||
|
if (svcHi) svcHtml += '<span class="home-chip warn">' + svcHi + ' 高重启</span> ';
|
||||||
|
if (!svcBad && !svcHi) svcHtml += '<span class="home-chip ok">正常</span> ';
|
||||||
|
} else {
|
||||||
|
svcHtml = '<span class="home-chip mut">不可用</span> ';
|
||||||
|
svcTxt = '—';
|
||||||
|
}
|
||||||
|
return homeCard('在跑 App 与服务', '<div class="home-card-grid">' +
|
||||||
|
homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) + '</div>');
|
||||||
|
}
|
||||||
|
function storageCard() {
|
||||||
|
const perf = homeData && homeData.perf;
|
||||||
|
if (!isAvail(perf)) return homeCard('存储', homeUnavailable());
|
||||||
|
const st = perf.storage || {}, tf = perf.tfcard || {};
|
||||||
|
let rows = homeKv('rootfs', (st.used != null ? fmtBytes(st.used * 1024) : '—') + ' / ' + (st.total != null ? fmtBytes(st.total * 1024) : '—'));
|
||||||
|
rows += (tf.present) ?
|
||||||
|
homeKv('TF 卡', fmtBytes((tf.used || 0) * 1024) + ' / ' + fmtBytes((tf.total || 0) * 1024)) :
|
||||||
|
homeKv('TF 卡', '<span class="home-chip mut">未插卡</span>');
|
||||||
|
return homeCard('存储', '<div class="home-card-grid">' + rows + '</div>');
|
||||||
|
}
|
||||||
|
function perfCard() {
|
||||||
|
const perf = homeData && homeData.perf;
|
||||||
|
if (!isAvail(perf)) return homeCard('性能小条', homeUnavailable());
|
||||||
|
const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {};
|
||||||
|
const cpuU = cpu.usage;
|
||||||
|
const memP = mem.total ? (mem.used / mem.total) * 100 : 0;
|
||||||
|
return homeCard('性能小条', '<div class="stat-grid" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr))">' +
|
||||||
|
'<div class="stat"><div class="stat-label">CPU</div><div class="stat-value">' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '<span class="stat-unit">%</span></div>' + barHtml(cpuU || 0) + '</div>' +
|
||||||
|
'<div class="stat"><div class="stat-label">内存</div><div class="stat-value">' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '</div>' + barHtml(mem.used && mem.total ? memP : 0) + '</div>' +
|
||||||
|
'<div class="stat"><div class="stat-label">温度</div><div class="stat-value">' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '<span class="stat-unit">℃</span>' : '—') + '</div></div>' +
|
||||||
|
'</div>');
|
||||||
|
}
|
||||||
|
function renderHomeCards() {
|
||||||
|
const body = $('#home-body');
|
||||||
|
if (!body || state.route !== 'home') return;
|
||||||
|
if (!homeData) { body.innerHTML = '<p class="placeholder">加载总览…</p>'; return; }
|
||||||
|
let h = '<div class="home-grid">' +
|
||||||
|
deviceCard() + batteryCard() + audioCard() + netCard() + healthCard() + storageCard() + perfCard() +
|
||||||
|
'</div>';
|
||||||
|
body.innerHTML = h;
|
||||||
|
const ad = homeData.audio;
|
||||||
|
if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad);
|
||||||
|
}
|
||||||
|
async function fetchHomeSystem() { // 静态设备信息(boot/默认启动);进入时拉一次,失败不阻塞
|
||||||
|
try { homeSys = await api('GET', '/api/system'); } catch (e) { homeSys = null; }
|
||||||
|
if (state.route === 'home') renderHomeCards();
|
||||||
|
}
|
||||||
|
async function fetchHomeLevel() { // 电平 1s 子节拍(独立于 overview 5s)
|
||||||
|
if (state.route !== 'home') return;
|
||||||
|
const el = $('#home-audio-level'); if (!el) return;
|
||||||
|
let d;
|
||||||
|
try { d = await api('GET', '/api/audio'); } catch (e) { return; }
|
||||||
|
if (state.route !== 'home') return;
|
||||||
|
updateLevelMeter(el, d);
|
||||||
|
const note = $('#home-audio-note');
|
||||||
|
if (note) note.textContent = (!d || !d.available) ? 'audiod 不可用' : (d.active ? '电平 · 1s 子节拍' : '无流');
|
||||||
|
}
|
||||||
|
async function fetchOverviewOnce() {
|
||||||
|
if (state.route !== 'home') return;
|
||||||
|
const r = await Promise.allSettled([api('GET', '/api/net/dns'), api('GET', '/api/overview')]);
|
||||||
|
if (r[1].status !== 'fulfilled') {
|
||||||
|
const b = $('#home-banner');
|
||||||
|
if (b) {
|
||||||
|
b.innerHTML = '<span class="ico">⚠️</span><span>总览加载失败:' + esc(errText(r[1].reason)) + '</span><button class="btn" id="home-retry">重试</button>';
|
||||||
|
b.hidden = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = $('#home-banner'); if (b) { b.hidden = true; b.innerHTML = ''; }
|
||||||
|
homeDns = (r[0].status === 'fulfilled') ? r[0].value : null;
|
||||||
|
homeData = r[1].value;
|
||||||
|
renderHomeCards();
|
||||||
|
}
|
||||||
|
function renderHome() {
|
||||||
|
stopHome();
|
||||||
|
resetSpark(); // 进入首页重置 sparkline 累积
|
||||||
|
homeData = null; homeDns = null; homeSys = null;
|
||||||
|
$('#content').innerHTML =
|
||||||
|
'<div class="page" id="home-page">' +
|
||||||
|
'<div id="home-banner" class="page-banner" hidden></div>' +
|
||||||
|
'<div id="home-body"><p class="placeholder">加载总览…</p></div>' +
|
||||||
|
'</div>';
|
||||||
|
fetchHomeSystem();
|
||||||
|
fetchOverviewOnce();
|
||||||
|
homeTimer = setInterval(fetchOverviewOnce, 5000);
|
||||||
|
homeLevelTimer = setInterval(fetchHomeLevel, 1000);
|
||||||
|
}
|
||||||
|
function bindHome() {
|
||||||
|
$('#content').addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('#home-retry')) { fetchOverviewOnce(); return; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 2e. 电源 / 功耗页(#/power;/api/power,只读) ----------------
|
||||||
|
* 电池卡 + 功耗估算卡(est_mw + confidence 徽标,必须如实标注)+ 子系统分解堆叠条。 */
|
||||||
|
let powerTimer = null;
|
||||||
|
function stopPower() { if (powerTimer) { clearInterval(powerTimer); powerTimer = null; } }
|
||||||
|
function batteryCardBody(b) {
|
||||||
|
if (!b) return homeUnavailable();
|
||||||
|
const pct = (b.pct != null) ? b.pct : null;
|
||||||
|
const state = b.state || '—';
|
||||||
|
const charging = (state === 'Charging' || state === 'Full');
|
||||||
|
const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : ''));
|
||||||
|
let h = '<div class="batt"><span class="batt-icon"><span class="lvl-fill ' + fillCls + '" style="width:' + (pct == null ? 0 : pct) + '%"></span></span>' +
|
||||||
|
'<span class="home-bigval">' + (pct == null ? '—' : pct) + '</span><span class="home-bigunit">%</span>' +
|
||||||
|
'<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(state) + '</span></div>';
|
||||||
|
h += '<table class="table"><tbody>' +
|
||||||
|
row('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') +
|
||||||
|
row('健康', b.health ? esc(b.health) : '—') +
|
||||||
|
row('技术', b.technology ? esc(b.technology) : '—') +
|
||||||
|
row('电池温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
|
||||||
|
row('充电器温度', b.charger_temp_c10 != null ? (b.charger_temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
|
||||||
|
row('循环次数', b.cycle_count != null ? b.cycle_count : '—') +
|
||||||
|
row('放电速率', b.rate_pct_min_x100 != null ? ((b.rate_pct_min_x100 / 100).toFixed(2) + ' %/min') : '—') +
|
||||||
|
row('续航估算', b.est_minutes_left != null ? (b.est_minutes_left + ' 分钟(剩余)') : (b.est_minutes_full != null ? (b.est_minutes_full + ' 分钟(充满)') : '—')) +
|
||||||
|
'</tbody></table>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function estimateCardBody(est) {
|
||||||
|
if (!est) return homeUnavailable();
|
||||||
|
if (est.est_mw == null) return '<div class="home-sub">' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '</div>';
|
||||||
|
const conf = est.confidence || 'estimated';
|
||||||
|
const cls = conf === 'measured' ? 'measured' : (conf === 'derived' ? 'derived' : (conf === 'none' ? 'none' : 'estimated'));
|
||||||
|
return '<div class="up-strip"><span class="home-bigval">' + est.est_mw + '</span><span class="home-bigunit">mW</span>' +
|
||||||
|
'<span class="conf-badge ' + cls + '">' + esc(conf) + (est.calibrated ? ' · 已标定' : ' · 经验值') + '</span></div>' +
|
||||||
|
'<p class="muted">Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。</p>';
|
||||||
|
}
|
||||||
|
function breakdownCardBody(bd) {
|
||||||
|
if (!bd) return homeUnavailable();
|
||||||
|
if (bd.degraded) return '<div class="home-sub">系统分解不可用(HibyOS 只读模式)</div>';
|
||||||
|
const comps = [
|
||||||
|
{ label: '屏幕(背光)', mw: bd.backlight_mw, color: '#4a8bf5' },
|
||||||
|
{ label: 'WiFi', mw: bd.wifi_mw, color: '#00bfa5' },
|
||||||
|
{ label: '蓝牙', mw: bd.bt_mw, color: '#ab47bc' },
|
||||||
|
{ label: '音频', mw: bd.audio_mw, color: '#ffb300' },
|
||||||
|
{ label: 'CPU', mw: bd.cpu_mw, color: '#ff7043' },
|
||||||
|
{ label: '基线', mw: bd.baseline_mw, color: '#8d6e63' }
|
||||||
|
].filter(c => c.mw != null && c.mw >= 0);
|
||||||
|
const total = comps.reduce((a, c) => a + c.mw, 0);
|
||||||
|
let stack = '<div class="stack">';
|
||||||
|
comps.forEach(c => { stack += '<span style="width:' + (total > 0 ? (c.mw / total * 100) : 0) + '%;background:' + c.color + '" title="' + esc(c.label) + ': ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)"></span>'; });
|
||||||
|
stack += '</div>';
|
||||||
|
let legend = '<div class="stack-legend">';
|
||||||
|
comps.forEach(c => {
|
||||||
|
legend += '<span class="seg"><span class="sw" style="background:' + c.color + '"></span>' + esc(c.label) + ' ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)</span>';
|
||||||
|
});
|
||||||
|
legend += '</div>';
|
||||||
|
if (bd.cpu_util_pct != null || bd.brightness_pct != null || bd.wifi_state || bd.bt_state || bd.audio_state) {
|
||||||
|
legend += '<p class="muted">' +
|
||||||
|
(bd.cpu_util_pct != null ? 'CPU 利用率 ' + esc(bd.cpu_util_pct) + '% ' : '') +
|
||||||
|
(bd.brightness_pct != null ? '· 亮度 ' + esc(bd.brightness_pct) + '% ' : '') +
|
||||||
|
(bd.wifi_state ? '· WiFi ' + esc(bd.wifi_state) : '') +
|
||||||
|
(bd.bt_state ? ' · BT ' + esc(bd.bt_state) : '') +
|
||||||
|
(bd.audio_state ? ' · 音频 ' + esc(bd.audio_state) : '') + '</p>';
|
||||||
|
}
|
||||||
|
return '<div class="home-sub">合计 ' + total + ' mW(分解为经验估算)</div>' + stack + legend;
|
||||||
|
}
|
||||||
|
function renderPowerBody(d) {
|
||||||
|
let h = '<div class="page" id="power-page">';
|
||||||
|
if (d && d.mode === 'readonly') h += '<div class="page-banner"><span class="ico">ℹ️</span><span>当前为电源只读观测(HibyOS 模式),仅显示电池采样,无功耗估算/分解。</span></div>';
|
||||||
|
h += '<div class="card"><h3 class="card-title">电池</h3>' + ((d && d.battery) ? batteryCardBody(d.battery) : homeUnavailable()) + '</div>';
|
||||||
|
h += '<div class="card"><h3 class="card-title">功耗估算</h3>' + ((d && d.estimate) ? estimateCardBody(d.estimate) : homeUnavailable()) + '</div>';
|
||||||
|
h += '<div class="card"><h3 class="card-title">子系统分解</h3>' + ((d && d.breakdown) ? breakdownCardBody(d.breakdown) : homeUnavailable()) + '</div>';
|
||||||
|
if (d && d.policy == null) h += '<p class="muted" style="margin-top:6px">电源策略(profile 四档/低电阈值)为 期3 功能,此处以不可用占位。</p>';
|
||||||
|
h += '</div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
async function fetchPowerOnce() {
|
||||||
|
const el = $('#power-page');
|
||||||
|
if (!el || state.route !== 'power') return;
|
||||||
|
let d;
|
||||||
|
try { d = await api('GET', '/api/power'); }
|
||||||
|
catch (e) { if (el && state.route === 'power') el.innerHTML = '<div class="page" id="power-page">' + emptyState('加载失败:' + errText(e), e.code) + '</div>'; return; }
|
||||||
|
if (state.route !== 'power') return;
|
||||||
|
el.innerHTML = renderPowerBody(d);
|
||||||
|
}
|
||||||
|
function renderPower() {
|
||||||
|
stopPower();
|
||||||
|
$('#content').innerHTML = '<div class="page" id="power-page"><p class="placeholder">加载电源数据…</p></div>';
|
||||||
|
fetchPowerOnce();
|
||||||
|
powerTimer = setInterval(fetchPowerOnce, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 2f. 进程 / 服务页(#/proc;/api/svcd + /api/ams,只读,无 restart/kill) ----------------
|
||||||
|
* svcd 服务表(state/pid/restart 计数≥3 高亮/exec/来源 board|tf)+ ams App 表(state/pid/crashes/launches/last_kind);
|
||||||
|
* 行点开经 /api/files 列 MeowOS/logs/<pkg>/ 找最新 stdout_*.log,再用 /api/logs/read 读尾部(崩溃尾巴)。 */
|
||||||
|
let procTimer = null;
|
||||||
|
let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空)
|
||||||
|
function stopProc() { if (procTimer) { clearInterval(procTimer); procTimer = null; } }
|
||||||
|
function svcStatePill(s) {
|
||||||
|
const map = { running: ['ok', '运行'], stopped: ['mut', '已停'], stopping: ['warn', '停止中'], disabled: ['mut', '禁用'], failed: ['bad', '失败'] };
|
||||||
|
const m = map[s] || ['mut', s || '—'];
|
||||||
|
return '<span class="h-pill ' + m[0] + '">' + esc(m[1]) + '</span>';
|
||||||
|
}
|
||||||
|
function appStatePill(s) {
|
||||||
|
const map = { fg: ['ok', '前台'], bg: ['warn', '后台'], stop: ['mut', '已停'] };
|
||||||
|
const m = map[s] || ['mut', s || '—'];
|
||||||
|
return '<span class="h-pill ' + m[0] + '">' + esc(m[1]) + '</span>';
|
||||||
|
}
|
||||||
|
function srcPill(src) {
|
||||||
|
const cls = (src === 'board') ? 'board' : (src === 'tf' ? 'tf' : '');
|
||||||
|
return src ? '<span class="proc-badge ' + cls + '">' + esc(src) + '</span>' : '—';
|
||||||
|
}
|
||||||
|
function renderProcBody(ams, svcd) {
|
||||||
|
let h = '<div class="page" id="proc-page">';
|
||||||
|
h += '<div class="card"><h3 class="card-title">服务(meow_svcd)</h3>';
|
||||||
|
if (svcd && svcd.available) {
|
||||||
|
const svcs = svcd.services || [];
|
||||||
|
h += '<table class="table"><thead><tr><th>服务</th><th>状态</th><th>PID</th><th>重启</th><th>退避</th><th>触发</th><th>来源</th><th>退出</th><th>执行</th></tr></thead><tbody>';
|
||||||
|
if (!svcs.length) h += '<tr><td colspan="9" class="empty">暂无服务</td></tr>';
|
||||||
|
svcs.forEach(s => {
|
||||||
|
h += '<tr><td class="mono">' + esc(s.name) + '</td>' +
|
||||||
|
'<td>' + svcStatePill(s.state) + '</td>' +
|
||||||
|
'<td>' + (s.pid > 0 ? esc(s.pid) : '—') + '</td>' +
|
||||||
|
'<td class="' + ((s.restart || 0) >= 3 ? 'restart-hi' : 'muted') + '">' + (s.restart || 0) + ((s.restart || 0) >= 3 ? ' ⚠' : '') + '</td>' +
|
||||||
|
'<td class="muted">' + esc(s.backoff_ms || '—') + '</td>' +
|
||||||
|
'<td class="muted">' + esc(s.trigger || '—') + '</td>' +
|
||||||
|
'<td>' + srcPill(s.src) + '</td>' +
|
||||||
|
'<td class="muted">' + esc(s.exit || '—') + '</td>' +
|
||||||
|
'<td class="mono muted">' + esc(s.exec || '—') + '</td></tr>';
|
||||||
|
});
|
||||||
|
h += '</tbody></table>';
|
||||||
|
} else h += homeUnavailable();
|
||||||
|
h += '</div>';
|
||||||
|
h += '<div class="card"><h3 class="card-title">App(meow_amsd,只读)</h3>';
|
||||||
|
if (ams && ams.available) {
|
||||||
|
const apps = ams.apps || [];
|
||||||
|
h += '<table class="table"><thead><tr><th>包名</th><th>名称</th><th>状态</th><th>PID</th><th>启动</th><th>崩溃</th><th>上次退出</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||||||
|
if (!apps.length) h += '<tr><td colspan="8" class="empty">无可观测 App</td></tr>';
|
||||||
|
apps.forEach(a => {
|
||||||
|
const openHere = expandedProc && expandedProc.pkg === a.pkg;
|
||||||
|
h += '<tr><td class="mono">' + esc(a.pkg) + '</td><td>' + esc(a.name || '—') + '</td>' +
|
||||||
|
'<td>' + appStatePill(a.state) + '</td><td>' + (a.pid > 0 ? esc(a.pid) : '—') + '</td>' +
|
||||||
|
'<td class="muted">' + esc(a.launches || 0) + '</td>' +
|
||||||
|
'<td class="' + ((a.crashes || 0) > 0 ? 'crash-hi' : 'muted') + '">' + (a.crashes || 0) + ((a.crashes || 0) > 0 ? ' ⚠' : '') + '</td>' +
|
||||||
|
'<td class="muted">' + esc(a.last_kind || '—') + (a.last_code != null && a.last_code !== 0 ? ' (' + esc(a.last_code) + ')' : '') + '</td>' +
|
||||||
|
'<td><div class="row-actions"><button class="btn btn-ghost" data-applog="' + esc(a.pkg) + '">' + (openHere ? '收起' : 'stdout 尾部') + '</button></div></td></tr>';
|
||||||
|
if (openHere) {
|
||||||
|
const lines = (expandedProc.lines || []);
|
||||||
|
h += '<tr class="proc-tail"><td colspan="8"><pre class="log-view">' + esc(lines.join('\n') || '(空)') +
|
||||||
|
(expandedProc.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
|
||||||
|
'<div class="muted">' + esc(a.pkg + '/' + expandedProc.file) + ' · 末尾 ' + lines.length + ' 行</div></td></tr>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
h += '</tbody></table>';
|
||||||
|
h += '<p class="muted">点击「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。仅只读,无 restart/kill。</p>';
|
||||||
|
} else h += homeUnavailable();
|
||||||
|
h += '</div></div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
async function newestStdout(pkg) {
|
||||||
|
try {
|
||||||
|
const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + pkg + '/'));
|
||||||
|
const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0);
|
||||||
|
if (!files.length) return null;
|
||||||
|
files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
||||||
|
return files[0].name;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
async function viewAppStdout(pkg, btn) {
|
||||||
|
const row = btn.closest('tr'); if (!row) return;
|
||||||
|
const next = row.nextElementSibling;
|
||||||
|
if (next && next.classList && next.classList.contains('proc-tail')) { next.remove(); expandedProc = null; btn.textContent = 'stdout 尾部'; return; }
|
||||||
|
if (expandedProc && expandedProc.pkg !== pkg) { // 先收起上一个 opened
|
||||||
|
const prevTail = document.querySelector('tr.proc-tail');
|
||||||
|
if (prevTail) prevTail.remove();
|
||||||
|
const prevBtn = document.querySelector('[data-applog="' + escSafeAttr(expandedProc.pkg) + '"]');
|
||||||
|
if (prevBtn) prevBtn.textContent = 'stdout 尾部';
|
||||||
|
expandedProc = null;
|
||||||
|
}
|
||||||
|
btn.textContent = '加载中…';
|
||||||
|
const tailTr = document.createElement('tr');
|
||||||
|
tailTr.className = 'proc-tail';
|
||||||
|
tailTr.innerHTML = '<td colspan="8"><div class="muted">读取 ' + esc(pkg) + ' 最新 stdout…</div></td>';
|
||||||
|
row.after(tailTr);
|
||||||
|
try {
|
||||||
|
const file = await newestStdout(pkg);
|
||||||
|
if (!file) { tailTr.innerHTML = '<td colspan="8"><div class="empty">未找到 ' + esc(pkg) + ' 的 stdout 日志</div></td>'; btn.textContent = 'stdout 尾部'; return; }
|
||||||
|
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200');
|
||||||
|
const lines = (d.lines || []);
|
||||||
|
expandedProc = { pkg: pkg, file: file, lines: lines, truncated: !!d.truncated };
|
||||||
|
tailTr.innerHTML = '<td colspan="8"><pre class="log-view">' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
|
||||||
|
'<div class="muted">' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行</div></td>';
|
||||||
|
btn.textContent = '收起';
|
||||||
|
} catch (err) {
|
||||||
|
tailTr.innerHTML = '<td colspan="8"><div class="empty">读取失败:' + esc(errText(err)) + '</div></td>';
|
||||||
|
btn.textContent = 'stdout 尾部';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function escSafeAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); }
|
||||||
|
async function fetchProcOnce() {
|
||||||
|
const el = $('#proc-page');
|
||||||
|
if (!el || state.route !== 'proc') return;
|
||||||
|
const r = await Promise.allSettled([api('GET', '/api/ams'), api('GET', '/api/svcd')]);
|
||||||
|
if (state.route !== 'proc') return;
|
||||||
|
if (r[0].status !== 'fulfilled' && r[1].status !== 'fulfilled') {
|
||||||
|
el.innerHTML = '<div class="page" id="proc-page">' + emptyState('加载失败:' + errText(r[0].reason || r[1].reason)) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = renderProcBody(r[0].status === 'fulfilled' ? r[0].value : null, r[1].status === 'fulfilled' ? r[1].value : null);
|
||||||
|
}
|
||||||
|
function renderProc() {
|
||||||
|
stopProc();
|
||||||
|
$('#content').innerHTML = '<div class="page" id="proc-page"><p class="placeholder">加载服务与进程…</p></div>';
|
||||||
|
fetchProcOnce();
|
||||||
|
procTimer = setInterval(fetchProcOnce, 5000);
|
||||||
|
}
|
||||||
|
function bindProc() {
|
||||||
|
$('#content').addEventListener('click', function (e) {
|
||||||
|
const b = e.target.closest('[data-applog]'); if (!b) return;
|
||||||
|
viewAppStdout(b.dataset.applog, b);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 2c. 网络管理页(meow_netmgrd 期2 面板侧) ----------------
|
/* ---------------- 2c. 网络管理页(meow_netmgrd 期2 面板侧) ----------------
|
||||||
@@ -586,24 +1100,24 @@
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
function netUnavailable(msg) {
|
// 网络状态(/api/net/status)瞬态失败 → 仅顶部内联错误条,不整页踢「不可用」占位;保留上次成功内容,5s 自动重试。
|
||||||
stopNet();
|
function netBannerErr(msg) {
|
||||||
const m = msg || 'meow_netmgrd 服务未运行或 IPC 不可达。';
|
const nb = $('#net-banner');
|
||||||
$('#content').innerHTML = '<div class="page" id="net-page">' +
|
if (!nb) return;
|
||||||
'<div class="card"><h3 class="card-title">网络管理</h3>' +
|
nb.innerHTML = '<span class="ico">⚠️</span><span>网络状态获取失败:' + esc(msg) + '(保留上次内容,5 秒后自动重试)</span>' +
|
||||||
'<div class="empty">网络管理服务不可用</div>' +
|
'<button class="btn" id="net-banner-retry">重试</button>';
|
||||||
'<p class="muted">' + esc(m) + '</p>' +
|
nb.hidden = false;
|
||||||
'<p class="muted">面板其它页不受影响;恢复 meow_netmgrd 后点击刷新重试。</p>' +
|
|
||||||
'<div class="field-row"><button class="btn" id="net-retry">重试</button></div>' +
|
|
||||||
'</div></div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义)
|
||||||
async function fetchNetOnce() {
|
async function fetchNetOnce() {
|
||||||
if (state.route !== 'net') return;
|
if (state.route !== 'net') return;
|
||||||
const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body');
|
const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body');
|
||||||
let st, cn, dn;
|
let st, cn, dn;
|
||||||
try { st = await api('GET', '/api/net/status'); }
|
try { st = await api('GET', '/api/net/status'); }
|
||||||
catch (e) { netUnavailable(errText(e)); return; }
|
catch (e) { netBannerErr(errText(e)); return; }
|
||||||
|
netLoaded = true;
|
||||||
|
const nb = $('#net-banner'); if (nb) { nb.hidden = true; nb.innerHTML = ''; }
|
||||||
try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); }
|
try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); }
|
||||||
catch (e) { cn = null; }
|
catch (e) { cn = null; }
|
||||||
try { dn = await api('GET', '/api/net/dns'); }
|
try { dn = await api('GET', '/api/net/dns'); }
|
||||||
@@ -642,8 +1156,10 @@
|
|||||||
async function renderNet() {
|
async function renderNet() {
|
||||||
stopNet();
|
stopNet();
|
||||||
netEntityFilter = '';
|
netEntityFilter = '';
|
||||||
|
netLoaded = false;
|
||||||
$('#content').innerHTML =
|
$('#content').innerHTML =
|
||||||
'<div class="page" id="net-page">' +
|
'<div class="page" id="net-page">' +
|
||||||
|
'<div id="net-banner" class="page-banner" hidden></div>' +
|
||||||
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
|
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
|
||||||
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
|
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
|
||||||
'<div class="field-row" style="margin-bottom:12px">' +
|
'<div class="field-row" style="margin-bottom:12px">' +
|
||||||
@@ -658,7 +1174,7 @@
|
|||||||
'<div class="field-row" style="margin-bottom:12px"><button class="btn" id="net-log-refresh">刷新</button></div>' +
|
'<div class="field-row" style="margin-bottom:12px"><button class="btn" id="net-log-refresh">刷新</button></div>' +
|
||||||
'<div id="net-log-body"><p class="placeholder">加载日志…</p></div></div>' +
|
'<div id="net-log-body"><p class="placeholder">加载日志…</p></div></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
netTimer = setInterval(fetchNetOnce, 5000); // status 失败时 netUnavailable 会 stopNet() 清掉
|
netTimer = setInterval(fetchNetOnce, 5000); // status 仅瞬态横幅,不停止轮询
|
||||||
fetchNetOnce(); // 填充 status + conns + dns
|
fetchNetOnce(); // 填充 status + conns + dns
|
||||||
fetchNetLog();
|
fetchNetLog();
|
||||||
}
|
}
|
||||||
@@ -673,7 +1189,7 @@
|
|||||||
$('#content').addEventListener('click', async function (e) {
|
$('#content').addEventListener('click', async function (e) {
|
||||||
if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; }
|
if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; }
|
||||||
if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; }
|
if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; }
|
||||||
if (e.target.closest('#net-retry')) { renderNet(); return; }
|
if (e.target.closest('#net-banner-retry') || e.target.closest('#net-retry')) { fetchNetOnce(); return; }
|
||||||
const flush = e.target.closest('#net-dns-flush');
|
const flush = e.target.closest('#net-dns-flush');
|
||||||
if (flush) {
|
if (flush) {
|
||||||
flush.disabled = true;
|
flush.disabled = true;
|
||||||
@@ -1046,6 +1562,9 @@
|
|||||||
'<button class="seg-btn" data-mode="light">浅色</button>' +
|
'<button class="seg-btn" data-mode="light">浅色</button>' +
|
||||||
'<button class="seg-btn" data-mode="dark">深色</button></div>' +
|
'<button class="seg-btn" data-mode="dark">深色</button></div>' +
|
||||||
'<p class="muted" style="margin-top:6px">跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。</p></div></div>';
|
'<p class="muted" style="margin-top:6px">跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。</p></div></div>';
|
||||||
|
// 面板 PIN(访问级鉴权):显示状态 + 修改/清空控件(走 /api/auth/* 校验旧 PIN + PUT /api/settings/panel.pin)
|
||||||
|
h += '<div class="card" id="pin-card"><h3 class="card-title">面板 PIN(访问鉴权)</h3>' +
|
||||||
|
'<div id="pin-status"><p class="muted">查询鉴权状态…</p></div></div>';
|
||||||
// 面板鉴权(可选防护)单独一卡
|
// 面板鉴权(可选防护)单独一卡
|
||||||
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
|
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
|
||||||
if (authItems.length) {
|
if (authItems.length) {
|
||||||
@@ -1072,6 +1591,89 @@
|
|||||||
$('#content').innerHTML = h;
|
$('#content').innerHTML = h;
|
||||||
applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作)
|
applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作)
|
||||||
loadFonts();
|
loadFonts();
|
||||||
|
loadPinStatus();
|
||||||
|
}
|
||||||
|
async function loadPinStatus() {
|
||||||
|
const box = $('#pin-status');
|
||||||
|
if (!box) return;
|
||||||
|
let st = null;
|
||||||
|
try { st = await api('GET', '/api/auth/status'); } catch (e) { box.innerHTML = '<p class="muted">无法查询鉴权状态:' + esc(errText(e)) + '</p>'; return; }
|
||||||
|
let txt, cls;
|
||||||
|
if (st.locked) { txt = '锁定中(' + Math.ceil((st.lock_remaining_ms || 0) / 1000) + ' 秒后可重试)'; cls = 'locked'; }
|
||||||
|
else if (st.pin_set) { txt = '已设置'; cls = 'set'; }
|
||||||
|
else if (st.legacy) { txt = '未设(旧密码屏障生效,建议改设为 PIN)'; cls = 'unset'; }
|
||||||
|
else { txt = '未设置(当前访问面板无需 PIN)'; cls = 'unset'; }
|
||||||
|
const canEdit = st.pin_set && !st.locked;
|
||||||
|
let h = '<div class="pin-status"><span class="dot ' + cls + '"></span><strong>' + esc(txt) + '</strong>' +
|
||||||
|
(st.session ? ' · <span class="muted">当前会话有效</span>' : ' · <span class="muted">未登录</span>') + '</div>';
|
||||||
|
h += '<div class="field-row" style="margin-top:8px">' +
|
||||||
|
'<button class="btn" id="pin-change" ' + (canEdit ? '' : 'disabled') + '>修改 PIN</button>' +
|
||||||
|
'<button class="btn btn-danger" id="pin-clear" ' + (canEdit ? '' : 'disabled') + '>清空 PIN</button>' +
|
||||||
|
(st.locked ? '<span class="muted">锁定期间不可修改/清空</span>' : '<span class="muted">修改/清空需先验证当前 PIN</span>') +
|
||||||
|
'</div>';
|
||||||
|
h += '<p class="muted" style="margin-top:6px">4 位数字 PIN,纯加盐哈希存储(不落明文);未设 PIN 时写端点一律拒绝。</p>';
|
||||||
|
box.innerHTML = h;
|
||||||
|
}
|
||||||
|
function pinEditDlg(mode) { // 返回 {old, n1}(n1=null 表示清空)或 null(取消)
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
const t = $('#pindlg-title'), b = $('#pindlg-body');
|
||||||
|
const oldIn = $('#pindlg-old'), n1 = $('#pindlg-new'), n2 = $('#pindlg-new2');
|
||||||
|
const err = $('#pindlg-err');
|
||||||
|
if (mode === 'change') {
|
||||||
|
t.textContent = '修改 PIN';
|
||||||
|
b.textContent = '请输入当前 PIN 与新 PIN(4 位数字)。';
|
||||||
|
oldIn.hidden = false; n1.hidden = false; n2.hidden = false;
|
||||||
|
n1.placeholder = '新 4 位 PIN'; n2.placeholder = '再次输入新 PIN';
|
||||||
|
} else {
|
||||||
|
t.textContent = '清空 PIN';
|
||||||
|
b.textContent = '清空后访问面板将不再要求 PIN。请输入当前 PIN 确认。';
|
||||||
|
oldIn.hidden = false; n1.hidden = true; n2.hidden = true;
|
||||||
|
}
|
||||||
|
err.hidden = true; oldIn.value = ''; n1.value = ''; n2.value = '';
|
||||||
|
oldIn.focus();
|
||||||
|
const scrim = $('#pindlg'); scrim.hidden = false;
|
||||||
|
const ok = $('#pindlg-ok'), cancel = $('#pindlg-cancel');
|
||||||
|
const done = function (v) { scrim.hidden = true; ok.onclick = cancel.onclick = scrim.onclick = null; resolve(v); };
|
||||||
|
ok.onclick = function () {
|
||||||
|
const old = oldIn.value.trim();
|
||||||
|
if (!old) { err.textContent = '请输入当前 PIN'; err.hidden = false; return; }
|
||||||
|
if (mode === 'change') {
|
||||||
|
const np = n1.value.trim();
|
||||||
|
if (!/^[0-9]{4}$/.test(np)) { err.textContent = '新 PIN 必须为 4 位数字'; err.hidden = false; return; }
|
||||||
|
if (np !== n2.value.trim()) { err.textContent = '两次输入的新 PIN 不一致'; err.hidden = false; return; }
|
||||||
|
done({ old: old, n1: np });
|
||||||
|
} else done({ old: old, n1: null });
|
||||||
|
};
|
||||||
|
cancel.onclick = function () { done(null); };
|
||||||
|
scrim.onclick = function (e) { if (e.target === scrim) done(null); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function pinEdit(mode) {
|
||||||
|
const v = await pinEditDlg(mode);
|
||||||
|
if (!v) return;
|
||||||
|
try { await api('POST', '/api/auth/login', { pin: v.old }); } // 先验证旧 PIN(客户端复核,后端改 PIN 本身不需旧值)
|
||||||
|
catch (e2) {
|
||||||
|
if (e2.code === 'LOCKED' || e2.status === 429) toast('错误次数过多,已锁定,请稍后重试(锁定 5 分钟)');
|
||||||
|
else if (e2.status === 401) toast('当前 PIN 验证失败');
|
||||||
|
else toast('验证失败:' + errText(e2));
|
||||||
|
loadPinStatus(); return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api('PUT', '/api/settings/panel.pin', { value: (v.n1 !== null ? v.n1 : '') });
|
||||||
|
toast(mode === 'change' ? 'PIN 已修改' : 'PIN 已清空');
|
||||||
|
loadPinStatus();
|
||||||
|
} catch (e3) { toast('操作失败:' + errText(e3)); }
|
||||||
|
}
|
||||||
|
function bindPin() {
|
||||||
|
const c = $('#content');
|
||||||
|
c.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('#pin-change')) { pinEdit('change'); return; }
|
||||||
|
if (e.target.closest('#pin-clear')) { pinEdit('clear'); return; }
|
||||||
|
});
|
||||||
|
['#pindlg-old', '#pindlg-new', '#pindlg-new2'].forEach(function (sel) {
|
||||||
|
const el = $(sel);
|
||||||
|
if (el) el.addEventListener('input', function () { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function loadFonts() {
|
async function loadFonts() {
|
||||||
const box = $('#font-list'); if (!box) return;
|
const box = $('#font-list'); if (!box) return;
|
||||||
@@ -1188,17 +1790,35 @@
|
|||||||
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
|
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
|
||||||
async function renderSystem() {
|
async function renderSystem() {
|
||||||
$('#content').innerHTML = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
|
$('#content').innerHTML = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
|
||||||
let d;
|
let d, pw = null;
|
||||||
try { d = await api('GET', '/api/system'); }
|
try { d = await api('GET', '/api/system'); }
|
||||||
catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||||
|
try { pw = await api('GET', '/api/power'); } catch (e) { pw = null; }
|
||||||
if (state.route !== 'system') return;
|
if (state.route !== 'system') return;
|
||||||
const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {};
|
const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {};
|
||||||
|
const boot = d.boot || {};
|
||||||
|
const curMode = boot.mode || boot.current || null; // 后端若暴露 /tmp/meow_boot_mode → boot.mode/current
|
||||||
|
const bootDef = boot.default || '—';
|
||||||
|
const mism = curMode && boot.default && curMode !== boot.default;
|
||||||
|
const batt = (pw && pw.battery) || {};
|
||||||
|
const charging = (batt.state === 'Charging' || batt.state === 'Full');
|
||||||
|
let modeHtml = mism
|
||||||
|
? '<span style="color:var(--error);font-weight:600">' + esc(curMode) + '</span> <span class="muted">(与默认启动不一致)</span>'
|
||||||
|
: (curMode ? esc(curMode) : '—');
|
||||||
|
let sysRows = row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) +
|
||||||
|
row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec));
|
||||||
|
if (pw && pw.battery) {
|
||||||
|
sysRows += row('电池', '<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(batt.state || '—') + '</span> ' +
|
||||||
|
(batt.pct != null ? batt.pct + ' %' : '—') +
|
||||||
|
(batt.mv != null ? ' · ' + (batt.mv / 1000).toFixed(2) + ' V' : '') +
|
||||||
|
(batt.health ? ' · ' + esc(batt.health) : ''));
|
||||||
|
}
|
||||||
|
sysRows += row('当前运行模式', modeHtml);
|
||||||
|
sysRows += row('默认启动', esc(bootDef));
|
||||||
|
sysRows += row('重启原因', esc(boot.reboot_reason || d.reboot_reason || '—'));
|
||||||
|
sysRows += row('升级状态', esc(boot.upgrade_status || d.upgrade_status || '—'));
|
||||||
let h = '<div class="page" id="system-page">';
|
let h = '<div class="page" id="system-page">';
|
||||||
h += '<div class="card"><h3 class="card-title">系统信息</h3><table class="table"><tbody>' +
|
h += '<div class="card"><h3 class="card-title">系统信息</h3><table class="table"><tbody>' + sysRows + '</tbody></table></div>';
|
||||||
row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) +
|
|
||||||
row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)) +
|
|
||||||
row('默认启动', esc((d.boot && d.boot.default) || '—')) +
|
|
||||||
'</tbody></table></div>';
|
|
||||||
h += '<div class="card"><h3 class="card-title">网络</h3><table class="table"><tbody>' +
|
h += '<div class="card"><h3 class="card-title">网络</h3><table class="table"><tbody>' +
|
||||||
row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') +
|
row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') +
|
||||||
row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') +
|
row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') +
|
||||||
@@ -1509,6 +2129,7 @@
|
|||||||
|
|
||||||
/* ---------------- 路由 ---------------- */
|
/* ---------------- 路由 ---------------- */
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
|
home: { title: '首页仪表盘', render: renderHome, bind: bindHome },
|
||||||
perf: { title: '实时性能', render: renderPerf, bind: null },
|
perf: { title: '实时性能', render: renderPerf, bind: null },
|
||||||
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
|
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
|
||||||
audio: { title: '音频状态', render: renderAudio, bind: null },
|
audio: { title: '音频状态', render: renderAudio, bind: null },
|
||||||
@@ -1521,7 +2142,9 @@
|
|||||||
screen: { title: '实时屏幕', render: renderScreen, bind: bindScreen },
|
screen: { title: '实时屏幕', render: renderScreen, bind: bindScreen },
|
||||||
system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem },
|
system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem },
|
||||||
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
|
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
|
||||||
notif: { title: '通知中心', render: renderNotif, bind: bindNotif }
|
notif: { title: '通知中心', render: renderNotif, bind: bindNotif },
|
||||||
|
power: { title: '电源 / 功耗', render: renderPower, bind: null },
|
||||||
|
proc: { title: '进程 / 服务', render: renderProc, bind: bindProc }
|
||||||
};
|
};
|
||||||
// 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定)
|
// 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定)
|
||||||
function bindNav() {
|
function bindNav() {
|
||||||
@@ -1530,13 +2153,16 @@
|
|||||||
}
|
}
|
||||||
function route() {
|
function route() {
|
||||||
if (state.unauth) { showLogin(authMode); return; } // 未认证 → 登录页(保持当前设置/登录态)
|
if (state.unauth) { showLogin(authMode); return; } // 未认证 → 登录页(保持当前设置/登录态)
|
||||||
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
|
const id = (location.hash || '#/home').replace('#/', '').split('?')[0] || 'home';
|
||||||
const r = ROUTES[id] || ROUTES.perf;
|
const r = ROUTES[id] || ROUTES.home;
|
||||||
state.route = id;
|
state.route = id;
|
||||||
stopPerf();
|
stopPerf();
|
||||||
stopAudio();
|
stopAudio();
|
||||||
stopNet();
|
stopNet();
|
||||||
stopScreenPoll();
|
stopScreenPoll();
|
||||||
|
stopHome();
|
||||||
|
stopPower();
|
||||||
|
stopProc();
|
||||||
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
|
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
|
||||||
$('#page-title').textContent = r.title;
|
$('#page-title').textContent = r.title;
|
||||||
r.render();
|
r.render();
|
||||||
@@ -1555,7 +2181,7 @@
|
|||||||
bindNav();
|
bindNav();
|
||||||
bindThemeUi();
|
bindThemeUi();
|
||||||
bindSlider();
|
bindSlider();
|
||||||
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet();
|
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin();
|
||||||
bindLogin();
|
bindLogin();
|
||||||
bindPwDlg();
|
bindPwDlg();
|
||||||
// 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录
|
// 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录
|
||||||
|
|||||||
Reference in new issue
Block a user