'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 = '