/* 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 '
' + '
RMS' + '
' + '−∞
' + '
PEAK' + '
' + '−∞
' + '
-60-40-200 dBFS
' + sparklineHtml() + '
'; } 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 '
' + esc(k) + '' + v + '
'; } function homeUnavailable() { return '
— 数据不可用
'; } function homeCard(title, body) { return '

' + esc(title) + '

' + body + '
'; } function modePill(m) { if (!m) return '—'; const cls = (m === 'meow') ? 'ok' : ((m === 'hiby') ? 'warn' : 'mut'); return '' + esc(m) + ''; } /* ---------------- 全局状态 ---------------- */ const state = { style: 'md3', mode: 'dark', // 已解析的风格/明暗(应用到 data-style/data-mode) devStyle: null, devTheme: null, // 设备 settings.conf 的 ui.style / display.theme(默认来源) conn: 'unknown', filesRel: '/', filesAbs: null, perfTimer: null, // 性能页轮询 route: 'perf', unauth: false, // 鉴权未通过(弹登录页) downloadNonce: 0 }; /* ---------------- 后端连接状态 ---------------- */ function setConn(ok) { state.conn = ok ? 'ok' : 'bad'; const dot = $('#conn-dot'); dot.dataset.state = state.conn; $('#conn-text').textContent = ok ? '在线' : '离线'; $('#offline-banner').hidden = !!ok; } /* ---------------- 调试日志(浏览器控制台可见,过滤 [mp]) ---------------- */ function dbg() { const a = Array.from(arguments); console.log.apply(console, ['[mp]'].concat(a)); } function dbgErr() { const a = Array.from(arguments); console.warn.apply(console, ['[mp][ERR]'].concat(a)); } /* ---------------- API 客户端 ---------------- */ // 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连 async function api(method, path, body) { let res; const headers = {}; const token = storageGet('meowpanel.token'); if (token) headers['X-MeowToken'] = token; const init = { method: method || 'GET' }; if (body instanceof FormData) { init.body = body; } else if (body !== undefined && body !== null) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } if (Object.keys(headers).length) init.headers = headers; const t0 = performance.now(); try { res = await fetch(path, init); } catch (e) { dbgErr(method || 'GET', path, '网络层失败:', e && e.message); setConn(false); throw { code: 'NETWORK', message: '后端未连接', status: 0 }; } const ms = Math.round(performance.now() - t0); /* 先读原文再解析:非 JSON 时能拿到原始内容前 300 字节进控制台 */ let text = ''; try { text = await res.text(); } catch (e) { /* 读体失败走 NOT_JSON */ } let json = null; try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; } if (!json || typeof json.ok !== 'boolean') { // 静态服务器返回 HTML / 404 等 dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '非 JSON 响应, len=' + text.length, '头:', text.slice(0, 200), '尾:', text.slice(-200)); try { fetch('/api/debug/report_bad_json', { method: 'POST', headers: { 'Content-Type': 'text/plain', 'X-MeowToken': storageGet('meowpanel.token') || '' }, body: text }); } catch (e2) {} setConn(res.ok ? true : false); throw { code: 'NOT_JSON', message: '后端返回了非 JSON 响应(HTTP ' + res.status + ')', status: res.status }; } if (!json.ok) { setConn(true); const code = (json.error && json.error.code) || 'ERROR'; dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '后端错误:', code, (json.error && json.error.message) || ''); if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页 throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status }; } if (ms > 800) dbg(method || 'GET', path, '慢响应', ms + 'ms'); setConn(true); return json.data; } // 下载(返回 blob URL) async function apiDownload(path) { let res; const headers = {}; const token = storageGet('meowpanel.token'); if (token) headers['X-MeowToken'] = token; try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); } catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; } if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; } setConn(true); return URL.createObjectURL(await res.blob()); } /* ---------------- toast / 确认弹窗 ---------------- */ let toastTimer = null; function toast(msg, ms) { const t = $('#toast'); t.textContent = msg; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, ms || 2600); } function confirmDlg(title, body, danger) { return new Promise(res => { $('#modal-title').textContent = title || '确认'; $('#modal-body').textContent = body || ''; const yes = $('#modal-yes'), no = $('#modal-no'); yes.className = 'btn btn-primary' + (danger ? ' btn-danger' : ''); const scrim = $('#modal'); scrim.hidden = false; const done = v => { scrim.hidden = true; yes.onclick = no.onclick = scrim.onclick = null; res(v); }; yes.onclick = () => done(true); no.onclick = () => done(false); scrim.onclick = e => { if (e.target === scrim) done(false); }; }); } /* ---------------- 访问级 PIN 鉴权(设置 PIN → 登录;401 自动跳登录;token 存 localStorage) ---------------- * 后端:访问面板即需登录;token 存面板进程内存(重启失效,3 天固定不滑动续期);连续 5 次错 PIN 锁 5 分钟。 * 前端:token 存 localStorage 随请求带 X-MeowToken;401 自动切登录页;未设 PIN 走首次设置流程。 */ let authMode = 'login'; // 'login' | 'setup' function loginErr(msg) { const err = $('#login-err'); if (err) { err.textContent = msg; err.hidden = false; } } function authRequired() { // 任一路由收到 401 → 弹登录页(会话失效/未认证) state.unauth = true; refreshAuthGate(); } async function refreshAuthGate() { // 查状态决定「首次设置」还是「登录」 let st = null; try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; } if (st && st.session) { state.unauth = false; hideLogin(); return; } if (st && !st.pin_set && !st.legacy) showLogin('setup'); else showLogin('login'); } function showLogin(mode) { state.unauth = true; authMode = mode || 'login'; const lg = $('#login'); if (lg) lg.hidden = false; const sub = $('#login-sub'); const pin2 = $('#login-pin2'); const btn = $('#login-btn'); if (authMode === 'setup') { if (sub) sub.textContent = '首次使用:请设置一个 4 位数字 PIN 作为本面板访问口令。'; if (pin2) pin2.hidden = false; if (btn) btn.textContent = '设 置'; } else { if (sub) sub.textContent = '此面板已开启访问鉴权,请输入 4 位 PIN。'; if (pin2) pin2.hidden = true; if (btn) btn.textContent = '登 录'; } const ip = $('#login-pin'); if (ip) { ip.value = ''; ip.focus(); } const ip2 = $('#login-pin2'); if (ip2 && authMode !== 'setup') ip2.value = ''; const err = $('#login-err'); if (err) err.hidden = true; } function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; } async function doLogin() { const pin = ($('#login-pin').value || '').trim(); const err = $('#login-err'); if (err) err.hidden = true; if (!pin) { loginErr('请输入 PIN'); return; } if (authMode === 'setup') { const pin2 = ($('#login-pin2').value || '').trim(); if (pin.length !== 4 || /[^0-9]/.test(pin)) { loginErr('PIN 必须为 4 位数字'); return; } if (pin !== pin2) { loginErr('两次输入的 PIN 不一致'); return; } const btn = $('#login-btn'); if (btn) btn.disabled = true; try { await api('POST', '/api/auth/setup', { pin: pin }); toast('PIN 已设置,请登录'); showLogin('login'); } catch (e2) { loginErr((e2 && e2.message) ? e2.message : '设置失败'); } finally { if (btn) btn.disabled = false; } return; } const btn = $('#login-btn'); if (btn) btn.disabled = true; try { const d = await api('POST', '/api/auth/login', { pin: pin }); storageSet('meowpanel.token', d.token); state.unauth = false; hideLogin(); toast('已登录'); if (started) route(); else startApp(); } catch (e) { if (e && (e.code === 'LOCKED' || e.status === 429)) loginErr(e.message || '错误次数过多,已锁定,请稍后重试'); else loginErr((e && e.message) ? e.message : '登录失败'); } finally { const b = $('#login-btn'); if (b) b.disabled = false; } } function bindLogin() { const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin); const ip = $('#login-pin'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); // PIN 输入:只允许数字,最长 4 位 [ip, $('#login-pin2')].forEach(el => { if (el) el.addEventListener('input', () => { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); }); } function passwordDlg(title, body) { return new Promise(res => { $('#pwdlg-title').textContent = title || '设置面板密码'; $('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。'; const inp = $('#pwdlg-input'); inp.value = ''; inp.focus(); const scrim = $('#pwdlg'); scrim.hidden = false; const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel'); const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); }; okb.onclick = () => done(inp.value); cancel.onclick = () => done(null); scrim.onclick = e => { if (e.target === scrim) done(null); }; inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); }; }); } async function setPanelPassword() { const np = await passwordDlg('设置面板密码', '请输入新密码(留空则清除密码)。'); if (np === null) return; // 取消 try { await api('PUT', '/api/settings/panel.auth_pass', { value: np }); toast(np ? '面板密码已设置' : '面板密码已清除'); renderSettings(); } catch (e) { toast('设置失败:' + errText(e)); } } function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ } /* ---------------- 主题(MD3/MIUIx × 明/暗) ---------------- * data-style(md3/miuix)× data-mode(dark/light),四套 CSS 变量表驱动。 * 默认值联动设备 settings.conf(ui.style / display.theme,经 GET /api/theme 读取); * 面板内选择存 localStorage,作为本面板覆盖(不写设备): * meowpanel.style = md3|miuix (缺省 = 跟随设备 ui.style) * meowpanel.mode = system|browser|light|dark * (缺省 system = 跟随设备 display.theme; * browser = 跟随浏览器 prefers-color-scheme,日/夜切换实时生效) * 顶栏切换器保留旧行为:本面板生效 + 经 PUT /api/theme 同步设备。 */ function browserMode() { try { return (window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark'; } catch (e) { return 'dark'; } } function storedStyle() { const v = storageGet('meowpanel.style'); return (v === 'md3' || v === 'miuix') ? v : null; } function storedMode() { const v = storageGet('meowpanel.mode'); return (v === 'system' || v === 'browser' || v === 'light' || v === 'dark') ? v : null; } function resolveStyle() { return storedStyle() || state.devStyle || 'md3'; } function resolveMode() { const m = storedMode() || 'system'; if (m === 'light' || m === 'dark') return m; if (m === 'system') return state.devTheme || browserMode(); // 设备未知时回退浏览器偏好 return browserMode(); } function devStyleLabel() { return state.devStyle ? (state.devStyle === 'miuix' ? 'MIUIx' : 'Material You') : '未知(后端离线)'; } function devThemeLabel() { return state.devTheme ? (state.devTheme === 'light' ? '浅色' : '深色') : '未知(后端离线)'; } function applyTheme() { const style = resolveStyle(), mode = resolveMode(); state.style = style; state.mode = mode; const root = document.documentElement; root.dataset.style = style; root.dataset.mode = mode; $$('#style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style)); $$('#theme-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); // 设置页「主题」卡(仅在该页渲染时存在) $$('#set-style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style)); $$('#set-mode-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === (storedMode() || 'system'))); const rst = $('#set-style-reset'); if (rst) rst.disabled = !storedStyle(); } async function sendTheme(patch) { try { await api('PUT', '/api/theme', patch); } catch (e) { toast('主题已本地切换(后端未同步:' + e.message + ')'); } } async function initTheme() { // 旧版键迁移:meowpanel.theme(dark/light)→ meowpanel.mode const old = storageGet('meowpanel.theme'); if (old) { if (!storedMode()) storageSet('meowpanel.mode', old === 'light' ? 'light' : 'dark'); storageDel('meowpanel.theme'); } applyTheme(); // 浏览器日/夜切换:模式为 browser(或 system 且设备主题未知)时实时跟随 try { if (window.matchMedia) { const mq = matchMedia('(prefers-color-scheme: dark)'); const onChange = function () { const m = storedMode() || 'system'; if (m === 'browser' || (m === 'system' && !state.devTheme)) applyTheme(); }; if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange); } } catch (e) {} // 设备默认值(ui.style/display.theme)——未覆盖时的默认来源 try { const t = await api('GET', '/api/theme'); state.devStyle = (t.style === 'md3' || t.style === 'miuix') ? t.style : null; state.devTheme = (t.theme === 'light' || t.theme === 'dark') ? t.theme : null; applyTheme(); if (state.route === 'settings') renderSettings(); // 刷新「主题」卡的设备当前值提示 } catch (e) { /* 后端离线,保持本地/浏览器偏好 */ } } // 顶栏「风格 / 明暗」切换器:本面板覆盖(localStorage)+ 同步设备 settings.conf function bindThemeUi() { $('#style-seg').addEventListener('click', function (e) { const b = e.target.closest('.seg-btn'); if (!b) return; storageSet('meowpanel.style', b.dataset.style); applyTheme(); sendTheme({ style: b.dataset.style }); }); $('#theme-seg').addEventListener('click', function (e) { const b = e.target.closest('.seg-btn'); if (!b) return; storageSet('meowpanel.mode', b.dataset.mode); // 顶栏=显式明/暗 applyTheme(); sendTheme({ theme: b.dataset.mode }); }); } /* ---------------- 页面空态 / 错误提示 ---------------- */ function emptyState(msg, code) { const t = code === 'NO_CARD' ? '未识别到 TF 卡' : (code === 'NOT_IMPLEMENTED' ? '该功能后端尚未实现' : msg); return '
' + esc(t) + '
'; } function errText(e) { if (e && e.code === 'NO_CARD') return '未识别到 TF 卡'; if (e && e.code === 'NOT_IMPLEMENTED') return '该功能后端尚未实现'; return (e && e.message) ? e.message : '未知错误'; } /* ---------------- 1. 性能页(2s 轮询 /api/perf) ---------------- */ function barHtml(p) { p = pct(p); const c = p >= 90 ? 'danger' : (p >= 70 ? 'warn' : ''); return '
'; } function stat(label, valueHtml, sub, barPct) { return '
' + esc(label) + '
' + valueHtml + (sub ? '
' + sub + '
' : '') + (barPct != null ? barHtml(barPct) : '') + '
'; } function renderPerfData(page, d) { const cpu = d.cpu || {}; const mem = d.memory || {}, st = d.storage || {}, tf = d.tfcard || {}, tmp = d.temp; let h = '

实时性能

'; h += '
运行时间:' + fmtUptime(d.uptime_sec) + '
'; h += stat('CPU', '' + (cpu.usage == null ? '—' : +cpu.usage.toFixed(1)) + '%', cpu.cores != null ? cpu.cores + ' 核 · 负载 ' + (cpu.load ? cpu.load.map(x => (+x).toFixed(2)).join(' / ') : '—') : '', cpu.usage || 0); const memP = mem.total ? (mem.used / mem.total) * 100 : 0; h += stat('内存', '' + fmtBytes((mem.used || 0) * 1024) + '/ ' + fmtBytes((mem.total || 0) * 1024) + '', '可用 ' + fmtBytes((mem.available || (mem.free || 0)) * 1024), memP); const stP = st.total ? (st.used / st.total) * 100 : 0; h += stat('存储 (rootfs)', '' + fmtBytes((st.used || 0) * 1024) + '/ ' + fmtBytes((st.total || 0) * 1024) + '', st.path || '', stP); if (tf) { if (tf.present) { const tfP = tf.total ? (tf.used / tf.total) * 100 : 0; h += stat('TF 卡', '' + fmtBytes((tf.used || 0) * 1024) + '/ ' + fmtBytes((tf.total || 0) * 1024) + '', fmtBytes((tf.avail || 0) * 1024) + ' 可用 · ' + (tf.filesystem || ''), tfP); } else { h += stat('TF 卡', '未插卡', ''); } } const tmpCpu = tmp && tmp.cpu != null ? +tmp.cpu.toFixed(1) : null; h += stat('温度', '' + (tmpCpu == null ? '—' : tmpCpu) + '℃', tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : ''); h += '
'; page.innerHTML = h; } async function fetchPerfOnce() { const page = $('#perf-page'); if (!page || state.route !== 'perf') return; let d; try { d = await api('GET', '/api/perf'); } catch (e) { if (page && state.route === 'perf') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route === 'perf') renderPerfData(page, d); } function stopPerf() { if (state.perfTimer) { clearInterval(state.perfTimer); state.perfTimer = null; } } function renderPerf() { $('#content').innerHTML = '

加载中…

'; if (state.perfTimer) stopPerf(); fetchPerfOnce(); state.perfTimer = setInterval(fetchPerfOnce, 2000); } /* ---------------- 2. 亮度 / 音量页 ---------------- */ let volTimer = null; function slur(step) { // step: {key,label,min,max,val} return '

' + esc(step.label) + '

' + '
' + step.val + '' + '' + '' + step.max + '
'; } async function renderBright() { $('#content').innerHTML = '

加载设置…

'; let bright, vol; try { bright = await api('GET', '/api/settings/display.brightness'); } catch (e) { $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } try { vol = await api('GET', '/api/settings/audio.volume'); } catch (e) { vol = null; } if (state.route !== 'bright') return; let h = '
'; h += slur({ key: 'display.brightness', label: '亮度', min: bright.min || 1, max: bright.max || 100, val: bright.value }); if (vol) h += slur({ key: 'audio.volume', label: '音量', min: vol.min || 0, max: vol.max || 100, val: vol.value }); else h += '

音量设置项不可用(后端未实现 audio.volume)

'; h += '

滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。

'; $('#content').innerHTML = h; } const saveSetting = debounce(async function (key, value, type) { try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key + ' = ' + value); } catch (e) { toast('保存失败:' + errText(e)); } }, 400); function bindSlider() { $('#content').addEventListener('input', function (e) { const r = e.target.closest('[data-slider]'); if (!r) return; const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value; saveSetting(r.dataset.key, +r.value, 'int'); }); } /* ---------------- 2b. 音频页(meow_audiod 状态快照,1s 轮询,只读;含 rms/peak 电平可视化) ---------------- * 电平:rms(青绿) + peak(黄) 双条 dBFS 表(-60..0 刻度)+ deci-dB 数值 + 近 30 采样 sparkline(客户端自累积)。 * 短流时显示「无流」;audiod 不在 → available:false。 */ let audioTimer = null; let audioVolDrag = false; // 音量滑条拖拽中(暂停 1s 重渲染,防打断交互) function stopAudio() { if (audioTimer) { clearInterval(audioTimer); audioTimer = null; } } function audioPortName(p) { // audiod numid=9 路由口语义(AUDIOD_DESIGN §5) const m = { 0: '空闲', 1: 'Line Out', 2: '3.5mm', 3: '4.4mm 平衡', 4: 'Type-C SPDIF' }; if (p == null || p < 0) return '—'; return m[p] || ('端口 ' + p); } function fmtFrames(n) { return (n == null) ? '—' : Number(n).toLocaleString(); } /* 路由口 → 中文(写选择器用;0=auto/2=3.5/3=4.4,绝不暴露 4=SPDIF/5=audiod 内部值)。 */ function audioRouteLabel(p) { const m = { 0: '自动', 2: '3.5mm', 3: '4.4mm' }; return m[p] || ('端口 ' + p); } /* 路由与音量写控件(批2):路由分段选择 + 音量滑条(上限 vol_max、平方锥度非线性标注)。 * 写级——前端切换/调高前 confirmDlg(选错口会无声)。当前值只读显示锥度后百分比(上限以内)。 */ function audioCtlHtml(d) { const vmax = (d && d.vol_max != null) ? d.vol_max : 100; const vol = (d && d.vol != null && d.vol >= 0) ? Math.min(d.vol, vmax) : 0; const cur = d.port; let s = '

路由与音量

' + row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) + row('输出口', audioPortName(d.port)) + row('音量(只读)', (d.vol != null && d.vol >= 0 ? '' + esc(d.vol) + ' %' : '—') + ' (上限 ' + esc(vmax) + ' · 平方锥度非线性)') + '
'; s += '
输出路由'; [0, 2, 3].forEach(p => { s += ''; }); s += '
'; s += '
' + vol + '' + '' + '' + vmax + '
'; s += '

切换/调节前会确认。选错输出口会无声;音量走平方锥度(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。

'; s += '
'; return s; } function audioFlowHtml(d) { let h = ''; if (!d || !d.available) { h += '

音频服务(meow_audiod)

' + '

audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。

'; } else { h += '

当前流

'; if (d.active) { h += '' + row('应用', esc(d.app || '—') + ' (sid ' + esc(d.sid) + ')') + row('采样率', esc(d.rate) + ' Hz') + row('声道', esc(d.ch) + ' ch') + row('格式', esc(d.fmt || '—')) + row('已写帧', fmtFrames(d.written_frames)) + row('已播放帧', fmtFrames(d.played_frames)) + row('underrun', '' + esc(d.underrun) + '') + row('状态', d.paused ? '已暂停' : '播放中') + '
'; } else { h += '

当前无活动音频流。

'; } h += '
'; h += audioCtlHtml(d); } return h; } async function fetchAudioOnce() { const flowEl = $('#audio-flow'); if (!flowEl || state.route !== 'audio') return; let d; try { d = await api('GET', '/api/audio'); } catch (e) { if (flowEl && state.route === 'audio') flowEl.innerHTML = '
加载失败:' + esc(errText(e)) + '
'; return; } if (state.route !== 'audio') return; if (audioVolDrag) 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 秒自动刷新 · 流信息只读;路由与音量为写级(确认后生效,不放测试音)。'; } } function renderAudio() { resetSpark(); // 进入页面重置 sparkline 累积 stopAudio(); $('#content').innerHTML = '
' + '

加载音频状态…

' + '

电平(dBFS)

' + '
' + levelMeterHtml() + '
' + '

每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI meow_audio tone,面板不放声)。

