/* 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 esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); } function fmtBytes(n) { if (n == null || isNaN(n)) return '—'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i]; } function fmtUptime(sec) { if (sec == null || isNaN(sec)) return '—'; sec = Math.floor(sec); const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60); return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分'; } function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; } function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; } function pct(p) { return Math.max(0, Math.min(100, p)); } /* ---------------- 全局状态 ---------------- */ const state = { style: 'md3', theme: 'dark', conn: 'unknown', filesRel: '/', filesAbs: null, perfTimer: null, // 性能页轮询 route: 'perf', 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 init = { method: method || 'GET' }; if (body instanceof FormData) { init.body = body; } else if (body !== undefined && body !== null) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); } 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'; 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; try { res = await fetch(path); } 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); }; }); } /* ---------------- 主题(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 '
' + esc(t) + '
'; } 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 '
'; } function stat(label, valueHtml, sub, barPct) { return '
' + esc(label) + '
' + valueHtml + (sub ? '
' + sub + '
' : '') + (barPct != null ? barHtml(barPct) : '') + '
'; } function renderPerfData(page, d) { const cpu = d.cpu || {}; const mem = d.memory || {}, st = d.storage || {}, tf = d.tfcard || {}, tmp = d.temp; let h = '

实时性能

'; h += '
运行时间:' + fmtUptime(d.uptime_sec) + '
'; h += stat('CPU', '' + (cpu.usage == null ? '—' : +cpu.usage.toFixed(1)) + '%', 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('内存', '' + fmtBytes((mem.used || 0) * 1024) + '/ ' + fmtBytes((mem.total || 0) * 1024) + '', '可用 ' + fmtBytes((mem.available || (mem.free || 0)) * 1024), memP); const stP = st.total ? (st.used / st.total) * 100 : 0; h += stat('存储 (rootfs)', '' + fmtBytes((st.used || 0) * 1024) + '/ ' + fmtBytes((st.total || 0) * 1024) + '', st.path || '', stP); if (tf) { if (tf.present) { const tfP = tf.total ? (tf.used / tf.total) * 100 : 0; h += stat('TF 卡', '' + fmtBytes((tf.used || 0) * 1024) + '/ ' + fmtBytes((tf.total || 0) * 1024) + '', fmtBytes((tf.avail || 0) * 1024) + ' 可用 · ' + (tf.filesystem || ''), tfP); } else { h += stat('TF 卡', '未插卡', ''); } } const tmpCpu = tmp && tmp.cpu != null ? +tmp.cpu.toFixed(1) : null; h += stat('温度', '' + (tmpCpu == null ? '—' : tmpCpu) + '℃', tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : ''); h += '
'; 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 = '

加载中…

'; if (state.perfTimer) stopPerf(); fetchPerfOnce(); state.perfTimer = setInterval(fetchPerfOnce, 2000); } /* ---------------- 2. 亮度 / 音量页 ---------------- */ let volTimer = null; function slur(step) { // step: {key,label,min,max,val} return '

' + esc(step.label) + '

' + '
' + step.val + '' + '' + '' + step.max + '
'; } async function renderBright() { $('#content').innerHTML = '

加载设置…

'; 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 = '
'; 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 += '

音量设置项不可用(后端未实现 audio.volume)

'; h += '

滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。

'; $('#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'); }); } /* ---------------- 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 = '
'; h += '根目录'; items.forEach(it => { h += '/' + esc(it.name) + ''; }); return h + '
'; } async function navFiles(rel) { const page = $('#files-page'); if (!page) return; let relPath = rel || '/'; page.innerHTML = '

加载中…

'; 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 += '
' + d.allowed.map(a => '').join(' ') + '
'; } h += '
' + '' + '' + '' + '
'; h += '
'; if (!d.items.length) h += ''; d.items.forEach(function (it) { const isDir = it.type === 'dir'; const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔'); h += '' + ''; }); h += '
名称大小修改时间操作
空目录
' + (isDir ? '' : '') + icon + ' ' + esc(it.name) + (isDir ? '' : '') + '' + (isDir ? '—' : fmtBytes(it.size)) + '' + fmtTime(it.mtime) + '
' + (isDir ? '' : '下载') + '' + '' + '
'; 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 = '
'; 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) { 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 = '

