diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx index a9d0ad9de..f565296a0 100644 --- a/src/components/home/clash-mode-card.tsx +++ b/src/components/home/clash-mode-card.tsx @@ -5,11 +5,11 @@ import { } from '@mui/icons-material' import { Box, Paper, Stack, Typography } from '@mui/material' import { useLockFn } from 'ahooks' -import { type ReactNode } from 'react' +import { type ReactNode, useState } from 'react' import { useTranslation } from 'react-i18next' -import { closeAllConnections } from 'tauri-plugin-mihomo-api' +import { type BaseConfig, closeAllConnections } from 'tauri-plugin-mihomo-api' -import { useRuntimeConfig } from '@/hooks/use-clash' +import { useClashMode, useRuntimeConfig } from '@/hooks/use-clash' import { useVerge } from '@/hooks/use-verge' import { useAppRefreshers, @@ -17,6 +17,8 @@ import { useCoreDataStatus, } from '@/providers/app-data-context' import { patchClashMode } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { queryClient } from '@/services/query-client' import type { TranslationKey } from '@/types/generated/i18n-keys' const CLASH_MODES = ['rule', 'global', 'direct'] as const @@ -61,32 +63,64 @@ export const ClashModeCard = () => { const { isCoreDataPending } = useCoreDataStatus() const { refreshClashConfig } = useAppRefreshers() + // 点击后到后端确认前的乐观模式,使按钮立即响应 + const [optimisticMode, setOptimisticMode] = useState(null) + + // 主源:mihomo /configs 的实时 mode(最准,但依赖严格反序列化,可能整体失败) const controllerMode = toClashMode(clashConfig?.mode) + // 主源不可用时,启用两个容错兜底来源 + const needFallback = !controllerMode const { data: runtimeConfig, isPending: isRuntimeConfigPending } = - useRuntimeConfig(!controllerMode) + useRuntimeConfig(needFallback) const runtimeMode = toClashMode(runtimeConfig?.mode) - const currentMode = controllerMode ?? runtimeMode + const { + data: backendMode, + isPending: isBackendModePending, + refetch: refetchBackendMode, + } = useClashMode(needFallback) + // backendMode(已保存 clash 配置)在每次切换时都会被 change_clash_mode 同步更新, + // 而 runtimeMode(生成的运行时配置)在 API 切换后不会刷新、可能陈旧, + // 因此优先用 backendMode,避免陈旧 runtime mode 遮住新值。 + const fallbackMode = toClashMode(backendMode) ?? runtimeMode + + const resolvedMode = controllerMode ?? fallbackMode + const currentMode = optimisticMode ?? resolvedMode const modeDescription = currentMode ? t(MODE_META[currentMode].description) - : isCoreDataPending || isRuntimeConfigPending + : isCoreDataPending || isRuntimeConfigPending || isBackendModePending ? '\u00A0' : t('home.components.clashMode.errors.communication') - // 切换模式的处理函数 + // 切换模式的处理函数:乐观更新 + 真实失败回滚并提示 const onChangeMode = useLockFn(async (mode: ClashMode) => { if (mode === currentMode) return if (verge?.auto_close_connection) { closeAllConnections() } + // 乐观置为目标模式,按钮立即反映点击 + setOptimisticMode(mode) try { + // patchClashMode 现在会在后端 PATCH 失败时 reject await patchClashMode(mode) - // 使用共享的刷新方法 - refreshClashConfig() } catch (error) { - console.error('Failed to change mode:', error) + // 真实失败:回滚乐观状态并提示用户 + setOptimisticMode(null) + showNotice.error(error) + return } + + // 成功:写穿主源缓存,使实时 mode 立即反映新值——即使随后的 /configs refetch + // 失败(TanStack 会保留旧 data),controllerMode 也不会再压过新值导致闪回。 + // 若主源从未成功过(old 为 undefined)则保持不动,改由兜底来源反映。 + queryClient.setQueryData( + ['getClashConfig'], + (old) => (old ? { ...old, mode } : old), + ) + // 刷新主源与兜底源以与后端对齐,待数据落地后再清除乐观状态 + await Promise.allSettled([refreshClashConfig(), refetchBackendMode()]) + setOptimisticMode(null) }) // 按钮样式 diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 8a6c5212e..dd770dffd 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -4,6 +4,7 @@ import { getVersion } from 'tauri-plugin-mihomo-api' import { getClashInfo, + getClashMode, getRuntimeConfig, patchClashConfig, } from '@/services/cmds' @@ -65,6 +66,16 @@ export const useRuntimeConfig = (shouldFetch: boolean = true) => { }) } +// Fault-tolerant fallback for the current proxy mode, read straight from the +// saved clash config on the backend (bypasses the strict BaseConfig path). +export const useClashMode = (shouldFetch: boolean = true) => { + return useQuery({ + queryKey: ['getClashMode'], + queryFn: getClashMode, + enabled: shouldFetch, + }) +} + export const useClash = () => { const { data: clash, refetch } = useRuntimeConfig() diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index 187902397..b5a143785 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -47,6 +47,8 @@ export const useLayoutEvents = ( 'getProxies', 'getVersion', 'getClashConfig', + 'getClashMode', + 'getRuntimeConfig', 'getProxyProviders', 'getRules', 'getRuleProviders', diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index 07ab5007c..f846aa22c 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -18,6 +18,7 @@ import { patchClashMode, updateProxyChainConfigInRuntime, } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' import { debugLog } from '@/utils/debug' const MODES = ['rule', 'global', 'direct'] as const @@ -61,8 +62,13 @@ const ProxyPage = () => { if (mode !== curMode && verge?.auto_close_connection) { closeAllConnections() } - await patchClashMode(mode) - refreshClashConfig() + try { + // patchClashMode 在后端 PATCH 失败时会 reject,需提示用户而非静默失败 + await patchClashMode(mode) + refreshClashConfig() + } catch (error) { + showNotice.error(error) + } }) const onToggleChainMode = useLockFn(async () => { diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 960aacdf5..abd4f4b7d 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -85,6 +85,12 @@ export async function getClashInfo() { return invoke('get_clash_info') } +// Fault-tolerant current proxy mode read (does not depend on mihomo /configs +// strict BaseConfig deserialization); used as a fallback for the home mode card. +export async function getClashMode() { + return invoke('get_clash_mode') +} + // Get runtime config which controlled by verge export async function getRuntimeConfig() { return invoke('get_runtime_config')