' + '
' + '
'; fetchAudioOnce(); audioTimer = setInterval(fetchAudioOnce, 1000); } /* 音频页交互(委托 #content,元素随 1s 轮询重建亦不失效):路由切换/音量滑条均写级 + confirm。 */ function bindAudio() { $('#content').addEventListener('click', async function (e) { const rb = e.target.closest('[data-audio-route]'); if (rb) { const port = parseInt(rb.dataset.audioRoute, 10); if (!(port === 0 || port === 2 || port === 3)) { toast('非法路由口(仅自动/3.5/4.4)'); return; } const ok = await confirmDlg('切换输出路由', '确认切换到「' + audioRouteLabel(port) + (port === 0 ? '(跟随插孔)' : '') + '」?\n选错输出口会无声,请确认当前插孔。', false); if (!ok) return; try { await api('POST', '/api/audio/route', { port: port }); toast('路由已切换:' + audioRouteLabel(port)); } catch (err) { toast('切换失败:' + errText(err)); } return; } }); $('#content').addEventListener('pointerdown', function (e) { const sl = e.target.closest('[data-audio-vol]'); if (sl) { audioVolDrag = true; const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; } }); $('#content').addEventListener('pointerup', function (e) { // 释放即复位;若值已提交 change 会再走确认,此处仅保证「点击未改动」时也不卡住重渲染 if (e.target.closest('[data-audio-vol]')) setTimeout(function () { audioVolDrag = false; }, 250); }); $('#content').addEventListener('input', function (e) { const sl = e.target.closest('[data-audio-vol]'); if (!sl) return; const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; }); $('#content').addEventListener('change', async function (e) { const sl = e.target.closest('[data-audio-vol]'); if (!sl) return; audioVolDrag = false; const v = parseInt(sl.value, 10); const vmax = parseInt(sl.max, 10) || 100; const ok = await confirmDlg('确认音量', '确认将音量设为 ' + v + ' %?(系统上限 ' + vmax + ',平方锥度非线性——越接近上限越响且非等比)', false); if (!ok) { fetchAudioOnce(); return; } // 取消 → 立即拉一次复位滑条 try { await api('POST', '/api/audio/volume', { value: v }); toast('音量已设为 ' + v + ' %'); fetchAudioOnce(); } catch (err) { toast('设置失败:' + errText(err)); fetchAudioOnce(); } }); } /* ---------------- 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, 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('设备信息', '
' + rows + '
'); } 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 = '
' + '' + (pct == null ? '—' : pct) + '%' + '' + esc(state) + '
'; inner += '
' + 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 + ' 分钟') : '')) + '
'; return homeCard('电量 / 充电', inner); } function audioCard() { const ad = homeData && homeData.audio; if (!isAvail(ad)) return homeCard('音频', homeUnavailable()); let body = ''; if (ad.active) { body += '
' + 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) + ' %' : '—')) + '
'; } else { body += '
当前无活跃音频流。
'; } body += '
' + levelMeterHtml() + '
' + '

