'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 . */ (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 = '登录前修改的主题设置已暂存' + '' + '' + ''; 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 + ')'; } /* 在 上生成自定义色全套变量(亮/暗两套) */ 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 等第三方脚本会覆写 (其 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 = ''; 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 = ''; 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 = ''; 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 '' + '' + ''; } /* 在页脚内弹出 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 = '
10
' + '
'; 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 = ''; 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
— 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 }); } })();