Files
MeowOS-For-R3P2/patch_v2/meowpanel/web/mp_pages.js
T
AskaEth a2aec783a7 feat(ui): M6.38批 — P1 MD3细化(SDK对话框28)+批②底部上滑回桌面+批6 ncm顶栏回播放页+批④面板后台列表+launcher UI微调(歌词页黑半透明底/播放页布局下移)+封面空artwork保留修复
- P1: meow_ui_dialog.c 对话框底框 radius_card→MEOW_SHAPE_EXTRA_LARGE(28) 三处(:103填充/:104描边/:361 BottomSheet); meow_ui_widgets.h 新增 MEOW_SHAPE_EXTRA_LARGE 28 宏; TextField/FAB/Slider/Progress 按设计文档保留
- 批②: np_gesture.h 新增 NP_GEST_HOME+np_home_gesture(底部热区上滑,与左缘返回对称互斥); now_playing.c 新增 np_draw_home_bar(屏底左缘pill指示条)+手势衔接; test_now_playing_widgets G5b
- 批6: ncm home.c 顶栏加回播放页按钮(MEOW_ICON_QUEUE_MUSIC)→push(ACT_PLAYER), 无音频也生效
- 批④: meowpanel /api/ams/bg 只读端点+前端后台列表页+杀/清复用kill通道
- UI微调: now_playing.c 播放页布局宏下移(NP_COVER_Y 84/NP_BTN_Y 612消底部留白)+歌词页背景统一黑色半透明叠层(弃封面取色底,删 np_draw_lyric_coverbg)
- 封面保留: now_playing.c/notif_center.c 空artwork保留上一张已解码封面(防暂停/开歌单致META报空artwork后封面闪没); test_np_cover C2断言更新+mirror同步

0warning;宿主测试 test_np_cover ALL PASS;launcher/ncm/panel 宿主测试无回归。二进制留批量批次未入库
2026-09-04 13:09:03 +08:00