' + (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', '
' + rows + '
'); } 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 += '' + svcBad + ' FAILED '; if (svcHi) svcHtml += '' + svcHi + ' 高重启 '; if (!svcBad && !svcHi) svcHtml += '正常 '; } else { svcHtml = '不可用 '; svcTxt = '—'; } return homeCard('在跑 App 与服务', '
' + homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) + '
'); } 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 卡', '未插卡'); return homeCard('存储', '
' + rows + '
'); } 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('性能小条', '
' + '
CPU
' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '%
' + barHtml(cpuU || 0) + '
' + '
内存
' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '
' + barHtml(mem.used && mem.total ? memP : 0) + '
' + '
温度
' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '℃' : '—') + '
' + '
'); } function renderHomeCards() { const body = $('#home-body'); if (!body || state.route !== 'home') return; if (!homeData) { body.innerHTML = '

加载总览…

'; return; } let h = '
' + deviceCard() + batteryCard() + audioCard() + netCard() + healthCard() + storageCard() + perfCard() + '
'; 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/overview')]); if (r[0].status !== 'fulfilled') { const b = $('#home-banner'); if (b) { b.innerHTML = '⚠️总览加载失败:' + esc(errText(r[0].reason)) + ''; b.hidden = false; } return; } const b = $('#home-banner'); if (b) { b.hidden = true; b.innerHTML = ''; } homeData = r[0].value; // 首页严格单请求:DNS 已并入 overview 的 net 块 renderHomeCards(); } function renderHome() { stopHome(); resetSpark(); // 进入首页重置 sparkline 累积 homeData = null; homeSys = null; $('#content').innerHTML = '
' + '' + '