加载应用列表…

'; (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 = '
'; h += '
' + '' + '' + '
'; h += '
'; if (!d.apps.length) h += '
尚未安装任何 MeowAPP
'; d.apps.forEach(a => { const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase()); h += '
' + '
' + ino + '
' + '
' + esc(a.name || a.package || '未命名') + '
' + '
' + esc(a.package || '') + '
' + '
版本 ' + esc(a.version || '—') + (a.invalid ? ' · 无效' : '') + (a.size ? ' · ' + fmtBytes(a.size) : '') + '
' + '
' + '' + '导出 .mpk' + '' + '
'; }); h += '
'; // 本地源 .mpk 安装(后端支持 path 形式) h += '

从卡上路径安装

' + '
' + '
'; $('#content').innerHTML = h; })(); } function bindApps() { const c = $('#content'); c.addEventListener('click', async function (e) { const t = e.target; const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh'); const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]'); const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go'); if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; } if (refresh) { renderApps(); return; } if (info) { appInfo(info.dataset.ainfo); return; } if (exp) { e.preventDefault(); doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk'); 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)); } } /* ---------------- 5. 设置镜像页 ---------------- */ function settingControl(it) { const key = it.key; if (it.type === 'bool') { return '
' + esc(key) + '
' + '
'; } if (it.type === 'int') { const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max); return '
' + '
' + esc(it.value == null ? '' : it.value) + '' + '
'; } if (it.type === 'enum') { const opts = (it.options || []).map(o => '').join(''); return '
'; } return '
'; } async function renderSettings() { $('#content').innerHTML = '

加载设置…

'; let d; try { d = await api('GET', '/api/settings'); } catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'settings') return; let h = '
'; h += '
' + '' + '
'; const items = d.items || []; // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 const groups = [['开关/布尔', items.filter(x => x.type === 'bool')], ['数值/滑条', items.filter(x => x.type === 'int')], ['枚举/选择', items.filter(x => x.type === 'enum')], ['字符串/路径', items.filter(x => x.type === 'str')]]; groups.forEach(function (g) { if (!g[1].length) return; h += '

' + g[0] + '

' + g[1].map(settingControl).join('') + '
'; }); h += '
'; $('#content').innerHTML = h; } 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) { 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('#set-restore')) { const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); if (!ok) return; try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); } catch (err) { toast('恢复失败:' + errText(err)); } } else if (e.target.closest('#set-refresh')) { renderSettings(); } }); } const updateSetting = debounce(async function (key, value, type) { try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); } catch (e) { toast('保存失败:' + errText(e)); } }, 350); /* ---------------- 6. 远程控制页 ---------------- */ function renderRemote() { const keys = [ { code: 115, label: '音量 +', ico: '🔊' }, { code: 114, label: '音量 −', ico: '🔉' }, { code: 116, label: '电源(单击)', ico: '⏻' }, { code: 164, label: '播放/暂停', ico: '⏯' }, { code: 163, label: '下一曲', ico: '⏭' }, { code: 165, label: '上一曲', ico: '⏮' }, { code: 158, label: '返回', ico: '↩' }, { code: 172, label: '主页', ico: '⌂' } ]; let h = '
'; h += '

模拟按键

' + '

行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。

' + '
' + keys.map(k => '' ).join('') + '
'; h += '

电源长按(电源菜单)

' + '
' + '发送 code=116 · press=long · ms=3000
'; h += '
'; $('#content').innerHTML = h; } async function sendKey(code, press, ms) { try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); } catch (e) { toast('发送失败:' + errText(e)); } } function bindRemote() { $('#content').addEventListener('click', function (e) { const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; } const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; } }); } /* ---------------- 7. 系统信息 / OTA 页 ---------------- */ async function renderSystem() { $('#content').innerHTML = '

加载系统信息…

'; let d; try { d = await api('GET', '/api/system'); } catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'system') return; const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {}; let h = '
'; h += '

系统信息

