feat: M6.10–M6.19 批量收编 + M6.19 推卡验收

- M7 launcher 重写 5 期(虚拟化宫格/meow_theme 单一权威/横幅动画/自定义 PIN 键盘)
- OOM 通用化(cachegov 上收 SDK)、自动切歌根治、meowpanel jv_free 泄漏修复、Hello 滚动卡顿修复
- ncm 歌词 UI / 媒体通知封面 / 封面分辨率 640
- 5 bug 修复:ncm 后台化 UI 叠层+点击穿透、再进 ncm 桌面叠层、播放页新旧 UI 叠层抢刷脏区(meow_ui_gfx 恢复边沿整帧重画)、PIN 亮屏亮度跳(keys.c meow_panel_on 按 settings_brightness_clamped 恢复)、通知中心封面显歌单封面(cover.c save_cover_raw 加 is_song + 命中块守卫)
- 四 App 统一重链新 meow_ui.a 51caee4a;M6.19=0.8.19 固件 2d2331f2 推卡自刷验收全绿
This commit is contained in:
AskaEth committed 2026-09-03 12:16:01 +08:00
1 parent c2ee7626a4
commit 3d70a3dd2c
176 files changed
+15507 -597

No files matched your search

+364 -27
View File
@@ -752,11 +752,16 @@
function healthCard() {
const ams = homeData && homeData.ams;
const svcd = homeData && homeData.svcd;
let appTxt = '—', svcBad = 0, svcHi = 0, svcTxt = '—';
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)) {
@@ -773,7 +778,8 @@
svcTxt = '—';
}
return homeCard('在跑 App 与服务', '<div class="home-card-grid">' +
homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) + '</div>');
homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) +
(fgHtml ? homeKv('前台应用', fgHtml) : '') + '</div>');
}
function storageCard() {
const perf = homeData && homeData.perf;
@@ -797,11 +803,37 @@
'<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>');
}
/* 首页功耗摘要条(est_mw + breakdown:复用 estimateCardBody/breakdownCardBody 保持一致口径,诚实标注 confidence)。
* 上游 power 不可用 → 不渲染该条(绝不假数据);HibyOS 只读 degraded → 显示说明。 */
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) : ''));
}
/* 最近异常事件条(power.events 摘要 top3 进 overview;完整列表走 /api/power/events)。 */
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 = '<div class="home-grid">' +
let h = powerSummaryCard() + eventsBar(); // 摘要条(功耗/事件)置顶
h += '<div class="home-grid">' +
deviceCard() + batteryCard() + audioCard() + netCard() + healthCard() + storageCard() + perfCard() +
'</div>';
body.innerHTML = h;
@@ -921,31 +953,126 @@
}
return '<div class="home-sub">合计 ' + total + ' mW(分解为经验估算)</div>' + stack + legend;
}
function renderPowerBody(d) {
let powerEditing = false; // 用户在编辑策略表单时暂停 5s 重渲染(防输入被轮询清空)
/* 电源策略卡:profile 四档 + 低电/临界阈值 + 亮度/息屏上限。前端确认后 POST /api/power/policy。
* 默认全关(enacted=0)如实标注;note 透传 powermgr 口径(只写 power.* 键,不直接写系统)。 */
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>';
if (d && d.policy == null) h += '<p class="muted" style="margin-top:6px">电源策略(profile 四档/低电阈值)为 期3 功能,此处以不可用占位。</p>';
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;
}
let powerData = null, powerEvents = null, powerHistory = null;
async function fetchPowerOnce() {
const el = $('#power-page');
if (!el || state.route !== 'power') return;
let d;
try { d = await api('GET', '/api/power'); }
catch (e) { if (el && state.route === 'power') el.innerHTML = '<div class="page" id="power-page">' + emptyState('加载失败:' + errText(e), e.code) + '</div>'; 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;
el.innerHTML = renderPowerBody(d);
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 重渲染(防输入被清空)
});
}
/* ---------------- 2f. 进程 / 服务页(#/proc;/api/svcd + /api/ams,只读,无 restart/kill) ----------------
* svcd 服务表(state/pid/restart 计数≥3 高亮/exec/来源 board|tf)+ ams App 表(state/pid/crashes/launches/last_kind);
@@ -1000,7 +1127,11 @@
'<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></div></td></tr>';
'<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') || '(空)') +
@@ -1009,7 +1140,7 @@
}
});
h += '</tbody></table>';
h += '<p class="muted">点击「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。仅只读,无 restart/kill。</p>';
h += '<p class="muted">「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。「启动/结束」经 amsd(require_auth);结束前台 App 需二次确认。</p>';
} else h += homeUnavailable();
h += '</div></div>';
return h;
@@ -1072,9 +1203,27 @@
procTimer = setInterval(fetchProcOnce, 5000);
}
function bindProc() {
$('#content').addEventListener('click', function (e) {
const b = e.target.closest('[data-applog]'); if (!b) return;
viewAppStdout(b.dataset.applog, b);
$('#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;
}
});
}
@@ -1110,12 +1259,18 @@
return 'net-state warn';
}
function netStatusCards(d) {
let h = '';
// 接口卡(lo/wlan0/usb-eth:状态+IP+速率)
const ifs = (d && d.ifaces) || [];
h += '<div class="card"><h3 class="card-title">网络接口</h3><table class="table"><thead>' +
'<tr><th>接口</th><th>类型</th><th>状态</th><th>IP 地址</th><th>网关</th><th>RX / TX</th></tr></thead><tbody>';
/* 网络接口卡(/api/net/ifaces:kind/ip/gw/rssi/rfkill/dns + RX/TX)。
* enforce 执行语义如实:仅 DNS 层拒绝(SOCK_DESTROY 在本内核为 no-op),绝不谎称「已断连」。 */
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 += 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;
@@ -1125,9 +1280,20 @@
'<td class="muted">' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + '</td></tr>';
});
h += '</tbody></table>';
if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null))
h += '<p class="muted">实时速率:↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p>';
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;
}
/* WiFi / 蓝牙摘要(/api/net/status:E0 概览,含实时速率)。接口细分已迁 netIfaceCards。 */
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>';
// WiFi 摘要
const w = (d && d.wifi) || {};
h += '<div class="card"><h3 class="card-title">WiFi</h3><table class="table"><tbody>';
@@ -1155,9 +1321,50 @@
return h;
}
/* 网络策略卡(/api/net/policy;写走 POST /api/net/policy,require_auth + 二次确认)。
* enforce 如实显示(dns_only):仅 DNS 层拒绝,不会真断连。 */
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>';
@@ -1202,8 +1409,9 @@
let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义)
async function fetchNetOnce() {
if (state.route !== 'net') return;
const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body');
let st, cn, dn;
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;
@@ -1212,8 +1420,12 @@
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);
}
@@ -1250,7 +1462,9 @@
$('#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) + '">' +
@@ -1290,6 +1504,60 @@
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;
}
});
}
@@ -1852,6 +2120,66 @@
catch (e) { toast('保存失败:' + errText(e)); }
}, 350);
/* ---------------- 6b. 远程控制页滑动手势(设备端靠触摸滑动操作,控制页据此补滑动手势) ----------------
* 说明:后端 /api/input/tap 仅支持「单点坐标注入」(屏幕页用),无滑动/手势注入端点;
* 故控制页滑动统一映射到 /api/key/:code 语义键(与本地按键行为一致),多步音量 = 逐键 tap 多次。 */
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
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);
}
// 依据滑动主轴位移换算音量步数(>=1,封顶 maxVolSteps)
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 !== 'remote' || swipeState) return; // 已有一指在手 → 忽略多点触控
if (!e.target.closest('#remote-page')) return;
const page = $('#remote-page'); if (!page) return;
const r = page.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);
}
/* ---------------- 6. 远程控制页 ---------------- */
function renderRemote() {
const keys = [
@@ -1864,12 +2192,16 @@
{ code: 158, label: '返回', ico: '↩' },
{ code: 172, label: '主页', ico: '⌂' }
];
let h = '<div class="page" id="remote-page">';
let h = '<div class="page" id="remote-page" style="touch-action:none">';
h += '<div class="card"><h3 class="card-title">模拟按键</h3>' +
'<p class="muted">行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。</p>' +
'<div class="key-grid">' + keys.map(k =>
'<button class="key-btn" data-code="' + k.code + '"><span class="k-ico">' + k.ico + '</span>' + esc(k.label) + '</button>'
).join('') + '</div></div>';
).join('') + '</div>' +
'<p class="muted" style="margin-top:12px;line-height:1.9"><b>滑动手势</b>(与按钮并存,轻点仍是点击):' +
'上滑=音量+ · 下滑=音量-(滑得越远步数越多,单次至多 ' + SWIPE.maxVolSteps + ' 步) · ' +
'左滑=下一曲 · 右滑=上一曲 · 控制区左缘右滑=返回</p>' +
'</div>';
h += '<div class="card"><h3 class="card-title">电源长按(电源菜单)</h3>' +
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" data-powerlong="1">长按电源 3s(电源菜单)</button>' +
'<span class="muted">发送 code=116 · press=long · ms=3000</span></div></div>';
@@ -1882,9 +2214,14 @@
}
function bindRemote() {
$('#content').addEventListener('click', function (e) {
if (Date.now() - lastSwipeAt < SWIPE.clickSuppressMs) return; // 刚滑完 → 屏蔽本次点击,防误触按钮
const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; }
const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; }
});
// 滑动手势:指针事件统一触摸/鼠标;#remote-page 已设 touch-action:none 防浏览器滚动劫持
$('#content').addEventListener('pointerdown', remotePointerDown);
window.addEventListener('pointerup', remotePointerUp);
window.addEventListener('pointercancel', remotePointerUp);
}
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
@@ -2328,7 +2665,7 @@
system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem },
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
notif: { title: '通知中心', render: renderNotif, bind: bindNotif },
power: { title: '电源 / 功耗', render: renderPower, bind: null },
power: { title: '电源 / 功耗', render: renderPower, bind: bindPower },
proc: { title: '进程 / 服务', render: renderProc, bind: bindProc }
};
// 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定)