加载总览…

' + '
'; 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 = '
' + '' + (pct == null ? '—' : pct) + '%' + '' + esc(state) + '
'; h += '' + 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 + ' 分钟(充满)') : '—')) + '
'; return h; } function estimateCardBody(est) { if (!est) return homeUnavailable(); if (est.est_mw == null) return '
' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '
'; const conf = est.confidence || 'estimated'; const cls = conf === 'measured' ? 'measured' : (conf === 'derived' ? 'derived' : (conf === 'none' ? 'none' : 'estimated')); return '
' + est.est_mw + 'mW' + '' + esc(conf) + (est.calibrated ? ' · 已标定' : ' · 经验值') + '
' + '

Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。

'; } function breakdownCardBody(bd) { if (!bd) return homeUnavailable(); if (bd.degraded) return '
系统分解不可用(HibyOS 只读模式)
'; 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 = '
'; comps.forEach(c => { stack += ''; }); stack += '
'; let legend = '
'; comps.forEach(c => { legend += '' + esc(c.label) + ' ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)'; }); legend += '
'; if (bd.cpu_util_pct != null || bd.brightness_pct != null || bd.wifi_state || bd.bt_state || bd.audio_state) { legend += '

' + (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 '
合计 ' + total + ' mW(分解为经验估算)
' + stack + legend; } function renderPowerBody(d) { let h = '
'; if (d && d.mode === 'readonly') h += '
ℹ️当前为电源只读观测(HibyOS 模式),仅显示电池采样,无功耗估算/分解。
'; h += '

电池

' + ((d && d.battery) ? batteryCardBody(d.battery) : homeUnavailable()) + '
'; h += '

功耗估算

' + ((d && d.estimate) ? estimateCardBody(d.estimate) : homeUnavailable()) + '
'; h += '

子系统分解

' + ((d && d.breakdown) ? breakdownCardBody(d.breakdown) : homeUnavailable()) + '
'; if (d && d.policy == null) h += '

电源策略(profile 四档/低电阈值)为 期3 功能,此处以不可用占位。

'; h += '
'; 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 = '
' + emptyState('加载失败:' + errText(e), e.code) + '
'; return; } if (state.route !== 'power') return; el.innerHTML = renderPowerBody(d); } function renderPower() { stopPower(); $('#content').innerHTML = '

加载电源数据…

'; 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// 找最新 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 '' + esc(m[1]) + ''; } function appStatePill(s) { const map = { fg: ['ok', '前台'], bg: ['warn', '后台'], stop: ['mut', '已停'] }; const m = map[s] || ['mut', s || '—']; return '' + esc(m[1]) + ''; } function srcPill(src) { const cls = (src === 'board') ? 'board' : (src === 'tf' ? 'tf' : ''); return src ? '' + esc(src) + '' : '—'; } function renderProcBody(ams, svcd) { let h = '
'; h += '

服务(meow_svcd)

'; if (svcd && svcd.available) { const svcs = svcd.services || []; h += ''; if (!svcs.length) h += ''; svcs.forEach(s => { h += '' + '' + '' + '' + '' + '' + '' + '' + ''; }); 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 || '—') + '
'; } else h += homeUnavailable(); h += '
'; h += '

App(meow_amsd,只读)

'; if (ams && ams.available) { const apps = ams.apps || []; h += ''; if (!apps.length) h += ''; apps.forEach(a => { const openHere = expandedProc && expandedProc.pkg === a.pkg; h += '' + '' + '' + '' + '' + ''; if (openHere) { const lines = (expandedProc.lines || []); h += ''; } }); h += '
包名名称状态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 + ' 行
'; h += '

点击「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。仅只读,无 restart/kill。

'; } else h += homeUnavailable(); h += '
'; 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 = '
读取 ' + esc(pkg) + ' 最新 stdout…
'; row.after(tailTr); try { const file = await newestStdout(pkg); if (!file) { tailTr.innerHTML = '
未找到 ' + esc(pkg) + ' 的 stdout 日志
'; 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 = '
' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
' + '
' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行
'; btn.textContent = '收起'; } catch (err) { tailTr.innerHTML = '
读取失败:' + esc(errText(err)) + '
'; 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 = '
' + emptyState('加载失败:' + errText(r[0].reason || r[1].reason)) + '
'; 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 = '

加载服务与进程…

'; 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 面板侧) ---------------- * 数据源:GET /api/net/status(E0 接口+WiFi/BT 摘要)、/api/net/conns?entity=(E8 连接表)、 * /api/net/dns(EC DNS 统计)、/api/net/dns/flush(EC flush 写级)、/api/net/log(net_*.log tail)。 * netmgrd 不在 → 后端 503(NET_UNAVAILABLE)→ 本页显示「网络管理服务不可用」占位,不白屏。 * status 成功即开始 5s 轮询(接口/WiFi/BT + 连接表 + DNS 统计);日志仅在进入页面/点「刷新」时拉取。 */ let netTimer = null; let netEntityFilter = ''; function stopNet() { if (netTimer) { clearInterval(netTimer); netTimer = null; } } function fmtDur(sec) { // 连接寿命(秒级精度) if (sec == null || isNaN(sec)) return '—'; sec = Math.floor(sec); if (sec < 60) return sec + ' 秒'; const m = Math.floor(sec / 60), s = sec % 60; if (m < 60) return m + ' 分 ' + s + ' 秒'; const h = Math.floor(m / 60), mm = m % 60; return h + ' 时 ' + mm + ' 分'; } function netBadge(e) { const i = e.indexOf(':'); const kind = i > 0 ? e.slice(0, i) : 'proc'; return '' + esc(kind) + '' + (i > 0 ? ' ' + esc(e.slice(i + 1)) + '' : ''); } function netStateCls(st) { if (!st) return ''; if (st === 'ESTABLISHED') return 'net-state ok'; if (st === 'LISTEN') return 'net-state listen'; if (st === 'UDP') return 'net-state listen'; return 'net-state warn'; } function netStatusCards(d) { let h = ''; // 接口卡(lo/wlan0/usb-eth:状态+IP+速率) const ifs = (d && d.ifaces) || []; h += '

网络接口

' + ''; if (!ifs.length) h += ''; ifs.forEach(function (f) { const up = !!f.up; h += '' + '' + '' + ''; }); h += '
接口类型状态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) + '
'; if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null)) h += '

实时速率:↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s

'; h += '
'; // WiFi 摘要 const w = (d && d.wifi) || {}; h += '

WiFi

'; if (w.connected) { h += row('SSID', '' + esc(w.ssid || '—') + ''); h += row('状态', '' + esc(w.state || '已连接') + ''); h += row('IP', esc(w.ip || '—')); h += row('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—')); } else { h += row('状态', '未连接'); } h += '
'; // 蓝牙摘要 const bt = (d && d.bt) || {}; h += '

蓝牙

'; if (bt.available) { h += row('状态', '' + (bt.powered ? '已开启' : '已关闭') + ''); h += row('地址', esc(bt.address || '—')); h += row('已配对', esc(bt.paired != null ? bt.paired : '—')); h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—')); } else { h += row('状态', '无蓝牙'); } h += '
'; return h; } function netConnsBody(cn) { const list = (cn && cn.conns) || []; let h = ''; if (!list.length) h += '
暂无连接' + (netEntityFilter ? '(该实体)' : '') + '
'; else { h += ''; list.forEach(function (c) { h += '' + '' + '' + '' + ''; }); h += '
实体远端(域名)本端状态寿命
' + netBadge(c.entity) + '' + esc(c.domain || c.remote || '—') + '' + esc(c.local || '—') + '' + esc(c.state || '—') + '' + fmtDur(c.age) + '
'; if (cn.truncated) h += '

连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)

'; } return h; } function netDnsBody(dn) { if (!dn) return '
DNS 代理不可用
'; let h = '' + row('状态', '' + (dn.enabled ? '已启用' : '已停用') + '') + row('模式', esc(dn.mode || '—')) + row('上游', esc(dn.upstream || '—')) + row('缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0)) + row('查询数', esc(dn.queries)) + row('拦截 (deny)', esc(dn.denies)) + row('首次域名', esc(dn.first_seen)) + row('resolv.conf', (dn.resolv_modified ? '已被代理改写' : '未改写')) + '
'; h += '

写操作(清缓存)需系统身份,面板路径可能被 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 = '
暂无 net_ 日志' + (d && !d.file ? '(netmgrd 未写日志)' : '') + '
'; return; } 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 = '
日志加载失败:' + esc(errText(e)) + '
'; } } async function renderNet() { stopNet(); netEntityFilter = ''; netLoaded = false; $('#content').innerHTML = '
' + '' + '

