Files
MeowOS-For-R3P2/patch_v2/meowpanel/web/mp_shared.js
T

467 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* mp_shared.js — MeowPanel 前端共享框架:全局状态 + API 客户端 + 公共工具 + 主题/弹窗/登录/滑条。
* 由 meowpanel.js 拆分而来(接口/行为不变)。作为经典脚本在 meowpanel.js 之前加载,暴露 window.MP。 */
(function () {
'use strict';
var MP = window.MP = window.MP || {};
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
const sparkRms = [], sparkPeak = [];
const SPARK_MAX = 30;
const state = {
style: 'md3', mode: 'dark', // 已解析的风格/明暗(应用到 <html> data-style/data-mode)
devStyle: null, devTheme: null, // 设备 settings.conf 的 ui.style / display.theme(默认来源)
conn: 'unknown',
filesRel: '/', filesAbs: null,
perfTimer: null, // 性能页轮询
route: 'perf',
unauth: false, // 鉴权未通过(弹登录页)
downloadNonce: 0
};
let toastTimer = null;
let authMode = 'login'; // 'login' | 'setup'
let volTimer = null;
const saveSetting = debounce(async function (key, value, type) {
try {
await api('PUT', '/api/settings/' + key, { value: value });
toast('已保存 ' + key + ' = ' + value);
} catch (e) { toast('保存失败:' + errText(e)); }
}, 400);
const LOG_GROUP_NAME = { all: '全部', system: '系统服务', app: 'App', audio_monitor: 'audio_monitor', power: 'power', net: 'net', crash: 'crash', ams: 'ams' };
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[m]));
}
function fmtBytes(n) {
if (n == null || isNaN(n)) return '—';
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
}
function fmtUptime(sec) {
if (sec == null || isNaN(sec)) return '—';
sec = Math.floor(sec);
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分';
}
function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; }
function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; }
function pct(p) { return Math.max(0, Math.min(100, p)); }
function deciToDb(v) { return (v == null || v <= -990) ? null : v / 10; }
function fmtDb(v) {
const db = deciToDb(v);
if (db == null) return '−∞';
return (db > 0 ? '+' : '') + db.toFixed(1);
}
function dbPct(v) { // -60..0 dBFS 映射到 0..100% 条宽
const db = deciToDb(v);
if (db == null) return 0;
return Math.max(0, Math.min(100, (db + 60) / 60 * 100));
}
function isAvail(b) { return !!(b && b.available !== false); }
function sparklineHtml() {
return '<svg class="lvl-spark" viewBox="0 0 100 46" preserveAspectRatio="none" aria-hidden="true">' +
'<line class="grid" x1="0" y1="46" x2="100" y2="46"/>' +
'<line class="grid" x1="0" y1="23" x2="100" y2="23"/>' +
'<polyline class="spk-rms" points="" fill="none" stroke="#00bfa5" stroke-width="1.6" vector-effect="non-scaling-stroke"/>' +
'<polyline class="spk-peak" points="" fill="none" stroke="#ffb300" stroke-width="1.6" vector-effect="non-scaling-stroke"/>' +
'</svg>';
}
function sparklinePoints(arr) {
const n = arr.length;
if (!n) return '';
return arr.map((v, i) => {
const x = (i / (SPARK_MAX - 1)) * 100;
return x.toFixed(1) + ',' + (46 - Math.max(0, Math.min(100, v)) / 100 * 40).toFixed(1);
}).join(' ');
}
function pushSpark(v, arr) { // deci-dB → 0..100 百分比入队(<=30 点)
arr.push(dbPct(v));
if (arr.length > SPARK_MAX) arr.shift();
}
function drawSpark(rootEl) {
const svg = rootEl && rootEl.querySelector('.lvl-spark');
if (!svg) return;
const r = svg.querySelector('.spk-rms'), p = svg.querySelector('.spk-peak');
if (r) r.setAttribute('points', sparklinePoints(sparkRms));
if (p) p.setAttribute('points', sparklinePoints(sparkPeak));
}
function resetSpark() { sparkRms.length = 0; sparkPeak.length = 0; }
function levelMeterHtml() {
return '<div class="lvl">' +
'<div class="lvl-row"><span class="lvl-lbl">RMS</span>' +
'<div class="lvl-meter"><div class="lvl-fill rms" style="width:0%"></div></div>' +
'<span class="lvl-val" data-val="rms">−∞</span></div>' +
'<div class="lvl-row"><span class="lvl-lbl">PEAK</span>' +
'<div class="lvl-meter"><div class="lvl-fill peak" style="width:0%"></div></div>' +
'<span class="lvl-val" data-val="peak">−∞</span></div>' +
'<div class="lvl-scale"><span>-60</span><span>-40</span><span>-20</span><span>0 dBFS</span></div>' +
sparklineHtml() + '</div>';
}
function updateLevelMeter(rootEl, d) {
if (!rootEl) return;
const rms = (d && d.available) ? d.rms : null;
const peak = (d && d.available) ? d.peak : null;
const fr = rootEl.querySelector('.lvl-fill.rms');
const fp = rootEl.querySelector('.lvl-fill.peak');
const vr = rootEl.querySelector('.lvl-val[data-val="rms"]');
const vp = rootEl.querySelector('.lvl-val[data-val="peak"]');
if (fr) fr.style.width = dbPct(rms) + '%';
if (fp) fp.style.width = dbPct(peak) + '%';
if (vr) vr.textContent = fmtDb(rms);
if (vp) vp.textContent = fmtDb(peak);
pushSpark(rms, sparkRms); pushSpark(peak, sparkPeak);
drawSpark(rootEl);
}
function homeKv(k, v) {
return '<div class="home-kv"><span class="k">' + esc(k) + '</span><span class="v">' + v + '</span></div>';
}
function homeUnavailable() { return '<div class="home-sub">— 数据不可用</div>'; }
function homeCard(title, body) { return '<div class="card"><h3 class="card-title">' + esc(title) + '</h3>' + body + '</div>'; }
function modePill(m) {
if (!m) return '<span class="home-chip mut">—</span>';
const cls = (m === 'meow') ? 'ok' : ((m === 'hiby') ? 'warn' : 'mut');
return '<span class="home-chip ' + cls + '">' + esc(m) + '</span>';
}
function setConn(ok) {
state.conn = ok ? 'ok' : 'bad';
const dot = $('#conn-dot');
dot.dataset.state = state.conn;
$('#conn-text').textContent = ok ? '在线' : '离线';
$('#offline-banner').hidden = !!ok;
}
function dbg() {
const a = Array.from(arguments);
console.log.apply(console, ['[mp]'].concat(a));
}
function dbgErr() {
const a = Array.from(arguments);
console.warn.apply(console, ['[mp][ERR]'].concat(a));
}
async function api(method, path, body) {
let res;
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
const init = { method: method || 'GET' };
if (body instanceof FormData) {
init.body = body;
} else if (body !== undefined && body !== null) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
if (Object.keys(headers).length) init.headers = headers;
const t0 = performance.now();
try {
res = await fetch(path, init);
} catch (e) {
dbgErr(method || 'GET', path, '网络层失败:', e && e.message);
setConn(false);
throw { code: 'NETWORK', message: '后端未连接', status: 0 };
}
const ms = Math.round(performance.now() - t0);
/* 先读原文再解析:非 JSON 时能拿到原始内容前 300 字节进控制台 */
let text = '';
try { text = await res.text(); } catch (e) { /* 读体失败走 NOT_JSON */ }
let json = null;
try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; }
if (!json || typeof json.ok !== 'boolean') {
// 静态服务器返回 HTML / 404 等
dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '非 JSON 响应, len=' + text.length, '头:', text.slice(0, 200), '尾:', text.slice(-200));
try { fetch('/api/debug/report_bad_json', { method: 'POST', headers: { 'Content-Type': 'text/plain', 'X-MeowToken': storageGet('meowpanel.token') || '' }, body: text }); } catch (e2) {}
setConn(res.ok ? true : false);
throw { code: 'NOT_JSON', message: '后端返回了非 JSON 响应(HTTP ' + res.status + ')', status: res.status };
}
if (!json.ok) {
setConn(true);
const code = (json.error && json.error.code) || 'ERROR';
dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '后端错误:', code, (json.error && json.error.message) || '');
if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
}
if (ms > 800) dbg(method || 'GET', path, '慢响应', ms + 'ms');
setConn(true);
return json.data;
}
async function apiDownload(path) {
let res;
const headers = {};
const token = storageGet('meowpanel.token');
if (token) headers['X-MeowToken'] = token;
try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); }
catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; }
if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; }
setConn(true);
return URL.createObjectURL(await res.blob());
}
function toast(msg, ms) {
const t = $('#toast');
t.textContent = msg; t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, ms || 2600);
}
function confirmDlg(title, body, danger) {
return new Promise(res => {
$('#modal-title').textContent = title || '确认';
$('#modal-body').textContent = body || '';
const yes = $('#modal-yes'), no = $('#modal-no');
yes.className = 'btn btn-primary' + (danger ? ' btn-danger' : '');
const scrim = $('#modal'); scrim.hidden = false;
const done = v => { scrim.hidden = true; yes.onclick = no.onclick = scrim.onclick = null; res(v); };
yes.onclick = () => done(true);
no.onclick = () => done(false);
scrim.onclick = e => { if (e.target === scrim) done(false); };
});
}
function loginErr(msg) { const err = $('#login-err'); if (err) { err.textContent = msg; err.hidden = false; } }
function authRequired() { // 任一路由收到 401 → 弹登录页(会话失效/未认证)
state.unauth = true;
refreshAuthGate();
}
async function refreshAuthGate() { // 查状态决定「首次设置」还是「登录」
let st = null;
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
if (st && st.session) { state.unauth = false; hideLogin(); return; }
if (st && !st.pin_set && !st.legacy) showLogin('setup');
else showLogin('login');
}
function showLogin(mode) {
state.unauth = true;
authMode = mode || 'login';
const lg = $('#login'); if (lg) lg.hidden = false;
const sub = $('#login-sub');
const pin2 = $('#login-pin2');
const btn = $('#login-btn');
if (authMode === 'setup') {
if (sub) sub.textContent = '首次使用:请设置一个 4 位数字 PIN 作为本面板访问口令。';
if (pin2) pin2.hidden = false;
if (btn) btn.textContent = '设 置';
} else {
if (sub) sub.textContent = '此面板已开启访问鉴权,请输入 4 位 PIN。';
if (pin2) pin2.hidden = true;
if (btn) btn.textContent = '登 录';
}
const ip = $('#login-pin'); if (ip) { ip.value = ''; ip.focus(); }
const ip2 = $('#login-pin2'); if (ip2 && authMode !== 'setup') ip2.value = '';
const err = $('#login-err'); if (err) err.hidden = true;
}
function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; }
async function doLogin() {
const pin = ($('#login-pin').value || '').trim();
const err = $('#login-err'); if (err) err.hidden = true;
if (!pin) { loginErr('请输入 PIN'); return; }
if (authMode === 'setup') {
const pin2 = ($('#login-pin2').value || '').trim();
if (pin.length !== 4 || /[^0-9]/.test(pin)) { loginErr('PIN 必须为 4 位数字'); return; }
if (pin !== pin2) { loginErr('两次输入的 PIN 不一致'); return; }
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
await api('POST', '/api/auth/setup', { pin: pin });
toast('PIN 已设置,请登录');
showLogin('login');
} catch (e2) { loginErr((e2 && e2.message) ? e2.message : '设置失败'); }
finally { if (btn) btn.disabled = false; }
return;
}
const btn = $('#login-btn'); if (btn) btn.disabled = true;
try {
const d = await api('POST', '/api/auth/login', { pin: pin });
storageSet('meowpanel.token', d.token);
state.unauth = false;
hideLogin();
toast('已登录');
if (started) route(); else startApp();
} catch (e) {
if (e && (e.code === 'LOCKED' || e.status === 429)) loginErr(e.message || '错误次数过多,已锁定,请稍后重试');
else loginErr((e && e.message) ? e.message : '登录失败');
} finally {
const b = $('#login-btn'); if (b) b.disabled = false;
}
}
function bindLogin() {
const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin);
const ip = $('#login-pin'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
// PIN 输入:只允许数字,最长 4 位
[ip, $('#login-pin2')].forEach(el => { if (el) el.addEventListener('input', () => { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); });
}
function passwordDlg(title, body) {
return new Promise(res => {
$('#pwdlg-title').textContent = title || '设置面板密码';
$('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。';
const inp = $('#pwdlg-input'); inp.value = ''; inp.focus();
const scrim = $('#pwdlg'); scrim.hidden = false;
const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel');
const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); };
okb.onclick = () => done(inp.value);
cancel.onclick = () => done(null);
scrim.onclick = e => { if (e.target === scrim) done(null); };
inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); };
});
}
function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ }
function browserMode() {
try { return (window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark'; }
catch (e) { return 'dark'; }
}
function storedStyle() { const v = storageGet('meowpanel.style'); return (v === 'md3' || v === 'miuix') ? v : null; }
function storedMode() { const v = storageGet('meowpanel.mode'); return (v === 'system' || v === 'browser' || v === 'light' || v === 'dark') ? v : null; }
function resolveStyle() { return storedStyle() || state.devStyle || 'md3'; }
function resolveMode() {
const m = storedMode() || 'system';
if (m === 'light' || m === 'dark') return m;
if (m === 'system') return state.devTheme || browserMode(); // 设备未知时回退浏览器偏好
return browserMode();
}
function devStyleLabel() { return state.devStyle ? (state.devStyle === 'miuix' ? 'MIUIx' : 'Material You') : '未知(后端离线)'; }
function devThemeLabel() { return state.devTheme ? (state.devTheme === 'light' ? '浅色' : '深色') : '未知(后端离线)'; }
function applyTheme() {
const style = resolveStyle(), mode = resolveMode();
state.style = style; state.mode = mode;
const root = document.documentElement;
root.dataset.style = style; root.dataset.mode = mode;
$$('#style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style));
$$('#theme-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
// 设置页「主题」卡(仅在该页渲染时存在)
$$('#set-style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style));
$$('#set-mode-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === (storedMode() || 'system')));
const rst = $('#set-style-reset'); if (rst) rst.disabled = !storedStyle();
}
async function initTheme() {
// 旧版键迁移:meowpanel.theme(dark/light)→ meowpanel.mode
const old = storageGet('meowpanel.theme');
if (old) {
if (!storedMode()) storageSet('meowpanel.mode', old === 'light' ? 'light' : 'dark');
storageDel('meowpanel.theme');
}
applyTheme();
// 浏览器日/夜切换:模式为 browser(或 system 且设备主题未知)时实时跟随
try {
if (window.matchMedia) {
const mq = matchMedia('(prefers-color-scheme: dark)');
const onChange = function () {
const m = storedMode() || 'system';
if (m === 'browser' || (m === 'system' && !state.devTheme)) applyTheme();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
} catch (e) {}
// 设备默认值(ui.style/display.theme)——未覆盖时的默认来源
try {
const t = await api('GET', '/api/theme');
state.devStyle = (t.style === 'md3' || t.style === 'miuix') ? t.style : null;
state.devTheme = (t.theme === 'light' || t.theme === 'dark') ? t.theme : null;
applyTheme();
if (state.route === 'settings') renderSettings(); // 刷新「主题」卡的设备当前值提示
} catch (e) { /* 后端离线,保持本地/浏览器偏好 */ }
}
function bindThemeUi() {
$('#style-seg').addEventListener('click', function (e) {
const b = e.target.closest('.seg-btn'); if (!b) return;
storageSet('meowpanel.style', b.dataset.style);
applyTheme();
sendTheme({ style: b.dataset.style });
});
$('#theme-seg').addEventListener('click', function (e) {
const b = e.target.closest('.seg-btn'); if (!b) return;
storageSet('meowpanel.mode', b.dataset.mode); // 顶栏=显式明/暗
applyTheme();
sendTheme({ theme: b.dataset.mode });
});
}
function emptyState(msg, code) {
const t = code === 'NO_CARD' ? '未识别到 TF 卡' : (code === 'NOT_IMPLEMENTED' ? '该功能后端尚未实现' : msg);
return '<div class="empty">' + esc(t) + '</div>';
}
function errText(e) {
if (e && e.code === 'NO_CARD') return '未识别到 TF 卡';
if (e && e.code === 'NOT_IMPLEMENTED') return '该功能后端尚未实现';
return (e && e.message) ? e.message : '未知错误';
}
function barHtml(p) {
p = pct(p);
const c = p >= 90 ? 'danger' : (p >= 70 ? 'warn' : '');
return '<div class="bar ' + c + '"><span style="width:' + p + '%"></span></div>';
}
function stat(label, valueHtml, sub, barPct) {
return '<div class="stat"><div class="stat-label">' + esc(label) + '</div>' + valueHtml +
(sub ? '<div class="stat-sub">' + sub + '</div>' : '') +
(barPct != null ? barHtml(barPct) : '') + '</div>';
}
function slur(step) { // step: {key,label,min,max,val}
return '<div class="card"><h3 class="card-title">' + esc(step.label) + '</h3>' +
'<div class="field-row"><span class="muted" style="width:44px">' + step.val + '</span>' +
'<input type="range" min="' + step.min + '" max="' + step.max + '" value="' + step.val + '" data-slider data-key="' + esc(step.key) + '">' +
'<span class="muted" style="width:44px;text-align:right">' + step.max + '</span></div></div>';
}
function bindSlider() {
$('#content').addEventListener('input', function (e) {
const r = e.target.closest('[data-slider]');
if (!r) return;
const row = r.closest('.field-row');
if (row) row.querySelector('.muted').textContent = r.value;
saveSetting(r.dataset.key, +r.value, 'int');
});
}
Object.assign(MP, { $, $$, 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 });
})();