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

1241 lines
66 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)); }
/* ---------------- 全局状态 ---------------- */
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 '<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 状态快照,5s 轮询,只读) ---------------- */
let audioTimer = null;
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(); }
function renderAudioData(page, 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 += '<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 ? esc(d.vol) + ' %' : '—')) +
'</tbody></table></div>';
}
h += '<p class="muted">每 5 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。</p>';
page.innerHTML = h;
}
async function fetchAudioOnce() {
const page = $('#audio-page');
if (!page || state.route !== 'audio') return;
let d;
try { d = await api('GET', '/api/audio'); }
catch (e) { if (page && state.route === 'audio') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route === 'audio') renderAudioData(page, d);
}
function renderAudio() {
$('#content').innerHTML = '<div class="page" id="audio-page"><p class="placeholder">加载音频状态…</p></div>';
stopAudio();
fetchAudioOnce();
audioTimer = setInterval(fetchAudioOnce, 5000);
}
/* ---------------- 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 || [];
// 面板鉴权(可选防护)单独一卡
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;
loadFonts();
}
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) {
if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; }
const fe = e.target.closest('[data-fexport]');
if (fe) {
const f = fe.dataset.fexport, w = fe.dataset.fweight;
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz');
return;
}
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
if (e.target.closest('#set-restore')) {
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
if (!ok) return;
try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); }
catch (err) { toast('恢复失败:' + errText(err)); }
} else if (e.target.closest('#set-refresh')) { renderSettings(); }
});
}
const updateSetting = debounce(async function (key, value, type) {
try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); }
catch (e) { toast('保存失败:' + errText(e)); }
}, 350);
/* ---------------- 6. 远程控制页 ---------------- */
function renderRemote() {
const keys = [
{ code: 115, label: '音量 +', ico: '🔊' },
{ code: 114, label: '音量 −', ico: '🔉' },
{ code: 116, label: '电源(单击)', ico: '⏻' },
{ code: 164, label: '播放/暂停', ico: '⏯' },
{ code: 163, label: '下一曲', ico: '⏭' },
{ code: 165, label: '上一曲', ico: '⏮' },
{ code: 158, label: '返回', ico: '↩' },
{ code: 172, label: '主页', ico: '⌂' }
];
let h = '<div class="page" id="remote-page">';
h += '<div class="card"><h3 class="card-title">模拟按键</h3>' +
'<p class="muted">行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。</p>' +
'<div class="key-grid">' + keys.map(k =>
'<button class="key-btn" data-code="' + k.code + '"><span class="k-ico">' + k.ico + '</span>' + esc(k.label) + '</button>'
).join('') + '</div></div>';
h += '<div class="card"><h3 class="card-title">电源长按(电源菜单)</h3>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" data-powerlong="1">长按电源 3s(电源菜单)</button>' +
'<span class="muted">发送 code=116 · press=long · ms=3000</span></div></div>';
h += '</div>';
$('#content').innerHTML = h;
}
async function sendKey(code, press, ms) {
try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); }
catch (e) { toast('发送失败:' + errText(e)); }
}
function bindRemote() {
$('#content').addEventListener('click', function (e) {
const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; }
const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; }
});
}
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
async function renderSystem() {
$('#content').innerHTML = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
let d;
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 = '<div class="page" id="system-page">';
h += '<div class="card"><h3 class="card-title">系统信息</h3><table class="table"><tbody>' +
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) || '—')) +
'</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. 日志页 ---------------- */
async function renderLogs() {
$('#content').innerHTML = '<div class="page" id="logs-page"><p class="placeholder">加载日志列表…</p></div>';
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 = '<div class="page" id="logs-page">';
h += '<div class="card"><h3 class="card-title">日志文件(MeowOS/logs)</h3>';
h += '<div class="field-row" style="margin-bottom:12px"><span class="muted">' + esc(d.dir || '') + '</span></div>';
h += '<table class="table"><thead><tr><th>文件</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!d.files.length) h += '<tr><td colspan="4" class="empty">暂无日志</td></tr>';
d.files.forEach(f => {
h += '<tr><td class="mono">' + esc(f.name) + '</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></div><pre class="log-view" id="log-view" hidden></pre></div>';
$('#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 = '<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 = {
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);
})();