加载网络状态…

' + '

每实体连接表

' + '
' + '' + '
' + '

加载连接表…

' + '

DNS 代理

' + '
' + '
' + '

加载 DNS 统计…

' + '

网络日志

' + '
' + '

加载日志…

' + '
'; netTimer = setInterval(fetchNetOnce, 5000); // status 仅瞬态横幅,不停止轮询 fetchNetOnce(); // 填充 status + conns + dns fetchNetLog(); } function bindNet() { $('#content').addEventListener('input', function (e) { const f = e.target.closest('#net-entity-filter'); if (!f) return; netEntityFilter = f.value.trim(); const cb = $('#net-conns-body'); if (cb) cb.textContent = '筛选…'; fetchNetConns(); }); $('#content').addEventListener('click', async function (e) { if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; } if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; } if (e.target.closest('#net-banner-retry') || e.target.closest('#net-retry')) { fetchNetOnce(); return; } const flush = e.target.closest('#net-dns-flush'); if (flush) { flush.disabled = true; try { const d = await api('POST', '/api/net/dns/flush'); toast('已清空 DNS 缓存(' + (d && d.entries != null ? d.entries : '') + ' 条)'); } catch (err) { toast('清缓存失败:' + errText(err)); } flush.disabled = false; fetchNetOnce(); } }); } /* ---------------- 3. 文件管理页 ---------------- */ // 返回带索引的 rel 显示面包屑 function joinRel(a, b) { if (!a || a === '/') return '/' + b; return (a.endsWith('/') ? a.slice(0, -1) : a) + '/' + b; } function crumbHtml(items, cur) { // items: [{name,rel}] let h = '
'; h += '根目录'; items.forEach(it => { h += '/' + esc(it.name) + ''; }); return h + '
'; } async function navFiles(rel) { const page = $('#files-page'); if (!page) return; let relPath = rel || '/'; page.innerHTML = '

加载中…

'; 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 += '
' + d.allowed.map(a => '').join(' ') + '
'; } h += '
' + '' + '' + '' + '
'; h += '
'; if (!d.items.length) h += ''; d.items.forEach(function (it) { const isDir = it.type === 'dir'; const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔'); h += '' + ''; }); h += '
名称大小修改时间操作
空目录
' + (isDir ? '' : '') + icon + ' ' + esc(it.name) + (isDir ? '' : '') + '' + (isDir ? '—' : fmtBytes(it.size)) + '' + fmtTime(it.mtime) + '
' + (isDir ? '' : '下载') + '' + '' + '
'; page.innerHTML = h; } function segments(rel) { const parts = (rel || '').split('/').filter(Boolean); const out = []; let cur = ''; parts.forEach(p => { cur += '/' + p; out.push({ name: p, rel: cur }); }); return out; } function renderFiles() { $('#content').innerHTML = '
'; navFiles(state.filesRel || '/'); } function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; } function bindFiles() { const c = $('#content'); c.addEventListener('click', async function (e) { const t = e.target; const enter = t.closest('[data-enter]'); const dl = t.closest('[data-dl]'); const ren = t.closest('[data-ren]'); const del = t.closest('[data-del]'); const dirlink = t.closest('[data-dir]'); const nav = t.closest('[data-nav]'); const crumb = t.closest('[data-rel]'); const btn = t.closest('button[id]'); if (crumb) { navFiles(crumb.dataset.rel); return; } if (nav) { navFiles(nav.dataset.nav); return; } if (dirlink || enter) { const n = (dirlink || enter).dataset.directory || (dirlink || enter).dataset.dir || (dirlink || enter).dataset.enter; navFiles(joinRel(state.filesRel, n)); return; } if (dl) { const abs = absFor(dl.dataset.dl); if (!abs) return toast('无当前目录'); doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl); return; } if (ren) { renameFile(ren.dataset.ren); return; } if (del) { removeFile(del.dataset.del); return; } if (btn && btn.id === 'file-newdir') { mkdirFile(); return; } if (btn && btn.id === 'file-upload') { const fi = $('#file-input'); if (fi) { fi.value = ''; fi.click(); } return; } if (btn && btn.id === 'file-refresh') { navFiles(state.filesRel); return; } }); c.addEventListener('change', function (e) { if (e.target.id === 'file-input') uploadFiles(e.target.files); }); } function doDownload(url, name) { return apiDownload(url).then(blobUrl => { const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'file'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); }).catch(e => toast('下载失败:' + errText(e))); } async function removeFile(name) { const abs = absFor(name); if (!abs) return; const ok = await confirmDlg('删除 ' + name, '此操作不可撤销,确认删除?\n' + abs, true); if (!ok) return; try { await api('DELETE', '/api/files?path=' + encodeURIComponent(abs)); toast('已删除 ' + name); navFiles(state.filesRel); } catch (e) { toast('删除失败:' + errText(e)); } } async function renameFile(name) { const abs = absFor(name); if (!abs) return; const nn = prompt('将「' + name + '」重命名为:', name); if (!nn || nn === name) return; try { await api('POST', '/api/files/rename', { path: abs, new_name: nn }); toast('已重命名'); navFiles(state.filesRel); } catch (e) { toast('重命名失败:' + errText(e)); } } async function mkdirFile() { const abs = state.filesAbs; if (!abs) return; const nn = prompt('新建目录名称(将创建于 ' + state.filesRel + '):', 'newdir'); if (!nn) return; try { await api('POST', '/api/files/mkdir', { path: joinRel(abs, nn) }); toast('已创建 ' + nn); navFiles(state.filesRel); } catch (e) { toast('创建失败:' + errText(e)); } } async function uploadFiles(files) { if (!files || !files.length) return; const abs = state.filesAbs; if (!abs) return toast('无当前目录'); const fd = new FormData(); fd.append('path', abs); Array.prototype.forEach.call(files, f => fd.append('file', f)); const n = files.length; toast('正在上传 ' + n + ' 个文件…'); try { await api('POST', '/api/files/upload', fd); toast('上传完成'); navFiles(state.filesRel); } catch (e) { toast('上传失败:' + errText(e)); } } /* ---------------- 4. 应用管理页 ---------------- */ function renderApps() { $('#content').innerHTML = '

加载应用列表…

'; (async function () { let d; try { d = await api('GET', '/api/apps'); } catch (e) { if (state.route === 'apps') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'apps') return; let h = '
'; h += '
' + '' + '' + '
'; h += '
'; if (!d.apps.length) h += '
尚未安装任何 MeowAPP
'; d.apps.forEach(a => { const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase()); h += '
' + '
' + ino + '
' + '
' + esc(a.name || a.package || '未命名') + '
' + '
' + esc(a.package || '') + '
' + '
版本 ' + esc(a.version || '—') + (a.invalid ? ' · 无效' : '') + (a.size ? ' · ' + fmtBytes(a.size) : '') + '
' + '
' + '' + '导出 .mpk' + '' + '
'; }); h += '
'; // 本地源 .mpk 安装(后端支持 path 形式) h += '

从卡上路径安装

' + '
' + '
'; $('#content').innerHTML = h; })(); } function bindApps() { const c = $('#content'); c.addEventListener('click', async function (e) { const t = e.target; const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh'); const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]'); const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go'); if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; } if (refresh) { renderApps(); return; } if (info) { appInfo(info.dataset.ainfo); return; } if (exp) { e.preventDefault(); if (exp.dataset.busy) return; /* 打包期间防连点 */ exp.dataset.busy = '1'; const oldTxt = exp.textContent; exp.textContent = '打包中…'; toast('正在打包导出 ' + exp.dataset.aexport + ',请稍候…'); doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk') .finally(() => { delete exp.dataset.busy; exp.textContent = oldTxt; }); return; } if (uninst) { appUninstall(uninst.dataset.auninst); return; } if (goPath) { installFromPath($('#app-install-path').value); return; } }); c.addEventListener('change', function (e) { if (e.target.id === 'app-file') installFromFile(e.target.files); }); } async function installFromFile(files) { if (!files || !files.length) return; const fd = new FormData(); fd.append('file', files[0]); toast('正在安装 ' + files[0].name + '…'); try { const d = await api('POST', '/api/apps/install', fd); toast('已安装 ' + (d.package || '应用') + (d.version ? ' v' + d.version : '')); renderApps(); } catch (e) { toast('安装失败:' + errText(e)); } } async function installFromPath(p) { if (!p) return toast('请输入 .mpk 路径'); toast('正在安装 ' + p + '…'); try { const d = await api('POST', '/api/apps/install', { path: p }); toast('已安装 ' + (d.package || '应用')); renderApps(); } catch (e) { toast('安装失败:' + errText(e)); } } async function appUninstall(pkg) { const ok = await confirmDlg('卸载 ' + pkg, '将移除应用与数据预留(数据默认保留)。确认卸载?', true); if (!ok) return; try { await api('DELETE', '/api/apps/' + pkg); toast('已卸载 ' + pkg); renderApps(); } catch (e) { toast('卸载失败:' + errText(e)); } } async function appInfo(pkg) { try { const d = await api('GET', '/api/apps/' + pkg); let txt = '包名: ' + d.package + '\n名称: ' + d.name + '\n版本: ' + d.version + '\nexec: ' + d.exec + '\nsize: ' + fmtBytes(d.size) + '\n路径: ' + d.path; if (d.description) txt += '\n描述: ' + d.description; alert(txt); } catch (e) { toast('获取详情失败:' + errText(e)); } } /* ---------------- 4b. 字体管理页(/api/fonts* 专属页) ---------------- */ function renderFontsPage() { $('#content').innerHTML = '
' + '

