2380 lines
134 KiB
JavaScript
2380 lines
134 KiB
JavaScript
/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||
}
|
||
});
|
||
}
|
||
|
||
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/<family>/<weight>/</code>;同 family/weight 冲突时会要求确认覆盖。' +
|
||
'导入后进设备「设置 → 字体」页,列表自动重扫生效。</p></div></div>';
|
||
loadFontsPage();
|
||
}
|
||
|
||
async function loadFontsPage() {
|
||
const box = $('#fpage-list'); if (!box) return;
|
||
let d;
|
||
try { d = await api('GET', '/api/fonts'); }
|
||
catch (e) { box.innerHTML = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
|
||
const fs = (d && d.fonts) || [];
|
||
if (!fs.length) { box.innerHTML = '<div class="empty">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)</div>'; return; }
|
||
let h = '<table class="table"><thead><tr><th>family</th><th>weight</th><th>名称</th><th>tier</th><th>glyph 数</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||
fs.forEach(f => {
|
||
h += '<tr><td class="mono">' + esc(f.family || '') + '</td><td class="mono">' + esc(f.weight || '') + '</td>' +
|
||
'<td>' + esc(f.name || '—') + '</td><td class="muted">' + esc(f.tier || '—') + '</td>' +
|
||
'<td class="muted">' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '</td>' +
|
||
'<td><div class="row-actions"><button class="btn btn-ghost" data-fpexport="' + esc(f.family || '') +
|
||
'" data-fpweight="' + esc(f.weight || '') + '">导出</button></div></td></tr>';
|
||
});
|
||
box.innerHTML = h + '</tbody></table>';
|
||
}
|
||
|
||
async function importFontPage(files) {
|
||
if (!files || !files.length) return;
|
||
const f = files[0];
|
||
const btn = $('#fpage-import');
|
||
if (btn && btn.disabled) return; // 上传中防连点(进度态)
|
||
const fd = new FormData(); fd.append('file', f);
|
||
if (btn) { btn.disabled = true; btn.textContent = '导入中…'; }
|
||
toast('正在导入 ' + f.name + '…');
|
||
let res = null;
|
||
try { res = await api('POST', '/api/fonts/import', fd); }
|
||
catch (err) {
|
||
if (err.code === 'CONFLICT') {
|
||
const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true);
|
||
if (ok) {
|
||
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
|
||
catch (e2) { toast('导入失败:' + errText(e2)); }
|
||
}
|
||
} else toast('导入失败:' + errText(err));
|
||
}
|
||
if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; }
|
||
if (res) {
|
||
toast('已导入 ' + (res.family || '') + '/' + (res.weight || ''));
|
||
if (res.warning) toast(res.warning);
|
||
else if (res.hint) toast(res.hint);
|
||
loadFontsPage();
|
||
}
|
||
}
|
||
|
||
function bindFonts() {
|
||
const c = $('#content');
|
||
c.addEventListener('click', function (e) {
|
||
if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; }
|
||
if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; }
|
||
const fe = e.target.closest('[data-fpexport]');
|
||
if (fe) {
|
||
if (fe.dataset.busy) return; // 打包期间防连点
|
||
fe.dataset.busy = '1';
|
||
const old = fe.textContent; fe.textContent = '打包中…';
|
||
const f = fe.dataset.fpexport, w = fe.dataset.fpweight;
|
||
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w),
|
||
(f || 'font') + '-' + (w || 'regular') + '.font.tar.gz')
|
||
.finally(() => { delete fe.dataset.busy; fe.textContent = old; });
|
||
return;
|
||
}
|
||
});
|
||
c.addEventListener('change', function (e) {
|
||
if (e.target.id === 'fpage-file') importFontPage(e.target.files);
|
||
});
|
||
}
|
||
|
||
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/<family>/<weight>/;导出把该三件套打包为 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, 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, 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, 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, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps });
|
||
})(); |