mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 23:12:52 +08:00
2425 lines
91 KiB
JavaScript
2425 lines
91 KiB
JavaScript
'use strict';
|
||
|
||
/* Liquid glass theme — light / dark / auto mode switch.
|
||
*
|
||
* The switch is injected right before the native LuCI refresh / poll
|
||
* indicator area (#indicators) in the top bar. On the lock screen
|
||
* (sysauth, which has no top bar) it is rendered as a fixed button in
|
||
* the top right corner instead.
|
||
*
|
||
* Modes are persisted in localStorage under "liquid-theme-mode" and the
|
||
* effective dark state is reflected on <html data-darkmode="true|false">.
|
||
*/
|
||
|
||
(function () {
|
||
if (typeof L == 'undefined' || typeof L.media != 'function')
|
||
return;
|
||
|
||
var MODE_KEY = 'liquid-theme-mode';
|
||
|
||
/* 主题配置写入 uci /etc/config/liquid(换客户端仍保留):
|
||
- 用 LuCI 官方 uci 实例(uci.load + uci.set + uci.save),
|
||
跨 luci 版本最兼容(L.bind 手拼 RPC 在其他固件上不可靠);
|
||
uci.save 直接写盘,不经过 ui.changes → 无待应用项、
|
||
不弹"保存更改"询问,改动立即生效
|
||
- 锁屏(登录页,未认证)不允许修改配置:只做本地预览
|
||
(localStorage),登录后刷新才真正持久化 */
|
||
/* 一次 POST 全部 option(对象 {option: value}),后端一次写盘,
|
||
避免多次请求并发时后写覆盖先写(accent 与 accent_custom 一起存) */
|
||
function saveConfig(opts) {
|
||
if (!document.body)
|
||
return;
|
||
/* 登录页:暂存到 sessionStorage,不发任何请求 */
|
||
if (document.body.classList.contains('liquid-login')) {
|
||
try {
|
||
var prev = JSON.parse(sessionStorage.getItem('liquid-pending') || '{}');
|
||
var merged = {};
|
||
for (var k in prev) merged[k] = prev[k];
|
||
for (var k2 in opts) merged[k2] = opts[k2];
|
||
sessionStorage.setItem('liquid-pending', JSON.stringify(merged));
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
/* 正常页面:POST 到 ucode controller 持久化 */
|
||
try {
|
||
var base = (window.L && L.env && L.env.admin_path)
|
||
? L.env.admin_path : '/cgi-bin/luci/admin/';
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', base + 'system/liquid/save_config');
|
||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||
xhr.send(JSON.stringify(opts));
|
||
} catch (e) {}
|
||
}
|
||
|
||
/* ===== 登录前暂存传递(main.js 顶部立即执行) =====
|
||
sessionStorage('liquid-pending') → localStorage(纯本地写入,极快)。
|
||
header ut 的 inline script 会用 localStorage 值覆盖 uci 旧值。
|
||
登录页不执行(登录页由 setMode 正常管理)。 */
|
||
(function applyPending() {
|
||
if (document.body && document.body.classList.contains('liquid-login'))
|
||
return;
|
||
try {
|
||
var raw = sessionStorage.getItem('liquid-pending');
|
||
if (!raw) return;
|
||
var data = JSON.parse(raw);
|
||
if (!data || typeof data !== 'object') return;
|
||
/* 写 localStorage(header ut 会读到并覆盖 uci 旧值) */
|
||
if (data.mode) localStorage.setItem('liquid-theme-mode', data.mode);
|
||
if (data.accent) localStorage.setItem('liquid-accent', data.accent);
|
||
if (data.accent_custom) localStorage.setItem('liquid-accent-custom', data.accent_custom);
|
||
if (data.bing) localStorage.setItem('liquid-bing', data.bing);
|
||
if (data.glass_opacity != null) localStorage.setItem('liquid-glass-opacity', String(data.glass_opacity));
|
||
} catch (e) {}
|
||
})();
|
||
|
||
/* 登录后提交暂存(DOMContentLoaded 后异步 POST 持久化,不改 DOM) */
|
||
function flushPending() {
|
||
/* flushPending 现在由 showPendingToast 的按钮触发,
|
||
此处保留为空壳,避免登录后自动提交 */
|
||
}
|
||
/* 登录后提示暂存设置(DOMContentLoaded 后检查 sessionStorage) */
|
||
function showPendingToast() {
|
||
if (document.body && document.body.classList.contains('liquid-login'))
|
||
return;
|
||
try {
|
||
if (!sessionStorage.getItem('liquid-pending')) return;
|
||
} catch (e) { return; }
|
||
|
||
/* 弹出玻璃 toast 提示 */
|
||
var toast = document.createElement('div');
|
||
toast.className = 'liquid-pending-toast';
|
||
toast.innerHTML =
|
||
'<span class="liquid-pending-toast-text">登录前修改的主题设置已暂存</span>' +
|
||
'<span class="liquid-pending-toast-countdown"></span>' +
|
||
'<button class="liquid-pending-toast-btn" data-action="apply">应用</button>' +
|
||
'<button class="liquid-pending-toast-btn liquid-pending-toast-dismiss" data-action="dismiss">忽略</button>';
|
||
document.body.appendChild(toast);
|
||
requestAnimationFrame(function () { toast.classList.add('show'); });
|
||
|
||
var countdownEl = toast.querySelector('.liquid-pending-toast-countdown');
|
||
var remaining = 30;
|
||
function tick() {
|
||
if (remaining <= 0) {
|
||
dismiss();
|
||
return;
|
||
}
|
||
countdownEl.textContent = remaining + 's';
|
||
remaining--;
|
||
countdownTimer = setTimeout(tick, 1000);
|
||
}
|
||
var countdownTimer = setTimeout(tick, 1000);
|
||
|
||
function applyPending() {
|
||
clearTimeout(countdownTimer);
|
||
try {
|
||
var raw = sessionStorage.getItem('liquid-pending');
|
||
if (raw) {
|
||
var data = JSON.parse(raw);
|
||
sessionStorage.removeItem('liquid-pending');
|
||
if (data && typeof data === 'object') {
|
||
var base = (window.L && L.env && L.env.admin_path)
|
||
? L.env.admin_path : '/cgi-bin/luci/admin/';
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', base + 'system/liquid/save_config', true);
|
||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||
xhr.send(JSON.stringify(data));
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
setTimeout(function () { location.reload(); }, 200);
|
||
}
|
||
|
||
function dismiss() {
|
||
clearTimeout(countdownTimer);
|
||
sessionStorage.removeItem('liquid-pending');
|
||
toast.classList.remove('show');
|
||
setTimeout(function () { toast.remove(); }, 300);
|
||
}
|
||
|
||
toast.querySelector('[data-action="apply"]').addEventListener('click', applyPending);
|
||
toast.querySelector('[data-action="dismiss"]').addEventListener('click', dismiss);
|
||
}
|
||
|
||
var mql = (typeof window.matchMedia == 'function')
|
||
? window.matchMedia('(prefers-color-scheme: dark)')
|
||
: null;
|
||
|
||
var MODES = [
|
||
{ id: 'light', icon: 'sun.svg', title: 'Light mode' },
|
||
{ id: 'dark', icon: 'moon.svg', title: 'Dark mode' },
|
||
{ id: 'auto', icon: 'auto.svg', title: 'Follow system' }
|
||
];
|
||
|
||
var ACCENTS = [
|
||
{ id: 'blue', color: '#2f7fe0', title: 'Blue' },
|
||
{ id: 'magenta', color: '#d63384', title: 'Magenta' },
|
||
{ id: 'amber', color: '#e8940f', title: 'Amber' },
|
||
{ id: 'purple', color: '#8e44ad', title: 'Tulip purple' },
|
||
{ id: 'lime', color: '#7fb52a', title: 'Yellow-green' }
|
||
];
|
||
|
||
var DEFAULT_ACCENT = '#2f7fe0';
|
||
|
||
/* 自定义主题色工具:hex 校验/规范化 + 由单个主色派生全套 accent 变量 */
|
||
function normalizeHex(v) {
|
||
v = String(v || '').trim().replace(/^#/, '');
|
||
if (/^[0-9a-fA-F]{3}$/.test(v))
|
||
v = v[0] + v[0] + v[1] + v[1] + v[2] + v[2];
|
||
return /^[0-9a-fA-F]{6}$/.test(v) ? '#' + v.toLowerCase() : null;
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
var n = parseInt(hex.slice(1), 16);
|
||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||
}
|
||
|
||
function shade(rgb, pct) {
|
||
var c = function (v) {
|
||
v = Math.round(pct > 0 ? v + (255 - v) * pct : v * (1 + pct));
|
||
return Math.max(0, Math.min(255, v));
|
||
};
|
||
return 'rgb(' + c(rgb.r) + ',' + c(rgb.g) + ',' + c(rgb.b) + ')';
|
||
}
|
||
|
||
function rgba(rgb, a) {
|
||
return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' + a + ')';
|
||
}
|
||
|
||
/* 在 <html> 上生成自定义色全套变量(亮/暗两套) */
|
||
function applyCustomAccent(hex) {
|
||
hex = normalizeHex(hex) || DEFAULT_ACCENT;
|
||
var rgb = hexToRgb(hex);
|
||
var root = document.documentElement;
|
||
root.style.setProperty('--accent-custom', hex);
|
||
root.style.setProperty('--accent-custom-medium', shade(rgb, -0.15));
|
||
root.style.setProperty('--accent-custom-low', shade(rgb, -0.32));
|
||
root.style.setProperty('--accent-custom-glow', rgba(rgb, 0.38));
|
||
root.style.setProperty('--accent-custom-grad', 'linear-gradient(135deg, ' + shade(rgb, 0.25) + ' 0%, ' + hex + ' 48%, ' + shade(rgb, -0.4) + ' 100%)');
|
||
root.style.setProperty('--accent-custom-glass', 'linear-gradient(135deg, ' + rgba(rgb, 0.3) + ', rgba(255,255,255,0.62))');
|
||
root.style.setProperty('--accent-custom-glass-soft', 'linear-gradient(135deg, ' + rgba(rgb, 0.24) + ', rgba(255,255,255,0.72))');
|
||
root.style.setProperty('--accent-custom-dark-high', shade(rgb, 0.3));
|
||
root.style.setProperty('--accent-custom-dark-medium', shade(rgb, 0.12));
|
||
root.style.setProperty('--accent-custom-dark-low', hex);
|
||
root.style.setProperty('--accent-custom-dark-glow', rgba(rgb, 0.42));
|
||
root.style.setProperty('--accent-custom-dark-grad', 'linear-gradient(135deg, ' + shade(rgb, 0.5) + ' 0%, ' + shade(rgb, 0.3) + ' 48%, ' + shade(rgb, 0.12) + ' 100%)');
|
||
root.style.setProperty('--accent-custom-dark-glass', 'linear-gradient(135deg, ' + rgba(rgb, 0.28) + ', rgba(46,58,84,0.45))');
|
||
root.style.setProperty('--accent-custom-dark-glass-soft', 'linear-gradient(135deg, ' + rgba(rgb, 0.22) + ', rgba(46,58,84,0.4))');
|
||
}
|
||
|
||
function getMode() {
|
||
var d = document.body ? document.body.getAttribute('data-liquid-mode') : null;
|
||
if (d)
|
||
return d;
|
||
try { return localStorage.getItem(MODE_KEY) || 'auto'; } catch (e) { return 'auto'; }
|
||
}
|
||
|
||
function setMode(mode) {
|
||
try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
|
||
if (document.body)
|
||
document.body.setAttribute('data-liquid-mode', mode);
|
||
applyMode(mode);
|
||
updateSwitch();
|
||
/* 明暗切换后 accent 背景的 RGB 需要用新模式的色值重写 */
|
||
setGlassOpacity(getGlassOpacity());
|
||
saveConfig({ mode: mode });
|
||
}
|
||
|
||
function isDark(mode) {
|
||
return (mode == 'dark') || ((mode == 'auto') && mql && mql.matches);
|
||
}
|
||
|
||
function applyMode(mode) {
|
||
var root = document.documentElement;
|
||
/* 加 transition 让明暗切换平滑过渡,避免整页重算导致白屏闪烁 */
|
||
root.style.transition = 'background-color .3s ease, color .3s ease';
|
||
root.setAttribute('data-darkmode', isDark(mode) ? 'true' : 'false');
|
||
root.setAttribute('data-liquid-mode', mode);
|
||
setTimeout(function () { root.style.transition = ''; }, 350);
|
||
}
|
||
|
||
/* 明暗模式守护:OpenClash 等第三方脚本会覆写 <html data-darkmode>
|
||
(其 ocApplyRootTheme 按背景亮度判定后直接 setAttribute,且默认
|
||
oc-theme=auto),导致一进入 OpenClash 页面主题就被强制切成暗黑。
|
||
这里用 MutationObserver 监视该属性,任何与主题有效模式不符的外部
|
||
写入都被立即还原,并顺带同步 meta[name=color-scheme]——主题的
|
||
明暗由主题自身决定,不受第三方反控制。 */
|
||
var _liquidGuardSelf = false;
|
||
function guardDarkmode() {
|
||
var root = document.documentElement;
|
||
if (root.getAttribute('data-liquid-guard'))
|
||
return;
|
||
root.setAttribute('data-liquid-guard', '1');
|
||
if (typeof MutationObserver == 'undefined')
|
||
return;
|
||
/* 还原逻辑:把 data-darkmode / color-scheme 拉回主题有效模式 */
|
||
function enforce() {
|
||
var want = isDark(getMode()) ? 'true' : 'false';
|
||
if (root.getAttribute('data-darkmode') !== want) {
|
||
_liquidGuardSelf = true;
|
||
root.setAttribute('data-darkmode', want);
|
||
_liquidGuardSelf = false;
|
||
}
|
||
var m = document.querySelector('meta[name="color-scheme"]');
|
||
if (m && m.content !== (want == 'true' ? 'dark' : 'light'))
|
||
m.content = (want == 'true' ? 'dark' : 'light');
|
||
syncThemeColor();
|
||
}
|
||
var obs = new MutationObserver(function () {
|
||
if (_liquidGuardSelf)
|
||
return;
|
||
enforce();
|
||
});
|
||
obs.observe(root, { attributes: true, attributeFilter: ['data-darkmode'] });
|
||
/* 关键:安装监视器时立即校正一次当前状态。OpenClash 的 common.js
|
||
常在 main.js(footer 加载)之前执行,auto 模式下按 body 背景
|
||
亮度误判成 dark 并已写入 data-darkmode;若只监视后续变化,
|
||
从 main.js 加载到 OpenClash 下次重算之间页面会一直保持暗黑,
|
||
形成"进 OpenClash 先闪暗再纠正"的窗口。footer 脚本在首帧绘制
|
||
前执行,此刻校正可在用户看到任何暗色之前完成还原。 */
|
||
enforce();
|
||
}
|
||
|
||
/* 浏览器工具栏配色(meta theme-color):取底部 foot 玻璃横幅(p.luci)
|
||
的真实渲染色,移动端浏览器工具栏才能与页面上下玻璃栏融为一体。
|
||
foot = --glass-bg-strong 半透明玻璃叠在 body 底色 --background-color-medium
|
||
上;theme-color 要不透明值,用 1x1 canvas 做真实叠加合成(canvas
|
||
原生解析 CSS 色值并完成 alpha 混合),随明暗/配色变量自动跟随,
|
||
不硬编码色值。顶栏 #menubar 与 foot 同为 --glass-bg-strong,
|
||
故该色同时贴合顶部地址栏区域。 */
|
||
function syncThemeColor() {
|
||
var meta = document.querySelector('meta[name="theme-color"]');
|
||
if (!meta)
|
||
return;
|
||
try {
|
||
var cs = getComputedStyle(document.documentElement);
|
||
var bg = (cs.getPropertyValue('--background-color-medium') || '').trim();
|
||
var glass = (cs.getPropertyValue('--glass-bg-strong') || '').trim();
|
||
if (!bg || !glass)
|
||
return;
|
||
var c = document.createElement('canvas');
|
||
c.width = 1;
|
||
c.height = 1;
|
||
var ctx = c.getContext('2d');
|
||
if (!ctx)
|
||
return;
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, 1, 1);
|
||
ctx.fillStyle = glass;
|
||
ctx.fillRect(0, 0, 1, 1);
|
||
var d = ctx.getImageData(0, 0, 1, 1).data;
|
||
var hex = '#';
|
||
for (var i = 0; i < 3; i++)
|
||
hex += ('0' + d[i].toString(16)).slice(-2);
|
||
if (meta.content !== hex)
|
||
meta.content = hex;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function updateSwitch() {
|
||
var mode = getMode();
|
||
[].forEach.call(document.querySelectorAll('.liquid-mode-btn'), function (b) {
|
||
b.classList.toggle('active', b.getAttribute('data-mode') == mode);
|
||
});
|
||
}
|
||
|
||
/* ---- accent color switch ---- */
|
||
|
||
function getAccent() {
|
||
var d = document.body ? document.body.getAttribute('data-liquid-accent') : null;
|
||
if (d)
|
||
return d;
|
||
try { return localStorage.getItem('liquid-accent') || 'blue'; } catch (e) { return 'blue'; }
|
||
}
|
||
|
||
function setAccent(id) {
|
||
try { localStorage.setItem('liquid-accent', id); } catch (e) {}
|
||
if (document.body)
|
||
document.body.setAttribute('data-liquid-accent', id);
|
||
document.documentElement.setAttribute('data-accent', id);
|
||
if (id == 'custom')
|
||
applyCustomAccent(getAccentCustom());
|
||
updateColorSwitch();
|
||
if (id != 'custom')
|
||
saveConfig({ accent: id });
|
||
}
|
||
|
||
function getAccentCustom() {
|
||
var d = document.body ? document.body.getAttribute('data-liquid-accent-custom') : null;
|
||
if (d)
|
||
return d;
|
||
try { return localStorage.getItem('liquid-accent-custom') || DEFAULT_ACCENT; } catch (e) { return DEFAULT_ACCENT; }
|
||
}
|
||
|
||
/* 设置自定义主题色:非法值退回默认蓝,但仍启用自定义模式 */
|
||
function setCustomAccent(hex) {
|
||
hex = normalizeHex(hex) || DEFAULT_ACCENT;
|
||
try { localStorage.setItem('liquid-accent-custom', hex); } catch (e) {}
|
||
if (document.body)
|
||
document.body.setAttribute('data-liquid-accent-custom', hex);
|
||
setAccent('custom');
|
||
saveConfig({ accent: 'custom', accent_custom: hex });
|
||
}
|
||
|
||
function getBing() {
|
||
var d = document.body ? document.body.getAttribute('data-liquid-bing') : null;
|
||
if (d)
|
||
return d;
|
||
try { return localStorage.getItem('liquid-bing') || '0'; } catch (e) { return '0'; }
|
||
}
|
||
|
||
function setBing(on) {
|
||
var v = on ? '1' : '0';
|
||
try { localStorage.setItem('liquid-bing', v); } catch (e) {}
|
||
if (document.body) {
|
||
document.body.setAttribute('data-liquid-bing', v);
|
||
/* 页面渲染时模板已确保当日壁纸缓存就绪,打开即视为可用,即时生效 */
|
||
document.body.setAttribute('data-liquid-bing-ok', v);
|
||
}
|
||
updateColorSwitch();
|
||
saveConfig({ bing: v });
|
||
}
|
||
|
||
/* ---- glass opacity slider ----
|
||
|
||
滑杆 0~100 → alpha 系数 0.00~1.00(0=全透,100=全不透)。
|
||
默认值 = 现有设计参数值(亮/暗各不同),
|
||
各变量 alpha = baseAlpha × (slider / default),
|
||
cap 到 [0, 1] 避免 RGBA 超值。
|
||
竖线 tick 标记默认位置,点击即回到默认。 */
|
||
|
||
var GLASS_OPACITY_DEFAULT_LIGHT = 42;
|
||
var GLASS_OPACITY_DEFAULT_DARK = 40;
|
||
|
||
function glassOpacityDefault() {
|
||
var dark = document.documentElement.getAttribute('data-darkmode') === 'true';
|
||
return dark ? GLASS_OPACITY_DEFAULT_DARK : GLASS_OPACITY_DEFAULT_LIGHT;
|
||
}
|
||
|
||
function getGlassOpacity() {
|
||
var d = document.body ? document.body.getAttribute('data-liquid-glass-opacity') : null;
|
||
if (d && d !== '')
|
||
return parseInt(d, 10);
|
||
try { return parseInt(localStorage.getItem('liquid-glass-opacity'), 10) || glassOpacityDefault(); } catch (e) { return glassOpacityDefault(); }
|
||
}
|
||
|
||
function setGlassOpacity(v) {
|
||
v = Math.max(0, Math.min(100, Math.round(v)));
|
||
try { localStorage.setItem('liquid-glass-opacity', String(v)); } catch (e) {}
|
||
if (document.body)
|
||
document.body.setAttribute('data-liquid-glass-opacity', String(v));
|
||
var def = glassOpacityDefault();
|
||
var factor = def > 0 ? Math.min(v / def, 100 / def) : 1;
|
||
var root = document.documentElement;
|
||
root.style.setProperty('--glass-opacity', factor.toFixed(2));
|
||
/* 直接重写 accent 背景色(JS 计算 rgba,比 CSS calc 在渐变里更可靠) */
|
||
_applyAccentAlpha(factor);
|
||
}
|
||
|
||
function _applyAccentAlpha(f) {
|
||
var root = document.documentElement;
|
||
var cs = getComputedStyle(root);
|
||
var clamp01 = function (x) { return Math.max(0, Math.min(1, x)); };
|
||
var a = function (varName, base) {
|
||
/* 从 CSS 变量读 base alpha(第 4 个参数),乘以 factor,clamp */
|
||
return clamp01(base * f);
|
||
};
|
||
/* 当前主题色的 accent-glass 变量基准 alpha(亮/暗通用):
|
||
取自计算后的变量值,直接重写 */
|
||
var accentHigh = cs.getPropertyValue('--primary-color-high').trim() || '#2f7fe0';
|
||
/* accent-glass / accent-glass-soft / accent-glow 的亮暗基准值 */
|
||
var dark = root.getAttribute('data-darkmode') === 'true';
|
||
var glowBase = dark ? 0.40 : 0.38;
|
||
var glassBase = dark ? [0.30, 0.50] : [0.32, 0.62];
|
||
var softBase = dark ? [0.24, 0.45] : [0.26, 0.72];
|
||
var glowA = a('--accent-glow-base', glowBase);
|
||
var glassA1 = a('--accent-glass-base', glassBase[0]);
|
||
var glassA2 = a('--accent-glass2-base', glassBase[1]);
|
||
var softA1 = a('--accent-soft-base', softBase[0]);
|
||
var softA2 = a('--accent-soft2-base', softBase[1]);
|
||
root.style.setProperty('--accent-glow', 'rgba(' + _hexToRgb(accentHigh) + ',' + glowA.toFixed(2) + ')');
|
||
root.style.setProperty('--accent-glass',
|
||
'linear-gradient(135deg, rgba(' + _hexToRgb(accentHigh) + ',' + glassA1.toFixed(2) + '), ' +
|
||
(dark ? 'rgba(46,58,84,' + glassA2.toFixed(2) + ')' : 'rgba(255,255,255,' + glassA2.toFixed(2) + ')') + ')');
|
||
root.style.setProperty('--accent-glass-soft',
|
||
'linear-gradient(135deg, rgba(' + _hexToRgb(accentHigh) + ',' + softA1.toFixed(2) + '), ' +
|
||
(dark ? 'rgba(46,58,84,' + softA2.toFixed(2) + ')' : 'rgba(255,255,255,' + softA2.toFixed(2) + ')') + ')');
|
||
}
|
||
|
||
function _hexToRgb(hex) {
|
||
hex = (hex || '#2f7fe0').replace('#', '');
|
||
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||
return parseInt(hex.substring(0,2),16) + ',' + parseInt(hex.substring(2,4),16) + ',' + parseInt(hex.substring(4,6),16);
|
||
}
|
||
|
||
function updateGlassSlider() {
|
||
var s = document.querySelector('.liquid-glass-slider');
|
||
if (s)
|
||
s.value = getGlassOpacity();
|
||
}
|
||
|
||
function initGlassOpacitySlider() {
|
||
var sw = document.querySelector('.liquid-color-switch');
|
||
if (!sw || sw.querySelector('.liquid-glass-slider-wrap'))
|
||
return;
|
||
|
||
var wrap = document.createElement('div');
|
||
wrap.className = 'liquid-glass-slider-wrap';
|
||
|
||
var slider = document.createElement('input');
|
||
slider.type = 'range';
|
||
slider.className = 'liquid-glass-slider';
|
||
slider.min = '0';
|
||
slider.max = '100';
|
||
slider.step = '1';
|
||
slider.value = String(getGlassOpacity());
|
||
slider.title = 'Glass opacity';
|
||
slider.setAttribute('aria-label', 'Glass opacity');
|
||
|
||
var def = glassOpacityDefault();
|
||
|
||
/* 计算 slider thumb 中心相对于 wrap 左边缘的像素位置 */
|
||
function thumbLeftPx(value) {
|
||
var slRect = slider.getBoundingClientRect();
|
||
var wRect = wrap.getBoundingClientRect();
|
||
/* thumb 的中心偏移 = slider 内容区 left + border + thumb半径 + value比例 × 可用宽度 */
|
||
var cs = getComputedStyle(slider);
|
||
var padL = parseFloat(cs.paddingLeft) || 0;
|
||
var padR = parseFloat(cs.paddingRight) || 0;
|
||
var bw = parseFloat(cs.borderLeftWidth) || 0;
|
||
var trackW = slRect.width - padL - padR - bw * 2;
|
||
var thumbHalf = 8; /* thumb 宽 16px,半径 8 */
|
||
var px = (slRect.left - wRect.left) + bw + padL + thumbHalf + (value / 100) * (trackW - thumbHalf * 2);
|
||
return px;
|
||
}
|
||
|
||
/* 浮动数值气泡:拖动时跟随 thumb 实时显示当前值 */
|
||
var bubble = document.createElement('div');
|
||
bubble.className = 'liquid-glass-slider-bubble';
|
||
bubble.style.display = 'none';
|
||
wrap.appendChild(bubble);
|
||
|
||
function updateBubble() {
|
||
var v = parseInt(slider.value, 10);
|
||
bubble.textContent = v;
|
||
bubble.style.left = thumbLeftPx(v) + 'px';
|
||
}
|
||
|
||
slider.addEventListener('input', function () {
|
||
setGlassOpacity(parseInt(slider.value, 10));
|
||
updateBubble();
|
||
});
|
||
slider.addEventListener('pointerdown', function () {
|
||
bubble.style.display = '';
|
||
updateBubble();
|
||
});
|
||
slider.addEventListener('pointerup', function () {
|
||
bubble.style.display = 'none';
|
||
saveConfig({ glass_opacity: parseInt(slider.value, 10) });
|
||
});
|
||
slider.addEventListener('pointercancel', function () {
|
||
bubble.style.display = 'none';
|
||
});
|
||
|
||
/* 默认值标记:百分比定位(border:none 后 thumb 百分比与 left 百分比一致) */
|
||
var tick = document.createElement('div');
|
||
tick.className = 'liquid-glass-slider-tick';
|
||
tick.title = 'Default';
|
||
tick.style.left = def + '%';
|
||
tick.addEventListener('click', function () {
|
||
slider.value = String(def);
|
||
setGlassOpacity(def);
|
||
saveConfig({ glass_opacity: def });
|
||
});
|
||
|
||
wrap.appendChild(slider);
|
||
wrap.appendChild(tick);
|
||
|
||
/* 锁屏页:滑杆放在胶囊容器外部下方,宽度跟随胶囊总宽 */
|
||
var loginCapsules = document.getElementById('liquid-login-capsules');
|
||
if (loginCapsules) {
|
||
wrap.classList.add('liquid-glass-slider-login');
|
||
loginCapsules.parentNode.insertBefore(wrap, loginCapsules.nextSibling);
|
||
/* 动态计算滑杆宽度 = 胶囊实际宽度 - 缩进 */
|
||
function syncSliderWidth() {
|
||
var cw = loginCapsules.offsetWidth;
|
||
if (cw > 0)
|
||
wrap.style.width = Math.max(40, cw - 50) + 'px';
|
||
}
|
||
setTimeout(syncSliderWidth, 50);
|
||
window.addEventListener('resize', syncSliderWidth);
|
||
} else {
|
||
sw.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
function updateColorSwitch() {
|
||
var accent = getAccent();
|
||
[].forEach.call(document.querySelectorAll('.liquid-color-btn'), function (b) {
|
||
b.classList.toggle('active', b.getAttribute('data-accent') == accent);
|
||
});
|
||
[].forEach.call(document.querySelectorAll('.liquid-bing-btn'), function (b) {
|
||
b.classList.toggle('active', getBing() == '1');
|
||
});
|
||
}
|
||
|
||
function initColorSwitch() {
|
||
if (document.getElementById('liquid-color-switch'))
|
||
return;
|
||
|
||
var wrap = document.createElement('span');
|
||
wrap.id = 'liquid-color-switch';
|
||
wrap.className = 'liquid-color-switch';
|
||
wrap.title = 'Accent color';
|
||
|
||
/* Bing 每日壁纸开关(五色之前):点亮启用在线壁纸 */
|
||
var bingBtn = document.createElement('button');
|
||
bingBtn.type = 'button';
|
||
bingBtn.className = 'liquid-color-btn liquid-bing-btn';
|
||
bingBtn.title = 'Bing 每日壁纸';
|
||
bingBtn.setAttribute('aria-label', 'Bing daily wallpaper');
|
||
bingBtn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><rect width="24" height="24" rx="5" fill="#008373"/><text x="12" y="17.5" font-family="Segoe UI, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff" text-anchor="middle">b</text></svg>';
|
||
bingBtn.addEventListener('click', function () {
|
||
setBing(getBing() != '1');
|
||
});
|
||
wrap.appendChild(bingBtn);
|
||
|
||
ACCENTS.forEach(function (c) {
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'liquid-color-btn liquid-accent-' + c.id;
|
||
btn.title = c.title;
|
||
btn.setAttribute('data-accent', c.id);
|
||
btn.setAttribute('aria-label', c.title);
|
||
btn.style.background = c.color;
|
||
btn.addEventListener('click', function () { setAccent(c.id); });
|
||
wrap.appendChild(btn);
|
||
});
|
||
|
||
/* 自定义主题色(5 色之后):彩虹圆点,点击下拉输入框输入颜色编码 */
|
||
var customBtn = document.createElement('button');
|
||
customBtn.type = 'button';
|
||
customBtn.className = 'liquid-color-btn liquid-custom-btn';
|
||
customBtn.title = 'Custom color';
|
||
customBtn.setAttribute('data-accent', 'custom');
|
||
customBtn.setAttribute('aria-label', 'Custom color');
|
||
wrap.appendChild(customBtn);
|
||
|
||
/* 菜单搜索按钮(Bing 之后):点击下推抽屉式搜索框,实时过滤
|
||
菜单项(支持中英文标题),再点或点外部收回 */
|
||
var searchBtn = document.createElement('button');
|
||
searchBtn.type = 'button';
|
||
searchBtn.className = 'liquid-color-btn liquid-search-btn';
|
||
searchBtn.title = 'Search menu';
|
||
searchBtn.setAttribute('aria-label', 'Search menu');
|
||
searchBtn.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2.4"/><line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>';
|
||
searchBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
toggleMenuSearch(e.currentTarget);
|
||
});
|
||
wrap.appendChild(searchBtn);
|
||
|
||
var pop = document.createElement('div');
|
||
pop.id = 'liquid-custom-pop';
|
||
pop.className = 'liquid-custom-pop';
|
||
pop.style.display = 'none';
|
||
var input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'liquid-custom-input';
|
||
input.placeholder = '#RRGGBB';
|
||
input.maxLength = 7;
|
||
input.setAttribute('spellcheck', 'false');
|
||
pop.appendChild(input);
|
||
wrap.appendChild(pop);
|
||
|
||
function toggleCustomPop() {
|
||
var show = pop.style.display == 'none';
|
||
pop.style.display = show ? 'block' : 'none';
|
||
if (show) {
|
||
input.value = getAccentCustom();
|
||
setTimeout(function () { input.focus(); input.select(); }, 0);
|
||
}
|
||
}
|
||
|
||
/* 离开输入框(blur)即保存;非法值退回默认蓝 */
|
||
function commitCustom() {
|
||
var hex = normalizeHex(input.value);
|
||
if (!hex)
|
||
hex = DEFAULT_ACCENT;
|
||
setCustomAccent(hex);
|
||
}
|
||
|
||
customBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
toggleCustomPop();
|
||
});
|
||
input.addEventListener('keydown', function (e) {
|
||
if (e.key == 'Enter') {
|
||
commitCustom();
|
||
pop.style.display = 'none';
|
||
}
|
||
else if (e.key == 'Escape') {
|
||
pop.style.display = 'none';
|
||
}
|
||
});
|
||
input.addEventListener('blur', function () { commitCustom(); });
|
||
document.addEventListener('click', function (e) {
|
||
if (!wrap.contains(e.target))
|
||
pop.style.display = 'none';
|
||
});
|
||
|
||
var menubar = document.querySelector('#mainmenu');
|
||
|
||
if (menubar) {
|
||
/* 菜单栏顶部(水平居中) */
|
||
menubar.insertBefore(wrap, menubar.firstChild);
|
||
}
|
||
else {
|
||
/* lock screen / pages without a menubar */
|
||
var capsules = document.getElementById('liquid-login-capsules');
|
||
if (capsules) {
|
||
/* 登录页:插入胶囊容器,不加 fixed 定位 */
|
||
capsules.appendChild(wrap);
|
||
}
|
||
else {
|
||
wrap.classList.add('liquid-color-switch-fixed');
|
||
document.body.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
updateColorSwitch();
|
||
}
|
||
|
||
/* ── 菜单搜索(抽屉式下推)────────────────────────────
|
||
搜索按钮点击:在 #mainmenu 顶部下推一个圆角矩形搜索框,
|
||
下面的菜单整体被往下推(抽屉式,非悬浮)。输入时实时过滤
|
||
菜单项(匹配显示文本 + 英文原题 data-title)。再次点按钮
|
||
或点击菜单外区域收回。 */
|
||
var menuSearchOpen = false;
|
||
var menuSearchBox = null;
|
||
var menuSearchInput = null;
|
||
|
||
function buildMenuSearchBox() {
|
||
menuSearchBox = document.createElement('div');
|
||
menuSearchBox.id = 'liquid-menu-search';
|
||
menuSearchBox.className = 'liquid-menu-search';
|
||
|
||
var icon = document.createElement('span');
|
||
icon.className = 'liquid-menu-search-icon';
|
||
icon.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2.4"/><line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>';
|
||
|
||
menuSearchInput = document.createElement('input');
|
||
menuSearchInput.type = 'text';
|
||
menuSearchInput.className = 'liquid-menu-search-input';
|
||
menuSearchInput.placeholder = '搜索 / Search';
|
||
menuSearchInput.setAttribute('spellcheck', 'false');
|
||
menuSearchInput.addEventListener('input', function () {
|
||
filterMenuItems();
|
||
updateClearBtn();
|
||
});
|
||
|
||
var clearBtn = document.createElement('button');
|
||
clearBtn.type = 'button';
|
||
clearBtn.className = 'liquid-menu-search-clear';
|
||
clearBtn.innerHTML = '×';
|
||
clearBtn.title = 'Clear';
|
||
clearBtn.addEventListener('click', function () {
|
||
menuSearchInput.value = '';
|
||
filterMenuItems();
|
||
updateClearBtn();
|
||
menuSearchInput.focus();
|
||
});
|
||
|
||
function updateClearBtn() {
|
||
clearBtn.style.display = menuSearchInput.value ? 'inline-flex' : 'none';
|
||
}
|
||
updateClearBtn();
|
||
|
||
menuSearchBox.appendChild(icon);
|
||
menuSearchBox.appendChild(menuSearchInput);
|
||
menuSearchBox.appendChild(clearBtn);
|
||
|
||
var menu = document.getElementById('mainmenu');
|
||
if (menu) {
|
||
/* 插在颜色胶囊之后(本功能区不动,搜索框从其下方推出) */
|
||
var sw = menu.querySelector('.liquid-color-switch');
|
||
if (sw && sw.nextSibling)
|
||
menu.insertBefore(menuSearchBox, sw.nextSibling);
|
||
else
|
||
menu.insertBefore(menuSearchBox, menu.firstChild);
|
||
}
|
||
else
|
||
document.body.appendChild(menuSearchBox);
|
||
|
||
return menuSearchBox;
|
||
}
|
||
|
||
function filterMenuItems() {
|
||
var q = (menuSearchInput.value || '').trim().toLowerCase();
|
||
var menu = document.getElementById('mainmenu');
|
||
if (!menu)
|
||
return;
|
||
|
||
/* 收集所有菜单项(一级/二级,含中英文标题) */
|
||
var items = [];
|
||
menu.querySelectorAll('ul.mainmenu li').forEach(function (li) {
|
||
var a = li.querySelector(':scope > a');
|
||
if (!a)
|
||
return;
|
||
var isL1 = li.parentElement && li.parentElement.classList.contains('l1');
|
||
items.push({
|
||
li: li,
|
||
text: (a.textContent || '').trim().toLowerCase(),
|
||
en: ((a.getAttribute('data-title') || '')).toLowerCase(),
|
||
isL1: isL1
|
||
});
|
||
});
|
||
|
||
/* 清空:恢复全部显示 + 还原展开状态(只留当前页所在菜单) */
|
||
if (!q) {
|
||
items.forEach(function (it) {
|
||
it.li.classList.remove('liquid-menu-hidden');
|
||
});
|
||
menu.querySelectorAll('ul.mainmenu.l1 > li').forEach(function (li) {
|
||
if (!li.classList.contains('selected'))
|
||
li.classList.remove('active');
|
||
});
|
||
return;
|
||
}
|
||
|
||
/* 匹配判断 */
|
||
items.forEach(function (it) {
|
||
it.match = (it.text.indexOf(q) != -1 || it.en.indexOf(q) != -1);
|
||
});
|
||
|
||
/* 按一级菜单分组处理:
|
||
一级匹配 → 展开并显示其全部二级项;
|
||
二级匹配 → 展开所属一级,显示匹配的二级项;
|
||
均不匹配 → 整组隐藏 */
|
||
menu.querySelectorAll('ul.mainmenu.l1 > li').forEach(function (li) {
|
||
var l1 = null, l2s = [];
|
||
items.forEach(function (it) {
|
||
if (it.li === li)
|
||
l1 = it;
|
||
else if (!it.isL1 && li.contains(it.li))
|
||
l2s.push(it);
|
||
});
|
||
var l1Match = l1 && l1.match;
|
||
var hasL2Match = l2s.some(function (it) { return it.match; });
|
||
|
||
if (l1Match || hasL2Match) {
|
||
li.classList.remove('liquid-menu-hidden');
|
||
li.classList.add('active'); /* 展开二级菜单 */
|
||
l2s.forEach(function (it) {
|
||
if (l1Match || it.match)
|
||
it.li.classList.remove('liquid-menu-hidden');
|
||
else
|
||
it.li.classList.add('liquid-menu-hidden');
|
||
});
|
||
}
|
||
else {
|
||
li.classList.add('liquid-menu-hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
function toggleMenuSearch(btn) {
|
||
if (menuSearchOpen) {
|
||
closeMenuSearch();
|
||
return;
|
||
}
|
||
|
||
if (!menuSearchBox)
|
||
buildMenuSearchBox();
|
||
|
||
menuSearchBox.classList.add('open');
|
||
menuSearchOpen = true;
|
||
var menu = document.getElementById('mainmenu');
|
||
if (menu)
|
||
menu.classList.add('liquid-search-open');
|
||
/* 搜索按钮与其他圆点一致的 active 高亮(用事件源按钮,可靠) */
|
||
if (btn)
|
||
btn.classList.add('active');
|
||
else {
|
||
var b = document.querySelector('.liquid-search-btn');
|
||
if (b)
|
||
b.classList.add('active');
|
||
}
|
||
setTimeout(function () { menuSearchInput.focus(); }, 50);
|
||
}
|
||
|
||
function closeMenuSearch() {
|
||
if (!menuSearchOpen)
|
||
return;
|
||
menuSearchOpen = false;
|
||
if (menuSearchBox)
|
||
menuSearchBox.classList.remove('open'); /* height/opacity transition 平滑收回 */
|
||
var menu = document.getElementById('mainmenu');
|
||
if (menu)
|
||
menu.classList.remove('liquid-search-open');
|
||
/* 搜索按钮还原 */
|
||
var btn = document.querySelector('.liquid-search-btn');
|
||
if (btn)
|
||
btn.classList.remove('active');
|
||
/* 清空过滤,恢复全部菜单 */
|
||
if (menuSearchInput) {
|
||
menuSearchInput.value = '';
|
||
filterMenuItems();
|
||
}
|
||
/* 还原菜单展开状态:只保留当前页所在的一级菜单展开 */
|
||
if (menu) {
|
||
menu.querySelectorAll('ul.mainmenu.l1 > li').forEach(function (li) {
|
||
if (!li.classList.contains('selected'))
|
||
li.classList.remove('active');
|
||
});
|
||
}
|
||
}
|
||
|
||
/* 点击菜单外区域收回(document 捕获阶段,排除搜索框/按钮自身) */
|
||
document.addEventListener('click', function (e) {
|
||
if (!menuSearchOpen)
|
||
return;
|
||
if (menuSearchBox && menuSearchBox.contains(e.target))
|
||
return;
|
||
if (e.target.closest && e.target.closest('.liquid-search-btn'))
|
||
return;
|
||
closeMenuSearch();
|
||
}, true);
|
||
|
||
function initSwitch() {
|
||
if (document.getElementById('liquid-mode-switch'))
|
||
return;
|
||
|
||
var wrap = document.createElement('span');
|
||
wrap.id = 'liquid-mode-switch';
|
||
wrap.className = 'liquid-mode-switch';
|
||
|
||
MODES.forEach(function (m) {
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'liquid-mode-btn liquid-mode-' + m.id;
|
||
btn.title = m.title;
|
||
btn.setAttribute('data-mode', m.id);
|
||
btn.setAttribute('aria-label', m.title);
|
||
|
||
var img = document.createElement('img');
|
||
img.src = L.media('svg/' + m.icon);
|
||
img.alt = '';
|
||
img.draggable = false;
|
||
|
||
btn.appendChild(img);
|
||
btn.addEventListener('click', function () { setMode(m.id); });
|
||
wrap.appendChild(btn);
|
||
});
|
||
|
||
var indicators = document.getElementById('indicators');
|
||
|
||
if (indicators && indicators.parentNode) {
|
||
/* 顶栏原位置(刷新指示区左侧) */
|
||
indicators.parentNode.insertBefore(wrap, indicators);
|
||
}
|
||
else {
|
||
/* lock screen / pages without a menubar */
|
||
var capsules = document.getElementById('liquid-login-capsules');
|
||
if (capsules) {
|
||
/* 登录页:插入胶囊容器,不加 fixed 定位 */
|
||
capsules.appendChild(wrap);
|
||
}
|
||
else {
|
||
wrap.classList.add('liquid-mode-switch-fixed');
|
||
document.body.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
updateSwitch();
|
||
}
|
||
|
||
/* keep auto mode in sync with OS theme changes */
|
||
if (mql && typeof mql.addEventListener == 'function') {
|
||
mql.addEventListener('change', function () {
|
||
if (getMode() == 'auto')
|
||
applyMode('auto');
|
||
});
|
||
}
|
||
else if (mql && typeof mql.addListener == 'function') {
|
||
mql.addListener(function () {
|
||
if (getMode() == 'auto')
|
||
applyMode('auto');
|
||
});
|
||
}
|
||
|
||
/* 修复 zh-cn 复数翻译缺失:多个网络时 "Part of networks:" 显示英文原文,
|
||
统一成中文 */
|
||
if (typeof N_ == 'function') {
|
||
var origN = N_;
|
||
N_ = function (count, one, other) {
|
||
if (one == 'Part of network:' && other == 'Part of networks:')
|
||
return '网络的一部分:';
|
||
return origN(count, one, other);
|
||
};
|
||
}
|
||
|
||
/* 内容区 tab 菜单追踪滑块(跟随 hover/选中项平滑滑动) */
|
||
function initTabSliders() {
|
||
document.querySelectorAll('ul.cbi-tabmenu').forEach(function (ul) {
|
||
if (ul.classList.contains('liquid-tab-slider-init'))
|
||
return;
|
||
|
||
ul.classList.add('liquid-tab-slider-init');
|
||
|
||
var ind = document.createElement('span');
|
||
ind.className = 'liquid-tab-indicator';
|
||
ul.appendChild(ind);
|
||
|
||
function moveTo(el) {
|
||
if (!el || !ul.contains(el)) {
|
||
ind.style.opacity = '0';
|
||
return;
|
||
}
|
||
var r = el.getBoundingClientRect();
|
||
var ur = ul.getBoundingClientRect();
|
||
ind.style.width = r.width + 'px';
|
||
ind.style.height = r.height + 'px';
|
||
ind.style.transform = 'translate(' + (r.left - ur.left) + 'px, ' + (r.top - ur.top) + 'px)';
|
||
ind.style.opacity = '1';
|
||
}
|
||
|
||
moveTo(ul.querySelector('li.cbi-tab'));
|
||
|
||
ul.addEventListener('mouseover', function (e) {
|
||
var li = e.target.closest ? e.target.closest('li') : null;
|
||
if (li && ul.contains(li))
|
||
moveTo(li);
|
||
});
|
||
ul.addEventListener('mouseleave', function () {
|
||
moveTo(ul.querySelector('li.cbi-tab'));
|
||
});
|
||
|
||
if (window.MutationObserver) {
|
||
var mo = new MutationObserver(function () {
|
||
moveTo(ul.querySelector('li.cbi-tab'));
|
||
});
|
||
mo.observe(ul, { attributes: true, subtree: true, attributeFilter: ['class'] });
|
||
}
|
||
});
|
||
}
|
||
|
||
/* 下拉宽度自适应:取选项中最长文本宽度 + 余量,总宽上限 240px
|
||
("保存并应用"组合按钮除外,保持 auto)。原生 select 同步。 */
|
||
function fitDropdownWidths() {
|
||
document.querySelectorAll('.cbi-dropdown:not(.cbi-button-apply)').forEach(function (dd) {
|
||
if (dd.classList.contains('liquid-dd-fit'))
|
||
return;
|
||
/* 打开中的下拉跳过测量:LuCI 正在克隆 preview/聚焦输入,
|
||
此时改 li/img 样式会干扰打开与自定义输入流程 */
|
||
if (dd.hasAttribute('open') || dd.classList.contains('open'))
|
||
return;
|
||
dd.classList.add('liquid-dd-fit');
|
||
var ul = dd.querySelector('ul');
|
||
if (!ul)
|
||
return;
|
||
var maxW = 0;
|
||
[].forEach.call(ul.querySelectorAll('li'), function (li) {
|
||
if (li.classList.contains('hide-close') || li.classList.contains('hide-open'))
|
||
return;
|
||
/* 隐藏 li 无法直接测宽:临时 absolute+hidden 测量。
|
||
图标(img)未加载时宽度为 0 会漏算:临时给 24px 估算 */
|
||
var imgs = li.querySelectorAll('img');
|
||
var saved = [];
|
||
[].forEach.call(imgs, function (img, i) {
|
||
saved[i] = img.style.width;
|
||
img.style.width = '24px';
|
||
img.style.flexShrink = '0';
|
||
});
|
||
var st = li.style;
|
||
st.display = 'block';
|
||
st.position = 'absolute';
|
||
st.visibility = 'hidden';
|
||
st.whiteSpace = 'nowrap';
|
||
var w = li.scrollWidth || 0;
|
||
st.display = '';
|
||
st.position = '';
|
||
st.visibility = '';
|
||
st.whiteSpace = '';
|
||
[].forEach.call(imgs, function (img, i) {
|
||
img.style.width = saved[i];
|
||
img.style.flexShrink = '';
|
||
});
|
||
if (w > maxW)
|
||
maxW = w;
|
||
});
|
||
|
||
/* 初始加载(读 uci 已选中项):当前值行 li[display] 是闭合时
|
||
唯一可见的项,实测其渲染宽(含图标),保证胶囊不短于已选项 */
|
||
[].forEach.call(ul.querySelectorAll('li[display]'), function (li) {
|
||
var imgs = li.querySelectorAll('img');
|
||
var saved = [];
|
||
[].forEach.call(imgs, function (img, i) {
|
||
saved[i] = img.style.width;
|
||
img.style.width = '24px';
|
||
img.style.flexShrink = '0';
|
||
});
|
||
var st = li.style;
|
||
st.position = 'absolute';
|
||
st.visibility = 'hidden';
|
||
st.whiteSpace = 'nowrap';
|
||
var w = li.scrollWidth || 0;
|
||
st.position = '';
|
||
st.visibility = '';
|
||
st.whiteSpace = '';
|
||
[].forEach.call(imgs, function (img, i) {
|
||
img.style.width = saved[i];
|
||
img.style.flexShrink = '';
|
||
});
|
||
if (w > maxW)
|
||
maxW = w;
|
||
});
|
||
if (maxW > 0) {
|
||
var w = Math.min(Math.max(maxW + 40, 60), 240);
|
||
dd.style.width = w + 'px';
|
||
dd.dataset.liquidW = w; /* 记录,关闭后恢复(luci 会清) */
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.cbi-select').forEach(function (sel) {
|
||
if (sel.classList.contains('liquid-dd-fit'))
|
||
return;
|
||
sel.classList.add('liquid-dd-fit');
|
||
var s = sel.querySelector('select');
|
||
if (!s)
|
||
return;
|
||
var maxW = 0;
|
||
[].forEach.call(s.options, function (o) {
|
||
var w = (o.text || '').length * 7.5;
|
||
if (w > maxW)
|
||
maxW = w;
|
||
});
|
||
if (maxW > 0)
|
||
sel.style.width = Math.min(Math.max(maxW + 60, 60), 240) + 'px';
|
||
});
|
||
}
|
||
|
||
/* 下拉框避让:按"屏幕上下可用空间"全局计算(而非卡片内相对位置):
|
||
哪一侧剩余空间更多就朝哪侧弹出;并给列表设动态 max-height,
|
||
使其始终完整落在屏幕内(超出部分出滚动条,不再被屏幕边缘裁掉) */
|
||
function adjustDropdownDirection() {
|
||
document.querySelectorAll('.cbi-dropdown').forEach(function (dd) {
|
||
var ul = dd.querySelector('ul.dropdown') || dd.querySelector('ul:not(.preview)');
|
||
if (!ul)
|
||
return;
|
||
/* 打开判定:优先 dd 的 open 状态;部分第三方渲染(如
|
||
OpenClash)不设 open 属性,改判 ul 是否带 dropdown class */
|
||
var open = dd.classList.contains('open') || dd.hasAttribute('open')
|
||
|| ul.classList.contains('dropdown');
|
||
|
||
if (!open) {
|
||
/* 关闭:清理 luci 开合流程残留的 inline 定位(触屏分支的
|
||
left/right 会把闭合胶囊横向拉长)并恢复 fit 宽度
|
||
(luci closeDropdown 会清 dd.style.width) */
|
||
ul.style.left = '';
|
||
ul.style.right = '';
|
||
ul.style.top = '';
|
||
ul.style.bottom = '';
|
||
ul.style.maxHeight = '';
|
||
releaseCardBlur();
|
||
if (dd.dataset.liquidW)
|
||
dd.style.width = dd.dataset.liquidW + 'px';
|
||
return;
|
||
}
|
||
|
||
/* 打开时暂时解除卡片链的 backdrop-filter:玻璃卡片形成
|
||
stacking context + containing block,内部 z-index 9999
|
||
出不去、fixed/absolute 被限制在卡片内(下拉被遮挡的根因)。
|
||
移除后下拉 z-index 可盖住后续卡片;关闭时 releaseCardBlur
|
||
恢复玻璃。 */
|
||
holdCardBlur(dd);
|
||
|
||
/* 清掉 luci 触屏分支残留的 left/right inline 定位:否则选项
|
||
列表可能被压成短短一横条/错位(F12 触发 resize 重算才恢复) */
|
||
ul.style.left = '';
|
||
ul.style.right = '';
|
||
|
||
/* 打开时校准宽度:选项实际渲染宽(含图标,无论 img/背景),
|
||
修正初始 fit 对图标漏算导致的"图标被截" */
|
||
var maxW = 0;
|
||
ul.querySelectorAll('li').forEach(function (li) {
|
||
var w = li.offsetWidth || 0;
|
||
if (w > maxW)
|
||
maxW = w;
|
||
});
|
||
if (maxW > 0) {
|
||
var nw = Math.min(Math.max(maxW + 40, 60), 240);
|
||
dd.style.width = nw + 'px';
|
||
dd.dataset.liquidW = nw;
|
||
}
|
||
|
||
var r = dd.getBoundingClientRect();
|
||
var vh = window.innerHeight;
|
||
var downSpace = vh - r.bottom; /* 按钮底 → 屏幕底 */
|
||
var upSpace = r.top; /* 屏幕顶 → 按钮顶 */
|
||
var maxH;
|
||
|
||
/* 先量出内容完整高度(临时去掉 max-height 限制) */
|
||
ul.style.maxHeight = '';
|
||
var fullH = ul.offsetHeight || 220;
|
||
|
||
if (downSpace >= upSpace) {
|
||
/* 下方空间更多:向下弹出,上限 = 下方可用空间 */
|
||
ul.style.top = 'calc(100% + 4px)';
|
||
ul.style.bottom = 'auto';
|
||
maxH = Math.max(60, downSpace - 8);
|
||
} else {
|
||
/* 上方空间更多:向上弹出,上限 = 上方可用空间 */
|
||
ul.style.top = 'auto';
|
||
ul.style.bottom = 'calc(100% + 4px)';
|
||
maxH = Math.max(60, upSpace - 8);
|
||
}
|
||
|
||
if (fullH > maxH)
|
||
ul.style.maxHeight = maxH + 'px';
|
||
else
|
||
ul.style.maxHeight = '';
|
||
});
|
||
}
|
||
|
||
/* ── 卡片 backdrop-filter 临时解除(下拉不被遮挡的关键)──
|
||
玻璃卡片 backdrop-filter 创建 stacking context + containing
|
||
block:内部下拉 z-index 9999 出不去、被后续卡片盖住。打开下拉
|
||
时给带 backdrop-filter 的祖先加 .liquid-dd-bf-off(CSS 置
|
||
backdrop-filter:none),关闭时移除。 */
|
||
var _bfChain = [];
|
||
function holdCardBlur(dd) {
|
||
releaseCardBlur();
|
||
var el = dd.parentElement;
|
||
while (el && el !== document.body) {
|
||
if (getComputedStyle(el).backdropFilter !== 'none' ||
|
||
getComputedStyle(el).webkitBackdropFilter !== 'none') {
|
||
el.classList.add('liquid-dd-bf-off');
|
||
_bfChain.push(el);
|
||
}
|
||
el = el.parentElement;
|
||
}
|
||
}
|
||
function releaseCardBlur() {
|
||
_bfChain.forEach(function (el) {
|
||
el.classList.remove('liquid-dd-bf-off');
|
||
});
|
||
_bfChain = [];
|
||
}
|
||
|
||
/* cbi-dropdown:选择后确保 li[display] 跟随选中项(当前值显示兜底) */
|
||
function syncDropdownValues() {
|
||
document.querySelectorAll('.cbi-dropdown').forEach(function (dd) {
|
||
if (dd.classList.contains('liquid-dd-init'))
|
||
return;
|
||
dd.classList.add('liquid-dd-init');
|
||
|
||
function sync() {
|
||
var ul = dd.querySelector('ul') || dd._liquidUl;
|
||
if (!ul)
|
||
return;
|
||
|
||
/* 复选框与 selected 状态同步(luci 偶发只改其一) */
|
||
ul.querySelectorAll('li').forEach(function (li) {
|
||
var cb = li.querySelector('input[type="checkbox"]');
|
||
if (cb)
|
||
cb.checked = li.hasAttribute('selected');
|
||
});
|
||
|
||
var open = dd.classList.contains('open') || dd.hasAttribute('open');
|
||
if (open)
|
||
return; /* 打开(多选打勾)时胶囊不变,避免频繁渲染 */
|
||
|
||
if (dd.hasAttribute('multiple')) {
|
||
/* 关闭后:把全部选中项设为 display 徽章(解除 luci 默认
|
||
只显示前几个的限制),竖向展示在胶囊内 */
|
||
var n = 0;
|
||
ul.querySelectorAll('li[display]').forEach(function (l) {
|
||
if (!l.hasAttribute('selected'))
|
||
l.removeAttribute('display');
|
||
});
|
||
ul.querySelectorAll('li[selected]').forEach(function (l) {
|
||
if (!l.hasAttribute('display'))
|
||
l.setAttribute('display', n);
|
||
n++;
|
||
});
|
||
return;
|
||
}
|
||
|
||
var sel = ul.querySelector('li[selected]');
|
||
var cur = ul.querySelector('li[display]');
|
||
if (sel && cur !== sel) {
|
||
if (cur)
|
||
cur.removeAttribute('display');
|
||
sel.setAttribute('display', '0');
|
||
}
|
||
}
|
||
|
||
sync();
|
||
/* luci 每次选值/取消都会派发 cbi-dropdown-change:同步勾选框与
|
||
selected 状态(点击即生效,不依赖 observer 时序) */
|
||
dd.addEventListener('cbi-dropdown-change', function () {
|
||
var u = dd.querySelector('ul') || dd._liquidUl;
|
||
if (!u)
|
||
return;
|
||
u.querySelectorAll('li').forEach(function (li) {
|
||
var cb = li.querySelector('input[type="checkbox"]');
|
||
if (cb)
|
||
cb.checked = li.hasAttribute('selected');
|
||
});
|
||
});
|
||
if (window.MutationObserver) {
|
||
var mo = new MutationObserver(function () {
|
||
sync();
|
||
adjustDropdownDirection();
|
||
});
|
||
mo.observe(dd, { attributes: true, subtree: true, attributeFilter: ['class', 'display', 'open', 'selected'] });
|
||
}
|
||
|
||
/* 兜底:点击选项后(ui.js toggleItem/closeDropdown 之后),
|
||
若关闭流程因任何原因中断(如 preview 缺失使 closeDropdown 抛错、
|
||
open 属性残留),强制把下拉重置为闭合态并让选中值显示在框里。 */
|
||
dd.addEventListener('click', function (e) {
|
||
/* 多选:luci 保持打开以便连续勾选,兜底不强制关闭;
|
||
但立即同步勾选框(当前事件循环结束、luci 处理完后) */
|
||
if (dd.hasAttribute('multiple')) {
|
||
setTimeout(function () {
|
||
var u = dd.querySelector('ul') || dd._liquidUl;
|
||
if (!u)
|
||
return;
|
||
u.querySelectorAll('li').forEach(function (li) {
|
||
var cb = li.querySelector('input[type="checkbox"]');
|
||
if (cb)
|
||
cb.checked = li.hasAttribute('selected');
|
||
});
|
||
}, 0);
|
||
return;
|
||
}
|
||
var li = e.target.closest ? e.target.closest('li') : null;
|
||
if (!li || !li.parentNode || !li.parentNode.classList.contains('dropdown'))
|
||
return;
|
||
/* 点击"自定义"输入行(unselectable / 含 create 输入框)时
|
||
不能强制关闭——那是输入框,需要滞留让用户输入 */
|
||
if (li.hasAttribute('unselectable') || li.querySelector('.create-item-input'))
|
||
return;
|
||
setTimeout(function () {
|
||
var ul = dd.querySelector('ul.dropdown');
|
||
if (!ul)
|
||
return; /* ui.js 已正常关闭 */
|
||
var pv = dd.querySelector('ul.preview');
|
||
if (pv && pv.parentNode === dd)
|
||
dd.removeChild(pv);
|
||
ul.classList.remove('dropdown');
|
||
ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
|
||
dd.removeAttribute('open');
|
||
var sel = ul.querySelector('li[selected]');
|
||
if (!sel)
|
||
return; /* 点击未生效(toggleItem 没跑),不动现状 */
|
||
ul.querySelectorAll('li[display]').forEach(function (l) {
|
||
if (l !== sel)
|
||
l.removeAttribute('display');
|
||
});
|
||
if (!sel.hasAttribute('display'))
|
||
sel.setAttribute('display', '0');
|
||
}, 0);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* 悬浮内容框(.cbi-tooltip):hover 时把 tooltip 临时移到 body 顶层
|
||
(fixed 定位),彻底脱离卡片 backdrop-filter 的 stacking context,
|
||
这样 tooltip 不会被相邻卡片遮挡。移出后放回原容器。
|
||
互斥保底:新的 hover 弹出时,先隐藏页面上所有其他 portal tooltip,
|
||
避免 mouseleave 偶尔不触发导致的残留停留。 */
|
||
function portalTooltips() {
|
||
document.querySelectorAll('.cbi-tooltip-container').forEach(function (c) {
|
||
if (c.classList.contains('liquid-tip-init'))
|
||
return;
|
||
c.classList.add('liquid-tip-init');
|
||
var tip = c.querySelector('.cbi-tooltip');
|
||
if (!tip)
|
||
return;
|
||
var origParent = tip.parentNode;
|
||
|
||
function hide() {
|
||
if (tip.parentNode !== origParent)
|
||
origParent.appendChild(tip);
|
||
tip.classList.remove('liquid-tip-ported');
|
||
tip.style.position = '';
|
||
tip.style.left = '';
|
||
tip.style.top = '';
|
||
tip.style.zIndex = '';
|
||
tip.style.opacity = '0';
|
||
tip.style.visibility = 'hidden';
|
||
tip.style.pointerEvents = '';
|
||
}
|
||
|
||
function show() {
|
||
/* 互斥保底:先隐藏页面上所有其他已显示的 portal tooltip */
|
||
document.querySelectorAll('.liquid-tip-ported').forEach(function (other) {
|
||
if (other !== tip) {
|
||
other.style.opacity = '0';
|
||
other.style.visibility = 'hidden';
|
||
other.classList.remove('liquid-tip-ported');
|
||
}
|
||
});
|
||
var r = c.getBoundingClientRect();
|
||
document.body.appendChild(tip);
|
||
tip.style.position = 'fixed';
|
||
tip.style.opacity = '0';
|
||
tip.style.visibility = 'visible';
|
||
tip.style.pointerEvents = 'none';
|
||
/* 自动避让:量出悬浮框尺寸,超出屏幕时翻转/收进视口,
|
||
保证完整显示(同一帧内完成,无闪烁) */
|
||
var tw = tip.offsetWidth, th = tip.offsetHeight;
|
||
var vw = window.innerWidth, vh = window.innerHeight;
|
||
var left = r.left, top = r.bottom + 6;
|
||
if (left + tw > vw - 8)
|
||
left = Math.max(8, vw - tw - 8);
|
||
if (top + th > vh - 8)
|
||
top = r.top - th - 6;
|
||
if (top < 8) top = 8;
|
||
if (left < 8) left = 8;
|
||
tip.style.left = left + 'px';
|
||
tip.style.top = top + 'px';
|
||
tip.style.zIndex = '99999';
|
||
tip.style.opacity = '1';
|
||
tip.style.visibility = 'visible';
|
||
tip.classList.add('liquid-tip-ported');
|
||
}
|
||
|
||
c.addEventListener('mouseenter', show);
|
||
c.addEventListener('mouseleave', hide);
|
||
});
|
||
}
|
||
|
||
/* 第三方插件的 fixed 弹窗(easytier .version-modal:刷新版本/重启服务
|
||
确认框):弹窗自身 position:fixed + inset:0 + flex 居中,本应相对
|
||
视口居中,但插在玻璃卡片内时,卡片的 backdrop-filter 会成为 fixed
|
||
的包含块,弹窗变成相对整张卡片居中——卡片很高时弹窗落在当前屏幕
|
||
之外(移动端要下划才能看到)。移到 body 下即恢复视口居中。
|
||
与 portalTooltips 同思路;easytier 用 getElementById + classList
|
||
切换 .show 显隐,移动节点不影响其逻辑。弹窗依赖的 --card-bg 等
|
||
变量定义在 :root,移出卡片不失效 */
|
||
/* 页面顶部的成功通知(如"系统密码已更改")LuCI 的 ui.addNotification
|
||
用 info 类、插在 #maincontent 顶部(#view 的兄弟节点)——视觉上
|
||
被内容淹没。这里把它们搬进 #modal_overlay 走既有的居中弹窗体系
|
||
(centerModals/watchModals 已在运行),点击或 6 秒后自动关闭。
|
||
只搬 info/notice/success(成功提示),danger/warning 留在原位。 */
|
||
function portalTopNotices() {
|
||
/* 页面顶部的成功通知(如"系统密码已更改")LuCI 用 info 类插在
|
||
#maincontent 顶部——视觉上被内容淹没。这里搬进主题自建的
|
||
独立遮罩层 #liquid-notice-overlay 居中弹出,6 秒/点击关闭。
|
||
不复用 ui.js 的 #modal_overlay:它里面常驻一个预创建的空
|
||
.modal 容器(showModal 底座),混用会把空壳一起显示出来
|
||
(两个框叠着),且 centerModals 会去居中那个空壳。
|
||
只搬 info/notice/success,danger/warning 留在原位。 */
|
||
var mc = document.getElementById('maincontent');
|
||
if (!mc)
|
||
return;
|
||
|
||
function getBox() {
|
||
var box = document.getElementById('liquid-notice-overlay');
|
||
if (!box) {
|
||
box = document.createElement('div');
|
||
box.id = 'liquid-notice-overlay';
|
||
document.body.appendChild(box);
|
||
}
|
||
return box;
|
||
}
|
||
|
||
function promote(node) {
|
||
if (node.nodeType !== 1 || !node.classList)
|
||
return;
|
||
if (!node.classList.contains('alert-message'))
|
||
return;
|
||
if (!(node.classList.contains('info') ||
|
||
node.classList.contains('notice') ||
|
||
node.classList.contains('success')))
|
||
return;
|
||
if (node.classList.contains('liquid-promoted'))
|
||
return;
|
||
var box = getBox();
|
||
node.classList.add('liquid-promoted');
|
||
box.appendChild(node);
|
||
box.classList.add('show');
|
||
var closed = false;
|
||
function close() {
|
||
if (closed)
|
||
return;
|
||
closed = true;
|
||
if (node.parentNode)
|
||
node.parentNode.removeChild(node);
|
||
if (!box.querySelector('.alert-message'))
|
||
box.classList.remove('show');
|
||
}
|
||
/* 点击任意处(含自带的"关闭"按钮)即关 */
|
||
node.addEventListener('click', close);
|
||
setTimeout(close, 6000);
|
||
}
|
||
|
||
/* 页面加载时已存在的通知 */
|
||
mc.querySelectorAll(':scope > .alert-message').forEach(promote);
|
||
if (window.MutationObserver) {
|
||
/* SPA 切页/保存后新插入的通知 */
|
||
new MutationObserver(function (muts) {
|
||
muts.forEach(function (m) {
|
||
m.addedNodes.forEach(promote);
|
||
});
|
||
}).observe(mc, { childList: true });
|
||
}
|
||
}
|
||
|
||
/* ── 页脚版本号 → 检查更新(对齐 pushbot 的检查更新设计)─────────
|
||
访问端浏览器前端 fetch GitHub raw Makefile(8s 超时),与本地
|
||
PKG_VERSION-PKG_RELEASE 比较,四态 frosted toast:
|
||
已最新(绿)/内测版(紫)/检测到更新(橙)/查询失败(红)。
|
||
- 非更新态:8s 自动消失(比 pushbot 多 4s),点卡片超链接作者仓库
|
||
- 更新态:不自动消失;按钮行 = pushbot 同款(一键更新/拉取新包/
|
||
下载链接/清理包/忽略)+ 多一个"作者仓库"在忽略左边
|
||
- 在线更新走主题自己的 OTA 端点(controller liquid.uc),
|
||
安装完成后倒计时刷新(轮询 act_version,与 pushbot 一致)
|
||
登录页不绑定(保留页脚版本号原跳转仓库超链接)。 */
|
||
function initVersionCheck() {
|
||
var REPO = 'https://github.com/zzsj0928/luci-theme-liquid';
|
||
var RAW_MK = 'https://raw.githubusercontent.com/zzsj0928/luci-theme-liquid/main/Makefile';
|
||
|
||
function api(name) {
|
||
var b = (window.L && L.env && L.env.admin_path)
|
||
? L.env.admin_path : '/cgi-bin/luci/admin/';
|
||
return b + 'system/liquid/' + name;
|
||
}
|
||
|
||
/* pushbot 同款版本比较:0.8-r69 → 0.8.69 逐段数字比 */
|
||
function cmpVer(a, b) {
|
||
a = String(a || '').replace(/^v/, '').replace(/-r/, '.');
|
||
b = String(b || '').replace(/^v/, '').replace(/-r/, '.');
|
||
var pa = a.split(/[.-]/), pb = b.split(/[.-]/), i, x, y;
|
||
for (i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||
x = parseInt(pa[i], 10) || 0;
|
||
y = parseInt(pb[i], 10) || 0;
|
||
if (x > y) return 1;
|
||
if (x < y) return -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function ringHtml(pct) {
|
||
return '<span class="liquid-ota-ring"><svg viewBox="0 0 36 36">'
|
||
+ '<circle cx="18" cy="18" r="15.9" class="liquid-ota-ring-bg"/>'
|
||
+ '<circle cx="18" cy="18" r="15.9" class="liquid-ota-ring-fg" style="stroke-dasharray:'
|
||
+ pct + ', 100"/></svg></span>';
|
||
}
|
||
|
||
/* 在页脚内弹出 frosted 卡片(overlay 锚定页脚盒子 → 页脚宽度内居中)。
|
||
inCard=true 时把提示盖到"检测到更新"卡片内部、卡片内上下左右
|
||
居中模糊弹出(流程内错误如"下载失败",对齐 pushbot
|
||
pb_ota_showError 设计);无更新卡片时退回页脚锚定 */
|
||
function showToast(msg, sub, cls, buttons, autoMs, clickRepo, extraStyle, inCard) {
|
||
var badge = document.querySelector('p.luci-foot a.liquid-version-link');
|
||
var footer = badge ? badge.closest('p.luci') : null;
|
||
var card = inCard ? document.querySelector('p.luci-foot .liquid-ver-toast.is-update') : null;
|
||
var parent = card || footer;
|
||
if (!parent) return null;
|
||
var ov = document.createElement('div');
|
||
ov.className = 'liquid-ver-overlay' + (card ? ' card-centered' : '');
|
||
var toast = document.createElement('div');
|
||
toast.className = 'liquid-ver-toast ' + cls + (clickRepo ? ' is-clickable' : '');
|
||
if (extraStyle) toast.style.cssText = extraStyle;
|
||
var tm = document.createElement('div');
|
||
tm.className = 'liquid-ver-toast-msg';
|
||
tm.textContent = msg;
|
||
toast.appendChild(tm);
|
||
if (sub) {
|
||
var ts = document.createElement('div');
|
||
ts.className = 'liquid-ver-toast-sub';
|
||
ts.textContent = sub;
|
||
toast.appendChild(ts);
|
||
}
|
||
if (buttons && buttons.length > 0) {
|
||
var row = document.createElement('div');
|
||
row.className = 'liquid-ota-btn-row';
|
||
buttons.forEach(function(cfg) {
|
||
var b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'liquid-ota-btn';
|
||
b.textContent = cfg.label;
|
||
if (cfg.id) b.id = cfg.id;
|
||
b.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (cfg.onClick) cfg.onClick(b, res);
|
||
});
|
||
row.appendChild(b);
|
||
});
|
||
toast.appendChild(row);
|
||
}
|
||
ov.appendChild(toast);
|
||
parent.appendChild(ov);
|
||
setTimeout(function() { ov.classList.add('show'); }, 20);
|
||
var autoId = null;
|
||
if (autoMs > 0) {
|
||
autoId = setTimeout(function() { res.dismiss(); }, autoMs);
|
||
}
|
||
var res = {
|
||
ov: ov,
|
||
dismiss: function() {
|
||
if (autoId) { clearTimeout(autoId); autoId = null; }
|
||
ov.classList.remove('show');
|
||
setTimeout(function() { ov.remove(); }, 550);
|
||
}
|
||
};
|
||
/* 非更新态:点卡片超链接作者仓库 */
|
||
if (clickRepo) {
|
||
toast.addEventListener('click', function(e) {
|
||
if (e.target.closest && e.target.closest('button')) return;
|
||
window.open(REPO, '_blank');
|
||
res.dismiss();
|
||
});
|
||
}
|
||
return res;
|
||
}
|
||
|
||
/* 流程内错误提示(下载失败等,pushbot 同款 4s 消失):
|
||
盖在更新卡片上、卡片内居中模糊弹出 */
|
||
function liquidOtaError(msg) {
|
||
showToast(msg, null, 'is-err', null, 4000, false, null, true);
|
||
}
|
||
|
||
/* 安装完成后的倒计时卡片:轮询 act_version,版本变化即刷新,
|
||
10s 兜底强制刷新(与 pushbot pb_ota_showCountdown 一致) */
|
||
function liquidOtaCountdown(local) {
|
||
var cdToast = showToast('安装完成', null, 'is-latest', null, 0, false,
|
||
'min-width:150px;max-width:220px;padding:12px 16px;border-radius:10px;text-align:center;transform:scale(0.85);');
|
||
if (!cdToast) return;
|
||
cdToast.ov.style.zIndex = '99999';
|
||
cdToast.ov.querySelector('.liquid-ver-toast').innerHTML =
|
||
'<div style="font-size:22px;font-weight:800;font-family:Menlo,Consolas,monospace" id="liquid_ota_countdown">10</div>'
|
||
+ '<div style="margin-top:6px"><button onclick="location.reload()" style="padding:4px 14px;border-radius:6px;border:1px solid rgba(255,255,255,0.35);background:rgba(255,255,255,0.15);color:#fff;font-size:11px;font-weight:600;cursor:pointer">立即刷新</button></div>';
|
||
var cdSec = 10;
|
||
var cdTimer = setInterval(function() {
|
||
cdSec--;
|
||
var el = document.getElementById('liquid_ota_countdown');
|
||
if (el) el.textContent = cdSec;
|
||
if (cdSec <= 0) { clearInterval(cdTimer); clearInterval(verTimer); location.reload(); }
|
||
}, 1000);
|
||
/* 每 2s 轮询主题版本,变了立即刷新 */
|
||
var verTimer = setInterval(function() {
|
||
var px = new XMLHttpRequest();
|
||
px.open('GET', api('version') + '?_=' + Date.now());
|
||
px.onload = function() {
|
||
try {
|
||
var d = JSON.parse(px.responseText);
|
||
if (d && d.version && local && d.version !== local) {
|
||
clearInterval(verTimer);
|
||
clearInterval(cdTimer);
|
||
location.reload();
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
px.send();
|
||
}, 2000);
|
||
}
|
||
|
||
function bind() {
|
||
/* 登录页不接管(保留原跳转仓库超链接),也避免选择器永远
|
||
匹配不到导致的无限 200ms 重试 */
|
||
if (document.body && document.body.classList.contains('liquid-login')) return;
|
||
var badge = document.querySelector('p.luci-foot a.liquid-version-link');
|
||
if (!badge) { setTimeout(bind, 200); return; }
|
||
if (badge._lvBound) return;
|
||
badge._lvBound = true;
|
||
badge.title = '点击检查更新';
|
||
|
||
badge.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
if (badge.classList.contains('is-checking')) return;
|
||
badge.classList.add('is-checking');
|
||
var local = badge.getAttribute('data-ver') || '';
|
||
/* 新检查前清掉旧卡片,避免叠加 */
|
||
var footer = badge.closest('p.luci');
|
||
if (footer)
|
||
footer.querySelectorAll('.liquid-ver-overlay').forEach(function(o) { o.remove(); });
|
||
|
||
var ctl = new AbortController();
|
||
var timer = setTimeout(function() { ctl.abort(); }, 8000);
|
||
fetch(RAW_MK, { signal: ctl.signal })
|
||
.then(function(r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.text();
|
||
})
|
||
.then(function(txt) {
|
||
clearTimeout(timer);
|
||
var mv = txt.match(/PKG_VERSION:=([0-9.]+)/);
|
||
var mr = txt.match(/PKG_RELEASE:=([0-9]+)/);
|
||
var remote = (mv && mr) ? mv[1] + '-r' + mr[1] : '';
|
||
if (!remote) throw new Error('parse');
|
||
var c = cmpVer(local, remote);
|
||
var msg, cls;
|
||
if (c > 0) { msg = '已是抢先体验的内测版!'; cls = 'is-ahead'; }
|
||
else if (c < 0) { msg = '作者仓库发布了新版本!'; cls = 'is-update'; }
|
||
else { msg = '已经是最新版本!'; cls = 'is-latest'; }
|
||
var sub = '📦 v' + local + ' ☁️ v' + remote;
|
||
var remoteVer = mv[1], remoteRel = mr[1];
|
||
|
||
var btns = null;
|
||
if (c < 0) {
|
||
btns = [
|
||
{
|
||
label: '一键更新',
|
||
id: 'liquid_ota_oneclick_btn',
|
||
onClick: function(btn, r2) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = ringHtml(0) + '0%';
|
||
/* 与"拉取新包"完全一致的轮询/报错链路(pushbot 原版一键更新缺
|
||
zeroCount 守卫会默默卡 0%):即刻轮询不等 onload、fail→下载失败、
|
||
卡0%×20s→GitHub Release 无法访问、网络错→下载失败 */
|
||
var zeroCount = 0;
|
||
var pollId = setInterval(function() {
|
||
var px = new XMLHttpRequest();
|
||
px.open('GET', api('ota_download_progress') + '?_=' + Date.now());
|
||
px.onload = function() {
|
||
try {
|
||
var pd = JSON.parse(px.responseText);
|
||
var pct = pd.progress || '0';
|
||
if (pct === 'done') {
|
||
clearInterval(pollId);
|
||
btn.textContent = '安装中...';
|
||
var ix = new XMLHttpRequest();
|
||
ix.open('GET', api('ota_install') + '?_=' + Date.now());
|
||
ix.send();
|
||
r2.dismiss();
|
||
liquidOtaCountdown(local);
|
||
} else if (pct === 'fail') {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '一键更新';
|
||
liquidOtaError('下载失败');
|
||
} else {
|
||
var num = parseInt(pct, 10) || 0;
|
||
if (num === 0) zeroCount++; else zeroCount = 0;
|
||
if (zeroCount >= 20) {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '一键更新';
|
||
liquidOtaError('GitHub Release 无法访问');
|
||
return;
|
||
}
|
||
btn.innerHTML = ringHtml(num) + num + '%';
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
px.send();
|
||
}, 1000);
|
||
var x = new XMLHttpRequest();
|
||
x.open('GET', api('ota_download') + '?ver=' + encodeURIComponent(remoteVer)
|
||
+ '&rel=' + encodeURIComponent(remoteRel) + '&_=' + Date.now());
|
||
x.onerror = function() {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '一键更新';
|
||
liquidOtaError('下载失败');
|
||
};
|
||
x.send();
|
||
}
|
||
},
|
||
{
|
||
label: '拉取新包',
|
||
id: 'liquid_ota_pull_btn',
|
||
onClick: function(btn, r2) {
|
||
/* 已下载完 → 直接安装 */
|
||
if (btn.getAttribute('data-ready') === '1') {
|
||
btn.disabled = true;
|
||
btn.textContent = '安装中...';
|
||
var ix = new XMLHttpRequest();
|
||
ix.open('GET', api('ota_install') + '?_=' + Date.now());
|
||
ix.send();
|
||
r2.dismiss();
|
||
liquidOtaCountdown(local);
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
btn.innerHTML = ringHtml(0) + '0%';
|
||
var zeroCount = 0;
|
||
var pollId = setInterval(function() {
|
||
var px = new XMLHttpRequest();
|
||
px.open('GET', api('ota_download_progress') + '?_=' + Date.now());
|
||
px.onload = function() {
|
||
try {
|
||
var pd = JSON.parse(px.responseText);
|
||
var pct = pd.progress || '0';
|
||
if (pct === 'done') {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '安装更新';
|
||
btn.classList.add('liquid-ota-ready');
|
||
btn.setAttribute('data-ready', '1');
|
||
} else if (pct === 'fail') {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '拉取新包';
|
||
liquidOtaError('下载失败');
|
||
} else {
|
||
var num = parseInt(pct, 10) || 0;
|
||
if (num === 0) zeroCount++; else zeroCount = 0;
|
||
if (zeroCount >= 20) {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '拉取新包';
|
||
liquidOtaError('GitHub Release 无法访问');
|
||
return;
|
||
}
|
||
btn.innerHTML = ringHtml(num) + num + '%';
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
px.send();
|
||
}, 1000);
|
||
/* 触发下载(后台进行),网络层失败与一键更新同样立即报错 */
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', api('ota_download') + '?ver=' + encodeURIComponent(remoteVer)
|
||
+ '&rel=' + encodeURIComponent(remoteRel) + '&_=' + Date.now());
|
||
xhr.onerror = function() {
|
||
clearInterval(pollId);
|
||
btn.disabled = false;
|
||
btn.textContent = '拉取新包';
|
||
liquidOtaError('下载失败');
|
||
};
|
||
xhr.send();
|
||
}
|
||
},
|
||
{
|
||
label: '下载链接',
|
||
onClick: function(btn, r2) {
|
||
var baseUrl = REPO + '/releases/download/luci-theme-liquid-v'
|
||
+ remoteVer + '-r' + remoteRel + '/';
|
||
var isApk = false;
|
||
var xh = new XMLHttpRequest();
|
||
xh.open('GET', api('detect_pkgmgr') + '?_=' + Date.now(), false);
|
||
xh.send();
|
||
if (xh.status === 200) {
|
||
try { var d = JSON.parse(xh.responseText); isApk = (d.pkgmgr === 'apk'); } catch (e) {}
|
||
}
|
||
var file = isApk
|
||
? ('luci-theme-liquid-' + remoteVer + '-r' + remoteRel + '.apk')
|
||
: ('luci-theme-liquid_' + remoteVer + '-r' + remoteRel + '_all.ipk');
|
||
window.open(baseUrl + file, '_blank');
|
||
r2.dismiss();
|
||
}
|
||
},
|
||
{
|
||
label: '清理包',
|
||
onClick: function(btn, r2) {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', api('ota_clear') + '?_=' + Date.now());
|
||
xhr.onload = function() {
|
||
btn.textContent = '已清理';
|
||
btn.disabled = true;
|
||
setTimeout(function() { r2.dismiss(); }, 1500);
|
||
};
|
||
xhr.send();
|
||
}
|
||
},
|
||
{
|
||
label: '作者仓库',
|
||
onClick: function(btn, r2) {
|
||
window.open(REPO, '_blank');
|
||
r2.dismiss();
|
||
}
|
||
},
|
||
{
|
||
label: '忽略',
|
||
onClick: function(btn, r2) {
|
||
r2.dismiss();
|
||
}
|
||
}
|
||
];
|
||
}
|
||
/* 更新态:不自动消失(0);其他态:8s(比 pushbot 多 4s)+ 点卡片跳仓库 */
|
||
showToast(msg, sub, cls, btns, btns ? 0 : 8000, !btns);
|
||
})
|
||
.catch(function() {
|
||
clearTimeout(timer);
|
||
showToast('网络异常,无法访问作者仓库! ☹️', '📦 v' + local, 'is-err', null, 8000, true);
|
||
})
|
||
.finally(function() { badge.classList.remove('is-checking'); });
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'complete')
|
||
bind();
|
||
else
|
||
window.addEventListener('load', bind);
|
||
setTimeout(bind, 200);
|
||
}
|
||
|
||
function portalFixedModals() {
|
||
document.querySelectorAll('.version-modal').forEach(function (m) {
|
||
if (m.parentNode !== document.body)
|
||
document.body.appendChild(m);
|
||
});
|
||
}
|
||
|
||
/* 文档级滚动配套(r22):LuCI SPA 切页(菜单/面包屑/标签链接 → XHR
|
||
换 #view 内容)不重置文档滚动位置,新页面会停在旧深度;安卓对
|
||
"初始滚动非零"的页面要先把滚到顶再下滑才肯收缩地址栏。这里在
|
||
点击站内导航链接后监听 #view 直接子节点变化(= 切页渲染完成;
|
||
深层的自动刷新/控件更新不触发,避免误回顶),一旦换页立即把
|
||
文档滚回顶部——每个页面都从顶开始,首次下滑即可收缩工具栏。
|
||
整页跳转时观察器随页面销毁,无副作用;5 秒兜底自动撤防 */
|
||
function initNavScrollTop() {
|
||
if (typeof MutationObserver == 'undefined')
|
||
return;
|
||
var mo = null, timer = 0;
|
||
function disarm() {
|
||
if (timer) { clearTimeout(timer); timer = 0; }
|
||
if (mo) { mo.disconnect(); mo = null; }
|
||
}
|
||
document.addEventListener('click', function (ev) {
|
||
if (ev.button !== 0)
|
||
return;
|
||
var a = (ev.target && ev.target.closest) ? ev.target.closest('a[href]') : null;
|
||
if (!a)
|
||
return;
|
||
var href = a.getAttribute('href') || '';
|
||
/* 只处理站内导航:排除纯锚点、外部链接、javascript 伪协议 */
|
||
if (!href || href.charAt(0) == '#' ||
|
||
/^(?:[a-z][a-z0-9+.-]*)?\/\//i.test(href) ||
|
||
href.toLowerCase().indexOf('javascript:') === 0)
|
||
return;
|
||
var view = document.getElementById('view');
|
||
if (!view)
|
||
return;
|
||
disarm();
|
||
mo = new MutationObserver(function () {
|
||
disarm();
|
||
window.scrollTo(0, 0);
|
||
});
|
||
mo.observe(view, { childList: true });
|
||
timer = setTimeout(disarm, 5000);
|
||
}, true);
|
||
}
|
||
|
||
/* 登录页 logo:luci 的 modal.login 是 JS 渲染的,注入内联 SVG
|
||
(跟随主题色 + 玻璃水滴感),替换原 CSS 背景图 */
|
||
function injectLoginLogo() {
|
||
var m = document.querySelector('body.liquid-login #modal_overlay > .modal.login');
|
||
if (!m || m.querySelector('.liquid-logo'))
|
||
return;
|
||
var w = document.createElement('div');
|
||
w.innerHTML = '<svg class="liquid-logo" viewBox="0 0 64 68" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><linearGradient id="liquid-lg-login" x1="0" y1="0" x2="0.3" y2="1"><stop offset="0%" stop-color="rgba(255,255,255,0.55)"/><stop offset="30%" stop-color="rgba(255,255,255,0.20)"/><stop offset="100%" stop-color="var(--primary-color-low)" stop-opacity="0.85"/></linearGradient><linearGradient id="liquid-lg-shine" x1="0.3" y1="0" x2="0.7" y2="0.6"><stop offset="0%" stop-color="white" stop-opacity="0.6"/><stop offset="60%" stop-color="white" stop-opacity="0"/></linearGradient></defs><path d="M32 3 C46 20 57 30 57 42 a25 25 0 0 1 -50 0 C7 30 18 20 32 3 Z" fill="url(#liquid-lg-login)"/><path d="M32 3 C46 20 57 30 57 42 a25 25 0 0 1 -50 0 C7 30 18 20 32 3 Z" fill="url(#liquid-lg-shine)"/><ellipse cx="23" cy="39" rx="9.5" ry="6" fill="#ffffff" opacity="0.35"/></svg>';
|
||
m.insertBefore(w.firstChild, m.firstChild);
|
||
}
|
||
|
||
/* 窗口尺寸变化时重新计算已打开下拉框的方向 */
|
||
window.addEventListener('resize', function () {
|
||
setTimeout(adjustDropdownDirection, 60);
|
||
});
|
||
|
||
/* 移动端菜单 top 跟随顶栏实际高度(防顶栏被撑高后菜单盖住它) */
|
||
function syncMenuTop() {
|
||
var bar = document.getElementById('menubar');
|
||
var menu = document.getElementById('mainmenu');
|
||
|
||
if (!bar || !menu)
|
||
return;
|
||
|
||
if (window.matchMedia && window.matchMedia('(max-width: 854px)').matches)
|
||
menu.style.top = (bar.offsetHeight + 1) + 'px';
|
||
else
|
||
menu.style.top = '';
|
||
}
|
||
|
||
/* #indicators 由 ui.js 动态渲染,顶栏高度在 DOMContentLoaded 时可能未定型:
|
||
延迟二次校准 + 汉堡点击时校准 */
|
||
function scheduleMenuTop() {
|
||
setTimeout(syncMenuTop, 0);
|
||
}
|
||
|
||
applyCustomAccent(getAccentCustom());
|
||
guardDarkmode();
|
||
syncThemeColor();
|
||
|
||
if (document.readyState == 'loading')
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
flushPending();
|
||
showPendingToast();
|
||
initSwitch();
|
||
initColorSwitch();
|
||
initGlassOpacitySlider();
|
||
syncMenuTop();
|
||
initTabSliders();
|
||
syncDropdownValues();
|
||
fitDropdownWidths();
|
||
portalTooltips();
|
||
portalFixedModals();
|
||
portalTopNotices();
|
||
initVersionCheck();
|
||
fixComboPillClick();
|
||
initNavScrollTop();
|
||
injectLoginLogo();
|
||
setTimeout(syncMenuTop, 300);
|
||
setTimeout(initTabSliders, 300);
|
||
setTimeout(syncDropdownValues, 300);
|
||
});
|
||
else {
|
||
flushPending();
|
||
showPendingToast();
|
||
initSwitch();
|
||
initColorSwitch();
|
||
initGlassOpacitySlider();
|
||
syncMenuTop();
|
||
initTabSliders();
|
||
syncDropdownValues();
|
||
fitDropdownWidths();
|
||
portalTooltips();
|
||
portalFixedModals();
|
||
portalTopNotices();
|
||
initVersionCheck();
|
||
fixComboPillClick();
|
||
initNavScrollTop();
|
||
injectLoginLogo();
|
||
setTimeout(syncMenuTop, 300);
|
||
setTimeout(initTabSliders, 300);
|
||
setTimeout(syncDropdownValues, 300);
|
||
}
|
||
|
||
/* 定时清除所有内联 opacity(非 0/1 的残留值,luci 的离线/hover
|
||
样式会残留在接口徽章图标/文字、删除按钮、'取消配置'按钮等元素上,
|
||
让已连接内容呈半透、被误判为未连接)。统一压回不透明。 */
|
||
setInterval(function () {
|
||
document.querySelectorAll('[style*="opacity"]').forEach(function (el) {
|
||
var o = el.style.opacity;
|
||
if (o && o !== '1' && o !== '0')
|
||
el.style.opacity = '';
|
||
});
|
||
}, 600);
|
||
|
||
/* 点击内容区(当前值行)也能稳定展开:LuCI 的 handleClick 虽支持整块
|
||
点击,但内容区 click 会冒泡到 window 的 closeAllDropdowns,导致
|
||
打开即关闭(闪烁)。拦截内容区 click,改为以胶囊本身为目标重新
|
||
触发,走 handleClick 的打开路径(其内部 stopPropagation,不再
|
||
冒泡到 window)。打开状态下的点击不拦截,LuCI 正常处理关闭。
|
||
作用于所有表单型 cbi-dropdown(原只匹配 Combobox 试点的
|
||
liquid-combo-pilot class,该替换已移除导致选择器落空);
|
||
排除 .btn/.cbi-button 型(按钮下拉有自己的动作语义) */
|
||
function fixComboPillClick() {
|
||
document.querySelectorAll('.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]').forEach(function (li) {
|
||
if (li.__liquidPillClick)
|
||
return;
|
||
li.__liquidPillClick = true;
|
||
li.addEventListener('click', function (ev) {
|
||
var sb = li.closest('.cbi-dropdown');
|
||
if (!sb || sb.hasAttribute('open'))
|
||
return;
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
sb.click();
|
||
});
|
||
});
|
||
}
|
||
|
||
/* 页面内容动态变化(view 切换、cbi 渲染等)时初始化新出现的 tab 菜单 */
|
||
if (window.MutationObserver) {
|
||
var tabObserver = new MutationObserver(function () {
|
||
initTabSliders();
|
||
syncDropdownValues();
|
||
fitDropdownWidths();
|
||
portalTooltips();
|
||
injectLoginLogo();
|
||
fixComboPillClick();
|
||
});
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
tabObserver.observe(document.body, { childList: true, subtree: true });
|
||
});
|
||
}
|
||
|
||
document.addEventListener('click', function (e) {
|
||
var nav = document.querySelector('#menubar .navigation');
|
||
if (nav && nav.contains(e.target)) {
|
||
scheduleMenuTop();
|
||
return;
|
||
}
|
||
/* 移动端:点击菜单(抽屉)外的任意区域 → 闭合侧边栏。
|
||
菜单内点击(子菜单展开等)不干预,菜单按钮交给原生 toggle */
|
||
var menu = document.getElementById('mainmenu');
|
||
if (menu && menu.classList.contains('active') && !menu.contains(e.target)) {
|
||
menu.classList.remove('active');
|
||
if (nav)
|
||
nav.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
window.addEventListener('resize', syncMenuTop);
|
||
|
||
/* 页面资源加载完成(图标 naturalWidth 就绪)后重测下拉宽度,
|
||
修正带图标选项的宽度估算 */
|
||
window.addEventListener('load', function () {
|
||
document.querySelectorAll('.cbi-dropdown.liquid-dd-fit').forEach(function (dd) {
|
||
dd.classList.remove('liquid-dd-fit');
|
||
});
|
||
fitDropdownWidths();
|
||
});
|
||
|
||
/* 全局兜底:任何下拉 open 属性变化(含动态 modal 里尚未经
|
||
syncDropdownValues 初始化的 dd)都重算定位 —— 修复某些终端
|
||
下拉只剩一横条(luci 残留 inline 定位 / maxHeight 1px) */
|
||
if (window.MutationObserver) {
|
||
var globalDdObserver = new MutationObserver(function (muts) {
|
||
var need = false;
|
||
muts.forEach(function (m) {
|
||
if (m.type === 'attributes' && m.attributeName === 'open')
|
||
need = true;
|
||
});
|
||
if (!need)
|
||
return;
|
||
adjustDropdownDirection();
|
||
/* luci 触屏分支用 rAF 动画(约 100ms)滚动定位,会在微任务
|
||
之后再次覆盖 inline 样式 —— 延迟再清理一次,覆盖它
|
||
(Windows 触屏/Edge 上尤甚) */
|
||
setTimeout(adjustDropdownDirection, 200);
|
||
});
|
||
globalDdObserver.observe(document.documentElement, {
|
||
attributes: true,
|
||
subtree: true,
|
||
attributeFilter: ['open']
|
||
});
|
||
}
|
||
|
||
/* ── Overview page memory/storage bars: render used/total text inside
|
||
the taller progress bar. LuCI's status include renders
|
||
<div class="cbi-progressbar" title="used / total (pc%)"><div style="width:N%"></div></div>
|
||
— we inject a centered label from the title attribute and keep it
|
||
in sync when LuCI re-renders (poll updates). ── */
|
||
function syncBarLabels() {
|
||
document.querySelectorAll('.cbi-section .cbi-progressbar').forEach(function (bar) {
|
||
var label = bar.querySelector('span.liquid-bar-label');
|
||
if (!label) {
|
||
label = document.createElement('span');
|
||
label.className = 'liquid-bar-label';
|
||
bar.appendChild(label);
|
||
}
|
||
/* keep in sync with LuCI's poll updates (title attribute) */
|
||
var txt = bar.title || '';
|
||
if (label.textContent !== txt)
|
||
label.textContent = txt;
|
||
});
|
||
}
|
||
|
||
/* overview renders its includes after the view instantiates; poll
|
||
both the DOM (async view load) and the title updates */
|
||
if (window.MutationObserver) {
|
||
var barObserver = new MutationObserver(function (muts) {
|
||
var need = false;
|
||
muts.forEach(function (m) {
|
||
if (m.type === 'childList' && m.addedNodes && m.addedNodes.length)
|
||
need = true;
|
||
if (m.type === 'attributes' && m.attributeName === 'title')
|
||
need = true;
|
||
});
|
||
if (need)
|
||
syncBarLabels();
|
||
});
|
||
barObserver.observe(document.body, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ['title']
|
||
});
|
||
}
|
||
setTimeout(syncBarLabels, 500);
|
||
setTimeout(syncBarLabels, 1500);
|
||
|
||
/* ── 矮弹窗居中器(限定范围:仅应用提示/待应用等矮弹窗)──
|
||
安卓浏览器布局视口(innerHeight) 含地址栏区域,与真实可视区
|
||
不一致(如 811 vs 695),且地址栏展开/收起时动态变化,纯 CSS
|
||
无法感知 —— 这是 visualViewport API 存在的原因。
|
||
用法:仅当弹窗内容高度 < 可视区(矮弹窗,居中是刚需)时用
|
||
visualViewport 像素级居中;超高弹窗(表单类如添加 DHCP)
|
||
绝不锁定,保持文档流 + overlay 滚动,不影响桌面端功能。 */
|
||
function centerModals() {
|
||
var vv = window.visualViewport;
|
||
var overlay = document.getElementById('modal_overlay');
|
||
var modal = overlay ? overlay.firstElementChild : null;
|
||
if (!modal || document.body.classList.contains('liquid-login'))
|
||
return;
|
||
var vh = vv ? vv.height : window.innerHeight;
|
||
var mh = modal.offsetHeight || 0;
|
||
if (!mh)
|
||
return;
|
||
/* 超高弹窗:不锁定,恢复文档流(margin 顶部对齐 + overlay 滚动) */
|
||
if (mh >= vh - 24) {
|
||
modal.style.position = '';
|
||
modal.style.top = '';
|
||
modal.style.left = '';
|
||
modal.style.margin = '';
|
||
modal.style.transform = '';
|
||
return;
|
||
}
|
||
/* 矮弹窗:visualViewport 像素级精确居中 */
|
||
var vw = vv ? vv.width : window.innerWidth;
|
||
var vx = vv ? vv.offsetLeft : 0;
|
||
var vy = vv ? vv.offsetTop : 0;
|
||
var mw = modal.offsetWidth || 0;
|
||
modal.style.position = 'fixed';
|
||
modal.style.top = Math.round(vy + (vh - mh) / 2) + 'px';
|
||
modal.style.left = Math.round(vx + (vw - mw) / 2) + 'px';
|
||
modal.style.margin = '0';
|
||
modal.style.transform = 'none';
|
||
}
|
||
|
||
/* 监听弹窗出现/内容变化(应用提示每秒重建;表单弹窗内容逐步渲染) */
|
||
var modalTimer = null;
|
||
function watchModals() {
|
||
if (document.body.classList.contains('modal-overlay-active')) {
|
||
if (!modalTimer) {
|
||
centerModals();
|
||
modalTimer = setInterval(centerModals, 200);
|
||
}
|
||
} else if (modalTimer) {
|
||
clearInterval(modalTimer);
|
||
modalTimer = null;
|
||
}
|
||
}
|
||
new MutationObserver(watchModals).observe(document.body, {
|
||
attributes: true,
|
||
attributeFilter: ['class']
|
||
});
|
||
window.addEventListener('resize', centerModals);
|
||
if (window.visualViewport)
|
||
window.visualViewport.addEventListener('resize', centerModals);
|
||
|
||
/* ── 多实例 section 子卡片(Dropbear 实例等)──────────
|
||
同一 .cbi-section 内有多个平级 .cbi-section-node 时,给每个
|
||
节点加 liquid-multi-node(CSS 渲染为独立子卡片+间隔),
|
||
同时给外层 section 加 liquid-multi-sec(删除按钮收边等)。
|
||
MutationObserver 覆盖动态增删实例(点击"添加实例")。 */
|
||
function styleMultiSections() {
|
||
document.querySelectorAll('.cbi-section').forEach(function (sec) {
|
||
var nodes = sec.querySelectorAll(':scope > .cbi-section-node');
|
||
if (nodes.length > 1) {
|
||
nodes.forEach(function (n) {
|
||
n.classList.add('liquid-multi-node');
|
||
});
|
||
sec.classList.add('liquid-multi-sec');
|
||
}
|
||
else {
|
||
sec.classList.remove('liquid-multi-sec');
|
||
}
|
||
});
|
||
}
|
||
styleMultiSections();
|
||
if (window.MutationObserver) {
|
||
var msObs = new MutationObserver(function () {
|
||
styleMultiSections();
|
||
});
|
||
msObs.observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
|
||
/* ── 宽表格:优先折叠按钮,再折叠文字 ──────────────────────
|
||
判定量的是“所有列按自身内容排开所需的宽度”(max-content:按钮
|
||
横排一行、文字不换行时的宽度),而不是表格实际渲染宽度——auto
|
||
表格布局在列内容总宽超出表宽时会先压文字列、表格盒本身仍装得下,
|
||
量实际宽度会漏判,结果就是文字先于按钮被折叠。
|
||
|
||
只要该需求宽度放不下(超出所在容器的内容宽或屏幕宽),就给表格
|
||
加 liquid-actions-overflow:操作列放开 form.js
|
||
stabilizeActionColumnWidth 写死的内联列宽、按钮改为每个独占一行,
|
||
宽度先让给文字;文字拿回后仍不够,才由文字换行兜底(对应 CSS 见
|
||
cascade.css 末尾段落)。
|
||
|
||
判定始终先移除类再量(量到的才是“按钮横排”的需求),避免加类→
|
||
不溢出→去类→又溢出的震荡;只处理含操作列的表格,无按钮的表格
|
||
维持原有排版与横向滚动。 */
|
||
var TBL_CLS = 'liquid-actions-overflow';
|
||
var tblTimer = null;
|
||
var tblRaf = 0;
|
||
|
||
/* 元素内容盒宽度:clientWidth 减掉左右 padding(表格比的是父级内容区) */
|
||
function contentBoxWidth(el) {
|
||
var cs = window.getComputedStyle(el);
|
||
return el.clientWidth -
|
||
(parseFloat(cs.paddingLeft) || 0) -
|
||
(parseFloat(cs.paddingRight) || 0);
|
||
}
|
||
|
||
/* 临时按内容展开量一次表格的需求宽(列不换行、按钮横排一行),
|
||
同步读取后立即还原样式,同一帧内不会产生可见闪烁 */
|
||
function needWidth(t) {
|
||
var st = t.style;
|
||
var w0 = st.width, mw0 = st.minWidth, mx0 = st.maxWidth;
|
||
st.width = 'max-content';
|
||
st.minWidth = '0';
|
||
st.maxWidth = 'none';
|
||
var w = t.getBoundingClientRect().width;
|
||
st.width = w0;
|
||
st.minWidth = mw0;
|
||
st.maxWidth = mx0;
|
||
return w;
|
||
}
|
||
|
||
/* 在“自然状态”(未折叠 + 操作列内联列宽为空)下执行 fn,随后原样还原。
|
||
所有样式改动与读数都发生在同一任务内,浏览器不会在中途绘制 ——
|
||
所以这套测量不会像“120ms 后再改样式”那样被肉眼看到(闪一下)。
|
||
内联列宽只在测量期间临时清空:量的才是“按钮横排放得下”的真实
|
||
需求宽(form.js stabilizeActionColumnWidth 写的值在折叠态下是
|
||
纵排后的 ~63px,会把需求宽算小)。 */
|
||
function inNaturalState(t, fn) {
|
||
var had = t.classList.contains(TBL_CLS);
|
||
if (had) t.classList.remove(TBL_CLS);
|
||
var acts = t.querySelectorAll('th.cbi-section-actions, td.cbi-section-actions');
|
||
var saved = [];
|
||
for (var i = 0; i < acts.length; i++) {
|
||
saved.push([acts[i], acts[i].style.width, acts[i].style.minWidth]);
|
||
acts[i].style.width = '';
|
||
acts[i].style.minWidth = '';
|
||
}
|
||
var ret = fn();
|
||
for (var k = 0; k < saved.length; k++) {
|
||
saved[k][0].style.width = saved[k][1];
|
||
saved[k][0].style.minWidth = saved[k][2];
|
||
}
|
||
if (had) t.classList.add(TBL_CLS);
|
||
return ret;
|
||
}
|
||
|
||
function tableOverflows(t) {
|
||
/* 全程在“自然状态”(未折叠 + 操作列内联宽为空)下判定:
|
||
类的摘挂、内联宽的清还都在同一任务内完成,浏览器中途不绘制
|
||
→ 测量本身不产生可见闪动 */
|
||
return inNaturalState(t, function () {
|
||
var doc = document.documentElement;
|
||
var parent = t.parentElement;
|
||
/* 1) 表格内容已超出表格盒(自身裁剪 / 内部横滚) */
|
||
if (t.scrollWidth > t.clientWidth + 1)
|
||
return true;
|
||
/* 2) 核心:需求宽(按钮横排放得下)> 可用宽 → 从按钮开始折 */
|
||
var need = needWidth(t);
|
||
var avail = parent ? contentBoxWidth(parent) : Infinity;
|
||
if (need > avail + 1 || need > doc.clientWidth + 1)
|
||
return true;
|
||
/* 3) 表格盒右缘已越出屏幕、且文档无法横向滚过去(被祖先裁切) */
|
||
var rect = t.getBoundingClientRect();
|
||
return rect.right > doc.clientWidth + 1 && doc.scrollWidth <= doc.clientWidth + 1;
|
||
});
|
||
}
|
||
|
||
/* form.js stabilizeActionColumnWidth 把操作列锁成内联 width/min-width;
|
||
折叠态下它量到的是“按钮纵排”宽(~63px),解除折叠后这个残留值会
|
||
卡住按钮 —— 只在“取消折叠”这一刻清掉,平时不动它(否则每次判定
|
||
都会改动列宽 → 页面闪一下) */
|
||
function clearActionInlineWidths(t) {
|
||
var acts = t.querySelectorAll('th.cbi-section-actions, td.cbi-section-actions');
|
||
for (var i = 0; i < acts.length; i++) {
|
||
if (acts[i].style.width || acts[i].style.minWidth) {
|
||
acts[i].style.width = '';
|
||
acts[i].style.minWidth = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 给多列表的单元格回填 data-title(文案取自同列表头 th)——窄屏"单元格
|
||
自带表头"(td::before { content: attr(data-title) },Argon 同款)依赖它。
|
||
概览 include 表(DHCP 租约 / DDNS / WiFi / UPnP …)与 DDNS 服务页的
|
||
自绘单元格都没有 data-title,这里统一补齐:只补缺失、不覆盖已有、
|
||
跳过操作列;轮询重绘出的新行下次判定时会补上。attribute 写入不触发
|
||
我们只监听 childList 的观察者 → 不会造成回调循环。 */
|
||
function backfillCellTitles() {
|
||
var tables = document.querySelectorAll('table.cbi-section-table, table.table');
|
||
for (var i = 0; i < tables.length; i++) {
|
||
var t = tables[i];
|
||
var head = t.querySelector(':scope > thead > tr, :scope > tbody > tr.table-titles, :scope > tbody > tr.cbi-section-table-titles, :scope > tr.table-titles');
|
||
if (!head) continue;
|
||
var ths = head.children;
|
||
var rows = t.querySelectorAll(':scope > tbody > tr');
|
||
for (var r = 0; r < rows.length; r++) {
|
||
var cells = rows[r].children;
|
||
for (var c = 0; c < ths.length && c < cells.length; c++) {
|
||
var cell = cells[c];
|
||
if (cell.tagName !== 'TD' || cell.hasAttribute('data-title') ||
|
||
cell.classList.contains('cbi-section-actions'))
|
||
continue;
|
||
var txt = (ths[c].innerText || ths[c].textContent || '')
|
||
.replace(/\s+/g, ' ').trim();
|
||
if (txt) cell.setAttribute('data-title', txt);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 多列表响应式打标:视口 ≤1100px 时给表格加 .liquid-grid(CSS 端据此
|
||
切到"一行三列 + 单元格自带表头")。断点放在这里(matchMedia 单一来源),
|
||
CSS 只认类、用 !important 压过 ≤854px 既有的 display:flex 规则。
|
||
1100px 是实测值:1150px 时中间列尚有 6.5 字/行,1100px 掉到 4.3;
|
||
移动端则是整表按内容宽撑开、只能横向滚动。 */
|
||
function syncGridView() {
|
||
var narrow = window.matchMedia
|
||
? window.matchMedia('(max-width: 1100px)').matches
|
||
: window.innerWidth <= 1100;
|
||
var list = document.querySelectorAll('table.cbi-section-table, table.table');
|
||
for (var i = 0; i < list.length; i++) {
|
||
var t = list[i];
|
||
/* 两类有专属布局的表不打标(CSS 端同样排除,这里保持标记语义一致):
|
||
接口页表(td[data-name="_ifacebox"])、标签/值两列表(td[width="33%"]) */
|
||
var skip = t.querySelector('td[data-name="_ifacebox"], td[width="33%"]');
|
||
if (narrow && !skip && t.querySelector('td'))
|
||
t.classList.add('liquid-grid');
|
||
else
|
||
t.classList.remove('liquid-grid');
|
||
}
|
||
}
|
||
|
||
function updateOverflowTables() {
|
||
syncGridView();
|
||
backfillCellTitles();
|
||
var list = document.querySelectorAll('table.cbi-section-table, table.table');
|
||
for (var i = 0; i < list.length; i++) {
|
||
var t = list[i];
|
||
/* 无操作列的表(概览"系统/内存/存储/网络"等标签-值两列表)与
|
||
折叠逻辑无关:不判定、不写任何样式 —— 与标签-值换行新规则
|
||
零交集(那套规则只认 td[width="33%"]),稳态下零 DOM 写 */
|
||
if (!t.querySelector('.cbi-section-actions')) {
|
||
if (t.classList.contains(TBL_CLS))
|
||
t.classList.remove(TBL_CLS);
|
||
continue;
|
||
}
|
||
var want = tableOverflows(t);
|
||
var has = t.classList.contains(TBL_CLS);
|
||
if (want && !has) {
|
||
t.classList.add(TBL_CLS);
|
||
} else if (!want && has) {
|
||
t.classList.remove(TBL_CLS);
|
||
clearActionInlineWidths(t);
|
||
}
|
||
/* 其余两种情况(要折且已折 / 不折且未折)净变化为零,不动 DOM */
|
||
}
|
||
}
|
||
|
||
function scheduleOverflowTables() {
|
||
/* rAF:在下一帧绘制前完成判定 → 首帧即最终状态,不再看到
|
||
“先按一行绘制、再瞬间堆叠”的折叠过程(用户反馈的闪一下) */
|
||
if (!tblRaf) {
|
||
tblRaf = window.requestAnimationFrame(function () {
|
||
tblRaf = 0;
|
||
updateOverflowTables();
|
||
});
|
||
}
|
||
/* 仍在 form.js 的 setTimeout(stabilize 列宽) 与其 resize 处理之后
|
||
补一次校正,拿到的才是内联列宽写完后的稳定状态 */
|
||
if (tblTimer)
|
||
clearTimeout(tblTimer);
|
||
tblTimer = setTimeout(function () {
|
||
tblTimer = null;
|
||
updateOverflowTables();
|
||
}, 120);
|
||
}
|
||
|
||
scheduleOverflowTables();
|
||
window.addEventListener('load', scheduleOverflowTables);
|
||
window.addEventListener('resize', scheduleOverflowTables);
|
||
if (window.MutationObserver) {
|
||
/* 行增删、轮询刷新状态文本都会改变表格宽度 */
|
||
new MutationObserver(scheduleOverflowTables)
|
||
.observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
})();
|