字体库

' + '
' + '' + '' + '
' + '

加载字体库列表…

' + '

上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' + 'MeowOS/fonts/<family>/<weight>/;同 family/weight 冲突时会要求确认覆盖。' + '导入后进设备「设置 → 字体」页,列表自动重扫生效。

'; loadFontsPage(); } async function loadFontsPage() { const box = $('#fpage-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; } let h = ''; fs.forEach(f => { h += '' + '' + '' + ''; }); box.innerHTML = h + '
familyweight名称tierglyph 数操作
' + esc(f.family || '') + '' + esc(f.weight || '') + '' + esc(f.name || '—') + '' + esc(f.tier || '—') + '' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '
'; } async function importFontPage(files) { if (!files || !files.length) return; const f = files[0]; const btn = $('#fpage-import'); if (btn && btn.disabled) return; // 上传中防连点(进度态) const fd = new FormData(); fd.append('file', f); if (btn) { btn.disabled = true; btn.textContent = '导入中…'; } toast('正在导入 ' + f.name + '…'); let res = null; try { res = await api('POST', '/api/fonts/import', fd); } catch (err) { if (err.code === 'CONFLICT') { const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true); if (ok) { try { res = await api('POST', '/api/fonts/import?force=1', fd); } catch (e2) { toast('导入失败:' + errText(e2)); } } } else toast('导入失败:' + errText(err)); } if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; } if (res) { toast('已导入 ' + (res.family || '') + '/' + (res.weight || '')); if (res.warning) toast(res.warning); else if (res.hint) toast(res.hint); loadFontsPage(); } } function bindFonts() { const c = $('#content'); c.addEventListener('click', function (e) { if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; } if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; } const fe = e.target.closest('[data-fpexport]'); if (fe) { if (fe.dataset.busy) return; // 打包期间防连点 fe.dataset.busy = '1'; const old = fe.textContent; fe.textContent = '打包中…'; const f = fe.dataset.fpexport, w = fe.dataset.fpweight; doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz') .finally(() => { delete fe.dataset.busy; fe.textContent = old; }); return; } }); c.addEventListener('change', function (e) { if (e.target.id === 'fpage-file') importFontPage(e.target.files); }); } /* ---------------- 5. 设置镜像页 ---------------- */ function settingControl(it) { const key = it.key; if (key === 'panel.auth_pass') { const txt = it.set ? '已设置' : '未设置'; return '
面板密码(' + txt + ')
' + '
'; } if (it.type === 'bool') { return '
' + esc(key) + '
' + '
'; } if (it.type === 'int') { const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max); return '
' + '
' + esc(it.value == null ? '' : it.value) + '' + '
'; } if (it.type === 'enum') { const opts = (it.options || []).map(o => '').join(''); return '
'; } return '
'; } async function renderSettings() { $('#content').innerHTML = '

加载设置…

'; let d; try { d = await api('GET', '/api/settings'); } catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'settings') return; let h = '
'; h += '
' + '' + '
'; const items = d.items || []; // 面板外观(主题):本面板覆盖(localStorage),默认跟随设备 ui.style / display.theme h += '

主题(面板外观)

' + '
' + '
' + '' + '
' + '
' + '

默认跟随设备 ui.style(当前:' + esc(devStyleLabel()) + ');在面板选择即为本面板覆盖,只影响网页观感(顶栏切换才会同步设备)。

' + '
' + '
' + '' + '' + '' + '
' + '

跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。

'; // 面板 PIN(访问级鉴权):显示状态 + 修改/清空控件(走 /api/auth/* 校验旧 PIN + PUT /api/settings/panel.pin) h += '

面板 PIN(访问鉴权)

' + '

查询鉴权状态…

'; // 面板鉴权(可选防护)单独一卡 const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass'); if (authItems.length) { h += '

面板鉴权(可选防护)

' + authItems.map(settingControl).join('') + '

开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。

'; } const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass'); // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')], ['数值/滑条', rest.filter(x => x.type === 'int')], ['枚举/选择', rest.filter(x => x.type === 'enum')], ['字符串/路径', rest.filter(x => x.type === 'str')]]; groups.forEach(function (g) { if (!g[1].length) return; h += '

' + g[0] + '

' + g[1].map(settingControl).join('') + '
'; }); // 字体库(.zip 导入 / 导出) h += '

字体库(.tar.gz 导入 / 导出)

' + '
' + '
' + '

加载字体库列表…

' + '

导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。

'; h += '
'; $('#content').innerHTML = h; applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作) 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 = '

无法查询鉴权状态:' + 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 = '
' + esc(txt) + '' + (st.session ? ' · 当前会话有效' : ' · 未登录') + '
'; h += '
' + '' + '' + (st.locked ? '锁定期间不可修改/清空' : '修改/清空需先验证当前 PIN') + '
'; h += '
' + '' + '设备下次亮屏免 PIN 进入一次(不清除 PIN)
'; 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 '
' + '
' + nm + '
' + meta + '
' + '
'; }).join(''); } async function importFont(files) { if (!files || !files.length) return; const f = files[0]; const fd = new FormData(); fd.append('file', f); toast('正在导入 ' + f.name + '…'); let res; try { res = await api('POST', '/api/fonts/import', fd); } catch (err) { if (err.code === 'CONFLICT') { const ok = await confirmDlg('覆盖字库', '目标字库已存在,覆盖导入吗?', true); if (!ok) return; try { res = await api('POST', '/api/fonts/import?force=1', fd); } catch (e2) { toast('导入失败:' + errText(e2)); return; } } else { toast('导入失败:' + errText(err)); return; } } toast('已导入 ' + ((res && res.family) || '') + '/' + ((res && res.weight) || '')); if (res && res.hint) toast(res.hint); loadFonts(); } function bindSettings() { const c = $('#content'); c.addEventListener('input', function (e) { const r = e.target.closest('[data-stype="int"]'); if (!r) return; const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value; updateSetting(r.dataset.skey, +r.value, 'int'); }); c.addEventListener('change', function (e) { if (e.target.id === 'font-file') { importFont(e.target.files); return; } const t = e.target; const sk = t.dataset.skey; if (!sk) return; if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool'); else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum'); else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); }); c.addEventListener('click', async function (e) { // 「主题(面板外观)」卡:本面板覆盖(localStorage),不写设备 const ss = e.target.closest('#set-style-seg .seg-btn'); if (ss) { storageSet('meowpanel.style', ss.dataset.style); applyTheme(); return; } const sm = e.target.closest('#set-mode-seg .seg-btn'); if (sm) { storageSet('meowpanel.mode', sm.dataset.mode); applyTheme(); return; } if (e.target.closest('#set-style-reset')) { storageDel('meowpanel.style'); applyTheme(); return; } if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; } const fe = e.target.closest('[data-fexport]'); if (fe) { const f = fe.dataset.fexport, w = fe.dataset.fweight; doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz'); return; } if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; } if (e.target.closest('#set-restore')) { const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); if (!ok) return; try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); } catch (err) { toast('恢复失败:' + errText(err)); } } else if (e.target.closest('#set-refresh')) { renderSettings(); } }); } const updateSetting = debounce(async function (key, value, type) { try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); } catch (e) { toast('保存失败:' + errText(e)); } }, 350); /* ---------------- 6. 远程控制页 ---------------- */ function renderRemote() { const keys = [ { code: 115, label: '音量 +', ico: '🔊' }, { code: 114, label: '音量 −', ico: '🔉' }, { code: 116, label: '电源(单击)', ico: '⏻' }, { code: 164, label: '播放/暂停', ico: '⏯' }, { code: 163, label: '下一曲', ico: '⏭' }, { code: 165, label: '上一曲', ico: '⏮' }, { code: 158, label: '返回', ico: '↩' }, { code: 172, label: '主页', ico: '⌂' } ]; let h = '
'; h += '

模拟按键

' + '

行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。

' + '
' + keys.map(k => '' ).join('') + '
'; h += '

