/* MeowPanel — 前端逻辑(纯静态,零第三方依赖) * * 对接协议:devdocs/plan/MEOWPANEL_PROTOCOL.md(草案,后端未实现)。 * 所有 fetch 失败都优雅降级为「后端未连接」,不假设后端就绪。 */ (function () { 'use strict'; /* ---------------- 小工具 ---------------- */ const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel)); function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); } function fmtBytes(n) { if (n == null || isNaN(n)) return '—'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i]; } function fmtUptime(sec) { if (sec == null || isNaN(sec)) return '—'; sec = Math.floor(sec); const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60); return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分'; } function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; } 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)); } /* ---------------- 音频电平表 / 仪表盘工具(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 ''; } 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 '
加载中…
加载设置…
音量设置项不可用(后端未实现 audio.volume)
'; h += '滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。
切换/调节前会确认。选错输出口会无声;音量走平方锥度(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。
'; s += 'audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。
当前无活动音频流。
'; } h += '加载音频状态…
每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI meow_audio tone,面板不放声)。
' + (ad.active ? '电平 · 1s 子节拍' : '无流') + '
'; return homeCard('音频', body); } function netCard() { const nd = homeData && homeData.net; if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable()); const w = nd.wifi || {}; const dn = (nd && nd.dns) || null; // 批2: DNS 统计已并入 /api/overview 的 net 块(首页严格单请求) let rows = ''; rows += homeKv('WiFi', w.connected ? ('已连接 ' + esc(w.ssid || '')) : '未连接'); 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', '—'); } return homeCard('WiFi / DNS', '加载总览…
'; return; } let h = '加载总览…
Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。
'; } function breakdownCardBody(bd) { if (!bd) return homeUnavailable(); if (bd.degraded) return '' + (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) : '') + '
'; } return '电源策略(profile 四档/低电阈值)为 期3 功能,此处以不可用占位。
'; h += '加载电源数据…
| 服务 | 状态 | PID | 重启 | 退避 | 触发 | 来源 | 退出 | 执行 |
|---|---|---|---|---|---|---|---|---|
| 暂无服务 | ||||||||
| ' + esc(s.name) + ' | ' + '' + svcStatePill(s.state) + ' | ' + '' + (s.pid > 0 ? esc(s.pid) : '—') + ' | ' + '' + (s.restart || 0) + ((s.restart || 0) >= 3 ? ' ⚠' : '') + ' | ' + '' + esc(s.backoff_ms || '—') + ' | ' + '' + esc(s.trigger || '—') + ' | ' + '' + srcPill(s.src) + ' | ' + '' + esc(s.exit || '—') + ' | ' + '' + esc(s.exec || '—') + ' |
| 包名 | 名称 | 状态 | PID | 启动 | 崩溃 | 上次退出 | 操作 |
|---|---|---|---|---|---|---|---|
| 无可观测 App | |||||||
| ' + esc(a.pkg) + ' | ' + esc(a.name || '—') + ' | ' + '' + appStatePill(a.state) + ' | ' + (a.pid > 0 ? esc(a.pid) : '—') + ' | ' + '' + esc(a.launches || 0) + ' | ' + '' + (a.crashes || 0) + ((a.crashes || 0) > 0 ? ' ⚠' : '') + ' | ' + '' + esc(a.last_kind || '—') + (a.last_code != null && a.last_code !== 0 ? ' (' + esc(a.last_code) + ')' : '') + ' | ' + '|
' + esc(lines.join('\n') || '(空)') +
(expandedProc.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '' +
'' + esc(a.pkg + '/' + expandedProc.file) + ' · 末尾 ' + lines.length + ' 行 | |||||||
点击「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。仅只读,无 restart/kill。
'; } else h += homeUnavailable(); h += '' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '' +
'加载服务与进程…
| 接口 | 类型 | 状态 | IP 地址 | 网关 | RX / TX |
|---|---|---|---|---|---|
| 无接口数据 | |||||
| ' + esc(f.name) + ' | ' + esc(f.kind || '—') + ' | ' + '' + (up ? 'UP' : 'DOWN') + ' | ' + '' + esc(f.ip || '—') + ' | ' + esc(f.gw || '—') + ' | ' + '' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + ' |
实时速率:↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s
'; h += '| 实体 | 远端(域名) | 本端 | 状态 | 寿命 |
|---|---|---|---|---|
| ' + netBadge(c.entity) + ' | ' + '' + esc(c.domain || c.remote || '—') + ' | ' + '' + esc(c.local || '—') + ' | ' + '' + esc(c.state || '—') + ' | ' + '' + fmtDur(c.age) + ' |
连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)
'; } return h; } function netDnsBody(dn) { if (!dn) return '写操作(清缓存)需系统身份,面板路径可能被 netmgrd 拒绝。
'; return h; } // 网络状态(/api/net/status)瞬态失败 → 仅顶部内联错误条,不整页踢「不可用」占位;保留上次成功内容,5s 自动重试。 function netBannerErr(msg) { const nb = $('#net-banner'); if (!nb) return; nb.innerHTML = '⚠️网络状态获取失败:' + esc(msg) + '(保留上次内容,5 秒后自动重试)' + ''; nb.hidden = false; } let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义) async function fetchNetOnce() { if (state.route !== 'net') return; const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body'); let st, cn, dn; try { st = await api('GET', '/api/net/status'); } 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) : '')); } catch (e) { cn = null; } try { dn = await api('GET', '/api/net/dns'); } catch (e) { dn = null; } if (state.route !== 'net') return; if (stEl) stEl.innerHTML = netStatusCards(st); if (cbEl) cbEl.innerHTML = netConnsBody(cn); if (dbEl) dbEl.innerHTML = netDnsBody(dn); } async function fetchNetConns() { if (state.route !== 'net') return; const cbEl = $('#net-conns-body'); if (!cbEl) return; let cn; try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); } catch (e) { cn = null; } if (state.route !== 'net') return; cbEl.innerHTML = netConnsBody(cn); } async function fetchNetLog() { if (state.route !== 'net') return; const el = $('#net-log-body'); try { const d = await api('GET', '/api/net/log'); if (el) { if (!d || !d.file) { el.innerHTML = '' + esc((d.lines || []).join('\n') || '(空)') +
(d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '';
const name = $('#net-log-file'); if (name) name.textContent = d.file;
}
} catch (e) { if (el) el.innerHTML = '加载网络状态…
加载连接表…
加载 DNS 统计…
加载日志…
加载中…
'; let d; try { d = await api('GET', '/api/files?path=' + encodeURIComponent(relPath)); } catch (e) { page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } state.filesRel = d.rel || relPath; state.filesAbs = d.path; let h = crumbHtml(segments(d.rel), d.rel); // 白名单快捷入口(根目录下) if (d.rel === '/' && d.allowed) { h += '| 名称 | 大小 | 修改时间 | 操作 |
|---|---|---|---|
| 空目录 | |||
| ' + (isDir ? '' : '') + icon + ' ' + esc(it.name) + (isDir ? '' : '') + ' | ' + (isDir ? '—' : fmtBytes(it.size)) + ' | ' + fmtTime(it.mtime) + ' | ' + '' +
(isDir ? '' : '下载') +
'' +
'' +
' |
加载应用列表…
加载字体库列表…
上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' +
'MeowOS/fonts/<family>/<weight>/;同 family/weight 冲突时会要求确认覆盖。' +
'导入后进设备「设置 → 字体」页,列表自动重扫生效。
字体库列表加载失败:' + esc(errText(e)) + '
'; return; } const fs = (d && d.fonts) || []; if (!fs.length) { box.innerHTML = '| family | weight | 名称 | tier | glyph 数 | 操作 |
|---|---|---|---|---|---|
| ' + esc(f.family || '') + ' | ' + esc(f.weight || '') + ' | ' + '' + esc(f.name || '—') + ' | ' + esc(f.tier || '—') + ' | ' + '' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + ' | ' + '
加载设置…
默认跟随设备 ui.style(当前:' + esc(devStyleLabel()) + ');在面板选择即为本面板覆盖,只影响网页观感(顶栏切换才会同步设备)。
跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。
查询鉴权状态…
开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。
加载字体库列表…
导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。
无法查询鉴权状态:' + esc(errText(e)) + '
'; 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 = '4 位数字 PIN,纯加盐哈希存储(不落明文);未设 PIN 时写端点一律拒绝。
'; 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', async function (e) { if (e.target.closest('#pin-change')) { pinEdit('change'); return; } if (e.target.closest('#pin-clear')) { pinEdit('clear'); return; } if (e.target.closest('#pin-unlock-once')) { try { await api('POST', '/api/system/pin-unlock-once', {}); toast('已下发:设备下次亮屏免 PIN 进入一次'); } catch (err) { toast('下发失败:' + errText(err)); } 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() { const box = $('#font-list'); if (!box) return; let d; try { d = await api('GET', '/api/fonts'); } catch (e) { box.innerHTML = '字体库列表加载失败:' + esc(errText(e)) + '
'; return; } const fs = (d && d.fonts) || []; if (!fs.length) { box.innerHTML = '暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。
'; return; } box.innerHTML = fs.map(f => { const nm = esc(f.name || f.family || ''); const meta = esc((f.family || '') + '/' + (f.weight || '') + (f.tier ? ' · ' + f.tier : '') + (f.glyph_count ? ' · ' + f.glyph_count + ' 字' : '')); return '行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。
' + '加载系统信息…
当前 ' + esc(ota.current || d.version || '—') + (ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '
' + '⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。
本 boot 无崩溃记录。
| 实体 | 崩溃数 | 最近时间 | 信号/退出 | 操作 |
|---|---|---|---|---|
| ' + esc(e.name) + ' | ' + '' + esc(e.count) + ((e.count || 0) > 0 ? ' ⚠' : '') + ' | ' + '' + esc(e.last_time || '—') + ' | ' + '' + esc(e.last_kind || '—') + '' + (e.last_code != null && e.last_code !== 0 ? ' (' + esc(e.last_code) + ')' : '') + ' | ' + '
内核 oops / segfault 捕获:' + esc(cr.dmesg_count) + ' 条' + ((cr.dmesg_tail || []).length ? '
' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '' : '') + '';
}
h += '| 文件 | 分组 | 大小 | 修改时间 | 操作 |
|---|---|---|---|---|
| 暂无日志(或过滤后无结果) | ||||
| ' + esc(f.name) + ' | ' + groupName(f.group) + ' | ' + fmtBytes(f.size) + ' | ' + fmtTime(f.mtime) + ' | ' + '|
加载日志列表…
| 加载… |
加载通知…
点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。
' + '载入中…
' + '