2479 lines
138 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* mp_pages.js — MeowPanel 前端各页渲染/绑定(home/perf/power/audio/net/files/apps/fonts/settings/system/logs/notif/proc + 合并的屏幕·遥控)。
* 由 meowpanel.js 拆分而来(接口/行为不变);函数体保持原样,仅顶部从 window.MP 解构共享框架。 */
(function () {
'use strict';
var MP = window.MP = window.MP || {};
const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer } = MP;
let audioTimer = null;
let audioVolDrag = false; // 音量滑条拖拽中(暂停 1s 重渲染,防打断交互)
let homeTimer = null, homeLevelTimer = null;
let homeData = null, homeSys = null;
let powerTimer = null;
let powerEditing = false; // 用户在编辑策略表单时暂停 5s 重渲染(防输入被轮询清空)
let powerData = null, powerEvents = null, powerHistory = null;
let procTimer = null;
let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空)
let netTimer = null;
let netEntityFilter = '';
let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义)
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);
const SWIPE = {
minDist: 40, // 判定为「滑动」的最小位移(px),小于此视为点按
axisRatio: 1.5, // 主轴位移须至少为次轴的倍数,防斜滑误判
edgePx: 56, // 距控制区左缘多少 px 内起手即视为「左缘滑」
maxVolSteps: 5, // 单次滑动最多递增音量步数(防连刷冲上限)
volStepPx: 70, // 每多滑这么多位移,多算一步音量
clickSuppressMs: 450 // 滑动后短暂屏蔽按钮点击,防误触
};
let swipeState = null; // 当前手势 {id,x0,y0,edge}
let lastSwipeAt = 0; // 上次触发手势的时刻,用于屏蔽随后可能的 click
const MP_ICONS = {
volup: '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 8.2a5 5 0 0 1 0 7.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M18.6 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
voldown: '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 9.5a5 5 0 0 0 0 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
brightup:'<svg viewBox="0 0 24 24"><path d="M12 4v2M12 18v2M4 12h2M18 12h2M6.3 6.3l1.4 1.4M16.3 16.3l1.4 1.4M17.7 6.3l-1.4 1.4M7.7 16.3l-1.4 1.4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="3.4" fill="currentColor"/></svg>',
brightdn:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2" fill="currentColor"/><path d="M12 6v1M12 17v1M6 12h1M17 12h1M8.2 8.2l.7.7M15.1 15.1l.7.7M15.8 8.2l-.7.7M8.9 15.1l-.7.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
power: '<svg viewBox="0 0 24 24"><path d="M12 3v8" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M6.6 6.6a7.5 7.5 0 1 0 10.8 0" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
play: '<svg viewBox="0 0 24 24"><path d="M7 5.5v13l11-6.5z" fill="currentColor"/></svg>',
next: '<svg viewBox="0 0 24 24"><path d="M5 5.5l9 6.5-9 6.5z" fill="currentColor"/><rect x="16.2" y="5" width="2.4" height="14" fill="currentColor"/></svg>',
prev: '<svg viewBox="0 0 24 24"><path d="M19 5.5l-9 6.5 9 6.5z" fill="currentColor"/><rect x="5.4" y="5" width="2.4" height="14" fill="currentColor"/></svg>'
};
let ctlBright = { min: 1, max: 100, value: 100 }; // 控制板亮度(读取自 /api/settings/display.brightness)
let logsGroup = 'all', logsPrefix = '';
let logsFiles = [];
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 叠加)
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)); }
}
async function sendTheme(patch) {
try { await api('PUT', '/api/theme', patch); }
catch (e) { toast('主题已本地切换(后端未同步:' + e.message + ')'); }
}
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);
}
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;
}
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 audioRouteLabel(p) {
const m = { 0: '自动', 2: '3.5mm', 3: '4.4mm' };
return m[p] || ('端口 ' + p);
}
function audioCtlHtml(d) {
const vmax = (d && d.vol_max != null) ? d.vol_max : 100;
const vol = (d && d.vol != null && d.vol >= 0) ? Math.min(d.vol, vmax) : 0;
const cur = d.port;
let s = '<div class="card"><h3 class="card-title">路由与音量</h3><table class="table"><tbody>' +
row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) +
row('输出口', audioPortName(d.port)) +
row('音量(只读)', (d.vol != null && d.vol >= 0 ? '<span class="' + (d.vol > vmax ? 'urgency-hi' : '') + '">' + esc(d.vol) + ' %</span>' : '—') +
' <span class="muted">(上限 ' + esc(vmax) + ' · 平方锥度非线性)</span>') +
'</tbody></table>';
s += '<div class="field-row" style="flex-wrap:wrap;gap:8px;margin-top:6px"><span class="muted" style="width:auto">输出路由</span>';
[0, 2, 3].forEach(p => {
s += '<button class="btn ' + (p === cur ? 'btn-primary' : 'btn-ghost') + '" data-audio-route="' + p + '">' + audioRouteLabel(p) + '</button>';
});
s += '</div>';
s += '<div class="field-row" style="margin-top:8px"><span class="muted" style="width:44px">' + vol + '</span>' +
'<input type="range" min="0" max="' + vmax + '" value="' + vol + '" data-audio-vol>' +
'<span class="muted" style="width:44px;text-align:right">' + vmax + '</span></div>';
s += '<p class="muted">切换/调节前会确认。选错输出口会<b>无声</b>;音量走<b>平方锥度</b>(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。</p>';
s += '</div>';
return s;
}
function audioFlowHtml(d) {
let h = '';
if (!d || !d.available) {
h += '<div class="card"><h3 class="card-title">音频服务(meow_audiod)</h3>' +
'<p class="muted">audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。</p></div>';
} else {
h += '<div class="card"><h3 class="card-title">当前流</h3>';
if (d.active) {
h += '<table class="table"><tbody>' +
row('应用', esc(d.app || '—') + ' <span class="muted">(sid ' + esc(d.sid) + ')</span>') +
row('采样率', esc(d.rate) + ' Hz') +
row('声道', esc(d.ch) + ' ch') +
row('格式', esc(d.fmt || '—')) +
row('已写帧', fmtFrames(d.written_frames)) +
row('已播放帧', fmtFrames(d.played_frames)) +
row('underrun', '<span class="' + (d.underrun > 0 ? 'urgency-hi' : '') + '">' + esc(d.underrun) + '</span>') +
row('状态', d.paused ? '已暂停' : '播放中') +
'</tbody></table>';
} else {
h += '<p class="muted">当前无活动音频流。</p>';
}
h += '</div>';
h += audioCtlHtml(d);
}
return h;
}
async function fetchAudioOnce() {
const flowEl = $('#audio-flow');
if (!flowEl || state.route !== 'audio') return;
let d;
try { d = await api('GET', '/api/audio'); }
catch (e) {
if (flowEl && state.route === 'audio') flowEl.innerHTML = '<div class="card"><div class="empty">加载失败:' + esc(errText(e)) + '</div></div>';
return;
}
if (state.route !== 'audio') return;
if (audioVolDrag) return; // 音量滑条拖拽中:暂停重渲染防打断(电平/控件保留当前交互态)
flowEl.innerHTML = audioFlowHtml(d);
updateLevelMeter($('#audio-level'), d);
const note = $('#audio-level-note');
if (note) {
if (!d || !d.available) note.innerHTML = 'audiod 不可用 · 每 1 秒自动刷新(路由/音量写端点不可用)。';
else if (!d.active) note.innerHTML = '当前无流 · 电平保持 −∞ · 每 1 秒自动刷新。路由/音量为写级,需登录会话。';
else note.innerHTML = '每 1 秒自动刷新 · 流信息只读;路由与音量为写级(确认后生效,不放测试音)。';
}
}
function renderAudio() {
resetSpark(); // 进入页面重置 sparkline 累积
stopAudio();
$('#content').innerHTML =
'<div class="page" id="audio-page">' +
'<div id="audio-flow"><p class="placeholder">加载音频状态…</p></div>' +
'<div class="card"><h3 class="card-title">电平(dBFS)</h3>' +
'<div id="audio-level">' + levelMeterHtml() + '</div>' +
'<p class="muted" id="audio-level-note">每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。</p>' +
'</div>' +
'</div>';
fetchAudioOnce();
audioTimer = setInterval(fetchAudioOnce, 1000);
}
function bindAudio() {
$('#content').addEventListener('click', async function (e) {
const rb = e.target.closest('[data-audio-route]');
if (rb) {
const port = parseInt(rb.dataset.audioRoute, 10);
if (!(port === 0 || port === 2 || port === 3)) { toast('非法路由口(仅自动/3.5/4.4)'); return; }
const ok = await confirmDlg('切换输出路由',
'确认切换到「' + audioRouteLabel(port) + (port === 0 ? '(跟随插孔)' : '') + '」?\n选错输出口会无声,请确认当前插孔。', false);
if (!ok) return;
try { await api('POST', '/api/audio/route', { port: port }); toast('路由已切换:' + audioRouteLabel(port)); }
catch (err) { toast('切换失败:' + errText(err)); }
return;
}
});
$('#content').addEventListener('pointerdown', function (e) {
const sl = e.target.closest('[data-audio-vol]');
if (sl) { audioVolDrag = true; const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; }
});
$('#content').addEventListener('pointerup', function (e) {
// 释放即复位;若值已提交 change 会再走确认,此处仅保证「点击未改动」时也不卡住重渲染
if (e.target.closest('[data-audio-vol]')) setTimeout(function () { audioVolDrag = false; }, 250);
});
$('#content').addEventListener('input', function (e) {
const sl = e.target.closest('[data-audio-vol]');
if (!sl) return;
const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value;
});
$('#content').addEventListener('change', async function (e) {
const sl = e.target.closest('[data-audio-vol]');
if (!sl) return;
audioVolDrag = false;
const v = parseInt(sl.value, 10);
const vmax = parseInt(sl.max, 10) || 100;
const ok = await confirmDlg('确认音量',
'确认将音量设为 ' + v + ' %?(系统上限 ' + vmax + ',平方锥度非线性——越接近上限越响且非等比)', false);
if (!ok) { fetchAudioOnce(); return; } // 取消 → 立即拉一次复位滑条
try {
await api('POST', '/api/audio/volume', { value: v });
toast('音量已设为 ' + v + ' %');
fetchAudioOnce();
} catch (err) { toast('设置失败:' + errText(err)); fetchAudioOnce(); }
});
}
function stopHome() {
if (homeTimer) { clearInterval(homeTimer); homeTimer = null; }
if (homeLevelTimer) { clearInterval(homeLevelTimer); homeLevelTimer = null; }
}
function deviceCard() {
const p = (homeData && homeData.perf && homeData.perf.system);
const s = homeSys;
const ver = (s && s.version) || (p && p.version) || '—';
const host = (s && s.hostname) || (p && p.hostname) || '—';
const ker = (s && s.kernel) || (p && p.kernel) || '—';
const arch = (s && s.arch) || (p && p.arch) || '—';
const bootD = (s && s.boot && s.boot.default) || '—';
const cur = (s && s.boot) ? (s.boot.mode || s.boot.current || null) : null;
let rows = '';
rows += homeKv('版本', esc(ver));
rows += homeKv('主机名', esc(host));
rows += homeKv('内核', esc(ker));
rows += homeKv('架构', esc(arch));
rows += homeKv('运行模式', modePill(cur));
rows += homeKv('默认启动', modePill(bootD));
return homeCard('设备信息', '<div class="home-card-grid">' + rows + '</div>');
}
function batteryCard() {
const pw = homeData && homeData.power;
if (!isAvail(pw)) return homeCard('电量 / 充电', homeUnavailable());
const b = pw.battery || {};
const pct = (b.pct != null) ? b.pct : null;
const state = b.state || '—';
const charging = (state === 'Charging' || state === 'Full');
const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : ''));
let inner = '<div class="batt"><span class="batt-icon"><span class="lvl-fill ' + fillCls + '" style="width:' + (pct == null ? 0 : pct) + '%"></span></span>' +
'<span class="home-bigval">' + (pct == null ? '—' : pct) + '</span><span class="home-bigunit">%</span>' +
'<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(state) + '</span></div>';
inner += '<div class="home-card-grid">' +
homeKv('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') +
homeKv('健康', b.health ? esc(b.health) : '—') +
homeKv('温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
(b.est_minutes_left != null ? homeKv('续航估算', b.est_minutes_left + ' 分钟') :
(b.est_minutes_full != null ? homeKv('充满估算', b.est_minutes_full + ' 分钟') : '')) +
'</div>';
return homeCard('电量 / 充电', inner);
}
function audioCard() {
const ad = homeData && homeData.audio;
if (!isAvail(ad)) return homeCard('音频', homeUnavailable());
let body = '';
if (ad.active) {
body += '<div class="home-card-grid">' +
homeKv('流', esc(ad.app || '—') + ' · ' + esc(ad.rate) + 'Hz ' + esc(ad.fmt || '')) +
homeKv('输出口', audioPortName(ad.port)) +
homeKv('音量', (ad.vol != null && ad.vol >= 0 ? esc(ad.vol) + ' %' : '—')) +
'</div>';
} else {
body += '<div class="home-sub">当前无活跃音频流。</div>';
}
body += '<div id="home-audio-level">' + levelMeterHtml() + '</div>' +
'<p class="muted" id="home-audio-note">' + (ad.active ? '电平 · 1s 子节拍' : '无流') + '</p>';
return homeCard('音频', body);
}
function netCard() {
const nd = homeData && homeData.net;
if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable());
const w = nd.wifi || {};
const dn = (nd && nd.dns) || null; // 批2: DNS 统计已并入 /api/overview 的 net 块(首页严格单请求)
let rows = '';
rows += homeKv('WiFi', w.connected ? ('<span class="home-chip ok">已连接</span> ' + esc(w.ssid || '')) : '<span class="home-chip warn">未连接</span>');
if (w.connected) {
rows += homeKv('IP', esc(w.ip || '—'));
rows += homeKv('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—'));
}
if (dn) {
rows += homeKv('DNS 上游', esc(dn.upstream || '—'));
rows += homeKv('DNS 缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0) + (dn.enabled ? '' : '(停用)'));
} else {
rows += homeKv('DNS', '<span class="home-chip mut">—</span>');
}
const pb = (nd && nd.pub_bytes) || null;
if (pb && pb.total) {
rows += homeKv('公网流量', '↓ ' + fmtBytes(pb.total.rx || 0) + ' · ↑ ' + fmtBytes(pb.total.tx || 0) + '<span class="muted">(TCP)</span>');
} else {
rows += homeKv('公网流量', '<span class="home-chip mut">—</span>');
}
return homeCard('WiFi / DNS', '<div class="home-card-grid">' + rows + '</div>');
}
function btCard() {
const nd = homeData && homeData.net;
const ad = homeData && homeData.audio;
let bt = nd && nd.bt;
if (!bt || !isAvail(nd)) return homeCard('蓝牙', homeUnavailable());
if (bt.available === false) return homeCard('蓝牙', '<div class="home-sub">无蓝牙</div>');
const on = !!bt.powered;
const conn = (bt.connected != null) ? bt.connected : 0;
let rows = '';
rows += homeKv('蓝牙', on ? '<span class="home-chip ok">已开启</span>' : '<span class="home-chip warn">已关闭</span>');
rows += homeKv('连接', conn > 0 ? ('<span class="home-chip ok">已连接 ' + esc(conn) + '</span>') : '<span class="home-chip mut">未连接</span>');
rows += homeKv('已配对', bt.paired != null ? esc(bt.paired) : '—');
if (bt.address) rows += homeKv('本机地址', esc(bt.address));
if (conn > 0) {
rows += homeKv('A2DP 编解码', bt.a2dp_codec ? esc(bt.a2dp_codec) : '—');
rows += homeKv('采样率', bt.a2dp_rate ? esc(bt.a2dp_rate) : '—');
} else {
rows += homeKv('A2DP 编解码', '<span class="home-chip mut">—</span>');
}
const target = ad && ad.target != null ? ad.target : null;
const volctl = ad && ad.volctl != null ? ad.volctl : null;
const targetName = target === 'bt' ? '蓝牙' : (target === 'dac' ? 'DAC' : null);
rows += homeKv('输出目标', targetName ? ('<span class="home-chip ' + (target === 'bt' ? 'ok' : 'mut') + '">' + esc(targetName) + '</span>') : '<span class="home-chip mut">—</span>');
rows += homeKv('音量控制', volctl ? esc(volctl) : '—');
if (ad && ad.vol != null && ad.vol >= 0) {
rows += homeKv('音量', esc(ad.vol) + ' %' + (volctl === 'avrcp' ? ' <span class="muted">(AVRCP)</span>' : ''));
}
return homeCard('蓝牙', '<div class="home-card-grid">' + rows + '</div>');
}
function healthCard() {
const ams = homeData && homeData.ams;
const svcd = homeData && homeData.svcd;
let appTxt = '—', svcBad = 0, svcHi = 0, svcTxt = '—', fgHtml = '';
if (isAvail(ams)) {
const apps = ams.apps || [];
const run = apps.filter(a => a.state === 'fg' || a.state === 'bg').length;
appTxt = run + ' 在跑 / ' + (ams.count || apps.length) + ' 已装';
const fg = ams.fg;
if (fg && fg.pkg) {
fgHtml = '<span class="home-chip ok">前台</span> ' + esc(fg.pkg) +
(fg.activity ? ' · <span class="muted">' + esc(fg.activity) + '</span>' : '');
}
}
let svcHtml;
if (isAvail(svcd)) {
const svcs = svcd.services || [];
svcBad = svcs.filter(s => s.state === 'failed').length;
svcHi = svcs.filter(s => (s.restart || 0) >= 3).length;
svcTxt = svcs.filter(s => s.state === 'running').length + ' 运行中';
svcHtml = '';
if (svcBad) svcHtml += '<span class="home-chip bad">' + svcBad + ' FAILED</span> ';
if (svcHi) svcHtml += '<span class="home-chip warn">' + svcHi + ' 高重启</span> ';
if (!svcBad && !svcHi) svcHtml += '<span class="home-chip ok">正常</span> ';
} else {
svcHtml = '<span class="home-chip mut">不可用</span> ';
svcTxt = '—';
}
return homeCard('在跑 App 与服务', '<div class="home-card-grid">' +
homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) +
(fgHtml ? homeKv('前台应用', fgHtml) : '') + '</div>');
}
function storageCard() {
const perf = homeData && homeData.perf;
if (!isAvail(perf)) return homeCard('存储', homeUnavailable());
const st = perf.storage || {}, tf = perf.tfcard || {};
let rows = homeKv('rootfs', (st.used != null ? fmtBytes(st.used * 1024) : '—') + ' / ' + (st.total != null ? fmtBytes(st.total * 1024) : '—'));
rows += (tf.present) ?
homeKv('TF 卡', fmtBytes((tf.used || 0) * 1024) + ' / ' + fmtBytes((tf.total || 0) * 1024)) :
homeKv('TF 卡', '<span class="home-chip mut">未插卡</span>');
return homeCard('存储', '<div class="home-card-grid">' + rows + '</div>');
}
function perfCard() {
const perf = homeData && homeData.perf;
if (!isAvail(perf)) return homeCard('性能小条', homeUnavailable());
const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {};
const cpuU = cpu.usage;
const memP = mem.total ? (mem.used / mem.total) * 100 : 0;
return homeCard('性能小条', '<div class="stat-grid" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr))">' +
'<div class="stat"><div class="stat-label">CPU</div><div class="stat-value">' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '<span class="stat-unit">%</span></div>' + barHtml(cpuU || 0) + '</div>' +
'<div class="stat"><div class="stat-label">内存</div><div class="stat-value">' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '</div>' + barHtml(mem.used && mem.total ? memP : 0) + '</div>' +
'<div class="stat"><div class="stat-label">温度</div><div class="stat-value">' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '<span class="stat-unit">℃</span>' : '—') + '</div></div>' +
'</div>');
}
function powerSummaryCard() {
const pw = homeData && homeData.power;
if (!isAvail(pw)) return '';
const est = pw.estimate;
if (!est) return '';
if (est.est_mw == null) return homeCard('功耗视角', '<div class="home-sub">' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '</div>');
return homeCard('功耗视角', estimateCardBody(est) + (pw.breakdown ? breakdownCardBody(pw.breakdown) : ''));
}
function eventsBar() {
const ev = homeData && homeData.events;
if (!ev || ev.available === false) return '';
const list = (ev && ev.events) || [];
if (!list.length) return '';
const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' };
let items = list.slice(0, 3).map(e => {
const tn = typeLabel[e[1]] || e[1];
const txt = String(e[3] == null ? '' : e[3]);
const cls = (e[1] === 'lowbat') ? 'warn' : (e[1] === 'charger' ? 'ok' : 'mut');
return '<span class="home-chip ' + cls + '" title="' + esc(txt) + '">' + esc(tn) + (txt ? ' · ' + esc(txt.length > 28 ? txt.slice(0, 28) + '…' : txt) : '') + '</span>';
}).join(' ');
return '<div class="card"><h3 class="card-title">最近事件</h3><div class="home-sub">' + items + '</div></div>';
}
function renderHomeCards() {
const body = $('#home-body');
if (!body || state.route !== 'home') return;
if (!homeData) { body.innerHTML = '<p class="placeholder">加载总览…</p>'; return; }
let h = powerSummaryCard() + eventsBar(); // 摘要条(功耗/事件)置顶
h += '<div class="home-grid">' +
deviceCard() + batteryCard() + audioCard() + netCard() + btCard() + healthCard() + storageCard() + perfCard() +
'</div>';
body.innerHTML = h;
const ad = homeData.audio;
if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad);
}
async function fetchHomeSystem() { // 静态设备信息(boot/默认启动);进入时拉一次,失败不阻塞
try { homeSys = await api('GET', '/api/system'); } catch (e) { homeSys = null; }
if (state.route === 'home') renderHomeCards();
}
async function fetchHomeLevel() { // 电平 1s 子节拍(独立于 overview 5s)
if (state.route !== 'home') return;
const el = $('#home-audio-level'); if (!el) return;
let d;
try { d = await api('GET', '/api/audio'); } catch (e) { return; }
if (state.route !== 'home') return;
updateLevelMeter(el, d);
const note = $('#home-audio-note');
if (note) note.textContent = (!d || !d.available) ? 'audiod 不可用' : (d.active ? '电平 · 1s 子节拍' : '无流');
}
async function fetchOverviewOnce() {
if (state.route !== 'home') return;
const r = await Promise.allSettled([api('GET', '/api/overview')]);
if (r[0].status !== 'fulfilled') {
const b = $('#home-banner');
if (b) {
b.innerHTML = '<span class="ico">⚠️</span><span>总览加载失败:' + esc(errText(r[0].reason)) + '</span><button class="btn" id="home-retry">重试</button>';
b.hidden = false;
}
return;
}
const b = $('#home-banner'); if (b) { b.hidden = true; b.innerHTML = ''; }
homeData = r[0].value; // 首页严格单请求:DNS 已并入 overview 的 net 块
renderHomeCards();
}
function renderHome() {
stopHome();
resetSpark(); // 进入首页重置 sparkline 累积
homeData = null; homeSys = null;
$('#content').innerHTML =
'<div class="page" id="home-page">' +
'<div id="home-banner" class="page-banner" hidden></div>' +
'<div id="home-body"><p class="placeholder">加载总览…</p></div>' +
'</div>';
fetchHomeSystem();
fetchOverviewOnce();
homeTimer = setInterval(fetchOverviewOnce, 5000);
homeLevelTimer = setInterval(fetchHomeLevel, 1000);
}
function bindHome() {
$('#content').addEventListener('click', function (e) {
if (e.target.closest('#home-retry')) { fetchOverviewOnce(); return; }
});
}
function stopPower() { if (powerTimer) { clearInterval(powerTimer); powerTimer = null; } }
function batteryCardBody(b) {
if (!b) return homeUnavailable();
const pct = (b.pct != null) ? b.pct : null;
const state = b.state || '—';
const charging = (state === 'Charging' || state === 'Full');
const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : ''));
let h = '<div class="batt"><span class="batt-icon"><span class="lvl-fill ' + fillCls + '" style="width:' + (pct == null ? 0 : pct) + '%"></span></span>' +
'<span class="home-bigval">' + (pct == null ? '—' : pct) + '</span><span class="home-bigunit">%</span>' +
'<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(state) + '</span></div>';
h += '<table class="table"><tbody>' +
row('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') +
row('健康', b.health ? esc(b.health) : '—') +
row('技术', b.technology ? esc(b.technology) : '—') +
row('电池温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
row('充电器温度', b.charger_temp_c10 != null ? (b.charger_temp_c10 / 10).toFixed(1) + ' ℃' : '—') +
row('循环次数', b.cycle_count != null ? b.cycle_count : '—') +
row('放电速率', b.rate_pct_min_x100 != null ? ((b.rate_pct_min_x100 / 100).toFixed(2) + ' %/min') : '—') +
row('续航估算', b.est_minutes_left != null ? (b.est_minutes_left + ' 分钟(剩余)') : (b.est_minutes_full != null ? (b.est_minutes_full + ' 分钟(充满)') : '—')) +
'</tbody></table>';
return h;
}
function estimateCardBody(est) {
if (!est) return homeUnavailable();
if (est.est_mw == null) return '<div class="home-sub">' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '</div>';
const conf = est.confidence || 'estimated';
const cls = conf === 'measured' ? 'measured' : (conf === 'derived' ? 'derived' : (conf === 'none' ? 'none' : 'estimated'));
return '<div class="up-strip"><span class="home-bigval">' + est.est_mw + '</span><span class="home-bigunit">mW</span>' +
'<span class="conf-badge ' + cls + '">' + esc(conf) + (est.calibrated ? ' · 已标定' : ' · 经验值') + '</span></div>' +
'<p class="muted">Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。</p>';
}
function breakdownCardBody(bd) {
if (!bd) return homeUnavailable();
if (bd.degraded) return '<div class="home-sub">系统分解不可用(HibyOS 只读模式)</div>';
const comps = [
{ label: '屏幕(背光)', mw: bd.backlight_mw, color: '#4a8bf5' },
{ label: 'WiFi', mw: bd.wifi_mw, color: '#00bfa5' },
{ label: '蓝牙', mw: bd.bt_mw, color: '#ab47bc' },
{ label: '音频', mw: bd.audio_mw, color: '#ffb300' },
{ label: 'CPU', mw: bd.cpu_mw, color: '#ff7043' },
{ label: '基线', mw: bd.baseline_mw, color: '#8d6e63' }
].filter(c => c.mw != null && c.mw >= 0);
const total = comps.reduce((a, c) => a + c.mw, 0);
let stack = '<div class="stack">';
comps.forEach(c => { stack += '<span style="width:' + (total > 0 ? (c.mw / total * 100) : 0) + '%;background:' + c.color + '" title="' + esc(c.label) + ': ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)"></span>'; });
stack += '</div>';
let legend = '<div class="stack-legend">';
comps.forEach(c => {
legend += '<span class="seg"><span class="sw" style="background:' + c.color + '"></span>' + esc(c.label) + ' ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)</span>';
});
legend += '</div>';
if (bd.cpu_util_pct != null || bd.brightness_pct != null || bd.wifi_state || bd.bt_state || bd.audio_state) {
legend += '<p class="muted">' +
(bd.cpu_util_pct != null ? 'CPU 利用率 ' + esc(bd.cpu_util_pct) + '% ' : '') +
(bd.brightness_pct != null ? '· 亮度 ' + esc(bd.brightness_pct) + '% ' : '') +
(bd.wifi_state ? '· WiFi ' + esc(bd.wifi_state) : '') +
(bd.bt_state ? ' · BT ' + esc(bd.bt_state) : '') +
(bd.audio_state ? ' · 音频 ' + esc(bd.audio_state) : '') + '</p>';
}
return '<div class="home-sub">合计 ' + total + ' mW(分解为经验估算)</div>' + stack + legend;
}
function powerProfileLabel(p) {
return { performance: '性能', balanced: '均衡', powersave: '省电', ultra: '超省电' }[p] || p;
}
function powerPolicyCard(pol) {
if (!pol) return '<div class="card"><h3 class="card-title">电源策略</h3>' + homeUnavailable() + '</div>';
const profiles = ['performance', 'balanced', 'powersave', 'ultra'];
const cur = pol.profile || 'balanced';
let btns = profiles.map(p => '<button class="btn btn-ghost pol-btn' + (p === cur ? ' pol-on' : '') + '" data-pol="' + esc(p) + '">' + powerProfileLabel(p) + '</button>').join(' ');
return '<div class="card"><h3 class="card-title">电源策略</h3>' +
'<div class="field-row" style="margin-bottom:12px"><span class="muted">省电档位:</span>' + btns + '</div>' +
'<table class="table"><tbody>' +
row('低电阈值', '<input type="number" id="pol-low" class="num pol-num" value="' + esc(pol.low_pct) + '" min="1" max="99"> %') +
row('临界阈值', '<input type="number" id="pol-crit" class="num pol-num" value="' + esc(pol.critical_pct) + '" min="1" max="99"> %') +
row('亮度上限', '<input type="number" id="pol-bri" class="num pol-num" value="' + esc(pol.brightness_cap) + '" min="0" max="101"> <span class="muted">0=不生效</span>') +
row('息屏上限', '<input type="number" id="pol-scr" class="num pol-num" value="' + esc(pol.screen_timeout_cap) + '" min="0"> <span class="muted">秒 · 0=不生效</span>') +
row('状态', (pol.enacted ? '<span class="home-chip ok">已手动开启</span>' : '<span class="home-chip mut">默认全关</span>')) +
'</tbody></table>' +
'<div class="field-row"><button class="btn btn-primary" id="pol-apply">保存阈值</button><span class="muted">切档即时生效(需确认);阈值点「保存阈值」。</span></div>' +
'<p class="muted">' + esc(pol.note || '') + '</p>' +
'</div>';
}
function powerEventsCard(ev) {
if (!ev) return '';
if (ev.available === false) return '<div class="card"><h3 class="card-title">电源事件</h3>' + homeUnavailable() + '</div>';
const list = (ev && ev.events) || [];
if (!list.length) return '<div class="card"><h3 class="card-title">电源事件</h3><div class="empty">暂无事件</div></div>';
const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' };
let rows = list.map(e => {
const tn = typeLabel[e[1]] || e[1];
return '<tr><td class="muted">' + esc(tn) + '</td><td>' + (e[2] != null ? esc(e[2]) + '%' : '—') + '</td><td>' + esc(e[3] || '') + '</td></tr>';
}).join('');
return '<div class="card"><h3 class="card-title">电源事件</h3>' +
'<table class="table"><thead><tr><th>类型</th><th>电量</th><th>说明</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function powerHistoryCard(h) {
if (!h) return '';
if (h.available === false) return '<div class="card"><h3 class="card-title">功耗历史</h3>' + homeUnavailable() + '</div>';
let body = '<table class="table"><tbody>' +
row('采样窗口', (h.n_samples || 0) + ' 点 · ' + fmtDur(h.window_sec)) +
row('电量范围', (h.min_pct != null ? h.min_pct : '—') + '% ~ ' + (h.max_pct != null ? h.max_pct : '—') + '%') +
row('起止电量', (h.start_pct != null ? h.start_pct : '—') + '% → ' + (h.end_pct != null ? h.end_pct : '—') + '%') +
row('平均功耗', (h.avg_mw != null ? h.avg_mw + ' mW' : '—')) +
'</tbody></table>';
return '<div class="card"><h3 class="card-title">功耗历史</h3>' + body + '</div>';
}
function renderPowerBody(d, ev, hi) {
let h = '<div class="page" id="power-page">';
if (d && d.mode === 'readonly') h += '<div class="page-banner"><span class="ico">ℹ️</span><span>当前为电源只读观测(HibyOS 模式),仅显示电池采样,无功耗估算/分解。</span></div>';
h += '<div class="card"><h3 class="card-title">电池</h3>' + ((d && d.battery) ? batteryCardBody(d.battery) : homeUnavailable()) + '</div>';
h += '<div class="card"><h3 class="card-title">功耗估算</h3>' + ((d && d.estimate) ? estimateCardBody(d.estimate) : homeUnavailable()) + '</div>';
h += '<div class="card"><h3 class="card-title">子系统分解</h3>' + ((d && d.breakdown) ? breakdownCardBody(d.breakdown) : homeUnavailable()) + '</div>';
h += ((d && d.policy) ? powerPolicyCard(d.policy) : '<div class="card"><h3 class="card-title">电源策略</h3>' + homeUnavailable() + '</div>');
h += powerEventsCard(ev);
h += powerHistoryCard(hi);
h += '</div>';
return h;
}
async function fetchPowerOnce() {
const el = $('#power-page');
if (!el || state.route !== 'power') return;
let d, ev, hi;
const r = await Promise.allSettled([api('GET', '/api/power'), api('GET', '/api/power/events'), api('GET', '/api/power/history')]);
if (r[0].status !== 'fulfilled') {
if (el && state.route === 'power') el.innerHTML = '<div class="page" id="power-page">' + emptyState('加载失败:' + errText(r[0].reason), (r[0].reason && r[0].reason.code) || '') + '</div>';
return;
}
if (state.route !== 'power') return;
d = r[0].value; ev = (r[1].status === 'fulfilled') ? r[1].value : null; hi = (r[2].status === 'fulfilled') ? r[2].value : null;
if (!powerEditing) el.innerHTML = renderPowerBody(d, ev, hi);
}
function renderPower() {
stopPower();
powerEditing = false;
powerData = null; powerEvents = null; powerHistory = null;
$('#content').innerHTML = '<div class="page" id="power-page"><p class="placeholder">加载电源数据…</p></div>';
fetchPowerOnce();
powerTimer = setInterval(fetchPowerOnce, 5000);
}
function bindPower() {
$('#content').addEventListener('click', async function (e) {
const pb = e.target.closest('.pol-btn');
if (pb) {
const p = pb.dataset.pol;
const ok = await confirmDlg('切换省电档位', '确认将省电档位切换为「' + powerProfileLabel(p) + '」?(写 settings.conf power.profile,launcher/sysui 期4消费生效)', false);
if (!ok) { powerEditing = false; fetchPowerOnce(); return; }
powerEditing = false;
try { await api('POST', '/api/power/policy', { item: 'profile', value: p }); toast('已切档:' + powerProfileLabel(p)); }
catch (err) { toast('切档失败:' + errText(err)); }
fetchPowerOnce();
return;
}
const apply = e.target.closest('#pol-apply');
if (apply) {
const low = parseInt($('#pol-low') ? $('#pol-low').value : '', 10);
const crit = parseInt($('#pol-crit') ? $('#pol-crit').value : '', 10);
const bri = parseInt($('#pol-bri') ? $('#pol-bri').value : '', 10);
const scr = parseInt($('#pol-scr') ? $('#pol-scr').value : '', 10);
if (isNaN(low) || isNaN(crit)) { toast('请输入有效的低电/临界阈值'); return; }
const ok = await confirmDlg('保存电源阈值', '确认写入 低电=' + low + '% / 临界=' + crit + '% / 亮度上限=' + (isNaN(bri) ? 0 : bri) + ' / 息屏上限=' + (isNaN(scr) ? 0 : scr) + 's?', false);
if (!ok) { powerEditing = false; fetchPowerOnce(); return; }
powerEditing = false;
try {
await api('POST', '/api/power/policy', { item: 'low-pct', value: low });
await api('POST', '/api/power/policy', { item: 'crit-pct', value: crit });
await api('POST', '/api/power/policy', { item: 'bri-cap', value: bri || 0 });
await api('POST', '/api/power/policy', { item: 'scr-cap', value: scr || 0 });
toast('电源阈值已保存');
} catch (err) { toast('保存失败:' + errText(err)); }
fetchPowerOnce();
return;
}
});
$('#content').addEventListener('focusin', function (e) {
if (e.target.closest('.pol-num')) powerEditing = true; // 编辑中暂停 5s 重渲染(防输入被清空)
});
}
function stopProc() { if (procTimer) { clearInterval(procTimer); procTimer = null; } }
function svcStatePill(s) {
const map = { running: ['ok', '运行'], stopped: ['mut', '已停'], stopping: ['warn', '停止中'], disabled: ['mut', '禁用'], failed: ['bad', '失败'] };
const m = map[s] || ['mut', s || '—'];
return '<span class="h-pill ' + m[0] + '">' + esc(m[1]) + '</span>';
}
function appStatePill(s) {
const map = { fg: ['ok', '前台'], bg: ['warn', '后台'], stop: ['mut', '已停'] };
const m = map[s] || ['mut', s || '—'];
return '<span class="h-pill ' + m[0] + '">' + esc(m[1]) + '</span>';
}
function srcPill(src) {
const cls = (src === 'board') ? 'board' : (src === 'tf' ? 'tf' : '');
return src ? '<span class="proc-badge ' + cls + '">' + esc(src) + '</span>' : '—';
}
function renderProcBody(ams, svcd) {
let h = '<div class="page" id="proc-page">';
h += '<div class="card"><h3 class="card-title">服务(meow_svcd)</h3>';
if (svcd && svcd.available) {
const svcs = svcd.services || [];
h += '<table class="table"><thead><tr><th>服务</th><th>状态</th><th>PID</th><th>重启</th><th>退避</th><th>触发</th><th>来源</th><th>退出</th><th>执行</th></tr></thead><tbody>';
if (!svcs.length) h += '<tr><td colspan="9" class="empty">暂无服务</td></tr>';
svcs.forEach(s => {
h += '<tr><td class="mono">' + esc(s.name) + '</td>' +
'<td>' + svcStatePill(s.state) + '</td>' +
'<td>' + (s.pid > 0 ? esc(s.pid) : '—') + '</td>' +
'<td class="' + ((s.restart || 0) >= 3 ? 'restart-hi' : 'muted') + '">' + (s.restart || 0) + ((s.restart || 0) >= 3 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(s.backoff_ms || '—') + '</td>' +
'<td class="muted">' + esc(s.trigger || '—') + '</td>' +
'<td>' + srcPill(s.src) + '</td>' +
'<td class="muted">' + esc(s.exit || '—') + '</td>' +
'<td class="mono muted">' + esc(s.exec || '—') + '</td></tr>';
});
h += '</tbody></table>';
} else h += homeUnavailable();
h += '</div>';
h += '<div class="card"><h3 class="card-title">App(meow_amsd,只读)</h3>';
if (ams && ams.available) {
const apps = ams.apps || [];
h += '<table class="table"><thead><tr><th>包名</th><th>名称</th><th>状态</th><th>PID</th><th>启动</th><th>崩溃</th><th>上次退出</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!apps.length) h += '<tr><td colspan="8" class="empty">无可观测 App</td></tr>';
apps.forEach(a => {
const openHere = expandedProc && expandedProc.pkg === a.pkg;
h += '<tr><td class="mono">' + esc(a.pkg) + '</td><td>' + esc(a.name || '—') + '</td>' +
'<td>' + appStatePill(a.state) + '</td><td>' + (a.pid > 0 ? esc(a.pid) : '—') + '</td>' +
'<td class="muted">' + esc(a.launches || 0) + '</td>' +
'<td class="' + ((a.crashes || 0) > 0 ? 'crash-hi' : 'muted') + '">' + (a.crashes || 0) + ((a.crashes || 0) > 0 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(a.last_kind || '—') + (a.last_code != null && a.last_code !== 0 ? ' (' + esc(a.last_code) + ')' : '') + '</td>' +
'<td><div class="row-actions">' +
'<button class="btn btn-ghost" data-applog="' + esc(a.pkg) + '">' + (openHere ? '收起' : 'stdout 尾部') + '</button>' +
'<button class="btn btn-ghost" data-amsstart="' + esc(a.pkg) + '">启动</button>' +
(a.pid > 0 ? '<button class="btn btn-ghost" data-amskill="' + esc(a.pkg) + '" data-fg="' + (a.state === 'fg' ? '1' : '0') + '">结束</button>' : '') +
'</div></td></tr>';
if (openHere) {
const lines = (expandedProc.lines || []);
h += '<tr class="proc-tail"><td colspan="8"><pre class="log-view">' + esc(lines.join('\n') || '(空)') +
(expandedProc.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
'<div class="muted">' + esc(a.pkg + '/' + expandedProc.file) + ' · 末尾 ' + lines.length + ' 行</div></td></tr>';
}
});
h += '</tbody></table>';
h += '<p class="muted">「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。「启动/结束」经 amsd(require_auth);结束前台 App 需二次确认。</p>';
} else h += homeUnavailable();
h += '</div></div>';
return h;
}
async function newestStdout(pkg) {
try {
const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + pkg + '/'));
const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0);
if (!files.length) return null;
files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
return files[0].name;
} catch (e) { return null; }
}
async function viewAppStdout(pkg, btn) {
const row = btn.closest('tr'); if (!row) return;
const next = row.nextElementSibling;
if (next && next.classList && next.classList.contains('proc-tail')) { next.remove(); expandedProc = null; btn.textContent = 'stdout 尾部'; return; }
if (expandedProc && expandedProc.pkg !== pkg) { // 先收起上一个 opened
const prevTail = document.querySelector('tr.proc-tail');
if (prevTail) prevTail.remove();
const prevBtn = document.querySelector('[data-applog="' + escSafeAttr(expandedProc.pkg) + '"]');
if (prevBtn) prevBtn.textContent = 'stdout 尾部';
expandedProc = null;
}
btn.textContent = '加载中…';
const tailTr = document.createElement('tr');
tailTr.className = 'proc-tail';
tailTr.innerHTML = '<td colspan="8"><div class="muted">读取 ' + esc(pkg) + ' 最新 stdout…</div></td>';
row.after(tailTr);
try {
const file = await newestStdout(pkg);
if (!file) { tailTr.innerHTML = '<td colspan="8"><div class="empty">未找到 ' + esc(pkg) + ' 的 stdout 日志</div></td>'; btn.textContent = 'stdout 尾部'; return; }
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200');
const lines = (d.lines || []);
expandedProc = { pkg: pkg, file: file, lines: lines, truncated: !!d.truncated };
tailTr.innerHTML = '<td colspan="8"><pre class="log-view">' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
'<div class="muted">' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行</div></td>';
btn.textContent = '收起';
} catch (err) {
tailTr.innerHTML = '<td colspan="8"><div class="empty">读取失败:' + esc(errText(err)) + '</div></td>';
btn.textContent = 'stdout 尾部';
}
}
function escSafeAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }
async function fetchProcOnce() {
const el = $('#proc-page');
if (!el || state.route !== 'proc') return;
const r = await Promise.allSettled([api('GET', '/api/ams'), api('GET', '/api/svcd')]);
if (state.route !== 'proc') return;
if (r[0].status !== 'fulfilled' && r[1].status !== 'fulfilled') {
el.innerHTML = '<div class="page" id="proc-page">' + emptyState('加载失败:' + errText(r[0].reason || r[1].reason)) + '</div>';
return;
}
el.innerHTML = renderProcBody(r[0].status === 'fulfilled' ? r[0].value : null, r[1].status === 'fulfilled' ? r[1].value : null);
}
function renderProc() {
stopProc();
$('#content').innerHTML = '<div class="page" id="proc-page"><p class="placeholder">加载服务与进程…</p></div>';
fetchProcOnce();
procTimer = setInterval(fetchProcOnce, 5000);
}
function bindProc() {
$('#content').addEventListener('click', async function (e) {
const b = e.target.closest('[data-applog]');
if (b) { viewAppStdout(b.dataset.applog, b); return; }
const st = e.target.closest('[data-amsstart]');
if (st) {
const pkg = st.dataset.amsstart;
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/start'); toast('已请求启动 ' + pkg); }
catch (err) { toast('启动失败:' + errText(err)); }
fetchProcOnce();
return;
}
const kl = e.target.closest('[data-amskill]');
if (kl) {
const pkg = kl.dataset.amskill, fg = kl.dataset.fg === '1';
const ok = await confirmDlg('结束 App', (fg ? '『' + pkg + '』正位于前台,' : '') + '确认结束 ' + pkg + '?(SIGTERM 语义)', true);
if (!ok) return;
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/kill'); toast('已请求结束 ' + pkg); }
catch (err) { toast('结束失败:' + errText(err)); }
fetchProcOnce();
return;
}
});
}
/* ---- 批④ 后台活动页(#/bg):列表 + 行内「杀死」 + 顶栏「一键清后台」(复用 /api/ams/bg 只读 + /api/ams/:pkg/kill) ---- */
let bgTimer = null;
let bgData = null; // {available,count,apps:[{pkg,name,pid,state,cpu_pct,rss_kb}]}
function stopBg() { if (bgTimer) { clearInterval(bgTimer); bgTimer = null; } }
function bgIconHtml(a) {
const ch = esc((a.name || a.pkg || '?').charAt(0).toUpperCase());
return '<span class="bg-ico">' + ch + '</span>';
}
function renderBgBody(d) {
let h = '<div class="page" id="bg-page">';
h += '<div class="card"><h3 class="card-title">后台活动 App(meow_amsd bg · 只读)</h3>';
if (!d || d.available === false) {
h += homeUnavailable();
} else {
const apps = d.apps || [];
if (!apps.length) {
h += '<div class="empty">无后台 App</div>';
} else {
h += '<div class="bg-list">';
apps.forEach(a => {
const cpu = (a.cpu_pct == null) ? '—' : a.cpu_pct.toFixed(1) + '%';
const mem = (a.rss_kb == null) ? '—' : fmtBytes(Number(a.rss_kb) * 1024);
const sub = 'CPU ' + cpu + ' · ' + mem + ' · 后台' +
(a.pid > 0 ? ' · pid ' + esc(a.pid) : '');
h += '<div class="bg-row">' + bgIconHtml(a) +
'<div class="bg-info"><div class="bg-name">' + esc(a.name || a.pkg || '未命名') + '</div>' +
'<div class="bg-sub">' + sub + '<span class="mono">' + esc(a.pkg) + '</span></div></div>' +
'<button class="btn btn-danger" data-bgkill="' + esc(a.pkg) + '">杀死</button></div>';
});
h += '</div>';
}
h += '<p class="muted">占用数据读自 /proc/&lt;pid&gt;(与启动器后台管理页同源、同口径);「杀死」经 amsd(require_auth)。CPU 首采样为「—」,轮询后显示真实占用;数据不可得时如实显示「—」,绝不编造。</p>';
}
h += '</div></div>';
return h;
}
async function fetchBgOnce() {
const el = $('#bg-page');
if (!el || state.route !== 'bg') return;
let d = null;
try { d = await api('GET', '/api/ams/bg'); }
catch (e) {
if (state.route === 'bg') el.innerHTML = '<div class="page" id="bg-page">' + emptyState('加载失败:' + errText(e), e.code) + '</div>';
const bc = $('#bg-clear'); if (bc) bc.disabled = true;
return;
}
if (state.route !== 'bg') return;
bgData = d;
el.innerHTML = renderBgBody(d);
const bc = $('#bg-clear');
if (bc) {
const n = (d && d.apps && d.apps.length) ? d.apps.length : 0;
bc.disabled = !n;
bc.textContent = '一键清后台' + (n ? ' (' + n + ')' : '');
}
}
function renderBg() {
stopBg();
$('#content').innerHTML = '<div class="page" id="bg-page"><p class="placeholder">加载后台 App…</p></div>';
fetchBgOnce();
bgTimer = setInterval(fetchBgOnce, 4000);
}
async function bgKill(pkg) {
const ok = await confirmDlg('结束 App', '确认结束后台 App ' + pkg + '?(SIGTERM 语义)', true);
if (!ok) return;
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/kill'); toast('已请求结束 ' + pkg); }
catch (e) { toast('结束失败:' + errText(e)); }
fetchBgOnce();
}
async function bgClearAll() {
const list = (bgData && bgData.apps) ? bgData.apps : [];
if (!list.length) { toast('无后台 App 可清'); return; }
const ok = await confirmDlg('一键清除后台', '将结束 ' + list.length + ' 个后台 App,确认?(SIGTERM 语义)', true);
if (!ok) return;
let done = 0, fail = 0;
for (const a of list) {
try { await api('POST', '/api/ams/' + encodeURIComponent(a.pkg) + '/kill'); done++; }
catch (e) { fail++; }
}
toast('已清 ' + done + ' 个后台 App' + (fail ? ',' + fail + ' 个失败' : ''));
fetchBgOnce();
}
function bindBg() {
$('#content').addEventListener('click', async function (e) {
const kl = e.target.closest('[data-bgkill]');
if (kl) { await bgKill(kl.dataset.bgkill); return; }
});
const bc = $('#bg-clear');
if (bc) bc.addEventListener('click', bgClearAll);
}
function stopNet() { if (netTimer) { clearInterval(netTimer); netTimer = null; } }
function fmtDur(sec) { // 连接寿命(秒级精度)
if (sec == null || isNaN(sec)) return '—';
sec = Math.floor(sec);
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + s + ' 秒';
const h = Math.floor(m / 60), mm = m % 60;
return h + ' 时 ' + mm + ' 分';
}
function netBadge(e) {
const i = e.indexOf(':');
const kind = i > 0 ? e.slice(0, i) : 'proc';
return '<span class="net-badge ' + esc(kind) + '">' + esc(kind) + '</span>' +
(i > 0 ? ' <span class="net-entity">' + esc(e.slice(i + 1)) + '</span>' : '');
}
function netStateCls(st) {
if (!st) return '';
if (st === 'ESTABLISHED') return 'net-state ok';
if (st === 'LISTEN') return 'net-state listen';
if (st === 'UDP') return 'net-state listen';
return 'net-state warn';
}
function netIfaceCards(ifd) {
if (!ifd || ifd.available === false) return '<div class="card"><h3 class="card-title">网络接口</h3>' + homeUnavailable() + '</div>';
const ifs = (ifd && ifd.ifaces) || [];
let h = '<div class="card"><h3 class="card-title">网络接口</h3>';
let meta = '<div class="home-sub" style="margin-bottom:8px">';
meta += (ifd.rssi != null) ? '信号 ' + esc(ifd.rssi) + ' dBm' : '信号 —';
if (ifd.dns && ifd.dns.length) meta += ' · DNS ' + esc(ifd.dns.join(' / '));
meta += '</div>';
h += '<p class="muted" style="margin-bottom:8px">RX / TX 为接口累计字节(含局域网/面板轮询/DNS 等内网往返),非手机热点公网消耗。</p>';
h += meta;
h += '<table class="table"><thead><tr><th>接口</th><th>类型</th><th>状态</th><th>IP 地址</th><th>网关</th><th>RX / TX</th></tr></thead><tbody>';
if (!ifs.length) h += '<tr><td colspan="6" class="empty">无接口数据</td></tr>';
ifs.forEach(function (f) {
const up = !!f.up;
h += '<tr><td class="mono">' + esc(f.name) + '</td><td><span class="net-kind">' + esc(f.kind || '—') + '</span></td>' +
'<td><span class="net-state ' + (up ? 'ok' : 'down') + '">' + (up ? 'UP' : 'DOWN') + '</span></td>' +
'<td class="mono">' + esc(f.ip || '—') + '</td><td class="mono">' + esc(f.gw || '—') + '</td>' +
'<td class="muted">' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + '</td></tr>';
});
h += '</tbody></table>';
const rf = (ifd && ifd.rfkill) || [];
if (rf.length) {
h += '<div class="rfkill-row">';
rf.forEach(r => h += '<span class="home-chip ' + (r.soft || r.hard ? 'warn' : 'ok') + '">' + esc(r.type) + (r.soft ? ' 软关' : '') + (r.hard ? ' 硬关' : '') + '</span>');
h += '</div>';
}
h += '</div>';
return h;
}
function pubBytesCard(pb) {
if (!pb) return '';
const t = pb.total || {};
let h = '<div class="card"><h3 class="card-title">公网流量</h3>';
h += '<p class="muted">按 TCP 连接计量(远端公网才计入);UDP/DNS/mDNS/ARP 不计。接口累计含内网往返,非公网消耗。</p>';
h += '<div class="home-sub" style="margin-bottom:8px">公网累计:↓ ' + fmtBytes(t.rx || 0) + ' · ↑ ' + fmtBytes(t.tx || 0) + '</div>';
const ents = pb.by_entity || [];
if (ents.length) {
h += '<table class="table"><thead><tr><th>实体</th><th>公网 ↓ / ↑</th><th>内网 ↓ / ↑</th></tr></thead><tbody>';
ents.forEach(e => {
h += '<tr><td class="net-entity">' + netBadge(e.entity) + '</td>' +
'<td class="muted">' + fmtBytes((e.pub && e.pub[0]) || 0) + ' / ' + fmtBytes((e.pub && e.pub[1]) || 0) + '</td>' +
'<td class="muted">' + fmtBytes((e.lan && e.lan[0]) || 0) + ' / ' + fmtBytes((e.lan && e.lan[1]) || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
const flows = pb.by_flow || [];
if (flows.length) {
h += '<div class="home-sub" style="margin-top:10px">公网流 Top</div>';
h += '<table class="table"><thead><tr><th>远端</th><th>实体</th><th>↓ / ↑</th></tr></thead><tbody>';
flows.forEach(f => {
h += '<tr><td class="mono">' + esc(f.ip || '') + ':' + esc('' + f.port) +
(f.domain ? ' <span class="muted">' + esc(f.domain) + '</span>' : '') + '</td>' +
'<td class="net-entity">' + netBadge(f.entity) + '</td>' +
'<td class="muted">' + fmtBytes(f.rx || 0) + ' / ' + fmtBytes(f.tx || 0) + '</td></tr>';
});
h += '</tbody></table>';
}
h += '</div>';
return h;
}
function netStatusCards(d) {
let h = '';
if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null))
h += '<div class="card"><p class="muted">实时速率(上行接口,不含回环):↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p></div>';
if (d && d.pub_bytes) h += pubBytesCard(d.pub_bytes);
// WiFi 摘要
const w = (d && d.wifi) || {};
h += '<div class="card"><h3 class="card-title">WiFi</h3><table class="table"><tbody>';
if (w.connected) {
h += row('SSID', '<span class="net-entity">' + esc(w.ssid || '—') + '</span>');
h += row('状态', '<span class="net-state ok">' + esc(w.state || '已连接') + '</span>');
h += row('IP', esc(w.ip || '—'));
h += row('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—'));
} else {
h += row('状态', '<span class="net-state warn">未连接</span>');
}
h += '</tbody></table></div>';
// 蓝牙摘要
const bt = (d && d.bt) || {};
h += '<div class="card"><h3 class="card-title">蓝牙</h3><table class="table"><tbody>';
if (bt.available) {
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
h += row('地址', esc(bt.address || '—'));
h += row('已配对', esc(bt.paired != null ? bt.paired : '—'));
h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—'));
} else {
h += row('状态', '<span class="net-state warn">无蓝牙</span>');
}
h += '</tbody></table></div>';
return h;
}
function netPolicyCard(pol) {
if (!pol || pol.available === false) return '<div class="card"><h3 class="card-title">网络策略</h3>' + homeUnavailable() + '</div>';
const enforce = pol.enforce || '';
const dnsOnly = enforce === 'dns_only';
let h = '<div class="card"><h3 class="card-title">网络策略</h3>';
h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">' + (dnsOnly ? 'ℹ️' : '⚠️') + '</span><span>' +
(dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '</span></div>';
const rules = pol.rules || [];
if (!rules.length) h += '<div class="empty">暂无策略规则(默认放行)</div>';
else {
h += '<table class="table"><thead><tr><th>实体</th><th>网络</th><th>白名单域名</th><th>黑名单域名</th><th style="text-align:right">操作</th></tr></thead><tbody>';
rules.forEach(r => {
h += '<tr><td class="mono">' + netBadge(r.entity) + '</td>' +
'<td><span class="home-chip ' + (r.net === 'block' ? 'bad' : 'ok') + '">' + (r.net === 'block' ? '阻断' : '放行') + '</span></td>' +
'<td class="muted">' + esc((r.allow_hosts || []).join(', ') || '—') + '</td>' +
'<td class="muted">' + esc((r.deny_hosts || []).join(', ') || '—') + '</td>' +
'<td style="text-align:right"><div class="row-actions">' +
'<button class="btn btn-ghost" data-ptoggle="' + esc(r.entity) + '" data-net="' + (r.net === 'block' ? 'allow' : 'block') + '">' + (r.net === 'block' ? '改放行' : '阻断') + '</button>' +
'<button class="btn btn-ghost" data-pdel="' + esc(r.entity) + '">删除</button></div></td></tr>';
});
h += '</tbody></table>';
}
h += '<div class="field-row" style="margin-top:12px"><input type="text" id="pol-entity" placeholder="app:os.meow.ncm / svc:x / proc:y" style="max-width:280px">' +
'<button class="btn btn-primary" id="pol-block">设为阻断</button>' +
'<button class="btn btn-ghost" id="pol-allow">设为放行</button></div>';
h += '<div class="field-row" style="margin-top:8px"><input type="text" id="pol-domain" placeholder="域名(+白 / -黑,如 +music.example.com)" style="max-width:280px">' +
'<button class="btn" id="pol-dom">添加域名规则</button></div>';
h += '<p class="muted">防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。</p>';
h += '</div>';
return h;
}
function netConnsBody(cn) {
const list = (cn && cn.conns) || [];
let h = '';
/* 连接 top 摘要(聚合计数,轻量——overview 与网速页的观察摘要,非完整归因)。 */
const entCnt = {};
(list || []).forEach(c => { entCnt[c.entity] = (entCnt[c.entity] || 0) + 1; });
const top = Object.keys(entCnt).sort((a, b) => entCnt[b] - entCnt[a]).slice(0, 3);
if (top.length) {
h += '<div class="home-sub" style="margin-bottom:8px">连接 top:' + top.map(e => '<span class="net-entity">' + netBadge(e) + ' ×' + entCnt[e] + '</span>').join(' · ') + '(共 ' + list.length + ' 条)</div>';
}
if (!list.length) h += '<div class="empty">暂无连接' + (netEntityFilter ? '(该实体)' : '') + '</div>';
else {
h += '<table class="table"><thead><tr><th>实体</th><th>远端(域名)</th><th>本端</th><th>状态</th><th>寿命</th></tr></thead><tbody>';
list.forEach(function (c) {
h += '<tr><td class="net-entity">' + netBadge(c.entity) + '</td>' +
'<td class="mono">' + esc(c.domain || c.remote || '—') + '</td>' +
'<td class="mono muted">' + esc(c.local || '—') + '</td>' +
'<td><span class="' + netStateCls(c.state) + '">' + esc(c.state || '—') + '</span></td>' +
'<td class="muted">' + fmtDur(c.age) + '</td></tr>';
});
h += '</tbody></table>';
if (cn.truncated) h += '<p class="muted">连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)</p>';
}
return h;
}
function netDnsBody(dn) {
if (!dn) return '<div class="empty">DNS 代理不可用</div>';
let h = '<table class="table"><tbody>' +
row('状态', '<span class="net-state ' + (dn.enabled ? 'ok' : 'warn') + '">' + (dn.enabled ? '已启用' : '已停用') + '</span>') +
row('模式', esc(dn.mode || '—')) +
row('上游', esc(dn.upstream || '—')) +
row('缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0)) +
row('查询数', esc(dn.queries)) +
row('拦截 (deny)', esc(dn.denies)) +
row('首次域名', esc(dn.first_seen)) +
row('resolv.conf', (dn.resolv_modified ? '<span class="muted">已被代理改写</span>' : '未改写')) +
'</tbody></table>';
h += '<p class="muted">写操作(清缓存)需系统身份,面板路径可能被 netmgrd 拒绝。</p>';
return h;
}
function netBannerErr(msg) {
const nb = $('#net-banner');
if (!nb) return;
nb.innerHTML = '<span class="ico">⚠️</span><span>网络状态获取失败:' + esc(msg) + '(保留上次内容,5 秒后自动重试)</span>' +
'<button class="btn" id="net-banner-retry">重试</button>';
nb.hidden = false;
}
async function fetchNetOnce() {
if (state.route !== 'net') return;
const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body'),
ifEl = $('#net-ifaces'), poEl = $('#net-policy');
let st, cn, dn, ifd, pol;
try { st = await api('GET', '/api/net/status'); }
catch (e) { netBannerErr(errText(e)); return; }
netLoaded = true;
const nb = $('#net-banner'); if (nb) { nb.hidden = true; nb.innerHTML = ''; }
try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); }
catch (e) { cn = null; }
try { dn = await api('GET', '/api/net/dns'); }
catch (e) { dn = null; }
try { ifd = await api('GET', '/api/net/ifaces'); } catch (e) { ifd = null; }
try { pol = await api('GET', '/api/net/policy'); } catch (e) { pol = null; }
if (state.route !== 'net') return;
if (ifEl) ifEl.innerHTML = netIfaceCards(ifd);
if (stEl) stEl.innerHTML = netStatusCards(st);
if (poEl) poEl.innerHTML = netPolicyCard(pol);
if (cbEl) cbEl.innerHTML = netConnsBody(cn);
if (dbEl) dbEl.innerHTML = netDnsBody(dn);
}
async function fetchNetConns() {
if (state.route !== 'net') return;
const cbEl = $('#net-conns-body'); if (!cbEl) return;
let cn;
try {
cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : ''));
} catch (e) { cn = null; }
if (state.route !== 'net') return;
cbEl.innerHTML = netConnsBody(cn);
}
async function fetchNetLog() {
if (state.route !== 'net') return;
const el = $('#net-log-body');
try {
const d = await api('GET', '/api/net/log');
if (el) {
if (!d || !d.file) { el.innerHTML = '<div class="empty">暂无 net_ 日志' + (d && !d.file ? '(netmgrd 未写日志)' : '') + '</div>'; return; }
el.innerHTML = '<pre class="log-view">' + esc((d.lines || []).join('\n') || '(空)') +
(d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>';
const name = $('#net-log-file'); if (name) name.textContent = d.file;
}
} catch (e) { if (el) el.innerHTML = '<div class="empty">日志加载失败:' + esc(errText(e)) + '</div>'; }
}
async function renderNet() {
stopNet();
netEntityFilter = '';
netLoaded = false;
$('#content').innerHTML =
'<div class="page" id="net-page">' +
'<div id="net-banner" class="page-banner" hidden></div>' +
'<div id="net-ifaces"><p class="placeholder">加载网络接口…</p></div>' +
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
'<div id="net-policy"><p class="placeholder">加载网络策略…</p></div>' +
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
'<input type="text" id="net-entity-filter" placeholder="筛选实体(app: / svc: / proc:)" style="max-width:280px" value="' + esc(netEntityFilter) + '">' +
'<button class="btn" id="net-conns-refresh">刷新</button></div>' +
'<div id="net-conns-body"><p class="placeholder">加载连接表…</p></div></div>' +
'<div class="card"><h3 class="card-title">DNS 代理</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
'<button class="btn" id="net-dns-flush">清空缓存</button></div>' +
'<div id="net-dns-body"><p class="placeholder">加载 DNS 统计…</p></div></div>' +
'<div class="card"><h3 class="card-title">网络日志 <span class="muted" id="net-log-file"></span></h3>' +
'<div class="field-row" style="margin-bottom:12px"><button class="btn" id="net-log-refresh">刷新</button></div>' +
'<div id="net-log-body"><p class="placeholder">加载日志…</p></div></div>' +
'</div>';
netTimer = setInterval(fetchNetOnce, 5000); // status 仅瞬态横幅,不停止轮询
fetchNetOnce(); // 填充 status + conns + dns
fetchNetLog();
}
function bindNet() {
$('#content').addEventListener('input', function (e) {
const f = e.target.closest('#net-entity-filter'); if (!f) return;
netEntityFilter = f.value.trim();
const cb = $('#net-conns-body'); if (cb) cb.textContent = '筛选…';
fetchNetConns();
});
$('#content').addEventListener('click', async function (e) {
if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; }
if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; }
if (e.target.closest('#net-banner-retry') || e.target.closest('#net-retry')) { fetchNetOnce(); return; }
const flush = e.target.closest('#net-dns-flush');
if (flush) {
flush.disabled = true;
try {
const d = await api('POST', '/api/net/dns/flush');
toast('已清空 DNS 缓存(' + (d && d.entries != null ? d.entries : '') + ' 条)');
} catch (err) { toast('清缓存失败:' + errText(err)); }
flush.disabled = false;
fetchNetOnce();
}
/* ---- 网络策略写(require_auth + 二次确认;enforce=dns_only 如实显示,不谎称断连) ---- */
const tgl = e.target.closest('[data-ptoggle]');
if (tgl) {
const ent = tgl.dataset.ptoggle, net = tgl.dataset.net;
const ok = await confirmDlg('网络策略', '确认将 ' + ent + ' 网络策略设为「' + (net === 'block' ? '阻断(DNS 层拒绝,不会真断连)' : '放行') + '」?', net === 'block');
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: net }); toast('已更新策略:' + ent); }
catch (err) { toast('策略更新失败:' + errText(err)); }
fetchNetOnce();
return;
}
const del = e.target.closest('[data-pdel]');
if (del) {
const ent = del.dataset.pdel;
const ok = await confirmDlg('删除策略', '确认删除 ' + ent + ' 的全部策略规则(恢复默认放行)?', true);
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'del', entity: ent }); toast('已删除策略:' + ent); }
catch (err) { toast('删除失败:' + errText(err)); }
fetchNetOnce();
return;
}
const blk = e.target.closest('#pol-block');
if (blk) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; }
const ok = await confirmDlg('阻断实体', '确认将 ' + ent + ' 设为阻断(DNS 层拒绝,不会真断连)?', true);
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'block' }); toast('已阻断:' + ent); }
catch (err) { toast('阻断失败:' + errText(err)); }
fetchNetOnce();
return;
}
const alw = e.target.closest('#pol-allow');
if (alw) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'allow' }); toast('已放行:' + ent); }
catch (err) { toast('放行失败:' + errText(err)); }
fetchNetOnce();
return;
}
const dom = e.target.closest('#pol-dom');
if (dom) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
const hst = ($('#pol-domain') ? $('#pol-domain').value.trim() : '');
if (!ent) { toast('请输入实体'); return; }
if (!hst || (hst[0] !== '+' && hst[0] !== '-')) { toast('域名须以 +(白)或 -(黑)开头'); return; }
const ok = await confirmDlg('域名规则', '确认对 ' + ent + ' ' + (hst[0] === '+' ? '加入白名单' : '加入黑名单') + ' ' + hst.slice(1) + '?', hst[0] === '-');
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_domain', entity: ent, arg: hst }); toast('域名规则已更新'); }
catch (err) { toast('域名规则失败:' + errText(err)); }
fetchNetOnce();
return;
}
});
}
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)); }
}
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)); }
}
function renderFontsPage() {
$('#content').innerHTML = '<div class="page" id="fonts-page">' +
'<div class="card"><h3 class="card-title">字体库</h3>' +
'<div class="field-row" style="margin-bottom:14px">' +
'<button class="btn btn-primary" id="fpage-import">上传字体库 .tar.gz</button>' +
'<button class="btn" id="fpage-refresh">刷新</button>' +
'<input type="file" id="fpage-file" accept=".tar.gz,.tgz,application/gzip" hidden></div>' +
'<div id="fpage-list"><p class="muted">加载字体库列表…</p></div>' +
'<p class="muted" style="margin-top:8px">上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' +
'<code>MeowOS/fonts/&lt;family&gt;/&lt;weight&gt;/</code>;同 family/weight 冲突时会要求确认覆盖。' +
'导入后进设备「设置 → 字体」页,列表自动重扫生效。</p></div></div>';
loadFontsPage();
}
async function loadFontsPage() {
const box = $('#fpage-list'); if (!box) return;
let d;
try { d = await api('GET', '/api/fonts'); }
catch (e) { box.innerHTML = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '<div class="empty">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)</div>'; return; }
let h = '<table class="table"><thead><tr><th>family</th><th>weight</th><th>名称</th><th>tier</th><th>glyph 数</th><th style="text-align:right">操作</th></tr></thead><tbody>';
fs.forEach(f => {
h += '<tr><td class="mono">' + esc(f.family || '') + '</td><td class="mono">' + esc(f.weight || '') + '</td>' +
'<td>' + esc(f.name || '—') + '</td><td class="muted">' + esc(f.tier || '—') + '</td>' +
'<td class="muted">' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-fpexport="' + esc(f.family || '') +
'" data-fpweight="' + esc(f.weight || '') + '">导出</button></div></td></tr>';
});
box.innerHTML = h + '</tbody></table>';
}
async function importFontPage(files) {
if (!files || !files.length) return;
const f = files[0];
const btn = $('#fpage-import');
if (btn && btn.disabled) return; // 上传中防连点(进度态)
const fd = new FormData(); fd.append('file', f);
if (btn) { btn.disabled = true; btn.textContent = '导入中…'; }
toast('正在导入 ' + f.name + '…');
let res = null;
try { res = await api('POST', '/api/fonts/import', fd); }
catch (err) {
if (err.code === 'CONFLICT') {
const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true);
if (ok) {
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
catch (e2) { toast('导入失败:' + errText(e2)); }
}
} else toast('导入失败:' + errText(err));
}
if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; }
if (res) {
toast('已导入 ' + (res.family || '') + '/' + (res.weight || ''));
if (res.warning) toast(res.warning);
else if (res.hint) toast(res.hint);
loadFontsPage();
}
}
function bindFonts() {
const c = $('#content');
c.addEventListener('click', function (e) {
if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; }
if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; }
const fe = e.target.closest('[data-fpexport]');
if (fe) {
if (fe.dataset.busy) return; // 打包期间防连点
fe.dataset.busy = '1';
const old = fe.textContent; fe.textContent = '打包中…';
const f = fe.dataset.fpexport, w = fe.dataset.fpweight;
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w),
(f || 'font') + '-' + (w || 'regular') + '.font.tar.gz')
.finally(() => { delete fe.dataset.busy; fe.textContent = old; });
return;
}
});
c.addEventListener('change', function (e) {
if (e.target.id === 'fpage-file') importFontPage(e.target.files);
});
}
function settingControl(it) {
const key = it.key;
if (key === 'panel.auth_pass') {
const txt = it.set ? '已设置' : '未设置';
return '<div class="field-row"><div style="flex:1" class="field-label">面板密码<span class="muted">(' + txt + ')</span></div>' +
'<button class="btn btn-primary" data-skey="panel.auth_pass" data-stype="pass">' + (it.set ? '修改密码' : '设置密码') + '</button></div>';
}
if (it.type === 'bool') {
return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' +
'<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' +
'<span class="track"><span class="thumb"></span></span></label></div>';
}
if (it.type === 'int') {
const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max);
return '<div class="field"><label class="field-label">' + esc(key) + '(' + mn + '–' + mx + ')</label>' +
'<div class="field-row"><span class="muted" style="width:44px">' + esc(it.value == null ? '' : it.value) + '</span>' +
'<input type="range" min="' + mn + '" max="' + mx + '" value="' + esc(it.value == null ? mn : it.value) + '" data-skey="' + esc(key) + '" data-stype="int"></div></div>';
}
if (it.type === 'enum') {
const opts = (it.options || []).map(o => '<option value="' + esc(o.name) + '" ' + (o.name === it.value ? 'selected' : '') + '>' + esc(o.label || o.name) + '</option>').join('');
return '<div class="field"><label class="field-label">' + esc(key) + '</label><select data-skey="' + esc(key) + '" data-stype="enum">' + opts + '</select></div>';
}
return '<div class="field"><label class="field-label">' + esc(key) + '</label><input type="text" value="' + esc(it.value == null ? '' : it.value) + '" data-skey="' + esc(key) + '" data-stype="str"></div>';
}
async function renderSettings() {
$('#content').innerHTML = '<div class="page" id="settings-page"><p class="placeholder">加载设置…</p></div>';
let d;
try { d = await api('GET', '/api/settings'); }
catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route !== 'settings') return;
let h = '<div class="page" id="settings-page">';
h += '<div class="field-row" style="margin-bottom:14px">' +
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
'<button class="btn" id="set-refresh">刷新</button></div>';
const items = d.items || [];
// 面板外观(主题):本面板覆盖(localStorage),默认跟随设备 ui.style / display.theme
h += '<div class="card" id="theme-card"><h3 class="card-title">主题(面板外观)</h3>' +
'<div class="field"><label class="field-label">风格</label>' +
'<div class="field-row wrap"><div class="seg" id="set-style-seg">' +
'<button class="seg-btn" data-style="md3">Material You</button>' +
'<button class="seg-btn" data-style="miuix">MIUIx</button></div>' +
'<button class="btn btn-ghost" id="set-style-reset">跟随设备</button></div>' +
'<p class="muted" style="margin-top:6px">默认跟随设备 ui.style(当前:' + esc(devStyleLabel()) + ');在面板选择即为本面板覆盖,只影响网页观感(顶栏切换才会同步设备)。</p></div>' +
'<div class="field" style="margin-bottom:0"><label class="field-label">明暗模式</label>' +
'<div class="seg" id="set-mode-seg">' +
'<button class="seg-btn" data-mode="system">跟随系统</button>' +
'<button class="seg-btn" data-mode="browser">跟随浏览器</button>' +
'<button class="seg-btn" data-mode="light">浅色</button>' +
'<button class="seg-btn" data-mode="dark">深色</button></div>' +
'<p class="muted" style="margin-top:6px">跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。</p></div></div>';
// 面板 PIN(访问级鉴权):显示状态 + 修改/清空控件(走 /api/auth/* 校验旧 PIN + PUT /api/settings/panel.pin)
h += '<div class="card" id="pin-card"><h3 class="card-title">面板 PIN(访问鉴权)</h3>' +
'<div id="pin-status"><p class="muted">查询鉴权状态…</p></div></div>';
// 面板鉴权(可选防护)单独一卡
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
if (authItems.length) {
h += '<div class="card"><h3 class="card-title">面板鉴权(可选防护)</h3>' + authItems.map(settingControl).join('') +
'<p class="muted" style="margin-top:8px">开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。</p></div>';
}
const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass');
// 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看
const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')],
['数值/滑条', rest.filter(x => x.type === 'int')],
['枚举/选择', rest.filter(x => x.type === 'enum')],
['字符串/路径', rest.filter(x => x.type === 'str')]];
groups.forEach(function (g) {
if (!g[1].length) return;
h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
});
// 字体库(.zip 导入 / 导出)
h += '<div class="card" id="fonts-card"><h3 class="card-title">字体库(.tar.gz 导入 / 导出)</h3>' +
'<div class="field-row"><button class="btn" id="font-import">导入字体库 .tar.gz</button>' +
'<input type="file" id="font-file" accept=".tar.gz,.tgz,application/gzip" hidden></div>' +
'<div id="font-list"><p class="muted">加载字体库列表…</p></div>' +
'<p class="muted" style="margin-top:8px">导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/&lt;family&gt;/&lt;weight&gt;/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。</p></div>';
h += '</div>';
$('#content').innerHTML = h;
applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作)
loadFonts();
loadPinStatus();
}
async function loadPinStatus() {
const box = $('#pin-status');
if (!box) return;
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { box.innerHTML = '<p class="muted">无法查询鉴权状态:' + esc(errText(e)) + '</p>'; return; }
let txt, cls;
if (st.locked) { txt = '锁定中(' + Math.ceil((st.lock_remaining_ms || 0) / 1000) + ' 秒后可重试)'; cls = 'locked'; }
else if (st.pin_set) { txt = '已设置'; cls = 'set'; }
else if (st.legacy) { txt = '未设(旧密码屏障生效,建议改设为 PIN)'; cls = 'unset'; }
else { txt = '未设置(当前访问面板无需 PIN)'; cls = 'unset'; }
const canEdit = st.pin_set && !st.locked;
let h = '<div class="pin-status"><span class="dot ' + cls + '"></span><strong>' + esc(txt) + '</strong>' +
(st.session ? ' · <span class="muted">当前会话有效</span>' : ' · <span class="muted">未登录</span>') + '</div>';
h += '<div class="field-row" style="margin-top:8px">' +
'<button class="btn" id="pin-change" ' + (canEdit ? '' : 'disabled') + '>修改 PIN</button>' +
'<button class="btn btn-danger" id="pin-clear" ' + (canEdit ? '' : 'disabled') + '>清空 PIN</button>' +
(st.locked ? '<span class="muted">锁定期间不可修改/清空</span>' : '<span class="muted">修改/清空需先验证当前 PIN</span>') +
'</div>';
h += '<div class="field-row" style="margin-top:6px">' +
'<button class="btn" id="pin-unlock-once" ' + (st.session ? '' : 'disabled') + '>一次性解锁设备</button>' +
'<span class="muted">设备下次亮屏免 PIN 进入一次(不清除 PIN)</span></div>';
h += '<p class="muted" style="margin-top:6px">4 位数字 PIN,纯加盐哈希存储(不落明文);未设 PIN 时写端点一律拒绝。</p>';
box.innerHTML = h;
}
function pinEditDlg(mode) { // 返回 {old, n1}(n1=null 表示清空)或 null(取消)
return new Promise(function (resolve) {
const t = $('#pindlg-title'), b = $('#pindlg-body');
const oldIn = $('#pindlg-old'), n1 = $('#pindlg-new'), n2 = $('#pindlg-new2');
const err = $('#pindlg-err');
if (mode === 'change') {
t.textContent = '修改 PIN';
b.textContent = '请输入当前 PIN 与新 PIN(4 位数字)。';
oldIn.hidden = false; n1.hidden = false; n2.hidden = false;
n1.placeholder = '新 4 位 PIN'; n2.placeholder = '再次输入新 PIN';
} else {
t.textContent = '清空 PIN';
b.textContent = '清空后访问面板将不再要求 PIN。请输入当前 PIN 确认。';
oldIn.hidden = false; n1.hidden = true; n2.hidden = true;
}
err.hidden = true; oldIn.value = ''; n1.value = ''; n2.value = '';
oldIn.focus();
const scrim = $('#pindlg'); scrim.hidden = false;
const ok = $('#pindlg-ok'), cancel = $('#pindlg-cancel');
const done = function (v) { scrim.hidden = true; ok.onclick = cancel.onclick = scrim.onclick = null; resolve(v); };
ok.onclick = function () {
const old = oldIn.value.trim();
if (!old) { err.textContent = '请输入当前 PIN'; err.hidden = false; return; }
if (mode === 'change') {
const np = n1.value.trim();
if (!/^[0-9]{4}$/.test(np)) { err.textContent = '新 PIN 必须为 4 位数字'; err.hidden = false; return; }
if (np !== n2.value.trim()) { err.textContent = '两次输入的新 PIN 不一致'; err.hidden = false; return; }
done({ old: old, n1: np });
} else done({ old: old, n1: null });
};
cancel.onclick = function () { done(null); };
scrim.onclick = function (e) { if (e.target === scrim) done(null); };
});
}
async function pinEdit(mode) {
const v = await pinEditDlg(mode);
if (!v) return;
try { await api('POST', '/api/auth/login', { pin: v.old }); } // 先验证旧 PIN(客户端复核,后端改 PIN 本身不需旧值)
catch (e2) {
if (e2.code === 'LOCKED' || e2.status === 429) toast('错误次数过多,已锁定,请稍后重试(锁定 5 分钟)');
else if (e2.status === 401) toast('当前 PIN 验证失败');
else toast('验证失败:' + errText(e2));
loadPinStatus(); return;
}
try {
await api('PUT', '/api/settings/panel.pin', { value: (v.n1 !== null ? v.n1 : '') });
toast(mode === 'change' ? 'PIN 已修改' : 'PIN 已清空');
loadPinStatus();
} catch (e3) { toast('操作失败:' + errText(e3)); }
}
function bindPin() {
const c = $('#content');
c.addEventListener('click', async function (e) {
if (e.target.closest('#pin-change')) { pinEdit('change'); return; }
if (e.target.closest('#pin-clear')) { pinEdit('clear'); return; }
if (e.target.closest('#pin-unlock-once')) {
try {
await api('POST', '/api/system/pin-unlock-once', {});
toast('已下发:设备下次亮屏免 PIN 进入一次');
} catch (err) { toast('下发失败:' + errText(err)); }
return;
}
});
['#pindlg-old', '#pindlg-new', '#pindlg-new2'].forEach(function (sel) {
const el = $(sel);
if (el) el.addEventListener('input', function () { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); });
});
}
async function loadFonts() {
const box = $('#font-list'); if (!box) return;
let d;
try { d = await api('GET', '/api/fonts'); }
catch (e) { box.innerHTML = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '<p class="muted">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。</p>'; return; }
box.innerHTML = fs.map(f => {
const nm = esc(f.name || f.family || '');
const meta = esc((f.family || '') + '/' + (f.weight || '') + (f.tier ? ' · ' + f.tier : '') + (f.glyph_count ? ' · ' + f.glyph_count + ' 字' : ''));
return '<div class="field-row"><div style="flex:1;overflow:hidden;text-overflow:ellipsis">' +
'<div>' + nm + '</div><div class="muted">' + meta + '</div></div>' +
'<button class="btn" data-fexport="' + esc(f.family || '') + '" data-fweight="' + esc(f.weight || '') + '">导出</button></div>';
}).join('');
}
async function importFont(files) {
if (!files || !files.length) return;
const f = files[0];
const fd = new FormData(); fd.append('file', f);
toast('正在导入 ' + f.name + '…');
let res;
try { res = await api('POST', '/api/fonts/import', fd); }
catch (err) {
if (err.code === 'CONFLICT') {
const ok = await confirmDlg('覆盖字库', '目标字库已存在,覆盖导入吗?', true);
if (!ok) return;
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
catch (e2) { toast('导入失败:' + errText(e2)); return; }
} else { toast('导入失败:' + errText(err)); return; }
}
toast('已导入 ' + ((res && res.family) || '') + '/' + ((res && res.weight) || ''));
if (res && res.hint) toast(res.hint);
loadFonts();
}
function bindSettings() {
const c = $('#content');
c.addEventListener('input', function (e) {
const r = e.target.closest('[data-stype="int"]'); if (!r) return;
const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value;
updateSetting(r.dataset.skey, +r.value, 'int');
});
c.addEventListener('change', function (e) {
if (e.target.id === 'font-file') { importFont(e.target.files); return; }
const t = e.target;
const sk = t.dataset.skey; if (!sk) return;
if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool');
else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum');
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
});
c.addEventListener('click', async function (e) {
// 「主题(面板外观)」卡:本面板覆盖(localStorage),不写设备
const ss = e.target.closest('#set-style-seg .seg-btn');
if (ss) { storageSet('meowpanel.style', ss.dataset.style); applyTheme(); return; }
const sm = e.target.closest('#set-mode-seg .seg-btn');
if (sm) { storageSet('meowpanel.mode', sm.dataset.mode); applyTheme(); return; }
if (e.target.closest('#set-style-reset')) { storageDel('meowpanel.style'); applyTheme(); return; }
if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; }
const fe = e.target.closest('[data-fexport]');
if (fe) {
const f = fe.dataset.fexport, w = fe.dataset.fweight;
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz');
return;
}
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
if (e.target.closest('#set-restore')) {
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
if (!ok) return;
try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); }
catch (err) { toast('恢复失败:' + errText(err)); }
} else if (e.target.closest('#set-refresh')) { renderSettings(); }
});
}
async function sendKeyLabel(code, label) {
try { await api('POST', '/api/key/' + code, { press: 'tap' }); toast(label + ' 已发送'); }
catch (e) { toast('发送失败:' + errText(e)); }
}
async function sendKeyRepeat(code, n, label) {
let count = 0, err = null;
for (let i = 0; i < n; i++) {
try { await api('POST', '/api/key/' + code, { press: 'tap' }); count++; }
catch (e) { err = errText(e); break; } // 失败即停,避免连刷
}
if (count > 0) toast(label + ' ×' + count);
if (err) toast('发送失败:' + err);
}
function volSteps(ay) {
return Math.max(1, Math.min(SWIPE.maxVolSteps, Math.round(ay / SWIPE.volStepPx)));
}
function handleRemoteGesture(dir, ax, ay) {
if (dir === 'volup') sendKeyRepeat(115, volSteps(ay), '音量+');
else if (dir === 'voldown') sendKeyRepeat(114, volSteps(ay), '音量−');
else if (dir === 'next') sendKeyLabel(163, '下一曲');
else if (dir === 'prev') sendKeyLabel(165, '上一曲');
else if (dir === 'back') sendKeyLabel(158, '返回');
}
function remotePointerDown(e) {
if (state.route !== 'screen' || swipeState) return; // 已有一指在手 → 忽略多点触控
if (!e.target.closest('#remote-pad')) return; // 手势只作用于右侧遥控器板(屏幕区点击=注入)
const pad = $('#remote-pad'); if (!pad) return;
const r = pad.getBoundingClientRect();
swipeState = { id: e.pointerId, x0: e.clientX, y0: e.clientY, edge: (e.clientX - r.left) <= SWIPE.edgePx };
}
function remotePointerUp(e) {
const s = swipeState;
if (!s || e.pointerId !== s.id) return;
swipeState = null;
const dx = e.clientX - s.x0, dy = e.clientY - s.y0;
const ax = Math.abs(dx), ay = Math.abs(dy);
if (Math.max(ax, ay) < SWIPE.minDist) return; // 轻点/未动 → 交给按钮 click 处理
let dir = null;
if (s.edge && dx > 0 && dx >= ay * SWIPE.axisRatio && dx >= SWIPE.minDist) dir = 'back'; // 左缘滑 → 返回
else if (ax >= ay * SWIPE.axisRatio) dir = (dx < 0 ? 'next' : 'prev'); // 水平 → 上下曲
else if (ay >= ax * SWIPE.axisRatio) dir = (dy < 0 ? 'volup' : 'voldown'); // 垂直 → 音量
if (!dir) return; // 斜滑/未达占优 → 忽略
lastSwipeAt = Date.now();
handleRemoteGesture(dir, ax, ay);
}
function remoteBtn(act, icon, label, title) {
return '<button class="remote-btn" data-act="' + act + '" title="' + esc(title || label) + '">' +
'<span class="r-ico">' + (MP_ICONS[icon] || '') + '</span><span class="r-lbl">' + esc(label) + '</span></button>';
}
function remotePadHtml() {
let h = '<div class="remote-pad" id="remote-pad">';
h += '<div class="remote-grid">';
h += remoteBtn('volup', 'volup', '音量+', '音量加(物理键红线内)');
h += remoteBtn('voldown', 'voldown', '音量−', '音量减(物理键红线内)');
h += remoteBtn('brightup', 'brightup', '亮度+', '调高背光亮度');
h += remoteBtn('brightdn', 'brightdn', '亮度−', '调低背光亮度');
h += remoteBtn('play', 'play', '播放/暂停', '媒体播放/暂停切换(code=164)');
h += remoteBtn('next', 'next', '下一曲', '下一曲(code=163)');
h += remoteBtn('prev', 'prev', '上一曲', '上一曲(code=165)');
h += remoteBtn('power', 'power', '电源', '电源键(单击;code=116)');
h += '</div>';
h += '<div class="remote-bright">' +
'<span class="r-lbl">指定亮度</span>' +
'<input type="range" id="ctl-bright" min="' + ctlBright.min + '" max="' + ctlBright.max + '" value="' + ctlBright.value + '">' +
'<span class="muted" id="ctl-bright-val" style="min-width:40px;text-align:right">' + ctlBright.value + '</span>' +
'</div>';
h += '<button class="btn" id="ctl-powerlong">' + (MP_ICONS.power || '') + ' <span>长按电源(电源菜单)</span></button>';
h += '<p class="muted" style="margin-top:8px;line-height:1.8">滑动手势(在遥控板上):上滑=音量+ · 下滑=音量- · 左滑=下一曲 · 右滑=上一曲 · 左缘右滑=返回。<br>' +
'<b>返回/主页</b>属物理键红线(<code>/api/key</code> 后端禁注入 158/172),故未提供注入按钮。</p>';
h += '</div>';
return h;
}
async function setBrightCtl(v) {
v = Math.max(ctlBright.min, Math.min(ctlBright.max, Math.round(v)));
if (v === ctlBright.value) return;
ctlBright.value = v;
const sl = $('#ctl-bright'); if (sl) sl.value = v;
const lbl = $('#ctl-bright-val'); if (lbl) lbl.textContent = v;
try { await api('PUT', '/api/settings/display.brightness', { value: v }); toast('亮度 = ' + v); }
catch (e) { toast('亮度保存失败:' + errText(e)); }
}
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)); }
}
async function renderSystem() {
$('#content').innerHTML = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
let d, pw = null;
try { d = await api('GET', '/api/system'); }
catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
try { pw = await api('GET', '/api/power'); } catch (e) { pw = null; }
if (state.route !== 'system') return;
const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {};
const boot = d.boot || {};
const curMode = boot.mode || boot.current || null; // 后端若暴露 /tmp/meow_boot_mode → boot.mode/current
const bootDef = boot.default || '—';
const mism = curMode && boot.default && curMode !== boot.default;
const batt = (pw && pw.battery) || {};
const charging = (batt.state === 'Charging' || batt.state === 'Full');
let modeHtml = mism
? '<span style="color:var(--error);font-weight:600">' + esc(curMode) + '</span> <span class="muted">(与默认启动不一致)</span>'
: (curMode ? esc(curMode) : '—');
let sysRows = row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) +
row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec));
if (pw && pw.battery) {
sysRows += row('电池', '<span class="home-chip ' + (charging ? 'ok' : 'mut') + '">' + esc(batt.state || '—') + '</span> ' +
(batt.pct != null ? batt.pct + ' %' : '—') +
(batt.mv != null ? ' · ' + (batt.mv / 1000).toFixed(2) + ' V' : '') +
(batt.health ? ' · ' + esc(batt.health) : ''));
}
sysRows += row('当前运行模式', modeHtml);
sysRows += row('默认启动', esc(bootDef));
sysRows += row('重启原因', esc(boot.reboot_reason || d.reboot_reason || '—'));
sysRows += row('升级状态', esc(boot.upgrade_status || d.upgrade_status || '—'));
let h = '<div class="page" id="system-page">';
h += '<div class="card"><h3 class="card-title">系统信息</h3><table class="table"><tbody>' + sysRows + '</tbody></table></div>';
h += '<div class="card"><h3 class="card-title">网络</h3><table class="table"><tbody>' +
row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') +
row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') +
row('mDNS', esc(net.hostname_local || '—')) +
'</tbody></table></div>';
h += '<div class="card"><h3 class="card-title">升级</h3>' +
'<p class="muted">当前 ' + esc(ota.current || d.version || '—') +
(ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '</p>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" id="sys-ota-check">检查升级</button>' +
'<button class="btn btn-danger" id="sys-ota">⚠️ 升级</button></div></div>';
h += '<div class="card danger-box"><h3 class="card-title">危险操作</h3>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-danger" id="sys-reboot">重启</button>' +
'<button class="btn btn-danger" id="sys-shutdown">关机</button></div>' +
'<p class="warn-note">⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。</p></div>';
h += '</div>';
$('#content').innerHTML = h;
}
function row(k, v) { return '<tr><td class="muted" style="width:120px">' + k + '</td><td>' + v + '</td></tr>'; }
function bindSystem() { // init 一次性委托绑定(不随渲染累积)
$('#content').addEventListener('click', async function (e) {
if (e.target.closest('#sys-reboot')) {
if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true))
try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); }
return;
}
if (e.target.closest('#sys-shutdown')) {
if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true))
try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); }
return;
}
if (e.target.closest('#sys-ota')) {
if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true))
try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); }
return;
}
if (e.target.closest('#sys-ota-check')) {
// 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级)
try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); }
catch (err) { toast('检查失败:' + errText(err)); }
return;
}
});
}
function groupName(g) { return LOG_GROUP_NAME[g] || (g || 'app'); }
function logsCrashCard(cr) {
const ents = (cr.entities || []).filter(e => e.name);
let h = '<div class="card"><h3 class="card-title">崩溃汇总(' + esc(cr.file || '本 boot · crash log') + ')</h3>';
if (!ents.length && !(cr.dmesg_count || 0)) { h += '<p class="muted">本 boot 无崩溃记录。</p></div>'; return h; }
h += '<table class="table"><thead><tr><th>实体</th><th>崩溃数</th><th>最近时间</th><th>信号/退出</th><th style="text-align:right">操作</th></tr></thead><tbody>';
ents.forEach(e => {
h += '<tr><td class="mono">' + esc(e.name) + '</td>' +
'<td class="' + ((e.count || 0) > 0 ? 'crash-hi' : 'muted') + '">' + esc(e.count) + ((e.count || 0) > 0 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(e.last_time || '—') + '</td>' +
'<td><span class="muted">' + esc(e.last_kind || '—') + '</span>' + (e.last_code != null && e.last_code !== 0 ? ' (' + esc(e.last_code) + ')' : '') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-crash-tail="' + escSafeAttr(e.name) + '">stdout 尾部</button></div></td></tr>';
});
h += '</tbody></table>';
if (cr.dmesg_count) {
h += '<p class="muted">内核 oops / segfault 捕获:<b>' + esc(cr.dmesg_count) + '</b> 条' +
((cr.dmesg_tail || []).length ? '<pre class="log-view">' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '</pre>' : '') + '</p>';
}
h += '</div>';
return h;
}
function renderLogsFiles(files) {
const el = $('#logs-files');
if (!el) return;
const filtered = files.filter(f => {
if (logsGroup !== 'all' && (f.group || 'app') !== logsGroup) return false;
if (logsPrefix && !f.name.toLowerCase().startsWith(logsPrefix.toLowerCase())) return false;
return true;
});
let h = '<table class="table"><thead><tr><th>文件</th><th>分组</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!filtered.length) h += '<tr><td colspan="5" class="empty">暂无日志(或过滤后无结果)</td></tr>';
filtered.forEach(f => {
h += '<tr><td class="mono">' + esc(f.name) + '</td><td class="muted">' + groupName(f.group) + '</td><td class="muted">' + fmtBytes(f.size) + '</td><td class="muted">' + fmtTime(f.mtime) + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-log="' + esc(f.name) + '">查看</button></div></td></tr>';
});
h += '</tbody></table>';
el.innerHTML = h;
}
async function renderLogs() {
$('#content').innerHTML = '<div class="page" id="logs-page"><p class="placeholder">加载日志列表…</p></div>';
const r = await Promise.allSettled([api('GET', '/api/logs'), api('GET', '/api/logs/crash')]);
if (state.route !== 'logs') return;
if (r[0].status !== 'fulfilled') {
$('#content').innerHTML = emptyState('加载失败:' + errText(r[0].reason), r[0].reason && r[0].reason.code);
return;
}
const d = r[0].value;
const cr = (r[1].status === 'fulfilled') ? r[1].value : null;
let h = '<div class="page" id="logs-page">';
if (cr && ((cr.count || 0) > 0 || (cr.dmesg_count || 0) > 0 || (cr.entities || []).length)) h += logsCrashCard(cr);
h += '<div class="card"><h3 class="card-title">日志文件(MeowOS/logs)</h3>';
h += '<div class="field-row" style="margin-bottom:8px"><span class="muted">' + esc(d.dir || '') + '</span></div>';
h += '<div class="field-row" style="flex-wrap:wrap;gap:6px;margin-bottom:12px">';
h += '<input class="log-prefix" data-log-prefix placeholder="按文件名前缀过滤" value="' + escSafeAttr(logsPrefix) + '" style="flex:1;min-width:140px">';
h += '</div>';
h += '<div class="field-row" style="flex-wrap:wrap;gap:6px;margin-bottom:12px">';
Object.keys(LOG_GROUP_NAME).forEach(g => {
h += '<button class="btn ' + (logsGroup === g ? 'btn-primary' : 'btn-ghost') + '" data-log-group="' + g + '">' + LOG_GROUP_NAME[g] + '</button>';
});
h += '</div>';
h += '<div id="logs-files"><table class="table"><tbody><tr><td class="empty">加载…</td></tr></tbody></table></div>';
h += '</div><pre class="log-view" id="log-view" hidden></pre></div>';
$('#content').innerHTML = h;
logsFiles = d.files || [];
renderLogsFiles(logsFiles);
}
function bindLogs() {
$('#content').addEventListener('click', async function (e) {
const gbtn = e.target.closest('[data-log-group]');
if (gbtn) {
logsGroup = gbtn.dataset.logGroup;
$$('[data-log-group]').forEach(b => b.classList.toggle('btn-primary', b === gbtn));
renderLogsFiles(logsFiles);
return;
}
const tb = e.target.closest('[data-crash-tail]');
if (tb) { await viewLogsCrashTail(tb.dataset.crashTail, tb); return; }
const t = e.target.closest('[data-log]'); if (!t) return;
const p = $('#log-view');
try {
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400');
p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : '');
} catch (err) { toast('查看失败:' + errText(err)); }
});
$('#content').addEventListener('input', function (e) {
const p = e.target.closest('[data-log-prefix]');
if (!p) return;
logsPrefix = p.value;
renderLogsFiles(logsFiles);
});
}
async function viewLogsCrashTail(name, btn) {
btn.textContent = '加载中…';
let file;
try {
const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + name + '/'));
const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0);
if (!files.length) { btn.textContent = 'stdout 尾部'; toast('未找到 ' + name + ' 的 stdout 日志'); return; }
files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
file = files[0].name;
} catch (err) { btn.textContent = 'stdout 尾部'; toast('查找失败:' + errText(err)); return; }
try {
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(name + '/' + file) + '&lines=200');
const lines = (d.lines || []);
const p = $('#log-view');
if (p) { p.hidden = false; p.textContent = name + '/' + file + '\n' + (lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : ''); }
} catch (err) { toast('读取失败:' + errText(err)); }
btn.textContent = 'stdout 尾部';
}
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)); }
}
});
}
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);
}
}
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)); }
}
async function renderScreen() {
screenFps = parseFloat(storageGet('screen.fps') || '1');
if (!(screenFps >= 0.2 && screenFps <= 2)) screenFps = 1;
try { // 读当前亮度 → 遥控板滑差/增减步进基线
const b = await api('GET', '/api/settings/display.brightness');
if (b && b.value != null) ctlBright = { min: b.min || 1, max: b.max || 100, value: b.value };
} catch (e) { /* 读不到用默认;不阻塞页面 */ }
if (state.route !== 'screen') return;
let h = '<div class="page" id="screen-page">';
h += '<div class="screen-remote">';
/* 左:实时画面 */
h += '<div class="screen-col">' +
'<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>';
/* 右:遥控板 */
h += '<div class="remote-col"><div class="card"><h3 class="card-title">遥控器</h3>' + remotePadHtml() + '</div></div>';
h += '</div></div>';
$('#content').innerHTML = h;
screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态
applyScreenDeltaDom();
// restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发
restartScreenPoll();
}
function bindScreen() {
$('#content').addEventListener('input', function (e) {
if (e.target.id === 'screen-fps') {
const v = parseFloat(e.target.value);
screenFps = v; storageSet('screen.fps', String(v));
const lbl = $('#screen-fps-lbl'); if (lbl) lbl.textContent = v.toFixed(1) + ' fps';
restartScreenPoll();
} else if (e.target.id === 'ctl-bright') {
setBrightCtl(parseFloat(e.target.value));
}
});
$('#content').addEventListener('change', function (e) {
if (e.target.id === 'screen-delta') {
screenDelta = e.target.checked;
screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种
applyScreenDeltaDom();
restartScreenPoll();
}
});
$('#content').addEventListener('click', function (e) {
if (Date.now() - lastSwipeAt < SWIPE.clickSuppressMs) return; // 刚滑完 → 屏蔽本次点击,防误触按钮
const act = e.target.closest('[data-act]');
if (act) {
const a = act.dataset.act;
if (a === 'volup') sendKey(115, 'tap');
else if (a === 'voldown') sendKey(114, 'tap');
else if (a === 'brightup') setBrightCtl(ctlBright.value + 5);
else if (a === 'brightdn') setBrightCtl(ctlBright.value - 5);
else if (a === 'play') sendKey(164, 'tap');
else if (a === 'next') sendKey(163, 'tap');
else if (a === 'prev') sendKey(165, 'tap');
else if (a === 'power') sendKey(116, 'tap');
return;
}
if (e.target.closest('#ctl-powerlong')) { sendKey(116, 'long', 3000); return; }
if (e.target.closest('#screen-img') || e.target.closest('#screen-canvas')) { screenTap(e); return; }
});
// 滑动手势(仅遥控板);屏幕区点击=注入,不参与手势
$('#content').addEventListener('pointerdown', remotePointerDown);
window.addEventListener('pointerup', remotePointerUp);
window.addEventListener('pointercancel', remotePointerUp);
// 页面隐藏(切到其它标签页)时停刷;回到本页继续
document.addEventListener('visibilitychange', function () {
if (document.hidden) stopScreenPoll();
else if (state.route === 'screen') restartScreenPoll();
});
}
Object.assign(MP, { absFor, appInfo, appStatePill, appUninstall, applyScreenDeltaDom, audioCard, audioCtlHtml, audioFlowHtml, audioPortName, audioRouteLabel, batteryCard, batteryCardBody, bgClearAll, bgIconHtml, bindBg, bindApps, bindAudio, bindFiles, bindFonts, bindHome, bindLogs, bindNet, bindNotif, bindPin, bindPower, bindProc, bindScreen, bindSettings, bindSystem, breakdownCardBody, btCard, createImgFromBlob, crumbHtml, deviceCard, doDownload, ensureScreenCanvas, escSafeAttr, estimateCardBody, eventsBar, fetchAudioOnce, fetchBgOnce, fetchHomeLevel, fetchHomeSystem, fetchNetConns, fetchNetLog, fetchNetOnce, fetchOverviewOnce, fetchPerfOnce, fetchPowerOnce, fetchProcOnce, fetchScreen, fmtDur, fmtFrames, groupName, handleRemoteGesture, healthCard, importFont, importFontPage, installFromFile, installFromPath, joinRel, loadFonts, loadFontsPage, loadPinStatus, logsCrashCard, mkdirFile, navFiles, netBadge, netBannerErr, netCard, netConnsBody, netDnsBody, netIfaceCards, netPolicyCard, netStateCls, netStatusCards, newestStdout, perfCard, pinEdit, pinEditDlg, powerEventsCard, powerHistoryCard, powerPolicyCard, powerProfileLabel, powerSummaryCard, pubBytesCard, remoteBtn, remotePadHtml, remotePointerDown, remotePointerUp, removeFile, renameFile, renderApps, renderAudio, renderBg, renderBgBody, renderBright, renderFiles, renderFontsPage, renderHome, renderHomeCards, renderLogs, renderLogsFiles, renderNet, renderNotif, renderPerf, renderPerfData, renderPower, renderPowerBody, renderProc, renderProcBody, renderScreen, renderSettings, renderSystem, restartScreenPoll, row, screenTap, seedScreenCanvas, segments, sendKey, sendKeyLabel, sendKeyRepeat, sendTheme, setBrightCtl, setPanelPassword, settingControl, srcPill, stopAudio, stopBg, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps });
})();