电源长按(电源菜单)

' + '
' + '发送 code=116 · press=long · ms=3000
'; h += '
'; $('#content').innerHTML = h; } async function sendKey(code, press, ms) { try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); } catch (e) { toast('发送失败:' + errText(e)); } } function bindRemote() { $('#content').addEventListener('click', function (e) { const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; } const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; } }); } /* ---------------- 7. 系统信息 / OTA 页 ---------------- */ async function renderSystem() { $('#content').innerHTML = '

加载系统信息…

'; let d, pw = null; try { d = await api('GET', '/api/system'); } 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; 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 ? '' + esc(curMode) + ' (与默认启动不一致)' : (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('电池', '' + esc(batt.state || '—') + ' ' + (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 = '
'; h += '

系统信息

' + sysRows + '
'; h += '

网络

' + row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') + row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') + row('mDNS', esc(net.hostname_local || '—')) + '
'; h += '

升级

' + '

当前 ' + esc(ota.current || d.version || '—') + (ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '

' + '
' + '
'; h += '

危险操作

' + '
' + '
' + '

⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。

'; h += '
'; $('#content').innerHTML = h; } function row(k, v) { return '' + k + '' + v + ''; } function bindSystem() { // init 一次性委托绑定(不随渲染累积) $('#content').addEventListener('click', async function (e) { if (e.target.closest('#sys-reboot')) { if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true)) try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); } return; } if (e.target.closest('#sys-shutdown')) { if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true)) try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); } return; } if (e.target.closest('#sys-ota')) { if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true)) try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); } return; } if (e.target.closest('#sys-ota-check')) { // 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级) try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); } catch (err) { toast('检查失败:' + errText(err)); } return; } }); } /* ---------------- 8. 日志页(批2 崩溃/日志中心:崩溃汇总 + 分组 + 前缀过滤 + 崩溃尾巴前置) ---------------- */ let logsGroup = 'all', logsPrefix = ''; const LOG_GROUP_NAME = { all: '全部', system: '系统服务', app: 'App', audio_monitor: 'audio_monitor', power: 'power', net: 'net', crash: 'crash', ams: 'ams' }; function groupName(g) { return LOG_GROUP_NAME[g] || (g || 'app'); } function logsCrashCard(cr) { const ents = (cr.entities || []).filter(e => e.name); let h = '

崩溃汇总(' + esc(cr.file || '本 boot · crash log') + ')

'; if (!ents.length && !(cr.dmesg_count || 0)) { h += '

本 boot 无崩溃记录。

'; return h; } h += ''; ents.forEach(e => { h += '' + '' + '' + '' + ''; }); h += '
实体崩溃数最近时间信号/退出操作
' + 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) + ')' : '') + '
'; if (cr.dmesg_count) { h += '

内核 oops / segfault 捕获:' + esc(cr.dmesg_count) + ' 条' + ((cr.dmesg_tail || []).length ? '

' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '
' : '') + '

'; } h += '
'; return h; } function renderLogsFiles(files) { const el = $('#logs-files'); if (!el) return; const filtered = files.filter(f => { if (logsGroup !== 'all' && (f.group || 'app') !== logsGroup) return false; if (logsPrefix && !f.name.toLowerCase().startsWith(logsPrefix.toLowerCase())) return false; return true; }); let h = ''; if (!filtered.length) h += ''; filtered.forEach(f => { h += '' + ''; }); h += '
文件分组大小修改时间操作
暂无日志(或过滤后无结果)
' + esc(f.name) + '' + groupName(f.group) + '' + fmtBytes(f.size) + '' + fmtTime(f.mtime) + '
'; el.innerHTML = h; } async function renderLogs() { $('#content').innerHTML = '

加载日志列表…

'; const r = await Promise.allSettled([api('GET', '/api/logs'), api('GET', '/api/logs/crash')]); if (state.route !== 'logs') return; if (r[0].status !== 'fulfilled') { $('#content').innerHTML = emptyState('加载失败:' + errText(r[0].reason), r[0].reason && r[0].reason.code); return; } const d = r[0].value; const cr = (r[1].status === 'fulfilled') ? r[1].value : null; let h = '
'; if (cr && ((cr.count || 0) > 0 || (cr.dmesg_count || 0) > 0 || (cr.entities || []).length)) h += logsCrashCard(cr); h += '

日志文件(MeowOS/logs)

'; h += '
' + esc(d.dir || '') + '
'; h += '
'; h += ''; h += '
'; h += '
'; Object.keys(LOG_GROUP_NAME).forEach(g => { h += ''; }); h += '
'; h += '
加载…
'; h += '
'; $('#content').innerHTML = h; logsFiles = d.files || []; renderLogsFiles(logsFiles); } function bindLogs() { $('#content').addEventListener('click', async function (e) { const gbtn = e.target.closest('[data-log-group]'); if (gbtn) { logsGroup = gbtn.dataset.logGroup; $$('[data-log-group]').forEach(b => b.classList.toggle('btn-primary', b === gbtn)); renderLogsFiles(logsFiles); return; } const tb = e.target.closest('[data-crash-tail]'); if (tb) { await viewLogsCrashTail(tb.dataset.crashTail, tb); return; } const t = e.target.closest('[data-log]'); if (!t) return; const p = $('#log-view'); try { const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400'); p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : ''); } catch (err) { toast('查看失败:' + errText(err)); } }); $('#content').addEventListener('input', function (e) { const p = e.target.closest('[data-log-prefix]'); if (!p) return; logsPrefix = p.value; renderLogsFiles(logsFiles); }); } /* 崩溃实体 → stdout 尾部:复用批1-B 的读取路径(/api/files 列 logs// → /api/logs/read 读尾部)。 */ let logsFiles = []; async function viewLogsCrashTail(name, btn) { btn.textContent = '加载中…'; let file; try { const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + name + '/')); const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0); if (!files.length) { btn.textContent = 'stdout 尾部'; toast('未找到 ' + name + ' 的 stdout 日志'); return; } files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); file = files[0].name; } catch (err) { btn.textContent = 'stdout 尾部'; toast('查找失败:' + errText(err)); return; } try { const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(name + '/' + file) + '&lines=200'); const lines = (d.lines || []); const p = $('#log-view'); if (p) { p.hidden = false; p.textContent = name + '/' + file + '\n' + (lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : ''); } } catch (err) { toast('读取失败:' + errText(err)); } btn.textContent = 'stdout 尾部'; } /* ---------------- 9. 通知页 ---------------- */ async function renderNotif() { $('#content').innerHTML = '

加载通知…

