- CSS:色值全量抽为 :root 变量,[data-style]×[data-mode] 四套主题表; MIUIx 色值对齐设备 meow_ui_theme.c(light F7F3EE/F57200、dark 181310/FF8C4A、radius 20/28、spacing 12、细描边); 根属性 data-theme 更名 data-mode - JS:默认联动设备 settings.conf(GET /api/theme 的 ui.style/display.theme); 面板覆盖存 localStorage(meowpanel.style / meowpanel.mode),不写设备; 模式 system/browser/light/dark,browser 走 prefers-color-scheme 并监听日夜切换实时生效; 旧键 meowpanel.theme 自动迁移;顶栏切换器保留原行为(本地生效 + PUT /api/theme 同步设备) - 设置页新增「主题(面板外观)」卡:风格 MD3/MIUIx(可一键回跟随设备)+ 明暗四选一 - 后端未改:/api/theme 已透传 ui.style/display.theme,无需新增
1313 lines
71 KiB
JavaScript
1313 lines
71 KiB
JavaScript
/* 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', 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;
|
||
}
|
||
|
||
/* ---------------- 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 × 明/暗) ----------------
|
||
* <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 状态快照,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/<family>/<weight>/</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>';
|
||
// 面板鉴权(可选防护)单独一卡
|
||
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/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。</p></div>';
|
||
h += '</div>';
|
||
$('#content').innerHTML = h;
|
||
applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作)
|
||
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) {
|
||
// 「主题(面板外观)」卡:本面板覆盖(localStorage),不写设备
|
||
const ss = e.target.closest('#set-style-seg .seg-btn');
|
||
if (ss) { storageSet('meowpanel.style', ss.dataset.style); applyTheme(); return; }
|
||
const sm = e.target.closest('#set-mode-seg .seg-btn');
|
||
if (sm) { storageSet('meowpanel.mode', sm.dataset.mode); applyTheme(); return; }
|
||
if (e.target.closest('#set-style-reset')) { storageDel('meowpanel.style'); applyTheme(); return; }
|
||
if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; }
|
||
const fe = e.target.closest('[data-fexport]');
|
||
if (fe) {
|
||
const f = fe.dataset.fexport, w = fe.dataset.fweight;
|
||
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz');
|
||
return;
|
||
}
|
||
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
|
||
if (e.target.closest('#set-restore')) {
|
||
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
|
||
if (!ok) return;
|
||
try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); }
|
||
catch (err) { toast('恢复失败:' + errText(err)); }
|
||
} else if (e.target.closest('#set-refresh')) { renderSettings(); }
|
||
});
|
||
}
|
||
const updateSetting = debounce(async function (key, value, type) {
|
||
try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); }
|
||
catch (e) { toast('保存失败:' + errText(e)); }
|
||
}, 350);
|
||
|
||
/* ---------------- 6. 远程控制页 ---------------- */
|
||
function renderRemote() {
|
||
const keys = [
|
||
{ code: 115, label: '音量 +', ico: '🔊' },
|
||
{ code: 114, label: '音量 −', ico: '🔉' },
|
||
{ code: 116, label: '电源(单击)', ico: '⏻' },
|
||
{ code: 164, label: '播放/暂停', ico: '⏯' },
|
||
{ code: 163, label: '下一曲', ico: '⏭' },
|
||
{ code: 165, label: '上一曲', ico: '⏮' },
|
||
{ code: 158, label: '返回', ico: '↩' },
|
||
{ code: 172, label: '主页', ico: '⌂' }
|
||
];
|
||
let h = '<div class="page" id="remote-page">';
|
||
h += '<div class="card"><h3 class="card-title">模拟按键</h3>' +
|
||
'<p class="muted">行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。</p>' +
|
||
'<div class="key-grid">' + keys.map(k =>
|
||
'<button class="key-btn" data-code="' + k.code + '"><span class="k-ico">' + k.ico + '</span>' + esc(k.label) + '</button>'
|
||
).join('') + '</div></div>';
|
||
h += '<div class="card"><h3 class="card-title">电源长按(电源菜单)</h3>' +
|
||
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" data-powerlong="1">长按电源 3s(电源菜单)</button>' +
|
||
'<span class="muted">发送 code=116 · press=long · ms=3000</span></div></div>';
|
||
h += '</div>';
|
||
$('#content').innerHTML = h;
|
||
}
|
||
async function sendKey(code, press, ms) {
|
||
try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); }
|
||
catch (e) { toast('发送失败:' + errText(e)); }
|
||
}
|
||
function bindRemote() {
|
||
$('#content').addEventListener('click', function (e) {
|
||
const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; }
|
||
const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; }
|
||
});
|
||
}
|
||
|
||
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
|
||
async function renderSystem() {
|
||
$('#content').innerHTML = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
|
||
let d;
|
||
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);
|
||
})();
|