/* MeowPanel — 前端逻辑(纯静态,零第三方依赖)
*
* 对接协议:devdocs/plan/MEOWPANEL_PROTOCOL.md(草案,后端未实现)。
* 所有 fetch 失败都优雅降级为「后端未连接」,不假设后端就绪。
*/
(function () {
'use strict';
/* ---------------- 小工具 ---------------- */
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[m]));
}
function fmtBytes(n) {
if (n == null || isNaN(n)) return '—';
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
}
function fmtUptime(sec) {
if (sec == null || isNaN(sec)) return '—';
sec = Math.floor(sec);
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分';
}
function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; }
function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; }
function pct(p) { return Math.max(0, Math.min(100, p)); }
/* ---------------- 全局状态 ---------------- */
const state = {
style: 'md3', theme: 'dark',
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;
}
/* ---------------- 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;
try {
res = await fetch(path, init);
} catch (e) {
setConn(false);
throw { code: 'NETWORK', message: '后端未连接', status: 0 };
}
let json = null;
try { json = await res.json(); } catch (e) { json = null; }
if (!json || typeof json.ok !== 'boolean') {
// 静态服务器返回 HTML / 404 等
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';
if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
}
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); };
});
}
/* ---------------- 鉴权(可选密码登录) ---------------- */
function authRequired() { // 任一路由收到 401 → 弹登录页
state.unauth = true;
showLogin();
}
function showLogin() {
state.unauth = true;
const lg = $('#login'); if (lg) lg.hidden = false;
const ip = $('#login-password'); if (ip) ip.focus();
}
function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; }
async function doLogin() {
const pw = $('#login-password').value;
const err = $('#login-err');
if (err) err.hidden = true;
if (!pw) { if (err) { err.textContent = '请输入密码'; err.hidden = false; } return; }
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
const d = await api('POST', '/api/auth/login', { password: pw });
storageSet('meowpanel.token', d.token);
state.unauth = false;
hideLogin();
toast('已登录');
if (started) route(); else startApp();
} catch (e) {
if (err) { err.textContent = (e && e.message) ? e.message : '登录失败'; err.hidden = false; }
} 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-password'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
}
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 × 深/浅) ---------------- */
function applyTheme(style, theme) {
state.style = style; state.theme = theme;
const root = document.documentElement;
root.dataset.style = style; root.dataset.theme = theme;
$$('#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.theme === theme));
try { localStorage.setItem('meowpanel.style', style); localStorage.setItem('meowpanel.theme', theme); } catch (e) {}
}
async function sendTheme(patch) {
try { await api('PUT', '/api/theme', patch); }
catch (e) { toast('主题已本地切换(后端未同步:' + e.message + ')'); }
}
async function initTheme() {
let style = 'md3', theme = 'dark';
try { style = localStorage.getItem('meowpanel.style') || 'md3'; theme = localStorage.getItem('meowpanel.theme') || 'dark'; } catch (e) {}
applyTheme(style, theme);
try {
const t = await api('GET', '/api/theme');
applyTheme(t.style || style, t.theme || theme);
} catch (e) { /* 后端离线,保持本地 */ }
}
// 顶栏「风格 / 明暗」切换器:本地立即切换 + 同步后端
function bindThemeUi() {
$('#style-seg').addEventListener('click', function (e) {
const b = e.target.closest('.seg-btn'); if (!b) return;
applyTheme(b.dataset.style, state.theme);
sendTheme({ style: b.dataset.style });
});
$('#theme-seg').addEventListener('click', function (e) {
const b = e.target.closest('.seg-btn'); if (!b) return;
applyTheme(state.style, b.dataset.theme);
sendTheme({ theme: b.dataset.theme });
});
}
/* ---------------- 页面空态 / 错误提示 ---------------- */
function emptyState(msg, code) {
const t = code === 'NO_CARD' ? '未识别到 TF 卡' : (code === 'NOT_IMPLEMENTED' ? '该功能后端尚未实现' : msg);
return '
';
h += '
' +
'' +
'' +
'
';
h += '
';
if (!d.apps.length) h += '
尚未安装任何 MeowAPP
';
d.apps.forEach(a => {
const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase());
h += '
' +
'
' + ino + '
' +
'
' + esc(a.name || a.package || '未命名') + '
' +
'
' + esc(a.package || '') + '
' +
'
版本 ' + esc(a.version || '—') + (a.invalid ? ' · 无效' : '') +
(a.size ? ' · ' + fmtBytes(a.size) : '') + '
' +
'
';
});
h += '
';
// 本地源 .mpk 安装(后端支持 path 形式)
h += '
';
$('#content').innerHTML = h;
})();
}
function bindApps() {
const c = $('#content');
c.addEventListener('click', async function (e) {
const t = e.target;
const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh');
const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]');
const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go');
if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; }
if (refresh) { renderApps(); return; }
if (info) { appInfo(info.dataset.ainfo); return; }
if (exp) {
e.preventDefault();
if (exp.dataset.busy) return; /* 打包期间防连点 */
exp.dataset.busy = '1';
const oldTxt = exp.textContent;
exp.textContent = '打包中…';
toast('正在打包导出 ' + exp.dataset.aexport + ',请稍候…');
doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk')
.finally(() => { delete exp.dataset.busy; exp.textContent = oldTxt; });
return;
}
if (uninst) { appUninstall(uninst.dataset.auninst); return; }
if (goPath) { installFromPath($('#app-install-path').value); return; }
});
c.addEventListener('change', function (e) {
if (e.target.id === 'app-file') installFromFile(e.target.files);
});
}
async function installFromFile(files) {
if (!files || !files.length) return;
const fd = new FormData(); fd.append('file', files[0]);
toast('正在安装 ' + files[0].name + '…');
try {
const d = await api('POST', '/api/apps/install', fd);
toast('已安装 ' + (d.package || '应用') + (d.version ? ' v' + d.version : ''));
renderApps();
} catch (e) { toast('安装失败:' + errText(e)); }
}
async function installFromPath(p) {
if (!p) return toast('请输入 .mpk 路径');
toast('正在安装 ' + p + '…');
try {
const d = await api('POST', '/api/apps/install', { path: p });
toast('已安装 ' + (d.package || '应用')); renderApps();
} catch (e) { toast('安装失败:' + errText(e)); }
}
async function appUninstall(pkg) {
const ok = await confirmDlg('卸载 ' + pkg, '将移除应用与数据预留(数据默认保留)。确认卸载?', true);
if (!ok) return;
try { await api('DELETE', '/api/apps/' + pkg); toast('已卸载 ' + pkg); renderApps(); }
catch (e) { toast('卸载失败:' + errText(e)); }
}
async function appInfo(pkg) {
try {
const d = await api('GET', '/api/apps/' + pkg);
let txt = '包名: ' + d.package + '\n名称: ' + d.name + '\n版本: ' + d.version + '\nexec: ' + d.exec +
'\nsize: ' + fmtBytes(d.size) + '\n路径: ' + d.path;
if (d.description) txt += '\n描述: ' + d.description;
alert(txt);
} catch (e) { toast('获取详情失败:' + errText(e)); }
}
/* ---------------- 4b. 字体管理页(/api/fonts* 专属页) ---------------- */
function renderFontsPage() {
$('#content').innerHTML = '
' +
'
字体库
' +
'
' +
'' +
'' +
'
' +
'
' +
'
上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' +
'MeowOS/fonts/<family>/<weight>/;同 family/weight 冲突时会要求确认覆盖。' +
'导入后进设备「设置 → 字体」页,列表自动重扫生效。
';
loadFontsPage();
}
async function loadFontsPage() {
const box = $('#fpage-list'); if (!box) return;
let d;
try { d = await api('GET', '/api/fonts'); }
catch (e) { box.innerHTML = '
字体库列表加载失败:' + esc(errText(e)) + '
'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '
暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)
'; return; }
let h = '
| family | weight | 名称 | tier | glyph 数 | 操作 |
';
fs.forEach(f => {
h += '| ' + esc(f.family || '') + ' | ' + esc(f.weight || '') + ' | ' +
'' + esc(f.name || '—') + ' | ' + esc(f.tier || '—') + ' | ' +
'' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + ' | ' +
' |
';
});
box.innerHTML = h + '
';
}
async function importFontPage(files) {
if (!files || !files.length) return;
const f = files[0];
const btn = $('#fpage-import');
if (btn && btn.disabled) return; // 上传中防连点(进度态)
const fd = new FormData(); fd.append('file', f);
if (btn) { btn.disabled = true; btn.textContent = '导入中…'; }
toast('正在导入 ' + f.name + '…');
let res = null;
try { res = await api('POST', '/api/fonts/import', fd); }
catch (err) {
if (err.code === 'CONFLICT') {
const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true);
if (ok) {
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
catch (e2) { toast('导入失败:' + errText(e2)); }
}
} else toast('导入失败:' + errText(err));
}
if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; }
if (res) {
toast('已导入 ' + (res.family || '') + '/' + (res.weight || ''));
if (res.warning) toast(res.warning);
else if (res.hint) toast(res.hint);
loadFontsPage();
}
}
function bindFonts() {
const c = $('#content');
c.addEventListener('click', function (e) {
if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; }
if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; }
const fe = e.target.closest('[data-fpexport]');
if (fe) {
if (fe.dataset.busy) return; // 打包期间防连点
fe.dataset.busy = '1';
const old = fe.textContent; fe.textContent = '打包中…';
const f = fe.dataset.fpexport, w = fe.dataset.fpweight;
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w),
(f || 'font') + '-' + (w || 'regular') + '.font.tar.gz')
.finally(() => { delete fe.dataset.busy; fe.textContent = old; });
return;
}
});
c.addEventListener('change', function (e) {
if (e.target.id === 'fpage-file') importFontPage(e.target.files);
});
}
/* ---------------- 5. 设置镜像页 ---------------- */
function settingControl(it) {
const key = it.key;
if (key === 'panel.auth_pass') {
const txt = it.set ? '已设置' : '未设置';
return '
面板密码(' + txt + ')
' +
'
';
}
if (it.type === 'bool') {
return '
';
}
if (it.type === 'int') {
const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max);
return '
';
}
if (it.type === 'enum') {
const opts = (it.options || []).map(o => '
').join('');
return '
';
}
return '
';
}
async function renderSettings() {
$('#content').innerHTML = '
';
let d;
try { d = await api('GET', '/api/settings'); }
catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route !== 'settings') return;
let h = '
';
h += '
' +
'' +
'
';
const items = d.items || [];
// 面板鉴权(可选防护)单独一卡
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
if (authItems.length) {
h += '
面板鉴权(可选防护)
' + authItems.map(settingControl).join('') +
'
开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。
';
}
const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass');
// 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看
const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')],
['数值/滑条', rest.filter(x => x.type === 'int')],
['枚举/选择', rest.filter(x => x.type === 'enum')],
['字符串/路径', rest.filter(x => x.type === 'str')]];
groups.forEach(function (g) {
if (!g[1].length) return;
h += '
' + g[0] + '
' + g[1].map(settingControl).join('') + '';
});
// 字体库(.zip 导入 / 导出)
h += '
字体库(.tar.gz 导入 / 导出)
' +
'
' +
'
' +
'
' +
'
导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。
';
h += '
';
$('#content').innerHTML = h;
loadFonts();
}
async function loadFonts() {
const box = $('#font-list'); if (!box) return;
let d;
try { d = await api('GET', '/api/fonts'); }
catch (e) { box.innerHTML = '
字体库列表加载失败:' + esc(errText(e)) + '
'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '
暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。
'; return; }
box.innerHTML = fs.map(f => {
const nm = esc(f.name || f.family || '');
const meta = esc((f.family || '') + '/' + (f.weight || '') + (f.tier ? ' · ' + f.tier : '') + (f.glyph_count ? ' · ' + f.glyph_count + ' 字' : ''));
return '
' +
'
' + nm + '
' + meta + '
' +
'
';
}).join('');
}
async function importFont(files) {
if (!files || !files.length) return;
const f = files[0];
const fd = new FormData(); fd.append('file', f);
toast('正在导入 ' + f.name + '…');
let res;
try { res = await api('POST', '/api/fonts/import', fd); }
catch (err) {
if (err.code === 'CONFLICT') {
const ok = await confirmDlg('覆盖字库', '目标字库已存在,覆盖导入吗?', true);
if (!ok) return;
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
catch (e2) { toast('导入失败:' + errText(e2)); return; }
} else { toast('导入失败:' + errText(err)); return; }
}
toast('已导入 ' + ((res && res.family) || '') + '/' + ((res && res.weight) || ''));
if (res && res.hint) toast(res.hint);
loadFonts();
}
function bindSettings() {
const c = $('#content');
c.addEventListener('input', function (e) {
const r = e.target.closest('[data-stype="int"]'); if (!r) return;
const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value;
updateSetting(r.dataset.skey, +r.value, 'int');
});
c.addEventListener('change', function (e) {
if (e.target.id === 'font-file') { importFont(e.target.files); return; }
const t = e.target;
const sk = t.dataset.skey; if (!sk) return;
if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool');
else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum');
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
});
c.addEventListener('click', async function (e) {
if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; }
const fe = e.target.closest('[data-fexport]');
if (fe) {
const f = fe.dataset.fexport, w = fe.dataset.fweight;
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz');
return;
}
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
if (e.target.closest('#set-restore')) {
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
if (!ok) return;
try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); }
catch (err) { toast('恢复失败:' + errText(err)); }
} else if (e.target.closest('#set-refresh')) { renderSettings(); }
});
}
const updateSetting = debounce(async function (key, value, type) {
try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); }
catch (e) { toast('保存失败:' + errText(e)); }
}, 350);
/* ---------------- 6. 远程控制页 ---------------- */
function renderRemote() {
const keys = [
{ code: 115, label: '音量 +', ico: '🔊' },
{ code: 114, label: '音量 −', ico: '🔉' },
{ code: 116, label: '电源(单击)', ico: '⏻' },
{ code: 164, label: '播放/暂停', ico: '⏯' },
{ code: 163, label: '下一曲', ico: '⏭' },
{ code: 165, label: '上一曲', ico: '⏮' },
{ code: 158, label: '返回', ico: '↩' },
{ code: 172, label: '主页', ico: '⌂' }
];
let h = '
';
h += '
模拟按键
' +
'
行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。
' +
'
' + keys.map(k =>
''
).join('') + '
';
h += '
电源长按(电源菜单)
' +
'
' +
'发送 code=116 · press=long · ms=3000
';
h += '
';
$('#content').innerHTML = h;
}
async function sendKey(code, press, ms) {
try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); }
catch (e) { toast('发送失败:' + errText(e)); }
}
function bindRemote() {
$('#content').addEventListener('click', function (e) {
const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; }
const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; }
});
}
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
async function renderSystem() {
$('#content').innerHTML = '
';
let d;
try { d = await api('GET', '/api/system'); }
catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route !== 'system') return;
const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {};
let h = '
';
h += '
系统信息
' +
row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) +
row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)) +
row('默认启动', esc((d.boot && d.boot.default) || '—')) +
'
';
h += '
网络
' +
row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') +
row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') +
row('mDNS', esc(net.hostname_local || '—')) +
'
';
h += '
升级
' +
'
当前 ' + esc(ota.current || d.version || '—') +
(ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '
' +
'
' +
'
';
h += '
危险操作
' +
'
' +
'
' +
'
⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。
';
h += '
';
$('#content').innerHTML = h;
}
function row(k, v) { return '
| ' + k + ' | ' + v + ' |
'; }
function bindSystem() { // init 一次性委托绑定(不随渲染累积)
$('#content').addEventListener('click', async function (e) {
if (e.target.closest('#sys-reboot')) {
if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true))
try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); }
return;
}
if (e.target.closest('#sys-shutdown')) {
if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true))
try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); }
return;
}
if (e.target.closest('#sys-ota')) {
if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true))
try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); }
return;
}
if (e.target.closest('#sys-ota-check')) {
// 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级)
try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); }
catch (err) { toast('检查失败:' + errText(err)); }
return;
}
});
}
/* ---------------- 8. 日志页 ---------------- */
async function renderLogs() {
$('#content').innerHTML = '
';
let d;
try { d = await api('GET', '/api/logs'); }
catch (e) { if (state.route === 'logs') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route !== 'logs') return;
let h = '
';
h += '
日志文件(MeowOS/logs)
';
h += '
' + esc(d.dir || '') + '
';
h += '
| 文件 | 大小 | 修改时间 | 操作 |
';
if (!d.files.length) h += '| 暂无日志 |
';
d.files.forEach(f => {
h += '| ' + esc(f.name) + ' | ' + fmtBytes(f.size) + ' | ' + fmtTime(f.mtime) + ' | ' +
' |
';
});
h += '
';
$('#content').innerHTML = h;
}
function bindLogs() {
$('#content').addEventListener('click', async function (e) {
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)); }
});
}
/* ---------------- 9. 通知页 ---------------- */
async function renderNotif() {
$('#content').innerHTML = '
';
let d;
try { d = await api('GET', '/api/notifications'); }
catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route !== 'notif') return;
let h = '
';
h += '
' +
'
';
const list = d.notifications || [];
if (!list.length) h += '
暂无通知
';
list.forEach(n => {
const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低');
h += '
' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '
' +
'
' + esc(n.summary || n.app_name || '通知') + '
' +
(n.body ? '
' + esc(n.body) + '
' : '') +
'
' + esc(n.app_name || '') + '' + fmtTime(n.ts) + '' +
'紧急度 ' + urg + '' +
(n.progress != null ? '进度 ' + n.progress + '%' : '') +
(n.ongoing ? '进行中' : '') + '
' +
'
';
});
h += '
';
$('#content').innerHTML = h;
}
function bindNotif() {
$('#content').addEventListener('click', async function (e) {
if (e.target.closest('#notif-refresh')) { renderNotif(); return; }
if (e.target.closest('#notif-clear')) {
if (await confirmDlg('清空通知', '将清空全部通知。确认?', true))
try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); }
catch (err) { toast('清空失败:' + errText(err)); }
return;
}
const del = e.target.closest('[data-ndel]'); if (del) {
try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); }
catch (err) { toast('清除失败:' + errText(err)); }
}
});
}
/* ---------------- 10. 实时屏幕 / 点击遥控 ----------------
* 轮询改「上一帧到了再取下一帧」(链式 setTimeout,绝不堆积/重叠):
* 每帧下载完成后按目标周期自校正 delay(周期 = max(0, interval - 请求耗时)),
* 故名义 fps 被尊重,且请求变慢时自动退化为 1/请求耗时。 */
let screenTimer = null, screenBusy = false, screenOn = false;
let screenObjUrl = null, screenFps = 1;
let screenLastMs = 0, screenMeasFps = 0; // 实测帧率(帧间间隔 EMA)
let screenDelta = false, screenCanvasRef = null, screenCanvasReady = false; // 增量模式(canvas 叠加)
function stopScreenPoll() {
screenOn = false;
if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; }
}
function restartScreenPoll() {
screenOn = true;
if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; }
// 请求在途时不重复启动(避免并发请求);在途帧结束后会按最新 screenFps 自续
if (!screenBusy) screenTimer = setTimeout(fetchScreen, 0);
}
async function fetchScreen() {
if (screenBusy) return;
screenBusy = true;
const t0 = performance.now();
let statusMsg = '';
try {
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
const deltaQ = screenDelta ? '&delta=1' : '';
const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70' + deltaQ,
Object.keys(headers).length ? { headers: headers } : undefined);
if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; return; }
const hd = (k) => res.headers.get(k);
if (screenDelta) {
const isDelta = hd('X-Meow-Delta') === '1';
const full = hd('X-Meow-Full') === '1';
const changed = parseInt(hd('X-Meow-Changed') || '-1', 10);
const fw = parseInt(hd('X-Meow-W') || '480', 10);
const fh = parseInt(hd('X-Meow-H') || '720', 10);
const blob = await res.blob();
if (!isDelta || full) { // 整帧(或旧后端无 delta 头)→ 播种/替换 canvas
const img = await createImgFromBlob(blob);
const cv = ensureScreenCanvas(fw, fh);
if (cv) { cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; }
statusMsg = '已更新' + (changed >= 0 ? '(整帧)' : '');
} else if (!screenCanvasReady) { // 尚未播种:取一整帧再继续
await seedScreenCanvas(fw, fh);
if (screenCanvasReady) statusMsg = '已更新(播种)';
} else if (changed === 0) { // 无变化:跳过叠加
statusMsg = '无变化';
} else { // 子图叠加
const subX = parseInt(hd('X-Meow-Sub-X') || '0', 10);
const subY = parseInt(hd('X-Meow-Sub-Y') || '0', 10);
const subW = parseInt(hd('X-Meow-Sub-W') || fw, 10);
const subH = parseInt(hd('X-Meow-Sub-H') || fh, 10);
const img = await createImgFromBlob(blob);
const cv = ensureScreenCanvas(fw, fh);
if (cv) cv.getContext('2d').drawImage(img, subX, subY, subW, subH);
statusMsg = '已更新(增量 ' + changed + ' 块)';
}
} else {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const img = $('#screen-img');
if (!img) { URL.revokeObjectURL(url); return; }
img.src = url;
if (screenObjUrl) URL.revokeObjectURL(screenObjUrl);
screenObjUrl = url;
statusMsg = '已更新';
}
// 实测帧率:上一帧到本帧间隔的 EMA(首帧无间隔)
const now = performance.now();
if (screenLastMs) {
const dt = (now - screenLastMs) / 1000;
screenMeasFps = screenMeasFps ? screenMeasFps * 0.7 + (1 / dt) * 0.3 : 1 / dt;
}
screenLastMs = now;
$('#screen-status').textContent = statusMsg + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString();
} catch (e) {
$('#screen-status').textContent = '后端未连接';
}
screenBusy = false;
// 自校正续链(本轮耗时计入周期,保证名义 fps)
if (screenOn) {
const intervalMs = Math.max(500, Math.round(1000 / screenFps));
const wait = Math.max(0, intervalMs - (performance.now() - t0));
screenTimer = setTimeout(fetchScreen, wait);
}
}
// 增量模式助手:blob → Image(一次性 objectURL,加载后即回收)
async function createImgFromBlob(blob) {
const url = URL.createObjectURL(blob);
try {
const img = new Image();
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
return img;
} finally { URL.revokeObjectURL(url); }
}
function ensureScreenCanvas(fw, fh) {
let cv = screenCanvasRef;
if (!cv) cv = screenCanvasRef = $('#screen-canvas');
if (!cv) return null;
if (cv.width !== fw || cv.height !== fh) { cv.width = fw; cv.height = fh; }
return cv;
}
async function seedScreenCanvas(fw, fh) {
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70',
Object.keys(headers).length ? { headers: headers } : undefined);
if (!res.ok) return false;
const img = await createImgFromBlob(await res.blob());
const cv = ensureScreenCanvas(fw, fh);
if (!cv) return false;
cv.getContext('2d').drawImage(img, 0, 0, fw, fh);
screenCanvasReady = true;
return true;
}
function applyScreenDeltaDom() {
const img = $('#screen-img'), cv = $('#screen-canvas');
if (!img || !cv) return;
if (screenDelta) { img.style.display = 'none'; cv.style.display = 'block'; }
else { img.style.display = 'block'; cv.style.display = 'none'; }
}
async function screenTap(e) {
const img = $('#screen-img'), cv = $('#screen-canvas');
const useCanvas = screenDelta && cv && cv.style.display !== 'none';
const el = useCanvas ? cv : img;
if (!el) return;
const r = el.getBoundingClientRect();
if (!r.width || !r.height) return;
// 前端按 480x720(设备原生分辨率)换算
const nx = Math.round((e.clientX - r.left) / r.width * 480);
const ny = Math.round((e.clientY - r.top) / r.height * 720);
const nxCL = Math.max(0, Math.min(479, nx)), nyCL = Math.max(0, Math.min(719, ny));
try {
const d = await api('POST', '/api/input/tap', { x: nxCL, y: nyCL });
if (d && d.dispatched) toast('已注入 (' + nxCL + ',' + nyCL + ')');
else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停');
else toast('注入未派发');
} catch (err) { toast('注入失败:' + errText(err)); }
}
function renderScreen() {
screenFps = parseFloat(storageGet('screen.fps') || '1');
if (!(screenFps >= 0.2 && screenFps <= 2)) screenFps = 1;
let h = '
';
h += '
实时画面
' +
'
点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。
' +
'
' +
'
![设备实时画面]()
' +
'
' +
'
' +
'
' +
'' +
'' +
'' + screenFps.toFixed(1) + ' fps' +
'
' +
'
' +
'' +
'
' +
'
载入中…
' +
'
';
$('#content').innerHTML = h;
screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态
const slider = $('#screen-fps');
slider.addEventListener('input', function () {
const v = parseFloat(slider.value);
screenFps = v; storageSet('screen.fps', String(v));
$('#screen-fps-lbl').textContent = v.toFixed(1) + ' fps';
restartScreenPoll();
});
const deltaBox = $('#screen-delta');
deltaBox.addEventListener('change', function () {
screenDelta = deltaBox.checked;
screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种
applyScreenDeltaDom();
restartScreenPoll();
});
$('#screen-img').addEventListener('click', screenTap);
$('#screen-canvas').addEventListener('click', screenTap);
applyScreenDeltaDom();
// restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发
restartScreenPoll();
}
function bindScreen() {
// 页面隐藏(切到其它标签页)时停刷;回到本页继续
document.addEventListener('visibilitychange', function () {
if (document.hidden) stopScreenPoll();
else if (state.route === 'screen') restartScreenPoll();
});
}
/* ---------------- 路由 ---------------- */
const ROUTES = {
perf: { title: '实时性能', render: renderPerf, bind: null },
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
audio: { title: '音频状态', render: renderAudio, bind: null },
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 }
};
// 一次性绑定(各菜单 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(); return; } // 未认证 → 登录页
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
const r = ROUTES[id] || ROUTES.perf;
state.route = id;
stopPerf();
stopAudio();
stopScreenPoll();
$$('.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();
bindLogin();
bindPwDlg();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
if (st && st.auth_enabled && !st.session) { showLogin(); return; }
startApp();
}
document.addEventListener('DOMContentLoaded', init);
})();