'; let d; try { d = await api('GET', '/api/notifications'); } catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'notif') return; let h = '
'; h += '
' + '
'; const list = d.notifications || []; if (!list.length) h += '
暂无通知
'; list.forEach(n => { const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低'); h += '
' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '
' + '
' + esc(n.summary || n.app_name || '通知') + '
' + (n.body ? '
' + esc(n.body) + '
' : '') + '
' + esc(n.app_name || '') + '' + fmtTime(n.ts) + '' + '紧急度 ' + urg + '' + (n.progress != null ? '进度 ' + n.progress + '%' : '') + (n.ongoing ? '进行中' : '') + '
' + '
'; }); h += '
'; $('#content').innerHTML = h; } function bindNotif() { $('#content').addEventListener('click', async function (e) { if (e.target.closest('#notif-refresh')) { renderNotif(); return; } if (e.target.closest('#notif-clear')) { if (await confirmDlg('清空通知', '将清空全部通知。确认?', true)) try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); } catch (err) { toast('清空失败:' + errText(err)); } return; } const del = e.target.closest('[data-ndel]'); if (del) { try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); } catch (err) { toast('清除失败:' + errText(err)); } } }); } /* ---------------- 10. 实时屏幕 / 点击遥控 ---------------- * 轮询改「上一帧到了再取下一帧」(链式 setTimeout,绝不堆积/重叠): * 每帧下载完成后按目标周期自校正 delay(周期 = max(0, interval - 请求耗时)), * 故名义 fps 被尊重,且请求变慢时自动退化为 1/请求耗时。 */ let screenTimer = null, screenBusy = false, screenOn = false; let screenObjUrl = null, screenFps = 1; let screenLastMs = 0, screenMeasFps = 0; // 实测帧率(帧间间隔 EMA) let screenDelta = false, screenCanvasRef = null, screenCanvasReady = false; // 增量模式(canvas 叠加) function stopScreenPoll() { screenOn = false; if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } } function restartScreenPoll() { screenOn = true; if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } // 请求在途时不重复启动(避免并发请求);在途帧结束后会按最新 screenFps 自续 if (!screenBusy) screenTimer = setTimeout(fetchScreen, 0); } async function fetchScreen() { if (screenBusy) return; screenBusy = true; const t0 = performance.now(); let statusMsg = ''; try { const headers = {}; const token = storageGet('meowpanel.token'); if (token) headers['X-MeowToken'] = token; const deltaQ = screenDelta ? '&delta=1' : ''; const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70' + deltaQ, Object.keys(headers).length ? { headers: headers } : undefined); if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; return; } const hd = (k) => res.headers.get(k); if (screenDelta) { const isDelta = hd('X-Meow-Delta') === '1'; const full = hd('X-Meow-Full') === '1'; const changed = parseInt(hd('X-Meow-Changed') || '-1', 10); const fw = parseInt(hd('X-Meow-W') || '480', 10); const fh = parseInt(hd('X-Meow-H') || '720', 10); const blob = await res.blob(); if (!isDelta || full) { // 整帧(或旧后端无 delta 头)→ 播种/替换 canvas const img = await createImgFromBlob(blob); const cv = ensureScreenCanvas(fw, fh); if (cv) { cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; } statusMsg = '已更新' + (changed >= 0 ? '(整帧)' : ''); } else if (!screenCanvasReady) { // 尚未播种:取一整帧再继续 await seedScreenCanvas(fw, fh); if (screenCanvasReady) statusMsg = '已更新(播种)'; } else if (changed === 0) { // 无变化:跳过叠加 statusMsg = '无变化'; } else { // 子图叠加 const subX = parseInt(hd('X-Meow-Sub-X') || '0', 10); const subY = parseInt(hd('X-Meow-Sub-Y') || '0', 10); const subW = parseInt(hd('X-Meow-Sub-W') || fw, 10); const subH = parseInt(hd('X-Meow-Sub-H') || fh, 10); const img = await createImgFromBlob(blob); const cv = ensureScreenCanvas(fw, fh); if (cv) cv.getContext('2d').drawImage(img, subX, subY, subW, subH); statusMsg = '已更新(增量 ' + changed + ' 块)'; } } else { const blob = await res.blob(); const url = URL.createObjectURL(blob); const img = $('#screen-img'); if (!img) { URL.revokeObjectURL(url); return; } img.src = url; if (screenObjUrl) URL.revokeObjectURL(screenObjUrl); screenObjUrl = url; statusMsg = '已更新'; } // 实测帧率:上一帧到本帧间隔的 EMA(首帧无间隔) const now = performance.now(); if (screenLastMs) { const dt = (now - screenLastMs) / 1000; screenMeasFps = screenMeasFps ? screenMeasFps * 0.7 + (1 / dt) * 0.3 : 1 / dt; } screenLastMs = now; $('#screen-status').textContent = statusMsg + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString(); } catch (e) { $('#screen-status').textContent = '后端未连接'; } screenBusy = false; // 自校正续链(本轮耗时计入周期,保证名义 fps) if (screenOn) { const intervalMs = Math.max(500, Math.round(1000 / screenFps)); const wait = Math.max(0, intervalMs - (performance.now() - t0)); screenTimer = setTimeout(fetchScreen, wait); } } // 增量模式助手:blob → Image(一次性 objectURL,加载后即回收) async function createImgFromBlob(blob) { const url = URL.createObjectURL(blob); try { const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); return img; } finally { URL.revokeObjectURL(url); } } function ensureScreenCanvas(fw, fh) { let cv = screenCanvasRef; if (!cv) cv = screenCanvasRef = $('#screen-canvas'); if (!cv) return null; if (cv.width !== fw || cv.height !== fh) { cv.width = fw; cv.height = fh; } return cv; } async function seedScreenCanvas(fw, fh) { const headers = {}; const token = storageGet('meowpanel.token'); if (token) headers['X-MeowToken'] = token; const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70', Object.keys(headers).length ? { headers: headers } : undefined); if (!res.ok) return false; const img = await createImgFromBlob(await res.blob()); const cv = ensureScreenCanvas(fw, fh); if (!cv) return false; cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; return true; } function applyScreenDeltaDom() { const img = $('#screen-img'), cv = $('#screen-canvas'); if (!img || !cv) return; if (screenDelta) { img.style.display = 'none'; cv.style.display = 'block'; } else { img.style.display = 'block'; cv.style.display = 'none'; } } async function screenTap(e) { const img = $('#screen-img'), cv = $('#screen-canvas'); const useCanvas = screenDelta && cv && cv.style.display !== 'none'; const el = useCanvas ? cv : img; if (!el) return; const r = el.getBoundingClientRect(); if (!r.width || !r.height) return; // 前端按 480x720(设备原生分辨率)换算 const nx = Math.round((e.clientX - r.left) / r.width * 480); const ny = Math.round((e.clientY - r.top) / r.height * 720); const nxCL = Math.max(0, Math.min(479, nx)), nyCL = Math.max(0, Math.min(719, ny)); try { const d = await api('POST', '/api/input/tap', { x: nxCL, y: nyCL }); if (d && d.dispatched) toast('已注入 (' + nxCL + ',' + nyCL + ')'); else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停'); else toast('注入未派发'); } catch (err) { toast('注入失败:' + errText(err)); } } function renderScreen() { screenFps = parseFloat(storageGet('screen.fps') || '1'); if (!(screenFps >= 0.2 && screenFps <= 2)) screenFps = 1; let h = '
'; h += '

实时画面

' + '

点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。

' + '
' + '设备实时画面' + '' + '
' + '
' + '' + '' + '' + screenFps.toFixed(1) + ' fps' + '
' + '
' + '' + '
' + '

载入中…

' + '
'; $('#content').innerHTML = h; screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态 const slider = $('#screen-fps'); slider.addEventListener('input', function () { const v = parseFloat(slider.value); screenFps = v; storageSet('screen.fps', String(v)); $('#screen-fps-lbl').textContent = v.toFixed(1) + ' fps'; restartScreenPoll(); }); const deltaBox = $('#screen-delta'); deltaBox.addEventListener('change', function () { screenDelta = deltaBox.checked; screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种 applyScreenDeltaDom(); restartScreenPoll(); }); $('#screen-img').addEventListener('click', screenTap); $('#screen-canvas').addEventListener('click', screenTap); applyScreenDeltaDom(); // restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发 restartScreenPoll(); } function bindScreen() { // 页面隐藏(切到其它标签页)时停刷;回到本页继续 document.addEventListener('visibilitychange', function () { if (document.hidden) stopScreenPoll(); else if (state.route === 'screen') restartScreenPoll(); }); } /* ---------------- 路由 ---------------- */ const ROUTES = { home: { title: '首页仪表盘', render: renderHome, bind: bindHome }, perf: { title: '实时性能', render: renderPerf, bind: null }, bright: { title: '亮度 · 音量', render: renderBright, bind: null }, audio: { title: '音频状态', render: renderAudio, bind: bindAudio }, net: { title: '网络管理', render: renderNet, bind: bindNet }, files: { title: '文件管理', render: renderFiles, bind: bindFiles }, apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps }, fonts: { title: '字体管理', render: renderFontsPage, bind: bindFonts }, settings: { title: '设置镜像', render: renderSettings, bind: bindSettings }, remote: { title: '远程控制', render: renderRemote, bind: bindRemote }, screen: { title: '实时屏幕', render: renderScreen, bind: bindScreen }, system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem }, logs: { title: '系统日志', render: renderLogs, bind: bindLogs }, notif: { title: '通知中心', render: renderNotif, bind: bindNotif }, power: { title: '电源 / 功耗', render: renderPower, bind: null }, proc: { title: '进程 / 服务', render: renderProc, bind: bindProc } }; // 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定) function bindNav() { $$('.nav-item').forEach(b => b.addEventListener('click', () => { location.hash = '#/' + b.dataset.route; })); $('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open')); } function route() { if (state.unauth) { showLogin(authMode); return; } // 未认证 → 登录页(保持当前设置/登录态) const id = (location.hash || '#/home').replace('#/', '').split('?')[0] || 'home'; const r = ROUTES[id] || ROUTES.home; state.route = id; dbg('route →', id); stopPerf(); stopAudio(); stopNet(); stopScreenPoll(); stopHome(); stopPower(); stopProc(); $$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id)); $('#page-title').textContent = r.title; r.render(); } /* ---------------- 启动 ---------------- */ let started = false; function startApp() { if (started) { route(); return; } started = true; initTheme(); window.addEventListener('hashchange', route); route(); } async function init() { bindNav(); bindThemeUi(); bindSlider(); bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin(); bindAudio(); bindLogin(); bindPwDlg(); // 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录 let st = null; try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; } if (st && st.session) { startApp(); return; } if (st && !st.pin_set && !st.legacy) { showLogin('setup'); return; } if (st) { showLogin('login'); return; } startApp(); // 后端不可达:仍启动(显示离线横幅) } document.addEventListener('DOMContentLoaded', init); })();