Merge pull request #519 from Open-Less/feat/frosted-glass-overlays

feat(ui): 划词追问框与语音胶囊磨砂玻璃质感
This commit is contained in:
TRIP authored and GitHub committed 2026-05-21 23:54:01 +08:00
commit 32d7c22a8c
16 files changed
+187 -23

No files matched your search

+40 -2
View File
@@ -474,6 +474,38 @@ async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, 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<u64>,
}
#[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<GithubDeviceStartResponse, String> {
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<GithubDevicePollResult, String> {
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")
+1
View File
@@ -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,
@@ -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<void>;
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
@@ -58,6 +59,7 @@ export function useAutoUpdate(): UseAutoUpdate {
const [version, setVersion] = useState('');
const [downloaded, setDownloaded] = useState(0);
const [contentLength, setContentLength] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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,
+5 -6
View File
@@ -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 上游限制)。
<div
className="ol-frost"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -258,13 +260,10 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
height: metrics.height,
boxSizing: metrics.boxSizing,
borderRadius: 999,
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: useBackdrop ? 'blur(28px) saturate(180%)' : 'none',
WebkitBackdropFilter: useBackdrop ? 'blur(28px) saturate(180%)' : 'none',
border: '1px solid rgba(255, 255, 255, 0.55)',
boxShadow: os === 'win'
? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 0.5px 0 rgba(255, 255, 255, 0.55)`
: `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 0.5px 0 rgba(255, 255, 255, 0.55)`,
? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`
: `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`,
color: 'var(--ol-ink)',
fontFamily: 'var(--ol-font-sans)',
transform: `scale(${scale.toFixed(4)})`,
+2
View File
@@ -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',
+2
View File
@@ -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: 'ドキュメント',
+2
View File
@@ -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: '문서',
+2
View File
@@ -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: '文档',
+2
View File
@@ -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: '文檔',
+12
View File
@@ -511,6 +511,18 @@ export function getWindowsImeStatus(): Promise<WindowsImeStatus> {
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
}
export interface NetworkCheckResult {
online: boolean;
latencyMs: number | null;
}
export function checkNetwork(): Promise<NetworkCheckResult> {
return invokeOrMock<NetworkCheckResult>('check_network', undefined, () => ({
online: true,
latencyMs: 42,
}));
}
export function listMicrophoneDevices(): Promise<MicrophoneDevice[]> {
return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices);
}
+7 -2
View File
@@ -580,8 +580,13 @@ export function Marketplace() {
{loadError && (
<Card padding={16} style={{ marginBottom: 12, borderColor: 'var(--ol-err)' }}>
<div style={{ fontSize: 12, color: 'var(--ol-err)' }}>
{t('marketplace.loadFailed', { err: loadError })}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 12, color: 'var(--ol-err)', flex: 1, wordBreak: 'break-word' }}>
{t('marketplace.loadFailed', { err: loadError })}
</div>
<Btn variant="blue" size="sm" onClick={() => void refresh()}>
{t('common.retry') ?? '重试'}
</Btn>
</div>
</Card>
)}
+5 -7
View File
@@ -201,7 +201,7 @@ export function QaPanel() {
}, [messages, status]);
return (
<div style={shellStyle}>
<div className="ol-frost" style={shellStyle}>
<Toolbar pinned={pinned} onTogglePin={onTogglePin} onClose={onClose} />
<div ref={scrollRef} style={contentStyle}>
{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)',
};
@@ -17,9 +17,24 @@ export function AboutUpdateControl({ tagline }: { tagline: string }) {
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
</Btn>
</div>
{(u.status === 'none' || u.status === 'error') && (
<div style={{ fontSize: 11, color: u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)', marginTop: 4 }}>
{u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')}
{u.status === 'none' && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
{t('settings.about.upToDate')}
</div>
)}
{u.status === 'error' && (
<div style={{ marginTop: 4 }}>
<div style={{ fontSize: 11, color: 'var(--ol-err)' }}>
{t('settings.about.updateError')}
</div>
{u.errorMessage && (
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginTop: 2, wordBreak: 'break-word' }}>
{u.errorMessage}
</div>
)}
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} style={{ marginTop: 4 }}>
{t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'}
</Btn>
</div>
)}
{isDialogStatus(u.status) && (
@@ -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<PermissionStatus | 'loading'>('loading');
const [hotkey, setHotkey] = useState<HotkeyStatus | null>(null);
const [windowsIme, setWindowsIme] = useState<WindowsImeStatus | null>(null);
const [network, setNetwork] = useState<NetworkCheckResult | null>(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() {
</SettingRow>
)}
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
{network && network.latencyMs != null && (
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{network.latencyMs}ms
</span>
)}
<NetworkStatusPill status={network} />
{network && !network.online && (
<Btn variant="ghost" size="sm" onClick={refreshNetwork}>
{t('common.retry') ?? '重试'}
</Btn>
)}
</div>
</SettingRow>
</Card>
@@ -207,6 +232,17 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) {
return <Pill tone="outline">{t('settings.permissions.windowsImeUnavailable')}</Pill>;
}
function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) {
const { t } = useTranslation();
if (!status) {
return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
}
if (status.online) {
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>;
}
return <Pill tone="outline">{t('settings.permissions.networkOffline') ?? '不可用'}</Pill>;
}
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');
+35
View File
@@ -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;
}
+7
View File
@@ -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);