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

2794 lines
159 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));
try { fetch('/api/debug/report_bad_json', { method: 'POST', headers: { 'Content-Type': 'text/plain', 'X-MeowToken': storageGet('meowpanel.token') || '' }, body: text }); } catch (e2) {}
setConn(res.ok ? true : false);
throw { code: 'NOT_JSON', message: '后端返回了非 JSON 响应(HTTP ' + res.status + ')', status: res.status };
}
if (!json.ok) {
setConn(true);
const code = (json.error && json.error.code) || 'ERROR';
dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '后端错误:', code, (json.error && json.error.message) || '');
if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
}
if (ms > 800) dbg(method || 'GET', path, '慢响应', ms + 'ms');
setConn(true);
return json.data;
}
// 下载(返回 blob URL)
async function apiDownload(path) {
let res;
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); }
catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; }
if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; }
setConn(true);
return URL.createObjectURL(await res.blob());
}
/* ---------------- toast / 确认弹窗 ---------------- */
let toastTimer = null;
function toast(msg, ms) {
const t = $('#toast');
t.textContent = msg; t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, ms || 2600);
}
function confirmDlg(title, body, danger) {
return new Promise(res => {
$('#modal-title').textContent = title || '确认';
$('#modal-body').textContent = body || '';
const yes = $('#modal-yes'), no = $('#modal-no');
yes.className = 'btn btn-primary' + (danger ? ' btn-danger' : '');
const scrim = $('#modal'); scrim.hidden = false;
const done = v => { scrim.hidden = true; yes.onclick = no.onclick = scrim.onclick = null; res(v); };
yes.onclick = () => done(true);
no.onclick = () => done(false);
scrim.onclick = e => { if (e.target === scrim) done(false); };
});
}
/* ---------------- 访问级 PIN 鉴权(设置 PIN → 登录;401 自动跳登录;token 存 localStorage) ----------------
* 后端:访问面板即需登录;token 存面板进程内存(重启失效,3 天固定不滑动续期);连续 5 次错 PIN 锁 5 分钟。
* 前端:token 存 localStorage 随请求带 X-MeowToken;401 自动切登录页;未设 PIN 走首次设置流程。 */
let authMode = 'login'; // 'login' | 'setup'
function loginErr(msg) { const err = $('#login-err'); if (err) { err.textContent = msg; err.hidden = false; } }
function authRequired() { // 任一路由收到 401 → 弹登录页(会话失效/未认证)
state.unauth = true;
refreshAuthGate();
}
async function refreshAuthGate() { // 查状态决定「首次设置」还是「登录」
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
if (st && st.session) { state.unauth = false; hideLogin(); return; }
if (st && !st.pin_set && !st.legacy) showLogin('setup');
else showLogin('login');
}
function showLogin(mode) {
state.unauth = true;
authMode = mode || 'login';
const lg = $('#login'); if (lg) lg.hidden = false;
const sub = $('#login-sub');
const pin2 = $('#login-pin2');
const btn = $('#login-btn');
if (authMode === 'setup') {
if (sub) sub.textContent = '首次使用:请设置一个 4 位数字 PIN 作为本面板访问口令。';
if (pin2) pin2.hidden = false;
if (btn) btn.textContent = '设 置';
} else {
if (sub) sub.textContent = '此面板已开启访问鉴权,请输入 4 位 PIN。';
if (pin2) pin2.hidden = true;
if (btn) btn.textContent = '登 录';
}
const ip = $('#login-pin'); if (ip) { ip.value = ''; ip.focus(); }
const ip2 = $('#login-pin2'); if (ip2 && authMode !== 'setup') ip2.value = '';
const err = $('#login-err'); if (err) err.hidden = true;
}
function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; }
async function doLogin() {
const pin = ($('#login-pin').value || '').trim();
const err = $('#login-err'); if (err) err.hidden = true;
if (!pin) { loginErr('请输入 PIN'); return; }
if (authMode === 'setup') {
const pin2 = ($('#login-pin2').value || '').trim();
if (pin.length !== 4 || /[^0-9]/.test(pin)) { loginErr('PIN 必须为 4 位数字'); return; }
if (pin !== pin2) { loginErr('两次输入的 PIN 不一致'); return; }
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
await api('POST', '/api/auth/setup', { pin: pin });
toast('PIN 已设置,请登录');
showLogin('login');
} catch (e2) { loginErr((e2 && e2.message) ? e2.message : '设置失败'); }
finally { if (btn) btn.disabled = false; }
return;
}
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
const d = await api('POST', '/api/auth/login', { pin: pin });
storageSet('meowpanel.token', d.token);
state.unauth = false;
hideLogin();
toast('已登录');
if (started) route(); else startApp();
} catch (e) {
if (e && (e.code === 'LOCKED' || e.status === 429)) loginErr(e.message || '错误次数过多,已锁定,请稍后重试');
else loginErr((e && e.message) ? e.message : '登录失败');
} finally {
const b = $('#login-btn'); if (b) b.disabled = false;
}
}
function bindLogin() {
const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin);
const ip = $('#login-pin'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
// PIN 输入:只允许数字,最长 4 位
[ip, $('#login-pin2')].forEach(el => { if (el) el.addEventListener('input', () => { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); });
}
function passwordDlg(title, body) {
return new Promise(res => {
$('#pwdlg-title').textContent = title || '设置面板密码';
$('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。';
const inp = $('#pwdlg-input'); inp.value = ''; inp.focus();
const scrim = $('#pwdlg'); scrim.hidden = false;
const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel');
const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); };
okb.onclick = () => done(inp.value);
cancel.onclick = () => done(null);
scrim.onclick = e => { if (e.target === scrim) done(null); };
inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); };
});
}
async function setPanelPassword() {
const np = await passwordDlg('设置面板密码', '请输入新密码(留空则清除密码)。');
if (np === null) return; // 取消
try {
await api('PUT', '/api/settings/panel.auth_pass', { value: np });
toast(np ? '面板密码已设置' : '面板密码已清除');
renderSettings();
} catch (e) { toast('设置失败:' + errText(e)); }
}
function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ }
/* ---------------- 主题(MD3/MIUIx × 明/暗) ----------------
* <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>');
}
const pb = (nd && nd.pub_bytes) || null;
if (pb && pb.total) {
rows += homeKv('公网流量', '↓ ' + fmtBytes(pb.total.rx || 0) + ' · ↑ ' + fmtBytes(pb.total.tx || 0) + '<span class="muted">(TCP)</span>');
} else {
rows += homeKv('公网流量', '<span class="home-chip mut">—</span>');
}
return homeCard('WiFi / DNS', '<div class="home-card-grid">' + rows + '</div>');
}
/* 蓝牙状态卡(M6.30):数据来自 overview 的 net.bt(netmgrd status pass-through)。
* 展示:蓝牙开关/已连接/已配对/本机地址/A2DP 编解码·采样率;输出目标(DAC|蓝牙)与音量控制(avrcp/dac)
* 依赖面板后端透传 audiod 的 target/volctl 键——当前 audio_fill 未透传,缺失时优雅降级为「—」(不谎报)。 */
function btCard() {
const nd = homeData && homeData.net;
const ad = homeData && homeData.audio;
let bt = nd && nd.bt;
if (!bt || !isAvail(nd)) return homeCard('蓝牙', homeUnavailable());
if (bt.available === false) return homeCard('蓝牙', '<div class="home-sub">无蓝牙</div>');
const on = !!bt.powered;
const conn = (bt.connected != null) ? bt.connected : 0;
let rows = '';
rows += homeKv('蓝牙', on ? '<span class="home-chip ok">已开启</span>' : '<span class="home-chip warn">已关闭</span>');
rows += homeKv('连接', conn > 0 ? ('<span class="home-chip ok">已连接 ' + esc(conn) + '</span>') : '<span class="home-chip mut">未连接</span>');
rows += homeKv('已配对', bt.paired != null ? esc(bt.paired) : '—');
if (bt.address) rows += homeKv('本机地址', esc(bt.address));
if (conn > 0) {
rows += homeKv('A2DP 编解码', bt.a2dp_codec ? esc(bt.a2dp_codec) : '—');
rows += homeKv('采样率', bt.a2dp_rate ? esc(bt.a2dp_rate) : '—');
} else {
rows += homeKv('A2DP 编解码', '<span class="home-chip mut">—</span>');
}
const target = ad && ad.target != null ? ad.target : null;
const volctl = ad && ad.volctl != null ? ad.volctl : null;
const targetName = target === 'bt' ? '蓝牙' : (target === 'dac' ? 'DAC' : null);
rows += homeKv('输出目标', targetName ? ('<span class="home-chip ' + (target === 'bt' ? 'ok' : 'mut') + '">' + esc(targetName) + '</span>') : '<span class="home-chip mut">—</span>');
rows += homeKv('音量控制', volctl ? esc(volctl) : '—');
if (ad && ad.vol != null && ad.vol >= 0) {
rows += homeKv('音量', esc(ad.vol) + ' %' + (volctl === 'avrcp' ? ' <span class="muted">(AVRCP)</span>' : ''));
}
return homeCard('蓝牙', '<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 = '—', fgHtml = '';
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) + ' 已装';
const fg = ams.fg;
if (fg && fg.pkg) {
fgHtml = '<span class="home-chip ok">前台</span> ' + esc(fg.pkg) +
(fg.activity ? ' · <span class="muted">' + esc(fg.activity) + '</span>' : '');
}
}
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)) +
(fgHtml ? homeKv('前台应用', fgHtml) : '') + '</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>');
}
/* 首页功耗摘要条(est_mw + breakdown:复用 estimateCardBody/breakdownCardBody 保持一致口径,诚实标注 confidence)。
* 上游 power 不可用 → 不渲染该条(绝不假数据);HibyOS 只读 degraded → 显示说明。 */
function powerSummaryCard() {
const pw = homeData && homeData.power;
if (!isAvail(pw)) return '';
const est = pw.estimate;
if (!est) return '';
if (est.est_mw == null) return homeCard('功耗视角', '<div class="home-sub">' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '</div>');
return homeCard('功耗视角', estimateCardBody(est) + (pw.breakdown ? breakdownCardBody(pw.breakdown) : ''));
}
/* 最近异常事件条(power.events 摘要 top3 进 overview;完整列表走 /api/power/events)。 */
function eventsBar() {
const ev = homeData && homeData.events;
if (!ev || ev.available === false) return '';
const list = (ev && ev.events) || [];
if (!list.length) return '';
const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' };
let items = list.slice(0, 3).map(e => {
const tn = typeLabel[e[1]] || e[1];
const txt = String(e[3] == null ? '' : e[3]);
const cls = (e[1] === 'lowbat') ? 'warn' : (e[1] === 'charger' ? 'ok' : 'mut');
return '<span class="home-chip ' + cls + '" title="' + esc(txt) + '">' + esc(tn) + (txt ? ' · ' + esc(txt.length > 28 ? txt.slice(0, 28) + '…' : txt) : '') + '</span>';
}).join(' ');
return '<div class="card"><h3 class="card-title">最近事件</h3><div class="home-sub">' + items + '</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 = powerSummaryCard() + eventsBar(); // 摘要条(功耗/事件)置顶
h += '<div class="home-grid">' +
deviceCard() + batteryCard() + audioCard() + netCard() + btCard() + 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;
}
let powerEditing = false; // 用户在编辑策略表单时暂停 5s 重渲染(防输入被轮询清空)
/* 电源策略卡:profile 四档 + 低电/临界阈值 + 亮度/息屏上限。前端确认后 POST /api/power/policy。
* 默认全关(enacted=0)如实标注;note 透传 powermgr 口径(只写 power.* 键,不直接写系统)。 */
function powerProfileLabel(p) {
return { performance: '性能', balanced: '均衡', powersave: '省电', ultra: '超省电' }[p] || p;
}
function powerPolicyCard(pol) {
if (!pol) return '<div class="card"><h3 class="card-title">电源策略</h3>' + homeUnavailable() + '</div>';
const profiles = ['performance', 'balanced', 'powersave', 'ultra'];
const cur = pol.profile || 'balanced';
let btns = profiles.map(p => '<button class="btn btn-ghost pol-btn' + (p === cur ? ' pol-on' : '') + '" data-pol="' + esc(p) + '">' + powerProfileLabel(p) + '</button>').join(' ');
return '<div class="card"><h3 class="card-title">电源策略</h3>' +
'<div class="field-row" style="margin-bottom:12px"><span class="muted">省电档位:</span>' + btns + '</div>' +
'<table class="table"><tbody>' +
row('低电阈值', '<input type="number" id="pol-low" class="num pol-num" value="' + esc(pol.low_pct) + '" min="1" max="99"> %') +
row('临界阈值', '<input type="number" id="pol-crit" class="num pol-num" value="' + esc(pol.critical_pct) + '" min="1" max="99"> %') +
row('亮度上限', '<input type="number" id="pol-bri" class="num pol-num" value="' + esc(pol.brightness_cap) + '" min="0" max="101"> <span class="muted">0=不生效</span>') +
row('息屏上限', '<input type="number" id="pol-scr" class="num pol-num" value="' + esc(pol.screen_timeout_cap) + '" min="0"> <span class="muted">秒 · 0=不生效</span>') +
row('状态', (pol.enacted ? '<span class="home-chip ok">已手动开启</span>' : '<span class="home-chip mut">默认全关</span>')) +
'</tbody></table>' +
'<div class="field-row"><button class="btn btn-primary" id="pol-apply">保存阈值</button><span class="muted">切档即时生效(需确认);阈值点「保存阈值」。</span></div>' +
'<p class="muted">' + esc(pol.note || '') + '</p>' +
'</div>';
}
function powerEventsCard(ev) {
if (!ev) return '';
if (ev.available === false) return '<div class="card"><h3 class="card-title">电源事件</h3>' + homeUnavailable() + '</div>';
const list = (ev && ev.events) || [];
if (!list.length) return '<div class="card"><h3 class="card-title">电源事件</h3><div class="empty">暂无事件</div></div>';
const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' };
let rows = list.map(e => {
const tn = typeLabel[e[1]] || e[1];
return '<tr><td class="muted">' + esc(tn) + '</td><td>' + (e[2] != null ? esc(e[2]) + '%' : '—') + '</td><td>' + esc(e[3] || '') + '</td></tr>';
}).join('');
return '<div class="card"><h3 class="card-title">电源事件</h3>' +
'<table class="table"><thead><tr><th>类型</th><th>电量</th><th>说明</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function powerHistoryCard(h) {
if (!h) return '';
if (h.available === false) return '<div class="card"><h3 class="card-title">功耗历史</h3>' + homeUnavailable() + '</div>';
let body = '<table class="table"><tbody>' +
row('采样窗口', (h.n_samples || 0) + ' 点 · ' + fmtDur(h.window_sec)) +
row('电量范围', (h.min_pct != null ? h.min_pct : '—') + '% ~ ' + (h.max_pct != null ? h.max_pct : '—') + '%') +
row('起止电量', (h.start_pct != null ? h.start_pct : '—') + '% → ' + (h.end_pct != null ? h.end_pct : '—') + '%') +
row('平均功耗', (h.avg_mw != null ? h.avg_mw + ' mW' : '—')) +
'</tbody></table>';
return '<div class="card"><h3 class="card-title">功耗历史</h3>' + body + '</div>';
}
function renderPowerBody(d, ev, hi) {
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>';
h += ((d && d.policy) ? powerPolicyCard(d.policy) : '<div class="card"><h3 class="card-title">电源策略</h3>' + homeUnavailable() + '</div>');
h += powerEventsCard(ev);
h += powerHistoryCard(hi);
h += '</div>';
return h;
}
let powerData = null, powerEvents = null, powerHistory = null;
async function fetchPowerOnce() {
const el = $('#power-page');
if (!el || state.route !== 'power') return;
let d, ev, hi;
const r = await Promise.allSettled([api('GET', '/api/power'), api('GET', '/api/power/events'), api('GET', '/api/power/history')]);
if (r[0].status !== 'fulfilled') {
if (el && state.route === 'power') el.innerHTML = '<div class="page" id="power-page">' + emptyState('加载失败:' + errText(r[0].reason), (r[0].reason && r[0].reason.code) || '') + '</div>';
return;
}
if (state.route !== 'power') return;
d = r[0].value; ev = (r[1].status === 'fulfilled') ? r[1].value : null; hi = (r[2].status === 'fulfilled') ? r[2].value : null;
if (!powerEditing) el.innerHTML = renderPowerBody(d, ev, hi);
}
function renderPower() {
stopPower();
powerEditing = false;
powerData = null; powerEvents = null; powerHistory = null;
$('#content').innerHTML = '<div class="page" id="power-page"><p class="placeholder">加载电源数据…</p></div>';
fetchPowerOnce();
powerTimer = setInterval(fetchPowerOnce, 5000);
}
function bindPower() {
$('#content').addEventListener('click', async function (e) {
const pb = e.target.closest('.pol-btn');
if (pb) {
const p = pb.dataset.pol;
const ok = await confirmDlg('切换省电档位', '确认将省电档位切换为「' + powerProfileLabel(p) + '」?(写 settings.conf power.profile,launcher/sysui 期4消费生效)', false);
if (!ok) { powerEditing = false; fetchPowerOnce(); return; }
powerEditing = false;
try { await api('POST', '/api/power/policy', { item: 'profile', value: p }); toast('已切档:' + powerProfileLabel(p)); }
catch (err) { toast('切档失败:' + errText(err)); }
fetchPowerOnce();
return;
}
const apply = e.target.closest('#pol-apply');
if (apply) {
const low = parseInt($('#pol-low') ? $('#pol-low').value : '', 10);
const crit = parseInt($('#pol-crit') ? $('#pol-crit').value : '', 10);
const bri = parseInt($('#pol-bri') ? $('#pol-bri').value : '', 10);
const scr = parseInt($('#pol-scr') ? $('#pol-scr').value : '', 10);
if (isNaN(low) || isNaN(crit)) { toast('请输入有效的低电/临界阈值'); return; }
const ok = await confirmDlg('保存电源阈值', '确认写入 低电=' + low + '% / 临界=' + crit + '% / 亮度上限=' + (isNaN(bri) ? 0 : bri) + ' / 息屏上限=' + (isNaN(scr) ? 0 : scr) + 's?', false);
if (!ok) { powerEditing = false; fetchPowerOnce(); return; }
powerEditing = false;
try {
await api('POST', '/api/power/policy', { item: 'low-pct', value: low });
await api('POST', '/api/power/policy', { item: 'crit-pct', value: crit });
await api('POST', '/api/power/policy', { item: 'bri-cap', value: bri || 0 });
await api('POST', '/api/power/policy', { item: 'scr-cap', value: scr || 0 });
toast('电源阈值已保存');
} catch (err) { toast('保存失败:' + errText(err)); }
fetchPowerOnce();
return;
}
});
$('#content').addEventListener('focusin', function (e) {
if (e.target.closest('.pol-num')) powerEditing = true; // 编辑中暂停 5s 重渲染(防输入被清空)
});
}
/* ---------------- 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>' +
'<button class="btn btn-ghost" data-amsstart="' + esc(a.pkg) + '">启动</button>' +
(a.pid > 0 ? '<button class="btn btn-ghost" data-amskill="' + esc(a.pkg) + '" data-fg="' + (a.state === 'fg' ? '1' : '0') + '">结束</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 末尾(崩溃尾巴)。「启动/结束」经 amsd(require_auth);结束前台 App 需二次确认。</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', async function (e) {
const b = e.target.closest('[data-applog]');
if (b) { viewAppStdout(b.dataset.applog, b); return; }
const st = e.target.closest('[data-amsstart]');
if (st) {
const pkg = st.dataset.amsstart;
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/start'); toast('已请求启动 ' + pkg); }
catch (err) { toast('启动失败:' + errText(err)); }
fetchProcOnce();
return;
}
const kl = e.target.closest('[data-amskill]');
if (kl) {
const pkg = kl.dataset.amskill, fg = kl.dataset.fg === '1';
const ok = await confirmDlg('结束 App', (fg ? '『' + pkg + '』正位于前台,' : '') + '确认结束 ' + pkg + '?(SIGTERM 语义)', true);
if (!ok) return;
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/kill'); toast('已请求结束 ' + pkg); }
catch (err) { toast('结束失败:' + errText(err)); }
fetchProcOnce();
return;
}
});
}
/* ---------------- 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';
}
/* 网络接口卡(/api/net/ifaces:kind/ip/gw/rssi/rfkill/dns + RX/TX)。
* enforce 执行语义如实:仅 DNS 层拒绝(SOCK_DESTROY 在本内核为 no-op),绝不谎称「已断连」。 */
function netIfaceCards(ifd) {
if (!ifd || ifd.available === false) return '<div class="card"><h3 class="card-title">网络接口</h3>' + homeUnavailable() + '</div>';
const ifs = (ifd && ifd.ifaces) || [];
let h = '<div class="card"><h3 class="card-title">网络接口</h3>';
let meta = '<div class="home-sub" style="margin-bottom:8px">';
meta += (ifd.rssi != null) ? '信号 ' + esc(ifd.rssi) + ' dBm' : '信号 —';
if (ifd.dns && ifd.dns.length) meta += ' · DNS ' + esc(ifd.dns.join(' / '));
meta += '</div>';
h += '<p class="muted" style="margin-bottom:8px">RX / TX 为接口累计字节(含局域网/面板轮询/DNS 等内网往返),非手机热点公网消耗。</p>';
h += meta;
h += '<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>';
const rf = (ifd && ifd.rfkill) || [];
if (rf.length) {
h += '<div class="rfkill-row">';
rf.forEach(r => h += '<span class="home-chip ' + (r.soft || r.hard ? 'warn' : 'ok') + '">' + esc(r.type) + (r.soft ? ' 软关' : '') + (r.hard ? ' 硬关' : '') + '</span>');
h += '</div>';
}
h += '</div>';
return h;
}
/* 公网流量卡(netmgrd pub_bytes:netlink sock_diag 按 TCP 连接累积,远端公网/内网分流)。
* 口径诚实:仅 TCP;UDP/DNS/mDNS/ARP 不计;内网本链路(面板轮询等)归 lan。 */
function pubBytesCard(pb) {
if (!pb) return '';
const t = pb.total || {};
let h = '<div class="card"><h3 class="card-title">公网流量</h3>';
h += '<p class="muted">按 TCP 连接计量(远端公网才计入);UDP/DNS/mDNS/ARP 不计。接口累计含内网往返,非公网消耗。</p>';
h += '<div class="home-sub" style="margin-bottom:8px">公网累计:↓ ' + fmtBytes(t.rx || 0) + ' · ↑ ' + fmtBytes(t.tx || 0) + '</div>';
const ents = pb.by_entity || [];
if (ents.length) {
h += '<table class="table"><thead><tr><th>实体</th><th>公网 ↓ / ↑</th><th>内网 ↓ / ↑</th></tr></thead><tbody>';
ents.forEach(e => {
h += '<tr><td class="net-entity">' + netBadge(e.entity) + '</td>' +
'<td class="muted">' + fmtBytes((e.pub && e.pub[0]) || 0) + ' / ' + fmtBytes((e.pub && e.pub[1]) || 0) + '</td>' +
'<td class="muted">' + fmtBytes((e.lan && e.lan[0]) || 0) + ' / ' + fmtBytes((e.lan && e.lan[1]) || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
const flows = pb.by_flow || [];
if (flows.length) {
h += '<div class="home-sub" style="margin-top:10px">公网流 Top</div>';
h += '<table class="table"><thead><tr><th>远端</th><th>实体</th><th>↓ / ↑</th></tr></thead><tbody>';
flows.forEach(f => {
h += '<tr><td class="mono">' + esc(f.ip || '') + ':' + esc('' + f.port) +
(f.domain ? ' <span class="muted">' + esc(f.domain) + '</span>' : '') + '</td>' +
'<td class="net-entity">' + netBadge(f.entity) + '</td>' +
'<td class="muted">' + fmtBytes(f.rx || 0) + ' / ' + fmtBytes(f.tx || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
h += '</div>';
return h;
}
/* WiFi / 蓝牙摘要(/api/net/status:E0 概览,含实时速率)。接口细分已迁 netIfaceCards。 */
function netStatusCards(d) {
let h = '';
if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null))
h += '<div class="card"><p class="muted">实时速率(上行接口,不含回环):↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p></div>';
if (d && d.pub_bytes) h += pubBytesCard(d.pub_bytes);
// 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;
}
/* 网络策略卡(/api/net/policy;写走 POST /api/net/policy,require_auth + 二次确认)。
* enforce 如实显示(dns_only):仅 DNS 层拒绝,不会真断连。 */
function netPolicyCard(pol) {
if (!pol || pol.available === false) return '<div class="card"><h3 class="card-title">网络策略</h3>' + homeUnavailable() + '</div>';
const enforce = pol.enforce || '';
const dnsOnly = enforce === 'dns_only';
let h = '<div class="card"><h3 class="card-title">网络策略</h3>';
h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">' + (dnsOnly ? 'ℹ️' : '⚠️') + '</span><span>' +
(dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '</span></div>';
const rules = pol.rules || [];
if (!rules.length) h += '<div class="empty">暂无策略规则(默认放行)</div>';
else {
h += '<table class="table"><thead><tr><th>实体</th><th>网络</th><th>白名单域名</th><th>黑名单域名</th><th style="text-align:right">操作</th></tr></thead><tbody>';
rules.forEach(r => {
h += '<tr><td class="mono">' + netBadge(r.entity) + '</td>' +
'<td><span class="home-chip ' + (r.net === 'block' ? 'bad' : 'ok') + '">' + (r.net === 'block' ? '阻断' : '放行') + '</span></td>' +
'<td class="muted">' + esc((r.allow_hosts || []).join(', ') || '—') + '</td>' +
'<td class="muted">' + esc((r.deny_hosts || []).join(', ') || '—') + '</td>' +
'<td style="text-align:right"><div class="row-actions">' +
'<button class="btn btn-ghost" data-ptoggle="' + esc(r.entity) + '" data-net="' + (r.net === 'block' ? 'allow' : 'block') + '">' + (r.net === 'block' ? '改放行' : '阻断') + '</button>' +
'<button class="btn btn-ghost" data-pdel="' + esc(r.entity) + '">删除</button></div></td></tr>';
});
h += '</tbody></table>';
}
h += '<div class="field-row" style="margin-top:12px"><input type="text" id="pol-entity" placeholder="app:os.meow.ncm / svc:x / proc:y" style="max-width:280px">' +
'<button class="btn btn-primary" id="pol-block">设为阻断</button>' +
'<button class="btn btn-ghost" id="pol-allow">设为放行</button></div>';
h += '<div class="field-row" style="margin-top:8px"><input type="text" id="pol-domain" placeholder="域名(+白 / -黑,如 +music.example.com)" style="max-width:280px">' +
'<button class="btn" id="pol-dom">添加域名规则</button></div>';
h += '<p class="muted">防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。</p>';
h += '</div>';
return h;
}
function netConnsBody(cn) {
const list = (cn && cn.conns) || [];
let h = '';
/* 连接 top 摘要(聚合计数,轻量——overview 与网速页的观察摘要,非完整归因)。 */
const entCnt = {};
(list || []).forEach(c => { entCnt[c.entity] = (entCnt[c.entity] || 0) + 1; });
const top = Object.keys(entCnt).sort((a, b) => entCnt[b] - entCnt[a]).slice(0, 3);
if (top.length) {
h += '<div class="home-sub" style="margin-bottom:8px">连接 top:' + top.map(e => '<span class="net-entity">' + netBadge(e) + ' ×' + entCnt[e] + '</span>').join(' · ') + '(共 ' + list.length + ' 条)</div>';
}
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'),
ifEl = $('#net-ifaces'), poEl = $('#net-policy');
let st, cn, dn, ifd, pol;
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; }
try { ifd = await api('GET', '/api/net/ifaces'); } catch (e) { ifd = null; }
try { pol = await api('GET', '/api/net/policy'); } catch (e) { pol = null; }
if (state.route !== 'net') return;
if (ifEl) ifEl.innerHTML = netIfaceCards(ifd);
if (stEl) stEl.innerHTML = netStatusCards(st);
if (poEl) poEl.innerHTML = netPolicyCard(pol);
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-ifaces"><p class="placeholder">加载网络接口…</p></div>' +
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
'<div id="net-policy"><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();
}
/* ---- 网络策略写(require_auth + 二次确认;enforce=dns_only 如实显示,不谎称断连) ---- */
const tgl = e.target.closest('[data-ptoggle]');
if (tgl) {
const ent = tgl.dataset.ptoggle, net = tgl.dataset.net;
const ok = await confirmDlg('网络策略', '确认将 ' + ent + ' 网络策略设为「' + (net === 'block' ? '阻断(DNS 层拒绝,不会真断连)' : '放行') + '」?', net === 'block');
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: net }); toast('已更新策略:' + ent); }
catch (err) { toast('策略更新失败:' + errText(err)); }
fetchNetOnce();
return;
}
const del = e.target.closest('[data-pdel]');
if (del) {
const ent = del.dataset.pdel;
const ok = await confirmDlg('删除策略', '确认删除 ' + ent + ' 的全部策略规则(恢复默认放行)?', true);
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'del', entity: ent }); toast('已删除策略:' + ent); }
catch (err) { toast('删除失败:' + errText(err)); }
fetchNetOnce();
return;
}
const blk = e.target.closest('#pol-block');
if (blk) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; }
const ok = await confirmDlg('阻断实体', '确认将 ' + ent + ' 设为阻断(DNS 层拒绝,不会真断连)?', true);
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'block' }); toast('已阻断:' + ent); }
catch (err) { toast('阻断失败:' + errText(err)); }
fetchNetOnce();
return;
}
const alw = e.target.closest('#pol-allow');
if (alw) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'allow' }); toast('已放行:' + ent); }
catch (err) { toast('放行失败:' + errText(err)); }
fetchNetOnce();
return;
}
const dom = e.target.closest('#pol-dom');
if (dom) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
const hst = ($('#pol-domain') ? $('#pol-domain').value.trim() : '');
if (!ent) { toast('请输入实体'); return; }
if (!hst || (hst[0] !== '+' && hst[0] !== '-')) { toast('域名须以 +(白)或 -(黑)开头'); return; }
const ok = await confirmDlg('域名规则', '确认对 ' + ent + ' ' + (hst[0] === '+' ? '加入白名单' : '加入黑名单') + ' ' + hst.slice(1) + '?', hst[0] === '-');
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_domain', entity: ent, arg: hst }); toast('域名规则已更新'); }
catch (err) { toast('域名规则失败:' + errText(err)); }
fetchNetOnce();
return;
}
});
}
/* ---------------- 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);
/* ---------------- 6b. 远程控制页滑动手势(设备端靠触摸滑动操作,控制页据此补滑动手势) ----------------
* 说明:后端 /api/input/tap 仅支持「单点坐标注入」(屏幕页用),无滑动/手势注入端点;
* 故控制页滑动统一映射到 /api/key/:code 语义键(与本地按键行为一致),多步音量 = 逐键 tap 多次。 */
const SWIPE = {
minDist: 40, // 判定为「滑动」的最小位移(px),小于此视为点按
axisRatio: 1.5, // 主轴位移须至少为次轴的倍数,防斜滑误判
edgePx: 56, // 距控制区左缘多少 px 内起手即视为「左缘滑」
maxVolSteps: 5, // 单次滑动最多递增音量步数(防连刷冲上限)
volStepPx: 70, // 每多滑这么多位移,多算一步音量
clickSuppressMs: 450 // 滑动后短暂屏蔽按钮点击,防误触
};
let swipeState = null; // 当前手势 {id,x0,y0,edge}
let lastSwipeAt = 0; // 上次触发手势的时刻,用于屏蔽随后可能的 click
async function sendKeyLabel(code, label) {
try { await api('POST', '/api/key/' + code, { press: 'tap' }); toast(label + ' 已发送'); }
catch (e) { toast('发送失败:' + errText(e)); }
}
async function sendKeyRepeat(code, n, label) {
let count = 0, err = null;
for (let i = 0; i < n; i++) {
try { await api('POST', '/api/key/' + code, { press: 'tap' }); count++; }
catch (e) { err = errText(e); break; } // 失败即停,避免连刷
}
if (count > 0) toast(label + ' ×' + count);
if (err) toast('发送失败:' + err);
}
// 依据滑动主轴位移换算音量步数(>=1,封顶 maxVolSteps)
function volSteps(ay) {
return Math.max(1, Math.min(SWIPE.maxVolSteps, Math.round(ay / SWIPE.volStepPx)));
}
function handleRemoteGesture(dir, ax, ay) {
if (dir === 'volup') sendKeyRepeat(115, volSteps(ay), '音量+');
else if (dir === 'voldown') sendKeyRepeat(114, volSteps(ay), '音量−');
else if (dir === 'next') sendKeyLabel(163, '下一曲');
else if (dir === 'prev') sendKeyLabel(165, '上一曲');
else if (dir === 'back') sendKeyLabel(158, '返回');
}
function remotePointerDown(e) {
if (state.route !== 'remote' || swipeState) return; // 已有一指在手 → 忽略多点触控
if (!e.target.closest('#remote-page')) return;
const page = $('#remote-page'); if (!page) return;
const r = page.getBoundingClientRect();
swipeState = { id: e.pointerId, x0: e.clientX, y0: e.clientY, edge: (e.clientX - r.left) <= SWIPE.edgePx };
}
function remotePointerUp(e) {
const s = swipeState;
if (!s || e.pointerId !== s.id) return;
swipeState = null;
const dx = e.clientX - s.x0, dy = e.clientY - s.y0;
const ax = Math.abs(dx), ay = Math.abs(dy);
if (Math.max(ax, ay) < SWIPE.minDist) return; // 轻点/未动 → 交给按钮 click 处理
let dir = null;
if (s.edge && dx > 0 && dx >= ay * SWIPE.axisRatio && dx >= SWIPE.minDist) dir = 'back'; // 左缘滑 → 返回
else if (ax >= ay * SWIPE.axisRatio) dir = (dx < 0 ? 'next' : 'prev'); // 水平 → 上下曲
else if (ay >= ax * SWIPE.axisRatio) dir = (dy < 0 ? 'volup' : 'voldown'); // 垂直 → 音量
if (!dir) return; // 斜滑/未达占优 → 忽略
lastSwipeAt = Date.now();
handleRemoteGesture(dir, ax, ay);
}
/* ---------------- 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" style="touch-action:none">';
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>' +
'<p class="muted" style="margin-top:12px;line-height:1.9"><b>滑动手势</b>(与按钮并存,轻点仍是点击):' +
'上滑=音量+ · 下滑=音量-(滑得越远步数越多,单次至多 ' + SWIPE.maxVolSteps + ' 步) · ' +
'左滑=下一曲 · 右滑=上一曲 · 控制区左缘右滑=返回</p>' +
'</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) {
if (Date.now() - lastSwipeAt < SWIPE.clickSuppressMs) return; // 刚滑完 → 屏蔽本次点击,防误触按钮
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; }
});
// 滑动手势:指针事件统一触摸/鼠标;#remote-page 已设 touch-action:none 防浏览器滚动劫持
$('#content').addEventListener('pointerdown', remotePointerDown);
window.addEventListener('pointerup', remotePointerUp);
window.addEventListener('pointercancel', remotePointerUp);
}
/* ---------------- 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: bindPower },
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);
})();