Files
MeowOS-For-R3P2/patch_v2/meowpanel/web/meowpanel.js
T

2382 lines
132 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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 '<svg class="lvl-spark" viewBox="0 0 100 46" preserveAspectRatio="none" aria-hidden="true">' +
'<line class="grid" x1="0" y1="46" x2="100" y2="46"/>' +
'<line class="grid" x1="0" y1="23" x2="100" y2="23"/>' +
'<polyline class="spk-rms" points="" fill="none" stroke="#00bfa5" stroke-width="1.6" vector-effect="non-scaling-stroke"/>' +
'<polyline class="spk-peak" points="" fill="none" stroke="#ffb300" stroke-width="1.6" vector-effect="non-scaling-stroke"/>' +
'</svg>';
}
function sparklinePoints(arr) {
const n = arr.length;
if (!n) return '';
return arr.map((v, i) => {
const x = (i / (SPARK_MAX - 1)) * 100;
return x.toFixed(1) + ',' + (46 - Math.max(0, Math.min(100, v)) / 100 * 40).toFixed(1);
}).join(' ');
}
function pushSpark(v, arr) { // deci-dB → 0..100 百分比入队(<=30 点)
arr.push(dbPct(v));
if (arr.length > SPARK_MAX) arr.shift();
}
function drawSpark(rootEl) {
const svg = rootEl && rootEl.querySelector('.lvl-spark');
if (!svg) return;
const r = svg.querySelector('.spk-rms'), p = svg.querySelector('.spk-peak');
if (r) r.setAttribute('points', sparklinePoints(sparkRms));
if (p) p.setAttribute('points', sparklinePoints(sparkPeak));
}
function resetSpark() { sparkRms.length = 0; sparkPeak.length = 0; }
function levelMeterHtml() {
return '<div class="lvl">' +
'<div class="lvl-row"><span class="lvl-lbl">RMS</span>' +
'<div class="lvl-meter"><div class="lvl-fill rms" style="width:0%"></div></div>' +
'<span class="lvl-val" data-val="rms">−∞</span></div>' +
'<div class="lvl-row"><span class="lvl-lbl">PEAK</span>' +
'<div class="lvl-meter"><div class="lvl-fill peak" style="width:0%"></div></div>' +
'<span class="lvl-val" data-val="peak">−∞</span></div>' +
'<div class="lvl-scale"><span>-60</span><span>-40</span><span>-20</span><span>0 dBFS</span></div>' +
sparklineHtml() + '</div>';
}
function updateLevelMeter(rootEl, d) {
if (!rootEl) return;
const rms = (d && d.available) ? d.rms : null;
const peak = (d && d.available) ? d.peak : null;
const fr = rootEl.querySelector('.lvl-fill.rms');
const fp = rootEl.querySelector('.lvl-fill.peak');
const vr = rootEl.querySelector('.lvl-val[data-val="rms"]');
const vp = rootEl.querySelector('.lvl-val[data-val="peak"]');
if (fr) fr.style.width = dbPct(rms) + '%';
if (fp) fp.style.width = dbPct(peak) + '%';
if (vr) vr.textContent = fmtDb(rms);
if (vp) vp.textContent = fmtDb(peak);
pushSpark(rms, sparkRms); pushSpark(peak, sparkPeak);
drawSpark(rootEl);
}
function homeKv(k, v) {
return '<div class="home-kv"><span class="k">' + esc(k) + '</span><span class="v">' + v + '</span></div>';
}
function homeUnavailable() { return '<div class="home-sub">— 数据不可用</div>'; }
function homeCard(title, body) { return '<div class="card"><h3 class="card-title">' + esc(title) + '</h3>' + body + '</div>'; }
function modePill(m) {
if (!m) return '<span class="home-chip mut">—</span>';
const cls = (m === 'meow') ? 'ok' : ((m === 'hiby') ? 'warn' : 'mut');
return '<span class="home-chip ' + cls + '">' + esc(m) + '</span>';
}
/* ---------------- 全局状态 ---------------- */
const state = {
style: 'md3', mode: 'dark', // 已解析的风格/明暗(应用到 <html> 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));
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 × 明/暗) ----------------
* <html> 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 '<div class="empty">' + esc(t) + '</div>';
}
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 '<div class="bar ' + c + '"><span style="width:' + p + '%"></span></div>';
}
function stat(label, valueHtml, sub, barPct) {
return '<div class="stat"><div class="stat-label">' + esc(label) + '</div>' + valueHtml +
(sub ? '<div class="stat-sub">' + sub + '</div>' : '') +
(barPct != null ? barHtml(barPct) : '') + '</div>';
}
function renderPerfData(page, d) {
const cpu = d.cpu || {};
const mem = d.memory || {}, st = d.storage || {}, tf = d.tfcard || {}, tmp = d.temp;
let h = '<div class="card"><h3 class="card-title">实时性能</h3>';
h += '<div class="muted">运行时间:' + fmtUptime(d.uptime_sec) + '</div><div class="stat-grid">';
h += stat('CPU', '<span class="stat-value">' + (cpu.usage == null ? '—' : +cpu.usage.toFixed(1)) + '<span class="stat-unit">%</span></span>',
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('内存', '<span class="stat-value">' + fmtBytes((mem.used || 0) * 1024) + '</span><span class="stat-unit">/ ' + fmtBytes((mem.total || 0) * 1024) + '</span>',
'可用 ' + fmtBytes((mem.available || (mem.free || 0)) * 1024), memP);
const stP = st.total ? (st.used / st.total) * 100 : 0;
h += stat('存储 (rootfs)', '<span class="stat-value">' + fmtBytes((st.used || 0) * 1024) + '</span><span class="stat-unit">/ ' + fmtBytes((st.total || 0) * 1024) + '</span>', st.path || '', stP);
if (tf) {
if (tf.present) {
const tfP = tf.total ? (tf.used / tf.total) * 100 : 0;
h += stat('TF 卡', '<span class="stat-value">' + fmtBytes((tf.used || 0) * 1024) + '</span><span class="stat-unit">/ ' + fmtBytes((tf.total || 0) * 1024) + '</span>',
fmtBytes((tf.avail || 0) * 1024) + ' 可用 · ' + (tf.filesystem || ''), tfP);
} else {
h += stat('TF 卡', '<span class="stat-value">未插卡</span>', '');
}
}
const tmpCpu = tmp && tmp.cpu != null ? +tmp.cpu.toFixed(1) : null;
h += stat('温度', '<span class="stat-value">' + (tmpCpu == null ? '—' : tmpCpu) + '<span class="stat-unit">℃</span></span>',
tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : '');
h += '</div></div>';
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 = '<div class="page" id="perf-page"><p class="placeholder">加载中…</p></div>';
if (state.perfTimer) stopPerf();
fetchPerfOnce();
state.perfTimer = setInterval(fetchPerfOnce, 2000);
}
/* ---------------- 2. 亮度 / 音量页 ---------------- */
let volTimer = null;
function slur(step) { // step: {key,label,min,max,val}
return '<div class="card"><h3 class="card-title">' + esc(step.label) + '</h3>' +
'<div class="field-row"><span class="muted" style="width:44px">' + step.val + '</span>' +
'<input type="range" min="' + step.min + '" max="' + step.max + '" value="' + step.val + '" data-slider data-key="' + esc(step.key) + '">' +
'<span class="muted" style="width:44px;text-align:right">' + step.max + '</span></div></div>';
}
async function renderBright() {
$('#content').innerHTML = '<div class="page" id="bright-page"><p class="placeholder">加载设置…</p></div>';
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 = '<div class="page" id="bright-page">';
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 += '<p class="muted">音量设置项不可用(后端未实现 audio.volume)</p>';
h += '<p class="muted">滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。</p></div>';
$('#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 = '<div class="card"><h3 class="card-title">路由与音量</h3><table class="table"><tbody>' +
row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) +
row('输出口', audioPortName(d.port)) +
row('音量(只读)', (d.vol != null && d.vol >= 0 ? '<span class="' + (d.vol > vmax ? 'urgency-hi' : '') + '">' + esc(d.vol) + ' %</span>' : '—') +
' <span class="muted">(上限 ' + esc(vmax) + ' · 平方锥度非线性)</span>') +
'</tbody></table>';
s += '<div class="field-row" style="flex-wrap:wrap;gap:8px;margin-top:6px"><span class="muted" style="width:auto">输出路由</span>';
[0, 2, 3].forEach(p => {
s += '<button class="btn ' + (p === cur ? 'btn-primary' : 'btn-ghost') + '" data-audio-route="' + p + '">' + audioRouteLabel(p) + '</button>';
});
s += '</div>';
s += '<div class="field-row" style="margin-top:8px"><span class="muted" style="width:44px">' + vol + '</span>' +
'<input type="range" min="0" max="' + vmax + '" value="' + vol + '" data-audio-vol>' +
'<span class="muted" style="width:44px;text-align:right">' + vmax + '</span></div>';
s += '<p class="muted">切换/调节前会确认。选错输出口会<b>无声</b>;音量走<b>平方锥度</b>(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。</p>';
s += '</div>';
return s;
}
function audioFlowHtml(d) {
let h = '';
if (!d || !d.available) {
h += '<div class="card"><h3 class="card-title">音频服务(meow_audiod)</h3>' +
'<p class="muted">audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。</p></div>';
} else {
h += '<div class="card"><h3 class="card-title">当前流</h3>';
if (d.active) {
h += '<table class="table"><tbody>' +
row('应用', esc(d.app || '—') + ' <span class="muted">(sid ' + esc(d.sid) + ')</span>') +
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', '<span class="' + (d.underrun > 0 ? 'urgency-hi' : '') + '">' + esc(d.underrun) + '</span>') +
row('状态', d.paused ? '已暂停' : '播放中') +
'</tbody></table>';
} else {
h += '<p class="muted">当前无活动音频流。</p>';
}
h += '</div>';
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 = '<div class="card"><div class="empty">加载失败:' + esc(errText(e)) + '</div></div>';
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 =
'<div class="page" id="audio-page">' +
'<div id="audio-flow"><p class="placeholder">加载音频状态…</p></div>' +
'<div class="card"><h3 class="card-title">电平(dBFS)</h3>' +
'<div id="audio-level">' + levelMeterHtml() + '</div>' +
'<p class="muted" id="audio-level-note">每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。</p>' +
'</div>' +
'</div>';
fetchAudioOnce();
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('设备信息', '<div class="home-card-grid">' + rows + '</div>');
}
function batteryCard() {
const pw = homeData && homeData.power;
if (!isAvail(pw)) return homeCard('电量 / 充电', homeUnavailable());
const b = pw.battery || {};
const pct = (b.pct != null) ? b.pct : null;
const state = b.state || '—';
const charging = (state === 'Charging' || state === 'Full');
const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : ''));
let inner = '<div class="batt"><span class="batt-icon"><span class="lvl-fill ' + fillCls + '" style="width:' + (pct == null ? 0 : pct) + '%"></span></span>' +
'<span class="home-bigval">' + (pct == null ? '—' : pct) + '</span><span class="home-bigunit">%</span>' +
'<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(state) + '</span></div>';
inner += '<div class="home-card-grid">' +
homeKv('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') +
homeKv('健康', b.health ? esc(b.health) : '—') +
homeKv('温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
(b.est_minutes_left != null ? homeKv('续航估算', b.est_minutes_left + ' 分钟') :
(b.est_minutes_full != null ? homeKv('充满估算', b.est_minutes_full + ' 分钟') : '')) +
'</div>';
return homeCard('电量 / 充电', inner);
}
function audioCard() {
const ad = homeData && homeData.audio;
if (!isAvail(ad)) return homeCard('音频', homeUnavailable());
let body = '';
if (ad.active) {
body += '<div class="home-card-grid">' +
homeKv('流', esc(ad.app || '—') + ' · ' + esc(ad.rate) + 'Hz ' + esc(ad.fmt || '')) +
homeKv('输出口', audioPortName(ad.port)) +
homeKv('音量', (ad.vol != null && ad.vol >= 0 ? esc(ad.vol) + ' %' : '—')) +
'</div>';
} else {
body += '<div class="home-sub">当前无活跃音频流。</div>';
}
body += '<div id="home-audio-level">' + levelMeterHtml() + '</div>' +
'<p class="muted" id="home-audio-note">' + (ad.active ? '电平 · 1s 子节拍' : '无流') + '</p>';
return homeCard('音频', body);
}
function netCard() {
const nd = homeData && homeData.net;
if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable());
const w = nd.wifi || {};
const dn = (nd && nd.dns) || null; // 批2: DNS 统计已并入 /api/overview 的 net 块(首页严格单请求)
let rows = '';
rows += homeKv('WiFi', w.connected ? ('<span class="home-chip ok">已连接</span> ' + esc(w.ssid || '')) : '<span class="home-chip warn">未连接</span>');
if (w.connected) {
rows += homeKv('IP', esc(w.ip || '—'));
rows += homeKv('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—'));
}
if (dn) {
rows += homeKv('DNS 上游', esc(dn.upstream || '—'));
rows += homeKv('DNS 缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0) + (dn.enabled ? '' : '(停用)'));
} else {
rows += homeKv('DNS', '<span class="home-chip mut">—</span>');
}
return homeCard('WiFi / DNS', '<div class="home-card-grid">' + rows + '</div>');
}
function healthCard() {
const ams = homeData && homeData.ams;
const svcd = homeData && homeData.svcd;
let appTxt = '—', svcBad = 0, svcHi = 0, svcTxt = '—';
if (isAvail(ams)) {
const apps = ams.apps || [];
const run = apps.filter(a => a.state === 'fg' || a.state === 'bg').length;
appTxt = run + ' 在跑 / ' + (ams.count || apps.length) + ' 已装';
}
let svcHtml;
if (isAvail(svcd)) {
const svcs = svcd.services || [];
svcBad = svcs.filter(s => s.state === 'failed').length;
svcHi = svcs.filter(s => (s.restart || 0) >= 3).length;
svcTxt = svcs.filter(s => s.state === 'running').length + ' 运行中';
svcHtml = '';
if (svcBad) svcHtml += '<span class="home-chip bad">' + svcBad + ' FAILED</span> ';
if (svcHi) svcHtml += '<span class="home-chip warn">' + svcHi + ' 高重启</span> ';
if (!svcBad && !svcHi) svcHtml += '<span class="home-chip ok">正常</span> ';
} else {
svcHtml = '<span class="home-chip mut">不可用</span> ';
svcTxt = '—';
}
return homeCard('在跑 App 与服务', '<div class="home-card-grid">' +
homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) + '</div>');
}
function storageCard() {
const perf = homeData && homeData.perf;
if (!isAvail(perf)) return homeCard('存储', homeUnavailable());
const st = perf.storage || {}, tf = perf.tfcard || {};
let rows = homeKv('rootfs', (st.used != null ? fmtBytes(st.used * 1024) : '—') + ' / ' + (st.total != null ? fmtBytes(st.total * 1024) : '—'));
rows += (tf.present) ?
homeKv('TF 卡', fmtBytes((tf.used || 0) * 1024) + ' / ' + fmtBytes((tf.total || 0) * 1024)) :
homeKv('TF 卡', '<span class="home-chip mut">未插卡</span>');
return homeCard('存储', '<div class="home-card-grid">' + rows + '</div>');
}
function perfCard() {
const perf = homeData && homeData.perf;
if (!isAvail(perf)) return homeCard('性能小条', homeUnavailable());
const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {};
const cpuU = cpu.usage;
const memP = mem.total ? (mem.used / mem.total) * 100 : 0;
return homeCard('性能小条', '<div class="stat-grid" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr))">' +
'<div class="stat"><div class="stat-label">CPU</div><div class="stat-value">' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '<span class="stat-unit">%</span></div>' + barHtml(cpuU || 0) + '</div>' +
'<div class="stat"><div class="stat-label">内存</div><div class="stat-value">' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '</div>' + barHtml(mem.used && mem.total ? memP : 0) + '</div>' +
'<div class="stat"><div class="stat-label">温度</div><div class="stat-value">' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '<span class="stat-unit">℃</span>' : '—') + '</div></div>' +
'</div>');
}
function renderHomeCards() {
const body = $('#home-body');
if (!body || state.route !== 'home') return;
if (!homeData) { body.innerHTML = '<p class="placeholder">加载总览…</p>'; return; }
let h = '<div class="home-grid">' +
deviceCard() + batteryCard() + audioCard() + netCard() + healthCard() + storageCard() + perfCard() +
'</div>';
body.innerHTML = h;
const ad = homeData.audio;
if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad);
}
async function fetchHomeSystem() { // 静态设备信息(boot/默认启动);进入时拉一次,失败不阻塞
try { homeSys = await api('GET', '/api/system'); } catch (e) { homeSys = null; }
if (state.route === 'home') renderHomeCards();
}
async function fetchHomeLevel() { // 电平 1s 子节拍(独立于 overview 5s)
if (state.route !== 'home') return;
const el = $('#home-audio-level'); if (!el) return;
let d;
try { d = await api('GET', '/api/audio'); } catch (e) { return; }
if (state.route !== 'home') return;
updateLevelMeter(el, d);
const note = $('#home-audio-note');
if (note) note.textContent = (!d || !d.available) ? 'audiod 不可用' : (d.active ? '电平 · 1s 子节拍' : '无流');
}
async function fetchOverviewOnce() {
if (state.route !== 'home') return;
const r = await Promise.allSettled([api('GET', '/api/overview')]);
if (r[0].status !== 'fulfilled') {
const b = $('#home-banner');
if (b) {
b.innerHTML = '<span class="ico">⚠️</span><span>总览加载失败:' + esc(errText(r[0].reason)) + '</span><button class="btn" id="home-retry">重试</button>';
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 =
'<div class="page" id="home-page">' +
'<div id="home-banner" class="page-banner" hidden></div>' +
'<div id="home-body"><p class="placeholder">加载总览…</p></div>' +
'</div>';
fetchHomeSystem();
fetchOverviewOnce();
homeTimer = setInterval(fetchOverviewOnce, 5000);
homeLevelTimer = setInterval(fetchHomeLevel, 1000);
}
function bindHome() {
$('#content').addEventListener('click', function (e) {
if (e.target.closest('#home-retry')) { fetchOverviewOnce(); return; }
});
}
/* ---------------- 2e. 电源 / 功耗页(#/power;/api/power,只读) ----------------
* 电池卡 + 功耗估算卡(est_mw + confidence 徽标,必须如实标注)+ 子系统分解堆叠条。 */
let powerTimer = null;
function stopPower() { if (powerTimer) { clearInterval(powerTimer); powerTimer = null; } }
function batteryCardBody(b) {
if (!b) return homeUnavailable();
const pct = (b.pct != null) ? b.pct : null;
const state = b.state || '—';
const charging = (state === 'Charging' || state === 'Full');
const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : ''));
let h = '<div class="batt"><span class="batt-icon"><span class="lvl-fill ' + fillCls + '" style="width:' + (pct == null ? 0 : pct) + '%"></span></span>' +
'<span class="home-bigval">' + (pct == null ? '—' : pct) + '</span><span class="home-bigunit">%</span>' +
'<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(state) + '</span></div>';
h += '<table class="table"><tbody>' +
row('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') +
row('健康', b.health ? esc(b.health) : '—') +
row('技术', b.technology ? esc(b.technology) : '—') +
row('电池温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
row('充电器温度', b.charger_temp_c10 != null ? (b.charger_temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
row('循环次数', b.cycle_count != null ? b.cycle_count : '—') +
row('放电速率', b.rate_pct_min_x100 != null ? ((b.rate_pct_min_x100 / 100).toFixed(2) + ' %/min') : '—') +
row('续航估算', b.est_minutes_left != null ? (b.est_minutes_left + ' 分钟(剩余)') : (b.est_minutes_full != null ? (b.est_minutes_full + ' 分钟(充满)') : '—')) +
'</tbody></table>';
return h;
}
function estimateCardBody(est) {
if (!est) return homeUnavailable();
if (est.est_mw == null) return '<div class="home-sub">' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '</div>';
const conf = est.confidence || 'estimated';
const cls = conf === 'measured' ? 'measured' : (conf === 'derived' ? 'derived' : (conf === 'none' ? 'none' : 'estimated'));
return '<div class="up-strip"><span class="home-bigval">' + est.est_mw + '</span><span class="home-bigunit">mW</span>' +
'<span class="conf-badge ' + cls + '">' + esc(conf) + (est.calibrated ? ' · 已标定' : ' · 经验值') + '</span></div>' +
'<p class="muted">Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。</p>';
}
function breakdownCardBody(bd) {
if (!bd) return homeUnavailable();
if (bd.degraded) return '<div class="home-sub">系统分解不可用(HibyOS 只读模式)</div>';
const comps = [
{ label: '屏幕(背光)', mw: bd.backlight_mw, color: '#4a8bf5' },
{ label: 'WiFi', mw: bd.wifi_mw, color: '#00bfa5' },
{ label: '蓝牙', mw: bd.bt_mw, color: '#ab47bc' },
{ label: '音频', mw: bd.audio_mw, color: '#ffb300' },
{ label: 'CPU', mw: bd.cpu_mw, color: '#ff7043' },
{ label: '基线', mw: bd.baseline_mw, color: '#8d6e63' }
].filter(c => c.mw != null && c.mw >= 0);
const total = comps.reduce((a, c) => a + c.mw, 0);
let stack = '<div class="stack">';
comps.forEach(c => { stack += '<span style="width:' + (total > 0 ? (c.mw / total * 100) : 0) + '%;background:' + c.color + '" title="' + esc(c.label) + ': ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)"></span>'; });
stack += '</div>';
let legend = '<div class="stack-legend">';
comps.forEach(c => {
legend += '<span class="seg"><span class="sw" style="background:' + c.color + '"></span>' + esc(c.label) + ' ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)</span>';
});
legend += '</div>';
if (bd.cpu_util_pct != null || bd.brightness_pct != null || bd.wifi_state || bd.bt_state || bd.audio_state) {
legend += '<p class="muted">' +
(bd.cpu_util_pct != null ? 'CPU 利用率 ' + esc(bd.cpu_util_pct) + '% ' : '') +
(bd.brightness_pct != null ? '· 亮度 ' + esc(bd.brightness_pct) + '% ' : '') +
(bd.wifi_state ? '· WiFi ' + esc(bd.wifi_state) : '') +
(bd.bt_state ? ' · BT ' + esc(bd.bt_state) : '') +
(bd.audio_state ? ' · 音频 ' + esc(bd.audio_state) : '') + '</p>';
}
return '<div class="home-sub">合计 ' + total + ' mW(分解为经验估算)</div>' + stack + legend;
}
function renderPowerBody(d) {
let h = '<div class="page" id="power-page">';
if (d && d.mode === 'readonly') h += '<div class="page-banner"><span class="ico">ℹ️</span><span>当前为电源只读观测(HibyOS 模式),仅显示电池采样,无功耗估算/分解。</span></div>';
h += '<div class="card"><h3 class="card-title">电池</h3>' + ((d && d.battery) ? batteryCardBody(d.battery) : homeUnavailable()) + '</div>';
h += '<div class="card"><h3 class="card-title">功耗估算</h3>' + ((d && d.estimate) ? estimateCardBody(d.estimate) : homeUnavailable()) + '</div>';
h += '<div class="card"><h3 class="card-title">子系统分解</h3>' + ((d && d.breakdown) ? breakdownCardBody(d.breakdown) : homeUnavailable()) + '</div>';
if (d && d.policy == null) h += '<p class="muted" style="margin-top:6px">电源策略(profile 四档/低电阈值)为 期3 功能,此处以不可用占位。</p>';
h += '</div>';
return h;
}
async function fetchPowerOnce() {
const el = $('#power-page');
if (!el || state.route !== 'power') return;
let d;
try { d = await api('GET', '/api/power'); }
catch (e) { if (el && state.route === 'power') el.innerHTML = '<div class="page" id="power-page">' + emptyState('加载失败:' + errText(e), e.code) + '</div>'; return; }
if (state.route !== 'power') return;
el.innerHTML = renderPowerBody(d);
}
function renderPower() {
stopPower();
$('#content').innerHTML = '<div class="page" id="power-page"><p class="placeholder">加载电源数据…</p></div>';
fetchPowerOnce();
powerTimer = setInterval(fetchPowerOnce, 5000);
}
/* ---------------- 2f. 进程 / 服务页(#/proc;/api/svcd + /api/ams,只读,无 restart/kill) ----------------
* svcd 服务表(state/pid/restart 计数≥3 高亮/exec/来源 board|tf)+ ams App 表(state/pid/crashes/launches/last_kind);
* 行点开经 /api/files 列 MeowOS/logs/<pkg>/ 找最新 stdout_*.log,再用 /api/logs/read 读尾部(崩溃尾巴)。 */
let procTimer = null;
let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空)
function stopProc() { if (procTimer) { clearInterval(procTimer); procTimer = null; } }
function svcStatePill(s) {
const map = { running: ['ok', '运行'], stopped: ['mut', '已停'], stopping: ['warn', '停止中'], disabled: ['mut', '禁用'], failed: ['bad', '失败'] };
const m = map[s] || ['mut', s || '—'];
return '<span class="h-pill ' + m[0] + '">' + esc(m[1]) + '</span>';
}
function appStatePill(s) {
const map = { fg: ['ok', '前台'], bg: ['warn', '后台'], stop: ['mut', '已停'] };
const m = map[s] || ['mut', s || '—'];
return '<span class="h-pill ' + m[0] + '">' + esc(m[1]) + '</span>';
}
function srcPill(src) {
const cls = (src === 'board') ? 'board' : (src === 'tf' ? 'tf' : '');
return src ? '<span class="proc-badge ' + cls + '">' + esc(src) + '</span>' : '—';
}
function renderProcBody(ams, svcd) {
let h = '<div class="page" id="proc-page">';
h += '<div class="card"><h3 class="card-title">服务(meow_svcd)</h3>';
if (svcd && svcd.available) {
const svcs = svcd.services || [];
h += '<table class="table"><thead><tr><th>服务</th><th>状态</th><th>PID</th><th>重启</th><th>退避</th><th>触发</th><th>来源</th><th>退出</th><th>执行</th></tr></thead><tbody>';
if (!svcs.length) h += '<tr><td colspan="9" class="empty">暂无服务</td></tr>';
svcs.forEach(s => {
h += '<tr><td class="mono">' + esc(s.name) + '</td>' +
'<td>' + svcStatePill(s.state) + '</td>' +
'<td>' + (s.pid > 0 ? esc(s.pid) : '—') + '</td>' +
'<td class="' + ((s.restart || 0) >= 3 ? 'restart-hi' : 'muted') + '">' + (s.restart || 0) + ((s.restart || 0) >= 3 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(s.backoff_ms || '—') + '</td>' +
'<td class="muted">' + esc(s.trigger || '—') + '</td>' +
'<td>' + srcPill(s.src) + '</td>' +
'<td class="muted">' + esc(s.exit || '—') + '</td>' +
'<td class="mono muted">' + esc(s.exec || '—') + '</td></tr>';
});
h += '</tbody></table>';
} else h += homeUnavailable();
h += '</div>';
h += '<div class="card"><h3 class="card-title">App(meow_amsd,只读)</h3>';
if (ams && ams.available) {
const apps = ams.apps || [];
h += '<table class="table"><thead><tr><th>包名</th><th>名称</th><th>状态</th><th>PID</th><th>启动</th><th>崩溃</th><th>上次退出</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!apps.length) h += '<tr><td colspan="8" class="empty">无可观测 App</td></tr>';
apps.forEach(a => {
const openHere = expandedProc && expandedProc.pkg === a.pkg;
h += '<tr><td class="mono">' + esc(a.pkg) + '</td><td>' + esc(a.name || '—') + '</td>' +
'<td>' + appStatePill(a.state) + '</td><td>' + (a.pid > 0 ? esc(a.pid) : '—') + '</td>' +
'<td class="muted">' + esc(a.launches || 0) + '</td>' +
'<td class="' + ((a.crashes || 0) > 0 ? 'crash-hi' : 'muted') + '">' + (a.crashes || 0) + ((a.crashes || 0) > 0 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(a.last_kind || '—') + (a.last_code != null && a.last_code !== 0 ? ' (' + esc(a.last_code) + ')' : '') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-applog="' + esc(a.pkg) + '">' + (openHere ? '收起' : 'stdout 尾部') + '</button></div></td></tr>';
if (openHere) {
const lines = (expandedProc.lines || []);
h += '<tr class="proc-tail"><td colspan="8"><pre class="log-view">' + esc(lines.join('\n') || '(空)') +
(expandedProc.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
'<div class="muted">' + esc(a.pkg + '/' + expandedProc.file) + ' · 末尾 ' + lines.length + ' 行</div></td></tr>';
}
});
h += '</tbody></table>';
h += '<p class="muted">点击「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。仅只读,无 restart/kill。</p>';
} else h += homeUnavailable();
h += '</div></div>';
return h;
}
async function newestStdout(pkg) {
try {
const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + pkg + '/'));
const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0);
if (!files.length) return null;
files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
return files[0].name;
} catch (e) { return null; }
}
async function viewAppStdout(pkg, btn) {
const row = btn.closest('tr'); if (!row) return;
const next = row.nextElementSibling;
if (next && next.classList && next.classList.contains('proc-tail')) { next.remove(); expandedProc = null; btn.textContent = 'stdout 尾部'; return; }
if (expandedProc && expandedProc.pkg !== pkg) { // 先收起上一个 opened
const prevTail = document.querySelector('tr.proc-tail');
if (prevTail) prevTail.remove();
const prevBtn = document.querySelector('[data-applog="' + escSafeAttr(expandedProc.pkg) + '"]');
if (prevBtn) prevBtn.textContent = 'stdout 尾部';
expandedProc = null;
}
btn.textContent = '加载中…';
const tailTr = document.createElement('tr');
tailTr.className = 'proc-tail';
tailTr.innerHTML = '<td colspan="8"><div class="muted">读取 ' + esc(pkg) + ' 最新 stdout…</div></td>';
row.after(tailTr);
try {
const file = await newestStdout(pkg);
if (!file) { tailTr.innerHTML = '<td colspan="8"><div class="empty">未找到 ' + esc(pkg) + ' 的 stdout 日志</div></td>'; btn.textContent = 'stdout 尾部'; return; }
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200');
const lines = (d.lines || []);
expandedProc = { pkg: pkg, file: file, lines: lines, truncated: !!d.truncated };
tailTr.innerHTML = '<td colspan="8"><pre class="log-view">' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
'<div class="muted">' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行</div></td>';
btn.textContent = '收起';
} catch (err) {
tailTr.innerHTML = '<td colspan="8"><div class="empty">读取失败:' + esc(errText(err)) + '</div></td>';
btn.textContent = 'stdout 尾部';
}
}
function escSafeAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }
async function fetchProcOnce() {
const el = $('#proc-page');
if (!el || state.route !== 'proc') return;
const r = await Promise.allSettled([api('GET', '/api/ams'), api('GET', '/api/svcd')]);
if (state.route !== 'proc') return;
if (r[0].status !== 'fulfilled' && r[1].status !== 'fulfilled') {
el.innerHTML = '<div class="page" id="proc-page">' + emptyState('加载失败:' + errText(r[0].reason || r[1].reason)) + '</div>';
return;
}
el.innerHTML = renderProcBody(r[0].status === 'fulfilled' ? r[0].value : null, r[1].status === 'fulfilled' ? r[1].value : null);
}
function renderProc() {
stopProc();
$('#content').innerHTML = '<div class="page" id="proc-page"><p class="placeholder">加载服务与进程…</p></div>';
fetchProcOnce();
procTimer = setInterval(fetchProcOnce, 5000);
}
function bindProc() {
$('#content').addEventListener('click', function (e) {
const b = e.target.closest('[data-applog]'); if (!b) return;
viewAppStdout(b.dataset.applog, b);
});
}
/* ---------------- 2c. 网络管理页(meow_netmgrd 期2 面板侧) ----------------
* 数据源: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 '<span class="net-badge ' + esc(kind) + '">' + esc(kind) + '</span>' +
(i > 0 ? ' <span class="net-entity">' + esc(e.slice(i + 1)) + '</span>' : '');
}
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 += '<div class="card"><h3 class="card-title">网络接口</h3><table class="table"><thead>' +
'<tr><th>接口</th><th>类型</th><th>状态</th><th>IP 地址</th><th>网关</th><th>RX / TX</th></tr></thead><tbody>';
if (!ifs.length) h += '<tr><td colspan="6" class="empty">无接口数据</td></tr>';
ifs.forEach(function (f) {
const up = !!f.up;
h += '<tr><td class="mono">' + esc(f.name) + '</td><td><span class="net-kind">' + esc(f.kind || '—') + '</span></td>' +
'<td><span class="net-state ' + (up ? 'ok' : 'down') + '">' + (up ? 'UP' : 'DOWN') + '</span></td>' +
'<td class="mono">' + esc(f.ip || '—') + '</td><td class="mono">' + esc(f.gw || '—') + '</td>' +
'<td class="muted">' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + '</td></tr>';
});
h += '</tbody></table>';
if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null))
h += '<p class="muted">实时速率:↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p>';
h += '</div>';
// WiFi 摘要
const w = (d && d.wifi) || {};
h += '<div class="card"><h3 class="card-title">WiFi</h3><table class="table"><tbody>';
if (w.connected) {
h += row('SSID', '<span class="net-entity">' + esc(w.ssid || '—') + '</span>');
h += row('状态', '<span class="net-state ok">' + esc(w.state || '已连接') + '</span>');
h += row('IP', esc(w.ip || '—'));
h += row('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—'));
} else {
h += row('状态', '<span class="net-state warn">未连接</span>');
}
h += '</tbody></table></div>';
// 蓝牙摘要
const bt = (d && d.bt) || {};
h += '<div class="card"><h3 class="card-title">蓝牙</h3><table class="table"><tbody>';
if (bt.available) {
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
h += row('地址', esc(bt.address || '—'));
h += row('已配对', esc(bt.paired != null ? bt.paired : '—'));
h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—'));
} else {
h += row('状态', '<span class="net-state warn">无蓝牙</span>');
}
h += '</tbody></table></div>';
return h;
}
function netConnsBody(cn) {
const list = (cn && cn.conns) || [];
let h = '';
if (!list.length) h += '<div class="empty">暂无连接' + (netEntityFilter ? '(该实体)' : '') + '</div>';
else {
h += '<table class="table"><thead><tr><th>实体</th><th>远端(域名)</th><th>本端</th><th>状态</th><th>寿命</th></tr></thead><tbody>';
list.forEach(function (c) {
h += '<tr><td class="net-entity">' + netBadge(c.entity) + '</td>' +
'<td class="mono">' + esc(c.domain || c.remote || '—') + '</td>' +
'<td class="mono muted">' + esc(c.local || '—') + '</td>' +
'<td><span class="' + netStateCls(c.state) + '">' + esc(c.state || '—') + '</span></td>' +
'<td class="muted">' + fmtDur(c.age) + '</td></tr>';
});
h += '</tbody></table>';
if (cn.truncated) h += '<p class="muted">连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)</p>';
}
return h;
}
function netDnsBody(dn) {
if (!dn) return '<div class="empty">DNS 代理不可用</div>';
let h = '<table class="table"><tbody>' +
row('状态', '<span class="net-state ' + (dn.enabled ? 'ok' : 'warn') + '">' + (dn.enabled ? '已启用' : '已停用') + '</span>') +
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 ? '<span class="muted">已被代理改写</span>' : '未改写')) +
'</tbody></table>';
h += '<p class="muted">写操作(清缓存)需系统身份,面板路径可能被 netmgrd 拒绝。</p>';
return h;
}
// 网络状态(/api/net/status)瞬态失败 → 仅顶部内联错误条,不整页踢「不可用」占位;保留上次成功内容,5s 自动重试。
function netBannerErr(msg) {
const nb = $('#net-banner');
if (!nb) return;
nb.innerHTML = '<span class="ico">⚠️</span><span>网络状态获取失败:' + esc(msg) + '(保留上次内容,5 秒后自动重试)</span>' +
'<button class="btn" id="net-banner-retry">重试</button>';
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 = '<div class="empty">暂无 net_ 日志' + (d && !d.file ? '(netmgrd 未写日志)' : '') + '</div>'; return; }
el.innerHTML = '<pre class="log-view">' + esc((d.lines || []).join('\n') || '(空)') +
(d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>';
const name = $('#net-log-file'); if (name) name.textContent = d.file;
}
} catch (e) { if (el) el.innerHTML = '<div class="empty">日志加载失败:' + esc(errText(e)) + '</div>'; }
}
async function renderNet() {
stopNet();
netEntityFilter = '';
netLoaded = false;
$('#content').innerHTML =
'<div class="page" id="net-page">' +
'<div id="net-banner" class="page-banner" hidden></div>' +
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
'<input type="text" id="net-entity-filter" placeholder="筛选实体(app: / svc: / proc:)" style="max-width:280px" value="' + esc(netEntityFilter) + '">' +
'<button class="btn" id="net-conns-refresh">刷新</button></div>' +
'<div id="net-conns-body"><p class="placeholder">加载连接表…</p></div></div>' +
'<div class="card"><h3 class="card-title">DNS 代理</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
'<button class="btn" id="net-dns-flush">清空缓存</button></div>' +
'<div id="net-dns-body"><p class="placeholder">加载 DNS 统计…</p></div></div>' +
'<div class="card"><h3 class="card-title">网络日志 <span class="muted" id="net-log-file"></span></h3>' +
'<div class="field-row" style="margin-bottom:12px"><button class="btn" id="net-log-refresh">刷新</button></div>' +
'<div id="net-log-body"><p class="placeholder">加载日志…</p></div></div>' +
'</div>';
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 = '<div class="crumbs" id="crumbs">';
h += '<a data-rel="/" id="crumb-root">根目录</a>';
items.forEach(it => { h += '<span>/</span><a data-rel="' + esc(it.rel) + '">' + esc(it.name) + '</a>'; });
return h + '</div>';
}
async function navFiles(rel) {
const page = $('#files-page');
if (!page) return;
let relPath = rel || '/';
page.innerHTML = '<p class="placeholder">加载中…</p>';
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 += '<div class="field-row wrap" style="margin-bottom:12px">' +
d.allowed.map(a => '<button class="btn" data-nav="' + esc('/' + a) + '">' + esc(a) + '</button>').join(' ') + '</div>';
}
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
'<button class="btn" id="file-newdir">新建目录</button>' +
'<button class="btn" id="file-upload">上传文件</button>' +
'<button class="btn" id="file-refresh">刷新</button>' +
'<input type="file" id="file-input" multiple hidden></div>';
h += '<div class="card"><table class="table"><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!d.items.length) h += '<tr><td colspan="4" class="empty">空目录</td></tr>';
d.items.forEach(function (it) {
const isDir = it.type === 'dir';
const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔');
h += '<tr><td>' + (isDir ? '<a class="dirlink" data-dir="' + esc(it.name) + '">' : '') + icon + ' ' + esc(it.name) + (isDir ? '</a>' : '') +
'</td><td class="muted">' + (isDir ? '—' : fmtBytes(it.size)) + '</td><td class="muted">' + fmtTime(it.mtime) + '</td>' +
'<td><div class="row-actions">' +
(isDir ? '<button class="btn btn-ghost" data-enter="' + esc(it.name) + '">打开</button>' : '<a class="btn btn-ghost" data-dl="' + esc(it.name) + '" href="#">下载</a>') +
'<button class="btn btn-ghost" data-ren="' + esc(it.name) + '">重命名</button>' +
'<button class="btn btn-ghost btn-danger" data-del="' + esc(it.name) + '">删除</button>' +
'</div></td></tr>';
});
h += '</tbody></table></div>';
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 = '<div class="page" id="files-page"></div>'; 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 = '<div class="page" id="apps-page"><p class="placeholder">加载应用列表…</p></div>';
(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 = '<div class="page" id="apps-page">';
h += '<div class="field-row" style="margin-bottom:14px">' +
'<button class="btn btn-primary" id="app-install">导入 .mpk 安装</button>' +
'<button class="btn" id="app-refresh">刷新</button>' +
'<input type="file" id="app-file" accept=".mpk" hidden></div>';
h += '<div class="app-grid">';
if (!d.apps.length) h += '<div class="empty" style="grid-column:1/-1">尚未安装任何 MeowAPP</div>';
d.apps.forEach(a => {
const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase());
h += '<div class="app-card" data-pkg="' + esc(a.package || '') + '">' +
'<div class="app-head"><div class="app-ico">' + ino + '</div>' +
'<div><div class="app-name">' + esc(a.name || a.package || '未命名') + '</div>' +
'<div class="app-pkg">' + esc(a.package || '') + '</div></div></div>' +
'<div class="app-meta">版本 ' + esc(a.version || '—') + (a.invalid ? ' · <span style="color:var(--error)">无效</span>' : '') +
(a.size ? ' · ' + fmtBytes(a.size) : '') + '</div>' +
'<div class="app-actions">' +
'<button class="btn btn-ghost" data-ainfo="' + esc(a.package) + '">详情</button>' +
'<a class="btn btn-ghost" data-aexport="' + esc(a.package) + '" href="#">导出 .mpk</a>' +
'<button class="btn btn-danger" data-auninst="' + esc(a.package) + '">卸载</button>' +
'</div></div>';
});
h += '</div>';
// 本地源 .mpk 安装(后端支持 path 形式)
h += '<div class="card"><h3 class="card-title">从卡上路径安装</h3>' +
'<div class="field-row"><input type="text" id="app-install-path" placeholder="如 /MeowOS/tmp/os.meow.hello.mpk">' +
'<button class="btn" id="app-install-path-go">安装</button></div></div>';
$('#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 = '<div class="page" id="fonts-page">' +
'<div class="card"><h3 class="card-title">字体库</h3>' +
'<div class="field-row" style="margin-bottom:14px">' +
'<button class="btn btn-primary" id="fpage-import">上传字体库 .tar.gz</button>' +
'<button class="btn" id="fpage-refresh">刷新</button>' +
'<input type="file" id="fpage-file" accept=".tar.gz,.tgz,application/gzip" hidden></div>' +
'<div id="fpage-list"><p class="muted">加载字体库列表…</p></div>' +
'<p class="muted" style="margin-top:8px">上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' +
'<code>MeowOS/fonts/&lt;family&gt;/&lt;weight&gt;/</code>;同 family/weight 冲突时会要求确认覆盖。' +
'导入后进设备「设置 → 字体」页,列表自动重扫生效。</p></div></div>';
loadFontsPage();
}
async function loadFontsPage() {
const box = $('#fpage-list'); if (!box) return;
let d;
try { d = await api('GET', '/api/fonts'); }
catch (e) { box.innerHTML = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '<div class="empty">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)</div>'; return; }
let h = '<table class="table"><thead><tr><th>family</th><th>weight</th><th>名称</th><th>tier</th><th>glyph 数</th><th style="text-align:right">操作</th></tr></thead><tbody>';
fs.forEach(f => {
h += '<tr><td class="mono">' + esc(f.family || '') + '</td><td class="mono">' + esc(f.weight || '') + '</td>' +
'<td>' + esc(f.name || '—') + '</td><td class="muted">' + esc(f.tier || '—') + '</td>' +
'<td class="muted">' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-fpexport="' + esc(f.family || '') +
'" data-fpweight="' + esc(f.weight || '') + '">导出</button></div></td></tr>';
});
box.innerHTML = h + '</tbody></table>';
}
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 '<div class="field-row"><div style="flex:1" class="field-label">面板密码<span class="muted">(' + txt + ')</span></div>' +
'<button class="btn btn-primary" data-skey="panel.auth_pass" data-stype="pass">' + (it.set ? '修改密码' : '设置密码') + '</button></div>';
}
if (it.type === 'bool') {
return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' +
'<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' +
'<span class="track"><span class="thumb"></span></span></label></div>';
}
if (it.type === 'int') {
const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max);
return '<div class="field"><label class="field-label">' + esc(key) + '(' + mn + '–' + mx + ')</label>' +
'<div class="field-row"><span class="muted" style="width:44px">' + esc(it.value == null ? '' : it.value) + '</span>' +
'<input type="range" min="' + mn + '" max="' + mx + '" value="' + esc(it.value == null ? mn : it.value) + '" data-skey="' + esc(key) + '" data-stype="int"></div></div>';
}
if (it.type === 'enum') {
const opts = (it.options || []).map(o => '<option value="' + esc(o.name) + '" ' + (o.name === it.value ? 'selected' : '') + '>' + esc(o.label || o.name) + '</option>').join('');
return '<div class="field"><label class="field-label">' + esc(key) + '</label><select data-skey="' + esc(key) + '" data-stype="enum">' + opts + '</select></div>';
}
return '<div class="field"><label class="field-label">' + esc(key) + '</label><input type="text" value="' + esc(it.value == null ? '' : it.value) + '" data-skey="' + esc(key) + '" data-stype="str"></div>';
}
async function renderSettings() {
$('#content').innerHTML = '<div class="page" id="settings-page"><p class="placeholder">加载设置…</p></div>';
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 = '<div class="page" id="settings-page">';
h += '<div class="field-row" style="margin-bottom:14px">' +
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
'<button class="btn" id="set-refresh">刷新</button></div>';
const items = d.items || [];
// 面板外观(主题):本面板覆盖(localStorage),默认跟随设备 ui.style / display.theme
h += '<div class="card" id="theme-card"><h3 class="card-title">主题(面板外观)</h3>' +
'<div class="field"><label class="field-label">风格</label>' +
'<div class="field-row wrap"><div class="seg" id="set-style-seg">' +
'<button class="seg-btn" data-style="md3">Material You</button>' +
'<button class="seg-btn" data-style="miuix">MIUIx</button></div>' +
'<button class="btn btn-ghost" id="set-style-reset">跟随设备</button></div>' +
'<p class="muted" style="margin-top:6px">默认跟随设备 ui.style(当前:' + esc(devStyleLabel()) + ');在面板选择即为本面板覆盖,只影响网页观感(顶栏切换才会同步设备)。</p></div>' +
'<div class="field" style="margin-bottom:0"><label class="field-label">明暗模式</label>' +
'<div class="seg" id="set-mode-seg">' +
'<button class="seg-btn" data-mode="system">跟随系统</button>' +
'<button class="seg-btn" data-mode="browser">跟随浏览器</button>' +
'<button class="seg-btn" data-mode="light">浅色</button>' +
'<button class="seg-btn" data-mode="dark">深色</button></div>' +
'<p class="muted" style="margin-top:6px">跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。</p></div></div>';
// 面板 PIN(访问级鉴权):显示状态 + 修改/清空控件(走 /api/auth/* 校验旧 PIN + PUT /api/settings/panel.pin)
h += '<div class="card" id="pin-card"><h3 class="card-title">面板 PIN(访问鉴权)</h3>' +
'<div id="pin-status"><p class="muted">查询鉴权状态…</p></div></div>';
// 面板鉴权(可选防护)单独一卡
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
if (authItems.length) {
h += '<div class="card"><h3 class="card-title">面板鉴权(可选防护)</h3>' + authItems.map(settingControl).join('') +
'<p class="muted" style="margin-top:8px">开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。</p></div>';
}
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 += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
});
// 字体库(.zip 导入 / 导出)
h += '<div class="card" id="fonts-card"><h3 class="card-title">字体库(.tar.gz 导入 / 导出)</h3>' +
'<div class="field-row"><button class="btn" id="font-import">导入字体库 .tar.gz</button>' +
'<input type="file" id="font-file" accept=".tar.gz,.tgz,application/gzip" hidden></div>' +
'<div id="font-list"><p class="muted">加载字体库列表…</p></div>' +
'<p class="muted" style="margin-top:8px">导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/&lt;family&gt;/&lt;weight&gt;/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。</p></div>';
h += '</div>';
$('#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 = '<p class="muted">无法查询鉴权状态:' + esc(errText(e)) + '</p>'; return; }
let txt, cls;
if (st.locked) { txt = '锁定中(' + Math.ceil((st.lock_remaining_ms || 0) / 1000) + ' 秒后可重试)'; cls = 'locked'; }
else if (st.pin_set) { txt = '已设置'; cls = 'set'; }
else if (st.legacy) { txt = '未设(旧密码屏障生效,建议改设为 PIN)'; cls = 'unset'; }
else { txt = '未设置(当前访问面板无需 PIN)'; cls = 'unset'; }
const canEdit = st.pin_set && !st.locked;
let h = '<div class="pin-status"><span class="dot ' + cls + '"></span><strong>' + esc(txt) + '</strong>' +
(st.session ? ' · <span class="muted">当前会话有效</span>' : ' · <span class="muted">未登录</span>') + '</div>';
h += '<div class="field-row" style="margin-top:8px">' +
'<button class="btn" id="pin-change" ' + (canEdit ? '' : 'disabled') + '>修改 PIN</button>' +
'<button class="btn btn-danger" id="pin-clear" ' + (canEdit ? '' : 'disabled') + '>清空 PIN</button>' +
(st.locked ? '<span class="muted">锁定期间不可修改/清空</span>' : '<span class="muted">修改/清空需先验证当前 PIN</span>') +
'</div>';
h += '<div class="field-row" style="margin-top:6px">' +
'<button class="btn" id="pin-unlock-once" ' + (st.session ? '' : 'disabled') + '>一次性解锁设备</button>' +
'<span class="muted">设备下次亮屏免 PIN 进入一次(不清除 PIN)</span></div>';
h += '<p class="muted" style="margin-top:6px">4 位数字 PIN,纯加盐哈希存储(不落明文);未设 PIN 时写端点一律拒绝。</p>';
box.innerHTML = h;
}
function pinEditDlg(mode) { // 返回 {old, n1}(n1=null 表示清空)或 null(取消)
return new Promise(function (resolve) {
const t = $('#pindlg-title'), b = $('#pindlg-body');
const oldIn = $('#pindlg-old'), n1 = $('#pindlg-new'), n2 = $('#pindlg-new2');
const err = $('#pindlg-err');
if (mode === 'change') {
t.textContent = '修改 PIN';
b.textContent = '请输入当前 PIN 与新 PIN(4 位数字)。';
oldIn.hidden = false; n1.hidden = false; n2.hidden = false;
n1.placeholder = '新 4 位 PIN'; n2.placeholder = '再次输入新 PIN';
} else {
t.textContent = '清空 PIN';
b.textContent = '清空后访问面板将不再要求 PIN。请输入当前 PIN 确认。';
oldIn.hidden = false; n1.hidden = true; n2.hidden = true;
}
err.hidden = true; oldIn.value = ''; n1.value = ''; n2.value = '';
oldIn.focus();
const scrim = $('#pindlg'); scrim.hidden = false;
const ok = $('#pindlg-ok'), cancel = $('#pindlg-cancel');
const done = function (v) { scrim.hidden = true; ok.onclick = cancel.onclick = scrim.onclick = null; resolve(v); };
ok.onclick = function () {
const old = oldIn.value.trim();
if (!old) { err.textContent = '请输入当前 PIN'; err.hidden = false; return; }
if (mode === 'change') {
const np = n1.value.trim();
if (!/^[0-9]{4}$/.test(np)) { err.textContent = '新 PIN 必须为 4 位数字'; err.hidden = false; return; }
if (np !== n2.value.trim()) { err.textContent = '两次输入的新 PIN 不一致'; err.hidden = false; return; }
done({ old: old, n1: np });
} else done({ old: old, n1: null });
};
cancel.onclick = function () { done(null); };
scrim.onclick = function (e) { if (e.target === scrim) done(null); };
});
}
async function pinEdit(mode) {
const v = await pinEditDlg(mode);
if (!v) return;
try { await api('POST', '/api/auth/login', { pin: v.old }); } // 先验证旧 PIN(客户端复核,后端改 PIN 本身不需旧值)
catch (e2) {
if (e2.code === 'LOCKED' || e2.status === 429) toast('错误次数过多,已锁定,请稍后重试(锁定 5 分钟)');
else if (e2.status === 401) toast('当前 PIN 验证失败');
else toast('验证失败:' + errText(e2));
loadPinStatus(); return;
}
try {
await api('PUT', '/api/settings/panel.pin', { value: (v.n1 !== null ? v.n1 : '') });
toast(mode === 'change' ? 'PIN 已修改' : 'PIN 已清空');
loadPinStatus();
} catch (e3) { toast('操作失败:' + errText(e3)); }
}
function bindPin() {
const c = $('#content');
c.addEventListener('click', 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 = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '<p class="muted">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。</p>'; 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 '<div class="field-row"><div style="flex:1;overflow:hidden;text-overflow:ellipsis">' +
'<div>' + nm + '</div><div class="muted">' + meta + '</div></div>' +
'<button class="btn" data-fexport="' + esc(f.family || '') + '" data-fweight="' + esc(f.weight || '') + '">导出</button></div>';
}).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 = '<div class="page" id="remote-page">';
h += '<div class="card"><h3 class="card-title">模拟按键</h3>' +
'<p class="muted">行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。</p>' +
'<div class="key-grid">' + keys.map(k =>
'<button class="key-btn" data-code="' + k.code + '"><span class="k-ico">' + k.ico + '</span>' + esc(k.label) + '</button>'
).join('') + '</div></div>';
h += '<div class="card"><h3 class="card-title">电源长按(电源菜单)</h3>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" data-powerlong="1">长按电源 3s(电源菜单)</button>' +
'<span class="muted">发送 code=116 · press=long · ms=3000</span></div></div>';
h += '</div>';
$('#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 = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
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
? '<span style="color:var(--error);font-weight:600">' + esc(curMode) + '</span> <span class="muted">(与默认启动不一致)</span>'
: (curMode ? esc(curMode) : '—');
let sysRows = row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) +
row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec));
if (pw && pw.battery) {
sysRows += row('电池', '<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(batt.state || '—') + '</span> ' +
(batt.pct != null ? batt.pct + ' %' : '—') +
(batt.mv != null ? ' · ' + (batt.mv / 1000).toFixed(2) + ' V' : '') +
(batt.health ? ' · ' + esc(batt.health) : ''));
}
sysRows += row('当前运行模式', modeHtml);
sysRows += row('默认启动', esc(bootDef));
sysRows += row('重启原因', esc(boot.reboot_reason || d.reboot_reason || '—'));
sysRows += row('升级状态', esc(boot.upgrade_status || d.upgrade_status || '—'));
let h = '<div class="page" id="system-page">';
h += '<div class="card"><h3 class="card-title">系统信息</h3><table class="table"><tbody>' + sysRows + '</tbody></table></div>';
h += '<div class="card"><h3 class="card-title">网络</h3><table class="table"><tbody>' +
row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') +
row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') +
row('mDNS', esc(net.hostname_local || '—')) +
'</tbody></table></div>';
h += '<div class="card"><h3 class="card-title">升级</h3>' +
'<p class="muted">当前 ' + esc(ota.current || d.version || '—') +
(ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '</p>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" id="sys-ota-check">检查升级</button>' +
'<button class="btn btn-danger" id="sys-ota">⚠️ 升级</button></div></div>';
h += '<div class="card danger-box"><h3 class="card-title">危险操作</h3>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-danger" id="sys-reboot">重启</button>' +
'<button class="btn btn-danger" id="sys-shutdown">关机</button></div>' +
'<p class="warn-note">⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。</p></div>';
h += '</div>';
$('#content').innerHTML = h;
}
function row(k, v) { return '<tr><td class="muted" style="width:120px">' + k + '</td><td>' + v + '</td></tr>'; }
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 = '<div class="card"><h3 class="card-title">崩溃汇总(' + esc(cr.file || '本 boot · crash log') + ')</h3>';
if (!ents.length && !(cr.dmesg_count || 0)) { h += '<p class="muted">本 boot 无崩溃记录。</p></div>'; return h; }
h += '<table class="table"><thead><tr><th>实体</th><th>崩溃数</th><th>最近时间</th><th>信号/退出</th><th style="text-align:right">操作</th></tr></thead><tbody>';
ents.forEach(e => {
h += '<tr><td class="mono">' + esc(e.name) + '</td>' +
'<td class="' + ((e.count || 0) > 0 ? 'crash-hi' : 'muted') + '">' + esc(e.count) + ((e.count || 0) > 0 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(e.last_time || '—') + '</td>' +
'<td><span class="muted">' + esc(e.last_kind || '—') + '</span>' + (e.last_code != null && e.last_code !== 0 ? ' (' + esc(e.last_code) + ')' : '') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-crash-tail="' + escSafeAttr(e.name) + '">stdout 尾部</button></div></td></tr>';
});
h += '</tbody></table>';
if (cr.dmesg_count) {
h += '<p class="muted">内核 oops / segfault 捕获:<b>' + esc(cr.dmesg_count) + '</b> 条' +
((cr.dmesg_tail || []).length ? '<pre class="log-view">' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '</pre>' : '') + '</p>';
}
h += '</div>';
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 = '<table class="table"><thead><tr><th>文件</th><th>分组</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!filtered.length) h += '<tr><td colspan="5" class="empty">暂无日志(或过滤后无结果)</td></tr>';
filtered.forEach(f => {
h += '<tr><td class="mono">' + esc(f.name) + '</td><td class="muted">' + groupName(f.group) + '</td><td class="muted">' + fmtBytes(f.size) + '</td><td class="muted">' + fmtTime(f.mtime) + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-log="' + esc(f.name) + '">查看</button></div></td></tr>';
});
h += '</tbody></table>';
el.innerHTML = h;
}
async function renderLogs() {
$('#content').innerHTML = '<div class="page" id="logs-page"><p class="placeholder">加载日志列表…</p></div>';
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 = '<div class="page" id="logs-page">';
if (cr && ((cr.count || 0) > 0 || (cr.dmesg_count || 0) > 0 || (cr.entities || []).length)) h += logsCrashCard(cr);
h += '<div class="card"><h3 class="card-title">日志文件(MeowOS/logs)</h3>';
h += '<div class="field-row" style="margin-bottom:8px"><span class="muted">' + esc(d.dir || '') + '</span></div>';
h += '<div class="field-row" style="flex-wrap:wrap;gap:6px;margin-bottom:12px">';
h += '<input class="log-prefix" data-log-prefix placeholder="按文件名前缀过滤" value="' + escSafeAttr(logsPrefix) + '" style="flex:1;min-width:140px">';
h += '</div>';
h += '<div class="field-row" style="flex-wrap:wrap;gap:6px;margin-bottom:12px">';
Object.keys(LOG_GROUP_NAME).forEach(g => {
h += '<button class="btn ' + (logsGroup === g ? 'btn-primary' : 'btn-ghost') + '" data-log-group="' + g + '">' + LOG_GROUP_NAME[g] + '</button>';
});
h += '</div>';
h += '<div id="logs-files"><table class="table"><tbody><tr><td class="empty">加载…</td></tr></tbody></table></div>';
h += '</div><pre class="log-view" id="log-view" hidden></pre></div>';
$('#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/<pkg>/ → /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 = '<div class="page" id="notif-page"><p class="placeholder">加载通知…</p></div>';
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 = '<div class="page" id="notif-page">';
h += '<div class="field-row" style="margin-bottom:14px"><button class="btn" id="notif-refresh">刷新</button>' +
'<button class="btn btn-danger" id="notif-clear">清空通知</button></div>';
const list = d.notifications || [];
if (!list.length) h += '<div class="empty">暂无通知</div>';
list.forEach(n => {
const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低');
h += '<div class="notif" data-id="' + esc(n.id) + '"><div class="n-ico">' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '</div>' +
'<div class="n-body"><div class="n-title">' + esc(n.summary || n.app_name || '通知') + '</div>' +
(n.body ? '<div class="n-text">' + esc(n.body) + '</div>' : '') +
'<div class="n-meta"><span>' + esc(n.app_name || '') + '</span><span>' + fmtTime(n.ts) + '</span>' +
'<span class="' + (n.urgency >= 2 ? 'urgency-hi' : '') + '">紧急度 ' + urg + '</span>' +
(n.progress != null ? '<span>进度 ' + n.progress + '%</span>' : '') +
(n.ongoing ? '<span>进行中</span>' : '') + '</div></div>' +
'<button class="btn btn-ghost" data-ndel="' + esc(n.id) + '">清除</button></div>';
});
h += '</div>';
$('#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 = '<div class="page" id="screen-page">';
h += '<div class="card"><h3 class="card-title">实时画面</h3>' +
'<p class="muted">点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。</p>' +
'<div class="screen-frame">' +
'<img id="screen-img" alt="设备实时画面">' +
'<canvas id="screen-canvas" style="display:none"></canvas>' +
'</div>' +
'<div class="field-row" style="margin-top:12px">' +
'<label class="muted" for="screen-fps">刷新率</label>' +
'<input type="range" id="screen-fps" min="0.2" max="2" step="0.1" value="' + screenFps + '">' +
'<span class="muted" id="screen-fps-lbl" style="min-width:56px">' + screenFps.toFixed(1) + ' fps</span>' +
'</div>' +
'<div class="field-row" style="margin-top:6px">' +
'<label class="muted"><input type="checkbox" id="screen-delta"' + (screenDelta ? ' checked' : '') + '> 增量传输(省流量)</label>' +
'</div>' +
'<p class="muted" id="screen-status">载入中…</p>' +
'</div></div>';
$('#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);
})();