From c729fc59e94200b26aa99b961aebe749f6f20677 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 20 May 2026 20:49:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ui+network):=20=E6=9B=B4=E6=96=B0/?= =?UTF-8?q?=E7=99=BB=E5=BD=95/=E7=BD=91=E7=BB=9C=E6=A3=80=E6=B5=8B/?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E5=8A=A0=E8=BD=BD=E5=9B=9B=E9=A1=B9=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 更新检查:超时从 8s 提至 15s,error 态展示具体错误原因 + 重试按钮 2. GitHub 登录:device flow 两个 HTTP 请求加 15s timeout,防网络差时永远卡住 3. 网络连通性:新增 check_network 命令(HEAD apic.openless.top + github.com), PermissionsSection 从硬编码"可用"改为真实探测 + 30s 轮询 + 离线重试 4. Marketplace:加载失败卡片增加重试按钮 5. i18n:五语言新增 networkOffline / settings.about.retryBtn Co-Authored-By: Claude Opus 4.6 --- openless-all/app/src-tauri/src/commands.rs | 42 ++++++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src/components/AutoUpdate.tsx | 10 ++++- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/ipc.ts | 12 ++++++ openless-all/app/src/pages/Marketplace.tsx | 9 +++- .../src/pages/settings/AboutUpdateControl.tsx | 21 ++++++++-- .../src/pages/settings/PermissionsSection.tsx | 40 +++++++++++++++++- 12 files changed, 135 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a689074d..6a915c26 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -474,6 +474,38 @@ async fn resolve_beta_manifest_endpoints() -> Result, String> { Ok(vec![mirror_url, direct_url]) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkCheckResult { + pub online: bool, + pub latency_ms: Option, +} + +#[tauri::command] +pub async fn check_network() -> NetworkCheckResult { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build(); + let client = match client { + Ok(c) => c, + Err(_) => return NetworkCheckResult { online: false, latency_ms: None }, + }; + let start = std::time::Instant::now(); + let endpoints = [ + "https://apic.openless.top/health", + "https://github.com", + ]; + for url in &endpoints { + if let Ok(resp) = client.head(*url).send().await { + if resp.status().is_success() || resp.status().is_redirection() { + let ms = start.elapsed().as_millis() as u64; + return NetworkCheckResult { online: true, latency_ms: Some(ms) }; + } + } + } + NetworkCheckResult { online: false, latency_ms: None } +} + #[tauri::command] pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { coord.hotkey_status() @@ -2812,7 +2844,10 @@ pub struct GithubDeviceStartResponse { #[tauri::command] pub async fn github_device_flow_start() -> Result { let client_id = get_github_oauth_client_id()?; - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("build http client: {e}"))?; let resp = client .post("https://github.com/login/device/code") .header("Accept", "application/json") @@ -2857,7 +2892,10 @@ pub async fn github_device_flow_poll( device_code: String, ) -> Result { let client_id = get_github_oauth_client_id()?; - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("build http client: {e}"))?; let token_resp = client .post("https://github.com/login/oauth/access_token") .header("Accept", "application/json") diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index b347239a..d269738b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -279,6 +279,7 @@ pub fn run() { commands::set_update_channel, commands::fetch_latest_beta_release, commands::app_check_update_with_channel, + commands::check_network, commands::get_hotkey_status, commands::get_hotkey_capability, commands::set_shortcut_recording_active, diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index cc23e57d..2a38be7b 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { isTauri, restartApp } from '../lib/ipc'; import { Btn } from '../pages/_atoms'; -const UPDATE_CHECK_TIMEOUT_MS = 8_000; // 缩短超时,让镜像站慢的情况能更快 fallback +const UPDATE_CHECK_TIMEOUT_MS = 15_000; interface AppUpdateMetadata { rid: number; @@ -44,6 +44,7 @@ export interface UseAutoUpdate { contentLength: number | null; checking: boolean; busy: boolean; + errorMessage: string | null; /** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */ checkForUpdates: () => Promise; /** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */ @@ -58,6 +59,7 @@ export function useAutoUpdate(): UseAutoUpdate { const [version, setVersion] = useState(''); const [downloaded, setDownloaded] = useState(0); const [contentLength, setContentLength] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); const checking = status === 'checking'; const busy = status === 'downloading' || status === 'installing'; @@ -89,6 +91,7 @@ export function useAutoUpdate(): UseAutoUpdate { const checkForUpdates = async () => { setStatus('checking'); setVersion(''); + setErrorMessage(null); resetProgress(); await closeUpdate(); try { @@ -120,6 +123,8 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('available'); } catch (error) { console.error('[updater] failed to check update', error); + const msg = error instanceof Error ? error.message : String(error); + setErrorMessage(msg); setStatus('error'); } }; @@ -146,6 +151,8 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('downloaded'); } catch (error) { console.error('[updater] failed to install update', error); + const msg = error instanceof Error ? error.message : String(error); + setErrorMessage(msg); await closeUpdate(); setStatus('error'); } @@ -167,6 +174,7 @@ export function useAutoUpdate(): UseAutoUpdate { contentLength, checking, busy, + errorMessage, checkForUpdates, installUpdate, dismissDialog, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 58834374..fa860125 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -682,6 +682,7 @@ export const en: typeof zhCN = { networkLabel: 'Network', networkDesc: 'Required for cloud ASR / LLM calls. Disable for local-only mode.', networkOk: 'Available', + networkOffline: 'Unavailable', checking: 'Checking…', granted: 'Granted', notApplicable: 'Not required', @@ -753,6 +754,7 @@ export const en: typeof zhCN = { checkingUpdate: 'Checking…', upToDate: 'You are already on the latest version.', updateError: 'Update check or install failed. Please try again later.', + retryBtn: 'Retry', openReleases: 'Open Releases', source: 'Source', docs: 'Docs', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f106e3c5..244673b7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -684,6 +684,7 @@ export const ja: typeof zhCN = { networkLabel: 'ネットワーク', networkDesc: 'クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。', networkOk: '利用可能', + networkOffline: '利用不可', checking: '確認中…', granted: '許可済み', notApplicable: '権限不要', @@ -755,6 +756,7 @@ export const ja: typeof zhCN = { checkingUpdate: '確認中…', upToDate: '現在最新バージョンです。', updateError: '確認またはアップデートに失敗しました。後で再試行してください。', + retryBtn: '再試行', openReleases: 'Releases を開く', source: 'ソース', docs: 'ドキュメント', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 854695fc..6dc84774 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -684,6 +684,7 @@ export const ko: typeof zhCN = { networkLabel: '네트워크', networkDesc: '클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.', networkOk: '사용 가능', + networkOffline: '사용 불가', checking: '확인 중…', granted: '허용됨', notApplicable: '권한 불필요', @@ -755,6 +756,7 @@ export const ko: typeof zhCN = { checkingUpdate: '확인 중…', upToDate: '현재 최신 버전입니다.', updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.', + retryBtn: '다시 시도', openReleases: 'Releases 열기', source: '소스', docs: '문서', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a46a3828..b4588314 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -680,6 +680,7 @@ export const zhCN = { networkLabel: '网络', networkDesc: '云端 ASR / LLM 必需,本地模式可关。', networkOk: '可用', + networkOffline: '不可用', checking: '检查中…', granted: '已授权', notApplicable: '无需授权', @@ -751,6 +752,7 @@ export const zhCN = { checkingUpdate: '检查中…', upToDate: '当前已是最新版本。', updateError: '检查或更新失败,请稍后重试。', + retryBtn: '重试', openReleases: '打开 Releases', source: '源码', docs: '文档', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 88e4f9a1..004111bf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -682,6 +682,7 @@ export const zhTW: typeof zhCN = { networkLabel: '網絡', networkDesc: '雲端 ASR / LLM 調用所必需。本地模式可關閉。', networkOk: '可用', + networkOffline: '不可用', checking: '檢查中…', granted: '已授權', notApplicable: '無需授權', @@ -753,6 +754,7 @@ export const zhTW: typeof zhCN = { checkingUpdate: '檢查中…', upToDate: '當前已是最新版本。', updateError: '檢查或更新失敗,請稍後重試。', + retryBtn: '重試', openReleases: '打開 Releases', source: '源碼', docs: '文檔', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index fb551d5d..5663f701 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -511,6 +511,18 @@ export function getWindowsImeStatus(): Promise { return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); } +export interface NetworkCheckResult { + online: boolean; + latencyMs: number | null; +} + +export function checkNetwork(): Promise { + return invokeOrMock('check_network', undefined, () => ({ + online: true, + latencyMs: 42, + })); +} + export function listMicrophoneDevices(): Promise { return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices); } diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index f547872c..ddcabaaa 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -580,8 +580,13 @@ export function Marketplace() { {loadError && ( -
- {t('marketplace.loadFailed', { err: loadError })} +
+
+ {t('marketplace.loadFailed', { err: loadError })} +
+ void refresh()}> + {t('common.retry') ?? '重试'} +
)} diff --git a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx index 16ed5719..e2fe75d5 100644 --- a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx +++ b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx @@ -17,9 +17,24 @@ export function AboutUpdateControl({ tagline }: { tagline: string }) { {u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
- {(u.status === 'none' || u.status === 'error') && ( -
- {u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')} + {u.status === 'none' && ( +
+ {t('settings.about.upToDate')} +
+ )} + {u.status === 'error' && ( +
+
+ {t('settings.about.updateError')} +
+ {u.errorMessage && ( +
+ {u.errorMessage} +
+ )} + + {t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'} +
)} {isDialogStatus(u.status) && ( diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 9be20a16..ad151aa2 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -7,12 +7,14 @@ import { Icon } from '../../components/Icon'; import { checkAccessibilityPermission, checkMicrophonePermission, + checkNetwork, getHotkeyStatus, getWindowsImeStatus, openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, } from '../../lib/ipc'; +import type { NetworkCheckResult } from '../../lib/ipc'; import type { HotkeyCapability, HotkeyStatus, @@ -30,6 +32,7 @@ export function PermissionsSection() { const [microphone, setMicrophone] = useState('loading'); const [hotkey, setHotkey] = useState(null); const [windowsIme, setWindowsIme] = useState(null); + const [network, setNetwork] = useState(null); const { capability } = useHotkeySettings(); const refreshPermissions = async () => { @@ -49,22 +52,34 @@ export function PermissionsSection() { setWindowsIme(await getWindowsImeStatus()); }; + const refreshNetwork = async () => { + try { + setNetwork(await checkNetwork()); + } catch { + setNetwork({ online: false, latencyMs: null }); + } + }; + useEffect(() => { refreshPermissions(); refreshHotkey(); refreshWindowsIme(); + refreshNetwork(); const hotkeyId = window.setInterval(refreshHotkey, 1000); // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); + const networkId = window.setInterval(refreshNetwork, 30000); const onFocus = () => { refreshPermissions(); refreshHotkey(); refreshWindowsIme(); + refreshNetwork(); }; window.addEventListener('focus', onFocus); return () => { window.clearInterval(hotkeyId); window.clearInterval(permissionId); + window.clearInterval(networkId); window.removeEventListener('focus', onFocus); }; }, []); @@ -157,8 +172,18 @@ export function PermissionsSection() { )} -
- {t('settings.permissions.networkOk')} +
+ {network && network.latencyMs != null && ( + + {network.latencyMs}ms + + )} + + {network && !network.online && ( + + {t('common.retry') ?? '重试'} + + )}
@@ -207,6 +232,17 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { return {t('settings.permissions.windowsImeUnavailable')}; } +function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) { + const { t } = useTranslation(); + if (!status) { + return {t('settings.permissions.checking')}; + } + if (status.online) { + return {t('settings.permissions.networkOk')}; + } + return {t('settings.permissions.networkOffline') ?? '不可用'}; +} + function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); From a88860cd901952302c29664814a70530ed0d4691 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 21 May 2026 23:26:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(ui):=20=E7=BB=99=E5=88=92=E8=AF=8D?= =?UTF-8?q?=E8=BF=BD=E9=97=AE=E6=A1=86=E4=B8=8E=E8=AF=AD=E9=9F=B3=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E5=8A=A0=E7=A3=A8=E7=A0=82=E7=8E=BB=E7=92=83=E8=B4=A8?= =?UTF-8?q?=E6=84=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webview 无法模糊透明窗口背后的桌面(Tauri 上游限制 #2827/#10064/#6876),原 backdrop-filter 在 qa / capsule 透明浮窗里是死代码。改用纯静态 CSS 的「假毛玻璃」:半透明白底 + 分形噪点颗粒 + 内描边高光 + 柔和阴影,三平台像素一致、零运行时开销。 - tokens.css: 新增 --ol-frost-grain(不透明灰度分形噪点 data URI) - global.css: 新增 .ol-frost 类,::before 叠噪点颗粒层 - QaPanel / Capsule: 外壳套用 .ol-frost,移除这两个面上死的 backdrop-filter --- openless-all/app/src/components/Capsule.tsx | 11 +++---- openless-all/app/src/pages/QaPanel.tsx | 12 +++---- openless-all/app/src/styles/global.css | 35 +++++++++++++++++++++ openless-all/app/src/styles/tokens.css | 7 +++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 283ce712..f848adb7 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -244,10 +244,12 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; const scale = os === 'win' ? 1 : 1 + ambient * 0.018; const shadowAlpha = 0.20 + ambient * 0.10; - const useBackdrop = true; return ( + // 假毛玻璃:半透明白底 + .ol-frost 噪点纹理 + 内描边高光 + 柔和阴影。 + // 不写 backdrop-filter —— webview 模糊不了透明窗口背后的桌面(Tauri 上游限制)。
+
{messages.length === 0 && status === 'idle' && ( @@ -611,6 +611,9 @@ function truncate(text: string, max: number): string { // ── 样式 ────────────────────────────────────────────────────────────── +// 假毛玻璃外壳:玻璃质感(体渐变 + 高光扫面 + 噪点颗粒)全部由 .ol-frost 提供; +// 这里只管布局 + 内描边高光 + 柔和阴影。webview 模糊不了透明窗口背后的桌面 +// (Tauri 上游限制),所以不写 background / backdrop-filter。 const shellStyle: CSSProperties = { width: '100%', height: '100vh', @@ -618,13 +621,8 @@ const shellStyle: CSSProperties = { flexDirection: 'column', borderRadius: 14, overflow: 'hidden', - // 浮窗 focus:false 在 macOS 上会让 backdrop-filter 不工作(透到桌面文字),所以 - // 改成接近不透明的实色背景。blur 仅作锦上添花,不再依赖它保证可读性。 - background: 'rgba(255, 255, 255, 0.97)', - backdropFilter: 'blur(24px) saturate(180%)', - WebkitBackdropFilter: 'blur(24px) saturate(180%)', border: '0.5px solid rgba(0, 0, 0, 0.08)', - boxShadow: 'var(--ol-shadow-lg)', + boxShadow: 'var(--ol-shadow-lg), inset 0 1px 0 0 rgba(255, 255, 255, 0.9)', fontFamily: 'var(--ol-font-sans)', color: 'var(--ol-ink)', }; diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 8e05f420..17fee90b 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -109,3 +109,38 @@ a { color: inherit; text-decoration: none; } } } +/* ── 假毛玻璃面(qa 追问框 / capsule 语音胶囊等透明浮窗)───────────────────── + webview 拿不到透明窗口背后的桌面像素 —— CSS backdrop-filter 模糊不了桌面, + 是 Tauri 已知的上游限制(issue #2827 / #10064 / #6876)。所以这里**不做 + 真实高斯模糊**:宿主元素保留半透明白底,这层 ::before 叠一张细颗粒噪点纹理 + (磨砂质感来源);内描边高光与柔和阴影由宿主自带的 box-shadow 提供。 + 纯静态 CSS,零运行时 / 动画开销,macOS / Windows / Linux 像素一致。 + 宿主元素自带 border-radius 即可,::before 噪点层 inherit 圆角;宿主不要再设 + backdrop-filter(死代码)。 */ +.ol-frost { + position: relative; + isolation: isolate; + /* 体渐变 + 左上高光扫面 —— 半透明白底(~0.9),给磨砂面体积感而不是死平。 */ + background: + radial-gradient(135% 86% at 15% -10%, + rgba(255, 255, 255, 0.97) 0%, + rgba(255, 255, 255, 0) 56%), + linear-gradient(161deg, + rgba(255, 255, 255, 0.93) 0%, + rgba(244, 247, 252, 0.88) 100%); +} + +.ol-frost::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + /* 不透明灰度噪点,normal 混合 —— 这是"磨砂颗粒"的来源。opacity 直接等于颗粒 + 强度:0.1 几乎看不见,0.32 能明显看出磨砂质感又不脏。要更糙往 0.45 调。 */ + background-image: var(--ol-frost-grain); + /* tile 与 SVG 视口同尺寸(100),stitchTiles 的无缝拼接才成立 */ + background-size: 100px 100px; + opacity: 0.32; + pointer-events: none; +} diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index 0c752e6f..42caa6b9 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -30,6 +30,13 @@ --ol-glass-blur: 20px; --ol-glass-blur-strong: 36px; + /* 假毛玻璃噪点 —— qa 追问框 / capsule 语音胶囊等透明浮窗用。详见 global.css .ol-frost。 + 关键点:feColorMatrix 用 matrix 把分形噪点**去色 + 强制 alpha=1**(不透明灰度噪点)。 + 早期版本用 saturate=0 保留了 feTurbulence 的随机 alpha,噪点本身就半透明,再叠 + ::before 的低 opacity,等于洗没了 —— 看不出磨砂颗粒。现在用不透明灰度噪点,由 + ::before 的 opacity 单独、可控地决定颗粒强度。 */ + --ol-frost-grain: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3E%3Cfilter%20id='g'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.95'%20numOctaves='1'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='matrix'%20values='0.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200%200%200%200%201'/%3E%3C/filter%3E%3Crect%20width='100'%20height='100'%20filter='url(%23g)'/%3E%3C/svg%3E"); + /* Motion */ --ol-motion-spring: cubic-bezier(0.16, 1, 0.3, 1); --ol-motion-soft: cubic-bezier(0.22, 0.8, 0.22, 1);