' + row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) + row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)) + row('默认启动', esc((d.boot && d.boot.default) || '—')) + '
'; h += '

网络

' + row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') + row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') + row('mDNS', esc(net.hostname_local || '—')) + '
'; h += '

升级

' + '

当前 ' + esc(ota.current || d.version || '—') + (ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '

' + '
' + '
'; h += '

危险操作

' + '
' + '
' + '

⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。

'; h += '
'; $('#content').innerHTML = h; } function row(k, v) { return '' + k + '' + v + ''; } function bindSystem() { // init 一次性委托绑定(不随渲染累积) $('#content').addEventListener('click', async function (e) { if (e.target.closest('#sys-reboot')) { if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true)) try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); } return; } if (e.target.closest('#sys-shutdown')) { if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true)) try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); } return; } if (e.target.closest('#sys-ota')) { if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true)) try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); } return; } if (e.target.closest('#sys-ota-check')) { // 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级) try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); } catch (err) { toast('检查失败:' + errText(err)); } return; } }); } /* ---------------- 8. 日志页 ---------------- */ async function renderLogs() { $('#content').innerHTML = '

加载日志列表…

'; let d; try { d = await api('GET', '/api/logs'); } catch (e) { if (state.route === 'logs') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'logs') return; let h = '
'; h += '

日志文件(MeowOS/logs)

'; h += '
' + esc(d.dir || '') + '
'; h += ''; if (!d.files.length) h += ''; d.files.forEach(f => { h += '' + ''; }); h += '
文件大小修改时间操作
暂无日志
' + esc(f.name) + '' + fmtBytes(f.size) + '' + fmtTime(f.mtime) + '
'; $('#content').innerHTML = h; } function bindLogs() { $('#content').addEventListener('click', async function (e) { const t = e.target.closest('[data-log]'); if (!t) return; const p = $('#log-view'); try { const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400'); p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : ''); } catch (err) { toast('查看失败:' + errText(err)); } }); } /* ---------------- 9. 通知页 ---------------- */ async function renderNotif() { $('#content').innerHTML = '

加载通知…

'; let d; try { d = await api('GET', '/api/notifications'); } catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } if (state.route !== 'notif') return; let h = '
'; h += '
' + '
'; const list = d.notifications || []; if (!list.length) h += '
暂无通知
'; list.forEach(n => { const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低'); h += '
' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '
' + '
' + esc(n.summary || n.app_name || '通知') + '
' + (n.body ? '
' + esc(n.body) + '
' : '') + '
' + esc(n.app_name || '') + '' + fmtTime(n.ts) + '' + '紧急度 ' + urg + '' + (n.progress != null ? '进度 ' + n.progress + '%' : '') + (n.ongoing ? '进行中' : '') + '
' + '
'; }); h += '
'; $('#content').innerHTML = h; } function bindNotif() { $('#content').addEventListener('click', async function (e) { if (e.target.closest('#notif-refresh')) { renderNotif(); return; } if (e.target.closest('#notif-clear')) { if (await confirmDlg('清空通知', '将清空全部通知。确认?', true)) try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); } catch (err) { toast('清空失败:' + errText(err)); } return; } const del = e.target.closest('[data-ndel]'); if (del) { try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); } catch (err) { toast('清除失败:' + errText(err)); } } }); } /* ---------------- 路由 ---------------- */ const ROUTES = { perf: { title: '实时性能', render: renderPerf, bind: null }, bright: { title: '亮度 · 音量', render: renderBright, bind: null }, files: { title: '文件管理', render: renderFiles, bind: bindFiles }, apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps }, settings: { title: '设置镜像', render: renderSettings, bind: bindSettings }, remote: { title: '远程控制', render: renderRemote, bind: bindRemote }, 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() { const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf'; const r = ROUTES[id] || ROUTES.perf; state.route = id; stopPerf(); $$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id)); $('#page-title').textContent = r.title; r.render(); } /* ---------------- 启动 ---------------- */ function init() { bindNav(); bindThemeUi(); bindSlider(); bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif(); initTheme(); window.addEventListener('hashchange', route); route(); } document.addEventListener('DOMContentLoaded', init); })();