mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-12 21:00:33 +08:00
refactor: migrate SWR to TanStack Query v5 (#6713)
Replace swr with @tanstack/react-query v5 across all hooks, providers, and components. Introduce singleton QueryClient, WS subscription pattern via useQuery+useEffect, and enforce component-layer cache access contract.
This commit is contained in:
parent
6f4ddb6db3
commit
a73fafaf9f
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,3 +16,4 @@ target
|
||||
CLAUDE.md
|
||||
.vfox.toml
|
||||
.vfox/
|
||||
.claude
|
||||
|
||||
@ -72,7 +72,7 @@ export default defineConfig([
|
||||
'@eslint-react/no-children-for-each': 'error',
|
||||
'@eslint-react/no-children-map': 'error',
|
||||
'@eslint-react/no-children-only': 'error',
|
||||
'@eslint-react/no-children-prop': 'error',
|
||||
'@eslint-react/jsx-no-children-prop': 'error',
|
||||
'@eslint-react/no-children-to-array': 'error',
|
||||
'@eslint-react/no-class-component': 'error',
|
||||
'@eslint-react/no-clone-element': 'error',
|
||||
@ -86,7 +86,7 @@ export default defineConfig([
|
||||
'@eslint-react/no-unstable-default-props': 'warn',
|
||||
'@eslint-react/no-unused-class-component-members': 'error',
|
||||
'@eslint-react/no-unused-state': 'error',
|
||||
'@eslint-react/no-useless-fragment': 'warn',
|
||||
'@eslint-react/jsx-no-useless-fragment': 'warn',
|
||||
'@eslint-react/prefer-destructuring-assignment': 'warn',
|
||||
|
||||
// TypeScript
|
||||
|
||||
@ -44,6 +44,7 @@
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/lab": "7.0.0-beta.17",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
@ -76,7 +77,6 @@
|
||||
"react-router": "^7.13.1",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"swr": "^2.4.1",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert",
|
||||
"types-pac": "^1.0.3",
|
||||
"validator": "^13.15.26"
|
||||
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -38,6 +38,9 @@ importers:
|
||||
'@mui/material':
|
||||
specifier: ^7.3.9
|
||||
version: 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.96.1
|
||||
version: 5.96.1(react@19.2.4)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -134,9 +137,6 @@ importers:
|
||||
rehype-raw:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
swr:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1(react@19.2.4)
|
||||
tauri-plugin-mihomo-api:
|
||||
specifier: github:clash-verge-rev/tauri-plugin-mihomo#revert
|
||||
version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b
|
||||
@ -1434,6 +1434,14 @@ packages:
|
||||
peerDependencies:
|
||||
'@svgr/core': '*'
|
||||
|
||||
'@tanstack/query-core@5.96.1':
|
||||
resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==}
|
||||
|
||||
'@tanstack/react-query@5.96.1':
|
||||
resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-table@8.21.3':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
@ -3345,11 +3353,6 @@ packages:
|
||||
svg-parser@2.0.4:
|
||||
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
|
||||
|
||||
swr@2.4.1:
|
||||
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
systemjs@6.15.1:
|
||||
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
|
||||
|
||||
@ -4963,6 +4966,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/query-core@5.96.1': {}
|
||||
|
||||
'@tanstack/react-query@5.96.1(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.96.1
|
||||
react: 19.2.4
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
@ -7064,12 +7074,6 @@ snapshots:
|
||||
|
||||
svg-parser@2.0.4: {}
|
||||
|
||||
swr@2.4.1(react@19.2.4):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
systemjs@6.15.1: {}
|
||||
|
||||
tar@7.5.13:
|
||||
|
||||
@ -5,23 +5,22 @@ import {
|
||||
VisibilityOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { useEffect } from 'foxact/use-abortable-effect'
|
||||
import { useIntersection } from 'foxact/use-intersection'
|
||||
import type { XOR } from 'foxts/ts-xor'
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWRImmutable from 'swr/immutable'
|
||||
|
||||
import { getIpInfo } from '@/services/api'
|
||||
import { SWR_EXTERNAL_API } from '@/services/config'
|
||||
|
||||
import { EnhancedCard } from './enhanced-card'
|
||||
|
||||
@ -78,7 +77,7 @@ type CountDownState = XOR<
|
||||
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
|
||||
({ children }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { mutate } = useIPInfo()
|
||||
const { refetch: mutate } = useIPInfo()
|
||||
|
||||
return (
|
||||
<EnhancedCard
|
||||
@ -117,7 +116,7 @@ export const IpInfoCard = () => {
|
||||
remainingSeconds: IP_REFRESH_SECONDS,
|
||||
})
|
||||
|
||||
const { data: ipInfo, error, isLoading, mutate } = useIPInfo()
|
||||
const { data: ipInfo, error, isLoading, refetch: mutate } = useIPInfo()
|
||||
|
||||
// function useEffectEvent
|
||||
const onCountdownTick = useEffectEvent(async () => {
|
||||
@ -422,5 +421,14 @@ export const IpInfoCard = () => {
|
||||
}
|
||||
|
||||
function useIPInfo() {
|
||||
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API)
|
||||
return useQuery({
|
||||
queryKey: [IP_INFO_CACHE_KEY],
|
||||
queryFn: getIpInfo,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 1,
|
||||
retryDelay: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
@ -101,7 +101,8 @@ export const SystemInfoCard = () => {
|
||||
// 检查更新
|
||||
const onCheckUpdate = useLockFn(async () => {
|
||||
try {
|
||||
const info = await triggerCheckUpdate()
|
||||
const result = await triggerCheckUpdate()
|
||||
const info = result.data
|
||||
if (!info?.available) {
|
||||
showNotice.success(
|
||||
'settings.components.verge.advanced.notifications.latestVersion',
|
||||
|
||||
@ -21,7 +21,6 @@ import { useLockFn } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useReducer, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mutate } from 'swr'
|
||||
|
||||
import { ConfirmViewer } from '@/components/profile/confirm-viewer'
|
||||
import { EditorViewer } from '@/components/profile/editor-viewer'
|
||||
@ -53,6 +52,7 @@ interface Props {
|
||||
selected: boolean
|
||||
activating: boolean
|
||||
itemData: IProfileItem
|
||||
mutateProfiles: () => Promise<void>
|
||||
onSelect: (force: boolean) => void
|
||||
onEdit: () => void
|
||||
onSave?: (prev?: string, curr?: string) => void
|
||||
@ -68,6 +68,7 @@ export const ProfileItem = (props: Props) => {
|
||||
selected,
|
||||
activating,
|
||||
itemData,
|
||||
mutateProfiles,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onSave,
|
||||
@ -383,7 +384,7 @@ export const ProfileItem = (props: Props) => {
|
||||
await updateProfile(itemData.uid, payload)
|
||||
|
||||
// 更新成功,刷新列表
|
||||
mutate('getProfiles')
|
||||
void mutateProfiles()
|
||||
} catch {
|
||||
// 更新完全失败(包括后端的回退尝试)
|
||||
// 不需要做处理,后端会通过事件通知系统发送错误
|
||||
@ -579,7 +580,7 @@ export const ProfileItem = (props: Props) => {
|
||||
if (customEvent.detail?.uid === itemData.uid) {
|
||||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }))
|
||||
// 刷新 profile 数据以获取最新的 updated 时间戳
|
||||
mutate('getProfiles')
|
||||
void mutateProfiles()
|
||||
// 更新完成后刷新显示
|
||||
if (showNextUpdate) {
|
||||
fetchNextUpdateTime()
|
||||
@ -599,7 +600,13 @@ export const ProfileItem = (props: Props) => {
|
||||
handleUpdateCompleted,
|
||||
)
|
||||
}
|
||||
}, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate])
|
||||
}, [
|
||||
fetchNextUpdateTime,
|
||||
itemData.uid,
|
||||
mutateProfiles,
|
||||
setLoadingCache,
|
||||
showNextUpdate,
|
||||
])
|
||||
|
||||
const handleSaveProfileDocument = useLockFn(async () => {
|
||||
const currentValue = profileDocument.value
|
||||
|
||||
@ -15,10 +15,10 @@ import { useLockFn } from 'ahooks'
|
||||
import type { Ref } from 'react'
|
||||
import { useImperativeHandle, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mutate } from 'swr'
|
||||
import { closeAllConnections, upgradeCore } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { BaseDialog, DialogRef } from '@/components/base'
|
||||
import { useClash, useClashInfo } from '@/hooks/use-clash'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { changeClashCore, restartCore } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@ -40,6 +40,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { verge, mutateVerge } = useVerge()
|
||||
const { mutateVersion } = useClash()
|
||||
const { invalidateClashConfig } = useClashInfo()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
@ -69,8 +71,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
|
||||
mutateVerge()
|
||||
setTimeout(async () => {
|
||||
mutate('getClashConfig')
|
||||
mutate('getVersion')
|
||||
invalidateClashConfig()
|
||||
mutateVersion()
|
||||
setChangingCore(null)
|
||||
}, 500)
|
||||
} catch (err) {
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mutate } from 'swr'
|
||||
|
||||
import {
|
||||
BaseDialog,
|
||||
@ -110,7 +109,8 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
const [hostOptions, setHostOptions] = useState<string[]>([])
|
||||
|
||||
const { clashConfig } = useAppData()
|
||||
const { indicator: isProxyReallyEnabled } = useSystemProxyState()
|
||||
const { indicator: isProxyReallyEnabled, invalidateProxyState } =
|
||||
useSystemProxyState()
|
||||
|
||||
const {
|
||||
enable_system_proxy: enabled,
|
||||
@ -166,10 +166,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
await patchVergeConfig({ enable_system_proxy: false })
|
||||
await sleep(200)
|
||||
await patchVergeConfig({ enable_system_proxy: true })
|
||||
await Promise.all([
|
||||
mutate('getSystemProxy'),
|
||||
mutate('getAutotemProxy'),
|
||||
])
|
||||
await invalidateProxyState()
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
@ -177,7 +174,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
}
|
||||
|
||||
updateProxy()
|
||||
}, [clashConfig?.mixedPort, value.pac])
|
||||
}, [clashConfig?.mixedPort, value.pac, invalidateProxyState])
|
||||
|
||||
const { systemProxyAddress } = useAppData()
|
||||
|
||||
@ -410,10 +407,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
}
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
mutate('getSystemProxy'),
|
||||
mutate('getAutotemProxy'),
|
||||
])
|
||||
await invalidateProxyState()
|
||||
|
||||
// 如果需要重置代理且代理当前启用
|
||||
if (needResetProxy && enabled) {
|
||||
@ -430,10 +424,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
await patchVergeConfig({ enable_system_proxy: false })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await patchVergeConfig({ enable_system_proxy: true })
|
||||
await Promise.all([
|
||||
mutate('getSystemProxy'),
|
||||
mutate('getAutotemProxy'),
|
||||
])
|
||||
await invalidateProxyState()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mutate } from 'swr'
|
||||
|
||||
import { DialogRef, Switch, TooltipIcon } from '@/components/base'
|
||||
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
|
||||
@ -62,7 +61,6 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
// 先触发UI更新立即看到反馈
|
||||
onChangeData({ enable_auto_launch: e })
|
||||
await patchVerge({ enable_auto_launch: e })
|
||||
await mutate('getAutoLaunchStatus')
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
// 如果出错,恢复原始状态
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import { getVersion } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import {
|
||||
@ -7,6 +7,12 @@ import {
|
||||
getRuntimeConfig,
|
||||
patchClashConfig,
|
||||
} from '@/services/cmds'
|
||||
import { queryClient } from '@/services/query-client'
|
||||
|
||||
type MutateClashUpdater =
|
||||
| ((old: IConfigData | undefined) => IConfigData | undefined)
|
||||
| IConfigData
|
||||
| undefined
|
||||
|
||||
const PORT_KEYS = [
|
||||
'port',
|
||||
@ -38,7 +44,7 @@ const validatePortRange = (port: number) => {
|
||||
if (port < 1000) {
|
||||
throw new Error('The port should not < 1000')
|
||||
}
|
||||
if (port > 65536) {
|
||||
if (port > 65535) {
|
||||
throw new Error('The port should not > 65536')
|
||||
}
|
||||
}
|
||||
@ -52,16 +58,35 @@ const validatePorts = (patch: ClashInfoPatch) => {
|
||||
}
|
||||
|
||||
export const useRuntimeConfig = (shouldFetch: boolean = true) => {
|
||||
return useSWR(shouldFetch ? 'getRuntimeConfig' : null, getRuntimeConfig)
|
||||
return useQuery({
|
||||
queryKey: ['getRuntimeConfig'],
|
||||
queryFn: getRuntimeConfig,
|
||||
enabled: shouldFetch,
|
||||
})
|
||||
}
|
||||
|
||||
export const useClash = () => {
|
||||
const { data: clash, mutate: mutateClash } = useRuntimeConfig()
|
||||
const { data: clash, refetch } = useRuntimeConfig()
|
||||
|
||||
const { data: versionData, mutate: mutateVersion } = useSWR(
|
||||
'getVersion',
|
||||
getVersion,
|
||||
)
|
||||
const { data: versionData, refetch: mutateVersion } = useQuery({
|
||||
queryKey: ['getVersion'],
|
||||
queryFn: getVersion,
|
||||
})
|
||||
|
||||
const mutateClash = (updater?: MutateClashUpdater, revalidate?: boolean) => {
|
||||
if (updater === undefined) {
|
||||
return refetch()
|
||||
}
|
||||
const next =
|
||||
typeof updater === 'function'
|
||||
? updater(queryClient.getQueryData<IConfigData>(['getRuntimeConfig']))
|
||||
: updater
|
||||
queryClient.setQueryData(['getRuntimeConfig'], next)
|
||||
if (revalidate !== false) {
|
||||
return refetch()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
|
||||
await patchClashConfig(patch)
|
||||
@ -82,10 +107,10 @@ export const useClash = () => {
|
||||
}
|
||||
|
||||
export const useClashInfo = () => {
|
||||
const { data: clashInfo, mutate: mutateInfo } = useSWR(
|
||||
'getClashInfo',
|
||||
getClashInfo,
|
||||
)
|
||||
const { data: clashInfo, refetch: mutateInfo } = useQuery({
|
||||
queryKey: ['getClashInfo'],
|
||||
queryFn: getClashInfo,
|
||||
})
|
||||
|
||||
const patchInfo = useLockFn(async (patch: ClashInfoPatch) => {
|
||||
if (!hasClashInfoPayload(patch)) return
|
||||
@ -94,12 +119,16 @@ export const useClashInfo = () => {
|
||||
|
||||
await patchClashConfig(patch)
|
||||
mutateInfo()
|
||||
mutate('getClashConfig')
|
||||
queryClient.invalidateQueries({ queryKey: ['getClashConfig'] })
|
||||
})
|
||||
|
||||
const invalidateClashConfig = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['getClashConfig'] })
|
||||
|
||||
return {
|
||||
clashInfo,
|
||||
mutateInfo,
|
||||
patchInfo,
|
||||
invalidateClashConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { mutate } from 'swr'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useMihomoWsSubscription } from './use-mihomo-ws-subscription'
|
||||
@ -33,7 +33,6 @@ const mergeConnectionSnapshot = (
|
||||
const nextConnections = payload.connections ?? []
|
||||
const previousActive = previous.activeConnections ?? []
|
||||
const nextById = new Map(nextConnections.map((conn) => [conn.id, conn]))
|
||||
const newIds = new Set(nextConnections.map((conn) => conn.id))
|
||||
|
||||
// Keep surviving connections in their previous relative order to reduce row reshuffle,
|
||||
// but constrain the array to the incoming snapshot length.
|
||||
@ -60,10 +59,11 @@ const mergeConnectionSnapshot = (
|
||||
}))
|
||||
|
||||
const activeConnections = [...carried, ...newcomers]
|
||||
const activeIds = new Set(activeConnections.map((conn) => conn.id))
|
||||
|
||||
const closedConnections = trimClosedConnections([
|
||||
...(previous.closedConnections ?? []),
|
||||
...previousActive.filter((conn) => !newIds.has(conn.id)),
|
||||
...previousActive.filter((conn) => !activeIds.has(conn.id)),
|
||||
])
|
||||
|
||||
return {
|
||||
@ -75,6 +75,7 @@ const mergeConnectionSnapshot = (
|
||||
}
|
||||
|
||||
export const useConnectionData = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { response, refresh, subscriptionCacheKey } =
|
||||
useMihomoWsSubscription<ConnectionMonitorData>({
|
||||
storageKey: 'mihomo_connection_date',
|
||||
@ -99,7 +100,7 @@ export const useConnectionData = () => {
|
||||
|
||||
const clearClosedConnections = () => {
|
||||
if (!subscriptionCacheKey) return
|
||||
mutate(subscriptionCacheKey, {
|
||||
queryClient.setQueryData<ConnectionMonitorData>([subscriptionCacheKey], {
|
||||
uploadTotal: response.data?.uploadTotal ?? 0,
|
||||
downloadTotal: response.data?.downloadTotal ?? 0,
|
||||
activeConnections: response.data?.activeConnections ?? [],
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { useMemo } from 'react'
|
||||
import useSWR from 'swr'
|
||||
|
||||
import { downloadIconCache } from '@/services/cmds'
|
||||
import { SWR_DEFAULTS } from '@/services/config'
|
||||
|
||||
export interface UseIconCacheOptions {
|
||||
icon?: string | null
|
||||
@ -24,17 +23,13 @@ export const useIconCache = ({
|
||||
const iconValue = icon?.trim() ?? ''
|
||||
const cacheKeyValue = cacheKey?.trim() ?? ''
|
||||
|
||||
const swrKey = useMemo(() => {
|
||||
if (!enabled || !iconValue.startsWith('http') || cacheKeyValue === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
return ['icon-cache', iconValue, cacheKeyValue] as const
|
||||
const isEnabled = useMemo(() => {
|
||||
return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
|
||||
}, [enabled, iconValue, cacheKeyValue])
|
||||
|
||||
const { data } = useSWR(
|
||||
swrKey,
|
||||
async () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['icon-cache', iconValue, cacheKeyValue],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const fileName = `${cacheKeyValue}-${getFileNameFromUrl(iconValue)}`
|
||||
const iconPath = await downloadIconCache(iconValue, fileName)
|
||||
@ -43,10 +38,15 @@ export const useIconCache = ({
|
||||
return ''
|
||||
}
|
||||
},
|
||||
SWR_DEFAULTS,
|
||||
)
|
||||
enabled: isEnabled,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: 2,
|
||||
})
|
||||
|
||||
if (!swrKey) {
|
||||
if (!isEnabled) {
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { mutate } from 'swr'
|
||||
import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { getClashLogs } from '@/services/cmds'
|
||||
@ -39,6 +39,7 @@ const appendLogs = (
|
||||
): ILogItem[] => clampLogs([...(current ?? []), ...incoming])
|
||||
|
||||
export const useLogData = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [clashLog] = useClashLog()
|
||||
const enableLog = clashLog.enable
|
||||
const logLevel = clashLog.logLevel
|
||||
@ -50,7 +51,6 @@ export const useLogData = () => {
|
||||
storageKey: 'mihomo_logs_date',
|
||||
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
|
||||
fallbackData: [],
|
||||
keepPreviousData: true,
|
||||
connect: () => MihomoWebSocket.connect_logs(logLevel),
|
||||
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@ -91,6 +91,9 @@ export const useLogData = () => {
|
||||
}
|
||||
parsed.time = dayjs().format('MM-DD HH:mm:ss')
|
||||
buffer.push(parsed)
|
||||
if (buffer.length > MAX_LOG_NUM) {
|
||||
buffer.splice(0, buffer.length - MAX_LOG_NUM)
|
||||
}
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
|
||||
}
|
||||
@ -133,7 +136,7 @@ export const useLogData = () => {
|
||||
const refreshGetClashLog = (clear = false) => {
|
||||
if (clear) {
|
||||
if (subscriptionCacheKey) {
|
||||
mutate(subscriptionCacheKey, [])
|
||||
queryClient.setQueryData<ILogItem[]>([subscriptionCacheKey], [])
|
||||
}
|
||||
} else {
|
||||
refresh()
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useLocalStorage } from 'foxact/use-local-storage'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { mutate, type MutatorCallback } from 'swr'
|
||||
import useSWRSubscription from 'swr/subscription'
|
||||
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
export const RECONNECT_DELAY_MS = 1000
|
||||
|
||||
type NextFn<T> = (error?: any, data?: T | MutatorCallback<T>) => void
|
||||
/**
|
||||
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
|
||||
* functional updater `(current?: T) => T`. The functional form is resolved
|
||||
* against the current cache entry before calling `queryClient.setQueryData`.
|
||||
*/
|
||||
type NextFn<T> = (
|
||||
error?: any,
|
||||
data?: T | ((current?: T) => T | undefined),
|
||||
) => void
|
||||
|
||||
interface HandlerContext<T> {
|
||||
next: NextFn<T>
|
||||
@ -25,7 +32,6 @@ interface UseMihomoWsSubscriptionOptions<T> {
|
||||
buildSubscriptKey: (date: number) => string | null
|
||||
fallbackData: T
|
||||
connect: () => Promise<MihomoWebSocket>
|
||||
keepPreviousData?: boolean
|
||||
/**
|
||||
* When > 0, coalesce rapid WebSocket messages by wrapping the `next`
|
||||
* function passed to `setupHandlers`. Only the most recent value is
|
||||
@ -46,7 +52,6 @@ export const useMihomoWsSubscription = <T>(
|
||||
buildSubscriptKey,
|
||||
fallbackData,
|
||||
connect,
|
||||
keepPreviousData = true,
|
||||
throttleMs,
|
||||
setupHandlers,
|
||||
} = options
|
||||
@ -56,148 +61,185 @@ export const useMihomoWsSubscription = <T>(
|
||||
const subscriptKey = buildSubscriptKey(date)
|
||||
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const wsRef = useRef<MihomoWebSocket | null>(null)
|
||||
const wsFirstConnectionRef = useRef<boolean>(true)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const response = useSWRSubscription<T, any, string | null>(
|
||||
subscriptKey,
|
||||
(_key, { next }) => {
|
||||
let isMounted = true
|
||||
const resolveNextData = useCallback(
|
||||
(
|
||||
data: T | ((current?: T) => T | undefined) | undefined,
|
||||
cacheKey: string,
|
||||
): T => {
|
||||
if (typeof data === 'function') {
|
||||
const updater = data as (current?: T) => T | undefined
|
||||
const current = queryClient.getQueryData<T>([cacheKey])
|
||||
return updater(current) ?? fallbackData
|
||||
}
|
||||
return data ?? fallbackData
|
||||
},
|
||||
[queryClient, fallbackData],
|
||||
)
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
const response = useQuery<T>({
|
||||
queryKey: subscriptionCacheKey
|
||||
? [subscriptionCacheKey]
|
||||
: ['$sub$__disabled__'],
|
||||
queryFn: () =>
|
||||
queryClient.getQueryData<T>([subscriptionCacheKey!]) ?? fallbackData,
|
||||
initialData: () =>
|
||||
queryClient.getQueryData<T>([
|
||||
subscriptionCacheKey ?? '$sub$__disabled__',
|
||||
]) ?? fallbackData,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
enabled: subscriptionCacheKey !== null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscriptionCacheKey) return
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeSocket = async () => {
|
||||
if (wsRef.current) {
|
||||
await wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleReconnect = async () => {
|
||||
if (!isMounted) return
|
||||
clearReconnectTimer()
|
||||
await closeSocket()
|
||||
if (!isMounted) return
|
||||
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
let throttleCleanup: (() => void) | undefined
|
||||
let wrappedNext: NextFn<T>
|
||||
|
||||
const baseNext: NextFn<T> = (error, data) => {
|
||||
if (error !== undefined && error !== null) {
|
||||
return
|
||||
}
|
||||
if (data === undefined) return
|
||||
const resolved = resolveNextData(data, subscriptionCacheKey)
|
||||
queryClient.setQueryData<T>([subscriptionCacheKey], resolved)
|
||||
}
|
||||
|
||||
if (throttleMs && throttleMs > 0) {
|
||||
let pendingData: T | ((current?: T) => T | undefined) | undefined
|
||||
let hasPending = false
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const flush = () => {
|
||||
timerId = null
|
||||
if (hasPending) {
|
||||
const data = pendingData
|
||||
pendingData = undefined
|
||||
hasPending = false
|
||||
baseNext(undefined, data)
|
||||
}
|
||||
}
|
||||
|
||||
const closeSocket = async () => {
|
||||
if (wsRef.current) {
|
||||
await wsRef.current.close()
|
||||
wsRef.current = null
|
||||
wrappedNext = (
|
||||
error?: any,
|
||||
data?: T | ((current?: T) => T | undefined),
|
||||
) => {
|
||||
if (error !== undefined && error !== null) {
|
||||
baseNext(error, data)
|
||||
return
|
||||
}
|
||||
if (!timerId) {
|
||||
baseNext(undefined, data)
|
||||
timerId = setTimeout(flush, throttleMs)
|
||||
} else {
|
||||
pendingData = data
|
||||
hasPending = true
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleReconnect = async () => {
|
||||
if (!isMounted) return
|
||||
clearReconnectTimer()
|
||||
await closeSocket()
|
||||
if (!isMounted) return
|
||||
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
let throttleCleanup: (() => void) | undefined
|
||||
let wrappedNext: NextFn<T> = next
|
||||
|
||||
if (throttleMs && throttleMs > 0) {
|
||||
let pendingData: T | MutatorCallback<T> | undefined
|
||||
let hasPending = false
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const flush = () => {
|
||||
throttleCleanup = () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId)
|
||||
timerId = null
|
||||
if (hasPending) {
|
||||
const data = pendingData
|
||||
pendingData = undefined
|
||||
hasPending = false
|
||||
next(undefined, data)
|
||||
}
|
||||
}
|
||||
|
||||
wrappedNext = (error?: any, data?: T | MutatorCallback<T>) => {
|
||||
if (error !== undefined && error !== null) {
|
||||
next(error, data)
|
||||
return
|
||||
}
|
||||
if (!timerId) {
|
||||
next(undefined, data)
|
||||
timerId = setTimeout(flush, throttleMs)
|
||||
} else {
|
||||
pendingData = data
|
||||
hasPending = true
|
||||
}
|
||||
}
|
||||
|
||||
throttleCleanup = () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId)
|
||||
timerId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wrappedNext = baseNext
|
||||
}
|
||||
|
||||
const {
|
||||
handleMessage: handleTextMessage,
|
||||
onConnected,
|
||||
cleanup,
|
||||
} = setupHandlers({
|
||||
next: wrappedNext,
|
||||
scheduleReconnect,
|
||||
isMounted: () => isMounted,
|
||||
})
|
||||
const {
|
||||
handleMessage: handleTextMessage,
|
||||
onConnected,
|
||||
cleanup,
|
||||
} = setupHandlers({
|
||||
next: wrappedNext,
|
||||
scheduleReconnect,
|
||||
isMounted: () => isMounted,
|
||||
})
|
||||
|
||||
const cleanupAll = () => {
|
||||
const cleanupAll = () => {
|
||||
clearReconnectTimer()
|
||||
throttleCleanup?.()
|
||||
cleanup?.()
|
||||
void closeSocket()
|
||||
}
|
||||
|
||||
const handleMessage = (msg: Message) => {
|
||||
if (msg.type !== 'Text') return
|
||||
handleTextMessage(msg.data)
|
||||
}
|
||||
|
||||
async function connectWs() {
|
||||
try {
|
||||
const ws_ = await connect()
|
||||
if (!isMounted) {
|
||||
await ws_.close()
|
||||
return
|
||||
}
|
||||
|
||||
wsRef.current = ws_
|
||||
clearReconnectTimer()
|
||||
throttleCleanup?.()
|
||||
cleanup?.()
|
||||
void closeSocket()
|
||||
}
|
||||
|
||||
const handleMessage = (msg: Message) => {
|
||||
if (msg.type !== 'Text') return
|
||||
handleTextMessage(msg.data)
|
||||
}
|
||||
|
||||
async function connectWs() {
|
||||
try {
|
||||
const ws_ = await connect()
|
||||
if (onConnected) {
|
||||
await onConnected(ws_)
|
||||
if (!isMounted) {
|
||||
await ws_.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wsRef.current = ws_
|
||||
clearReconnectTimer()
|
||||
|
||||
if (onConnected) {
|
||||
await onConnected(ws_)
|
||||
if (!isMounted) {
|
||||
await ws_.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ws_.addListener(handleMessage)
|
||||
} catch (ignoreError) {
|
||||
if (!wsRef.current && isMounted) {
|
||||
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
|
||||
}
|
||||
ws_.addListener(handleMessage)
|
||||
} catch (ignoreError) {
|
||||
if (!wsRef.current && isMounted) {
|
||||
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
if (wsFirstConnectionRef.current || !wsRef.current) {
|
||||
wsFirstConnectionRef.current = false
|
||||
cleanupAll()
|
||||
void connectWs()
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
wsFirstConnectionRef.current = true
|
||||
cleanupAll()
|
||||
}
|
||||
},
|
||||
{
|
||||
fallbackData,
|
||||
keepPreviousData,
|
||||
},
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (subscriptionCacheKey) {
|
||||
mutate(subscriptionCacheKey)
|
||||
}
|
||||
|
||||
if (wsFirstConnectionRef.current || !wsRef.current) {
|
||||
wsFirstConnectionRef.current = false
|
||||
cleanupAll()
|
||||
void connectWs()
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
wsFirstConnectionRef.current = true
|
||||
cleanupAll()
|
||||
}
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
|
||||
}, [subscriptionCacheKey])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import useSWR from 'swr'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getNetworkInterfacesInfo } from '@/services/cmds'
|
||||
|
||||
export const useNetworkInterfaces = () => {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
'getNetworkInterfacesInfo',
|
||||
getNetworkInterfacesInfo,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
fallbackData: [],
|
||||
},
|
||||
)
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refetch: mutate,
|
||||
} = useQuery({
|
||||
queryKey: ['getNetworkInterfacesInfo'],
|
||||
queryFn: getNetworkInterfacesInfo,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
initialData: [],
|
||||
})
|
||||
|
||||
return {
|
||||
networkInterfaces: data || [],
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { selectNodeForGroup } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import {
|
||||
@ -7,32 +7,37 @@ import {
|
||||
patchProfile,
|
||||
patchProfilesConfig,
|
||||
} from '@/services/cmds'
|
||||
import { queryClient } from '@/services/query-client'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
export const useProfiles = () => {
|
||||
const {
|
||||
data: profiles,
|
||||
mutate: mutateProfiles,
|
||||
refetch,
|
||||
error,
|
||||
isValidating,
|
||||
} = useSWR('getProfiles', getProfiles, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 500, // 减少去重时间,提高响应性
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 1000,
|
||||
refreshInterval: 0, // 完全由手动控制
|
||||
onError: (error) => {
|
||||
console.error('[useProfiles] SWR错误:', error)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
isFetching: isValidating,
|
||||
} = useQuery({
|
||||
queryKey: ['getProfiles'],
|
||||
queryFn: async () => {
|
||||
const data = await getProfiles()
|
||||
debugLog(
|
||||
'[useProfiles] 配置数据更新成功,配置数量:',
|
||||
data?.items?.length || 0,
|
||||
)
|
||||
return data
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 500,
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
refetchInterval: false,
|
||||
})
|
||||
|
||||
const mutateProfiles = async () => {
|
||||
await refetch()
|
||||
}
|
||||
|
||||
const patchProfiles = async (
|
||||
value: Partial<IProfilesConfig>,
|
||||
signal?: AbortSignal,
|
||||
@ -105,8 +110,14 @@ export const useProfiles = () => {
|
||||
`[ActivateSelected] 当前profile有 ${selected.length} 个代理选择配置`,
|
||||
)
|
||||
|
||||
type SelectedEntry = { name?: string; now?: string }
|
||||
const selectedMap = Object.fromEntries(
|
||||
selected.map((each) => [each.name!, each.now!]),
|
||||
(selected as SelectedEntry[])
|
||||
.filter(
|
||||
(each): each is SelectedEntry & { name: string; now: string } =>
|
||||
each.name != null && each.now != null,
|
||||
)
|
||||
.map((each) => [each.name, each.now]),
|
||||
)
|
||||
|
||||
let hasChange = false
|
||||
@ -168,10 +179,10 @@ export const useProfiles = () => {
|
||||
hasChange = true
|
||||
try {
|
||||
await selectNodeForGroup(name, savedProxy)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.warn(
|
||||
`[ActivateSelected] 切换代理组 ${name} 失败:`,
|
||||
error.message,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -187,15 +198,21 @@ export const useProfiles = () => {
|
||||
debugLog(`[ActivateSelected] 完成代理切换,保存新的选择配置`)
|
||||
|
||||
try {
|
||||
await patchProfile(profileData.current!, { selected: newSelected })
|
||||
await patchProfile(current.uid, { selected: newSelected })
|
||||
debugLog('[ActivateSelected] 代理选择配置保存成功')
|
||||
|
||||
await mutate('getProxies', calcuProxies())
|
||||
} catch (error: any) {
|
||||
console.error('[ActivateSelected] 保存代理选择配置失败:', error.message)
|
||||
queryClient.setQueryData(['getProxies'], await calcuProxies())
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[ActivateSelected] 保存代理选择配置失败:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[ActivateSelected] 处理代理选择失败:', error.message)
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[ActivateSelected] 处理代理选择失败:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useRef } from 'react'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useAppData } from '@/providers/app-data-context'
|
||||
import { getAutotemProxy } from '@/services/cmds'
|
||||
import { queryClient } from '@/services/query-client'
|
||||
|
||||
// 系统代理状态检测统一逻辑
|
||||
export const useSystemProxyState = () => {
|
||||
const { verge, mutateVerge, patchVerge } = useVerge()
|
||||
const { sysproxy, clashConfig } = useAppData()
|
||||
const { data: autoproxy } = useSWR('getAutotemProxy', getAutotemProxy, {
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
const { data: autoproxy } = useQuery({
|
||||
queryKey: ['getAutotemProxy'],
|
||||
queryFn: getAutotemProxy,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
|
||||
const {
|
||||
@ -41,7 +44,10 @@ export const useSystemProxyState = () => {
|
||||
const busyRef = useRef(false)
|
||||
|
||||
const toggleSystemProxy = async (enabled: boolean) => {
|
||||
mutateVerge({ ...verge, enable_system_proxy: enabled }, false)
|
||||
mutateVerge(
|
||||
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
|
||||
false,
|
||||
)
|
||||
pendingRef.current = enabled
|
||||
|
||||
if (busyRef.current) return
|
||||
@ -58,13 +64,23 @@ export const useSystemProxyState = () => {
|
||||
}
|
||||
} finally {
|
||||
busyRef.current = false
|
||||
await Promise.all([mutate('getSystemProxy'), mutate('getAutotemProxy')])
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['getSystemProxy'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['getAutotemProxy'] }),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateProxyState = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['getSystemProxy'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['getAutotemProxy'] }),
|
||||
])
|
||||
|
||||
return {
|
||||
indicator,
|
||||
configState: enable_system_proxy ?? false,
|
||||
toggleSystemProxy,
|
||||
invalidateProxyState,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useSWR from 'swr'
|
||||
|
||||
import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@ -27,12 +27,12 @@ export function useSystemState() {
|
||||
const disablingTunRef = useRef(false)
|
||||
|
||||
const {
|
||||
data: systemState,
|
||||
mutate: mutateSystemState,
|
||||
data: systemState = defaultSystemState,
|
||||
refetch: mutateSystemState,
|
||||
isLoading,
|
||||
} = useSWR(
|
||||
'getSystemState',
|
||||
async () => {
|
||||
} = useQuery({
|
||||
queryKey: ['getSystemState'],
|
||||
queryFn: async () => {
|
||||
const [runningMode, isAdminMode, isServiceOk] = await Promise.all([
|
||||
getRunningMode(),
|
||||
isAdmin(),
|
||||
@ -40,12 +40,9 @@ export function useSystemState() {
|
||||
])
|
||||
return { runningMode, isAdminMode, isServiceOk } as SystemState
|
||||
},
|
||||
{
|
||||
suspense: true,
|
||||
refreshInterval: 30000,
|
||||
fallback: defaultSystemState,
|
||||
},
|
||||
)
|
||||
refetchInterval: 30000,
|
||||
placeholderData: defaultSystemState,
|
||||
})
|
||||
|
||||
const isSidecarMode = systemState.runningMode === 'Sidecar'
|
||||
const isServiceMode = systemState.runningMode === 'Service'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import useSWR, { mutate as globalMutate, SWRConfiguration } from 'swr'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { queryClient } from '@/services/query-client'
|
||||
import { checkUpdateSafe } from '@/services/update'
|
||||
|
||||
import { useVerge } from './use-verge'
|
||||
@ -12,8 +13,6 @@ export interface UpdateInfo {
|
||||
downloadAndInstall: (onEvent?: any) => Promise<void>
|
||||
}
|
||||
|
||||
// --- Last check timestamp (shared via SWR + localStorage) ---
|
||||
|
||||
const LAST_CHECK_KEY = 'last_check_update'
|
||||
|
||||
export const readLastCheckTime = (): number | null => {
|
||||
@ -26,16 +25,13 @@ export const readLastCheckTime = (): number | null => {
|
||||
export const updateLastCheckTime = (timestamp?: number): number => {
|
||||
const now = timestamp ?? Date.now()
|
||||
localStorage.setItem(LAST_CHECK_KEY, now.toString())
|
||||
globalMutate(LAST_CHECK_KEY, now, false)
|
||||
queryClient.setQueryData([LAST_CHECK_KEY], now)
|
||||
return now
|
||||
}
|
||||
|
||||
// --- useUpdate hook ---
|
||||
|
||||
export const useUpdate = (
|
||||
enabled: boolean = true,
|
||||
options?: SWRConfiguration,
|
||||
) => {
|
||||
export const useUpdate = (enabled: boolean = true) => {
|
||||
const { verge } = useVerge()
|
||||
const { auto_check_update } = verge || {}
|
||||
|
||||
@ -46,26 +42,28 @@ export const useUpdate = (
|
||||
|
||||
const {
|
||||
data: updateInfo,
|
||||
mutate: checkUpdate,
|
||||
isValidating,
|
||||
} = useSWR(shouldCheck ? 'checkUpdate' : null, checkUpdateSafe, {
|
||||
errorRetryCount: 2,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
focusThrottleInterval: 36e5, // 1 hour
|
||||
refreshInterval: 24 * 60 * 60 * 1000, // 24 hours
|
||||
dedupingInterval: 60 * 60 * 1000, // 1 hour
|
||||
...options,
|
||||
onSuccess: (...args) => {
|
||||
refetch: checkUpdate,
|
||||
isFetching: isValidating,
|
||||
} = useQuery({
|
||||
queryKey: ['checkUpdate'],
|
||||
queryFn: async () => {
|
||||
const result = await checkUpdateSafe()
|
||||
updateLastCheckTime()
|
||||
options?.onSuccess?.(...args)
|
||||
return result
|
||||
},
|
||||
enabled: shouldCheck,
|
||||
retry: 2,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
refetchInterval: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
// Shared last check timestamp
|
||||
const { data: lastCheckUpdate } = useSWR(LAST_CHECK_KEY, readLastCheckTime, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
const { data: lastCheckUpdate } = useQuery({
|
||||
queryKey: [LAST_CHECK_KEY],
|
||||
queryFn: readLastCheckTime,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@ -1,28 +1,52 @@
|
||||
import useSWR from 'swr'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
|
||||
import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
|
||||
|
||||
export const useVerge = () => {
|
||||
const qc = useQueryClient()
|
||||
const initialVergeConfig = getPreloadConfig()
|
||||
const { data: verge, mutate: mutateVerge } = useSWR(
|
||||
'getVergeConfig',
|
||||
async () => {
|
||||
|
||||
const { data: verge, refetch } = useQuery({
|
||||
queryKey: ['getVergeConfig'],
|
||||
queryFn: async () => {
|
||||
const config = await getVergeConfig()
|
||||
setPreloadConfig(config)
|
||||
return config
|
||||
},
|
||||
{
|
||||
fallbackData: initialVergeConfig ?? undefined,
|
||||
revalidateOnMount: !initialVergeConfig,
|
||||
},
|
||||
)
|
||||
initialData: initialVergeConfig ?? undefined,
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
const patchVerge = async (value: Partial<IVergeConfig>) => {
|
||||
await patchVergeConfig(value)
|
||||
mutateVerge()
|
||||
const mutateVerge = (
|
||||
updaterOrData?:
|
||||
| IVergeConfig
|
||||
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
|
||||
| undefined,
|
||||
_revalidate?: boolean,
|
||||
) => {
|
||||
if (updaterOrData === undefined) {
|
||||
void refetch()
|
||||
return
|
||||
}
|
||||
if (typeof updaterOrData === 'function') {
|
||||
const prev = qc.getQueryData<IVergeConfig>(['getVergeConfig'])
|
||||
const next = updaterOrData(prev)
|
||||
qc.setQueryData(['getVergeConfig'], next)
|
||||
} else {
|
||||
qc.setQueryData(['getVergeConfig'], updaterOrData)
|
||||
}
|
||||
}
|
||||
|
||||
const patchVerge = useCallback(
|
||||
async (value: Partial<IVergeConfig>) => {
|
||||
await patchVergeConfig(value)
|
||||
await refetch()
|
||||
},
|
||||
[refetch],
|
||||
)
|
||||
|
||||
return {
|
||||
verge,
|
||||
mutateVerge,
|
||||
|
||||
14
src/main.tsx
14
src/main.tsx
@ -2,6 +2,7 @@ import './assets/styles/index.scss'
|
||||
import './services/monaco'
|
||||
|
||||
import { ResizeObserver } from '@juggle/resize-observer'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ComposeContextProvider } from 'foxact/compose-context-provider'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
@ -18,6 +19,7 @@ import {
|
||||
resolveThemeMode,
|
||||
getPreloadConfig,
|
||||
} from './services/preload'
|
||||
import { queryClient } from './services/query-client'
|
||||
import {
|
||||
LoadingCacheProvider,
|
||||
ThemeModeProvider,
|
||||
@ -50,11 +52,13 @@ const initializeApp = (initialThemeMode: 'light' | 'dark') => {
|
||||
<React.StrictMode>
|
||||
<ComposeContextProvider contexts={contexts}>
|
||||
<BaseErrorBoundary>
|
||||
<WindowProvider>
|
||||
<AppDataProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AppDataProvider>
|
||||
</WindowProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WindowProvider>
|
||||
<AppDataProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AppDataProvider>
|
||||
</WindowProvider>
|
||||
</QueryClientProvider>
|
||||
</BaseErrorBoundary>
|
||||
</ComposeContextProvider>
|
||||
</React.StrictMode>,
|
||||
|
||||
@ -27,7 +27,6 @@ import type { CSSProperties } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import iconDark from '@/assets/image/icon_dark.svg?react'
|
||||
import iconLight from '@/assets/image/icon_light.svg?react'
|
||||
@ -259,249 +258,220 @@ const Layout = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
errorRetryCount: 3,
|
||||
// TODO remove the 5000ms
|
||||
errorRetryInterval: 5000,
|
||||
onError: (error, key) => {
|
||||
// FIXME the condition should not be handle gllobally
|
||||
if (key !== 'getAutotemProxy') {
|
||||
console.error(`SWR Error for ${key}:`, error)
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME we need a better way to handle the retry when first booting app
|
||||
const silentKeys = ['getVersion', 'getClashConfig', 'getAutotemProxy']
|
||||
if (silentKeys.includes(key)) return
|
||||
|
||||
console.error(`[SWR Error] Key: ${key}, Error:`, error)
|
||||
},
|
||||
dedupingInterval: 2000,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* 左侧底部窗口控制按钮 */}
|
||||
<NoticeManager position={verge?.notice_position} />
|
||||
<div
|
||||
style={{
|
||||
animation: 'fadeIn 0.5s',
|
||||
WebkitAnimation: 'fadeIn 0.5s',
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* 左侧底部窗口控制按钮 */}
|
||||
<NoticeManager position={verge?.notice_position} />
|
||||
<div
|
||||
style={{
|
||||
animation: 'fadeIn 0.5s',
|
||||
WebkitAnimation: 'fadeIn 0.5s',
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Paper
|
||||
square
|
||||
elevation={0}
|
||||
className={`${OS} layout${navCollapsed ? ' layout--nav-collapsed' : ''}`}
|
||||
style={{
|
||||
borderTopLeftRadius: '0px',
|
||||
borderTopRightRadius: '0px',
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (
|
||||
OS === 'windows' &&
|
||||
!['input', 'textarea'].includes(
|
||||
e.currentTarget.tagName.toLowerCase(),
|
||||
) &&
|
||||
!e.currentTarget.isContentEditable
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
sx={[
|
||||
({ palette }) => ({ bgcolor: palette.background.paper }),
|
||||
OS === 'linux'
|
||||
? {
|
||||
borderRadius: '8px',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
>
|
||||
{/* Custom titlebar - rendered only when decorated is false, memoized for performance */}
|
||||
{customTitlebar}
|
||||
</style>
|
||||
<Paper
|
||||
square
|
||||
elevation={0}
|
||||
className={`${OS} layout${navCollapsed ? ' layout--nav-collapsed' : ''}`}
|
||||
style={{
|
||||
borderTopLeftRadius: '0px',
|
||||
borderTopRightRadius: '0px',
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (
|
||||
OS === 'windows' &&
|
||||
!['input', 'textarea'].includes(
|
||||
e.currentTarget.tagName.toLowerCase(),
|
||||
) &&
|
||||
!e.currentTarget.isContentEditable
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
sx={[
|
||||
({ palette }) => ({ bgcolor: palette.background.paper }),
|
||||
OS === 'linux'
|
||||
? {
|
||||
borderRadius: '8px',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
>
|
||||
{/* Custom titlebar - rendered only when decorated is false, memoized for performance */}
|
||||
{customTitlebar}
|
||||
|
||||
<div className="layout-content">
|
||||
<div className="layout-content__left">
|
||||
<div className="the-logo" data-tauri-drag-region="false">
|
||||
<div
|
||||
data-tauri-drag-region="true"
|
||||
style={{
|
||||
height: '27px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<SvgIcon
|
||||
component={isDark ? iconDark : iconLight}
|
||||
style={{
|
||||
height: '36px',
|
||||
width: '36px',
|
||||
marginTop: '-3px',
|
||||
marginRight: '5px',
|
||||
marginLeft: '-3px',
|
||||
}}
|
||||
inheritViewBox
|
||||
/>
|
||||
<LogoSvg fill={isDark ? 'white' : 'black'} />
|
||||
</div>
|
||||
<UpdateButton className="the-newbtn" />
|
||||
</div>
|
||||
|
||||
{menuUnlocked && (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
mx: 'auto',
|
||||
mb: 1,
|
||||
maxWidth: 250,
|
||||
borderRadius: 1.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
color: theme.palette.warning.contrastText,
|
||||
bgcolor:
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.warning.dark,
|
||||
})}
|
||||
>
|
||||
{t('layout.components.navigation.menu.reorderMode')}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{menuUnlocked ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleMenuDragEnd}
|
||||
>
|
||||
<SortableContext items={menuOrder}>
|
||||
<List
|
||||
className="the-menu"
|
||||
onContextMenu={handleMenuContextMenu}
|
||||
>
|
||||
{menuOrder.map((path) => {
|
||||
const item = navItemMap.get(path)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<SortableNavMenuItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
label={t(item.label)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<List
|
||||
className="the-menu"
|
||||
onContextMenu={handleMenuContextMenu}
|
||||
>
|
||||
{menuOrder.map((path) => {
|
||||
const item = navItemMap.get(path)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<LayoutItem
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
icon={item.icon}
|
||||
>
|
||||
{t(item.label)}
|
||||
</LayoutItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
open={Boolean(menuContextPosition)}
|
||||
onClose={handleMenuContextClose}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
menuContextPosition
|
||||
? {
|
||||
top: menuContextPosition.top,
|
||||
left: menuContextPosition.left,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
transitionDuration={200}
|
||||
slotProps={{
|
||||
list: {
|
||||
sx: { py: 0.5 },
|
||||
},
|
||||
<div className="layout-content">
|
||||
<div className="layout-content__left">
|
||||
<div className="the-logo" data-tauri-drag-region="false">
|
||||
<div
|
||||
data-tauri-drag-region="true"
|
||||
style={{
|
||||
height: '27px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleToggleNavCollapsed} dense>
|
||||
{navCollapsed
|
||||
? t('layout.components.navigation.menu.expandNavBar')
|
||||
: t('layout.components.navigation.menu.collapseNavBar')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
|
||||
dense
|
||||
>
|
||||
{menuUnlocked
|
||||
? t('layout.components.navigation.menu.lock')
|
||||
: t('layout.components.navigation.menu.unlock')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleResetMenuOrder}
|
||||
dense
|
||||
disabled={isDefaultOrder}
|
||||
>
|
||||
{t('layout.components.navigation.menu.restoreDefaultOrder')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<div className="the-traffic">
|
||||
<LayoutTraffic />
|
||||
<SvgIcon
|
||||
component={isDark ? iconDark : iconLight}
|
||||
style={{
|
||||
height: '36px',
|
||||
width: '36px',
|
||||
marginTop: '-3px',
|
||||
marginRight: '5px',
|
||||
marginLeft: '-3px',
|
||||
}}
|
||||
inheritViewBox
|
||||
/>
|
||||
<LogoSvg fill={isDark ? 'white' : 'black'} />
|
||||
</div>
|
||||
<UpdateButton className="the-newbtn" />
|
||||
</div>
|
||||
|
||||
<div className="layout-content__right">
|
||||
<div className="the-bar"></div>
|
||||
<div className="the-content">
|
||||
<BaseErrorBoundary>
|
||||
<Outlet />
|
||||
</BaseErrorBoundary>
|
||||
{logsPageMountedRef.current && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: isLogsPage ? undefined : 'none',
|
||||
}}
|
||||
{menuUnlocked && (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
mx: 'auto',
|
||||
mb: 1,
|
||||
maxWidth: 250,
|
||||
borderRadius: 1.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
color: theme.palette.warning.contrastText,
|
||||
bgcolor:
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.warning.dark,
|
||||
})}
|
||||
>
|
||||
{t('layout.components.navigation.menu.reorderMode')}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{menuUnlocked ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleMenuDragEnd}
|
||||
>
|
||||
<SortableContext items={menuOrder}>
|
||||
<List
|
||||
className="the-menu"
|
||||
onContextMenu={handleMenuContextMenu}
|
||||
>
|
||||
<LogsPage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menuOrder.map((path) => {
|
||||
const item = navItemMap.get(path)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<SortableNavMenuItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
label={t(item.label)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
|
||||
{menuOrder.map((path) => {
|
||||
const item = navItemMap.get(path)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
|
||||
{t(item.label)}
|
||||
</LayoutItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
open={Boolean(menuContextPosition)}
|
||||
onClose={handleMenuContextClose}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
menuContextPosition
|
||||
? {
|
||||
top: menuContextPosition.top,
|
||||
left: menuContextPosition.left,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
transitionDuration={200}
|
||||
slotProps={{
|
||||
list: {
|
||||
sx: { py: 0.5 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleToggleNavCollapsed} dense>
|
||||
{navCollapsed
|
||||
? t('layout.components.navigation.menu.expandNavBar')
|
||||
: t('layout.components.navigation.menu.collapseNavBar')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
|
||||
dense
|
||||
>
|
||||
{menuUnlocked
|
||||
? t('layout.components.navigation.menu.lock')
|
||||
: t('layout.components.navigation.menu.unlock')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleResetMenuOrder}
|
||||
dense
|
||||
disabled={isDefaultOrder}
|
||||
>
|
||||
{t('layout.components.navigation.menu.restoreDefaultOrder')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<div className="the-traffic">
|
||||
<LayoutTraffic />
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
|
||||
<div className="layout-content__right">
|
||||
<div className="the-bar"></div>
|
||||
<div className="the-content">
|
||||
<BaseErrorBoundary>
|
||||
<Outlet />
|
||||
</BaseErrorBoundary>
|
||||
{logsPageMountedRef.current && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: isLogsPage ? undefined : 'none',
|
||||
}}
|
||||
>
|
||||
<LogsPage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { useEffect } from 'react'
|
||||
import { mutate } from 'swr'
|
||||
|
||||
import { useListen } from '@/hooks/use-listen'
|
||||
import { queryClient } from '@/services/query-client'
|
||||
|
||||
export const useLayoutEvents = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
@ -14,8 +14,9 @@ export const useLayoutEvents = (
|
||||
const unlisteners: Array<() => void> = []
|
||||
let disposed = false
|
||||
const revalidateKeys = (keys: readonly string[]) => {
|
||||
const keySet = new Set(keys)
|
||||
mutate((key) => typeof key === 'string' && keySet.has(key))
|
||||
keys.forEach((key) => {
|
||||
queryClient.invalidateQueries({ queryKey: [key] })
|
||||
})
|
||||
}
|
||||
|
||||
const register = (
|
||||
|
||||
@ -22,15 +22,22 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { LoadingButton } from '@mui/lab'
|
||||
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listen, TauriEvent } from '@tauri-apps/api/event'
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { throttle } from 'lodash-es'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from 'react-router'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { BasePage, BaseStyledTextField, DialogRef } from '@/components/base'
|
||||
@ -55,6 +62,7 @@ import {
|
||||
updateProfile,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { queryClient } from '@/services/query-client'
|
||||
import { useSetLoadingCache, useThemeMode } from '@/services/states'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
@ -67,7 +75,7 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
|
||||
// 检查请求是否已过期
|
||||
const isRequestOutdated = (
|
||||
currentSequence: number,
|
||||
requestSequenceRef: any,
|
||||
requestSequenceRef: RefObject<number>,
|
||||
profile: string,
|
||||
) => {
|
||||
if (currentSequence !== requestSequenceRef.current) {
|
||||
@ -222,14 +230,14 @@ const ProfilePage = () => {
|
||||
debugLog('[紧急刷新] 开始强制刷新所有数据')
|
||||
|
||||
try {
|
||||
// 清除所有SWR缓存
|
||||
await mutate(() => true, undefined, { revalidate: false })
|
||||
// 只失效 profiles 相关 query,不影响 WS 订阅、IP 缓存等其他 query
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['getProfiles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['getRuntimeLogs'] }),
|
||||
])
|
||||
|
||||
// 强制重新获取配置数据
|
||||
await mutateProfiles(undefined, {
|
||||
revalidate: true,
|
||||
rollbackOnError: false,
|
||||
})
|
||||
await mutateProfiles()
|
||||
|
||||
// 等待状态稳定后增强配置
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
@ -249,10 +257,10 @@ const ProfilePage = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
|
||||
'getRuntimeLogs',
|
||||
getRuntimeLogs,
|
||||
)
|
||||
const { data: chainLogs = {}, refetch: mutateLogs } = useQuery({
|
||||
queryKey: ['getRuntimeLogs'],
|
||||
queryFn: getRuntimeLogs,
|
||||
})
|
||||
|
||||
const viewerRef = useRef<ProfileViewerRef>(null)
|
||||
const configRef = useRef<DialogRef>(null)
|
||||
@ -316,9 +324,10 @@ const ProfilePage = () => {
|
||||
}
|
||||
|
||||
// 强化的刷新策略
|
||||
// maxRetries 设为 1:useProfiles 内部 useQuery 已配置 retry:3,业务层只需 1 次额外重试
|
||||
const performRobustRefresh = async () => {
|
||||
let retryCount = 0
|
||||
const maxRetries = 5
|
||||
const maxRetries = 1
|
||||
const baseDelay = 200
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
@ -326,10 +335,7 @@ const ProfilePage = () => {
|
||||
debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`)
|
||||
|
||||
// 强制刷新,绕过所有缓存
|
||||
await mutateProfiles(undefined, {
|
||||
revalidate: true,
|
||||
rollbackOnError: false,
|
||||
})
|
||||
await mutateProfiles()
|
||||
|
||||
// 等待状态稳定
|
||||
await new Promise((resolve) =>
|
||||
@ -350,8 +356,11 @@ const ProfilePage = () => {
|
||||
// 所有重试失败后的最后尝试
|
||||
console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`)
|
||||
try {
|
||||
// 清除SWR缓存并重新获取
|
||||
await mutate('getProfiles', getProfiles(), { revalidate: true })
|
||||
// 清除缓存并重新获取
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: ['getProfiles'],
|
||||
queryFn: getProfiles,
|
||||
})
|
||||
await onEnhance(false)
|
||||
showNotice.error(
|
||||
'profiles.page.feedback.notifications.importNeedsRefresh',
|
||||
@ -1007,6 +1016,7 @@ const ProfilePage = () => {
|
||||
selected={profiles.current === item.uid}
|
||||
activating={activatings.includes(item.uid)}
|
||||
itemData={item}
|
||||
mutateProfiles={mutateProfiles}
|
||||
onSelect={(f) => onSelect(item.uid, f)}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
onSave={async (prev, curr) => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
getBaseConfig,
|
||||
getRuleProviders,
|
||||
@ -15,10 +15,24 @@ import {
|
||||
getRunningMode,
|
||||
getSystemProxy,
|
||||
} from '@/services/cmds'
|
||||
import { SWR_DEFAULTS, SWR_MIHOMO } from '@/services/config'
|
||||
|
||||
import { AppDataContext, AppDataContextType } from './app-data-context'
|
||||
|
||||
const TQ_MIHOMO = {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 1500,
|
||||
retry: 3,
|
||||
retryDelay: 2000,
|
||||
} as const
|
||||
|
||||
const TQ_DEFAULTS = {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 5000,
|
||||
retry: 2,
|
||||
} as const
|
||||
|
||||
// 全局数据提供者组件
|
||||
export const AppDataProvider = ({
|
||||
children,
|
||||
@ -27,35 +41,35 @@ export const AppDataProvider = ({
|
||||
}) => {
|
||||
const { verge } = useVerge()
|
||||
|
||||
const { data: proxiesData, mutate: refreshProxy } = useSWR(
|
||||
'getProxies',
|
||||
calcuProxies,
|
||||
SWR_MIHOMO,
|
||||
)
|
||||
const { data: proxiesData, refetch: refreshProxy } = useQuery({
|
||||
queryKey: ['getProxies'],
|
||||
queryFn: calcuProxies,
|
||||
...TQ_MIHOMO,
|
||||
})
|
||||
|
||||
const { data: clashConfig, mutate: refreshClashConfig } = useSWR(
|
||||
'getClashConfig',
|
||||
getBaseConfig,
|
||||
SWR_MIHOMO,
|
||||
)
|
||||
const { data: clashConfig, refetch: refreshClashConfig } = useQuery({
|
||||
queryKey: ['getClashConfig'],
|
||||
queryFn: getBaseConfig,
|
||||
...TQ_MIHOMO,
|
||||
})
|
||||
|
||||
const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR(
|
||||
'getProxyProviders',
|
||||
calcuProxyProviders,
|
||||
SWR_MIHOMO,
|
||||
)
|
||||
const { data: proxyProviders, refetch: refreshProxyProviders } = useQuery({
|
||||
queryKey: ['getProxyProviders'],
|
||||
queryFn: calcuProxyProviders,
|
||||
...TQ_MIHOMO,
|
||||
})
|
||||
|
||||
const { data: ruleProviders, mutate: refreshRuleProviders } = useSWR(
|
||||
'getRuleProviders',
|
||||
getRuleProviders,
|
||||
SWR_MIHOMO,
|
||||
)
|
||||
const { data: ruleProviders, refetch: refreshRuleProviders } = useQuery({
|
||||
queryKey: ['getRuleProviders'],
|
||||
queryFn: getRuleProviders,
|
||||
...TQ_MIHOMO,
|
||||
})
|
||||
|
||||
const { data: rulesData, mutate: refreshRules } = useSWR(
|
||||
'getRules',
|
||||
getRules,
|
||||
SWR_MIHOMO,
|
||||
)
|
||||
const { data: rulesData, refetch: refreshRules } = useQuery({
|
||||
queryKey: ['getRules'],
|
||||
queryFn: getRules,
|
||||
...TQ_MIHOMO,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let lastProfileId: string | null = null
|
||||
@ -79,7 +93,7 @@ export const AppDataProvider = ({
|
||||
}
|
||||
|
||||
const addWindowListener = (eventName: string, handler: EventListener) => {
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
// eslint-disable-next-line @eslint-react/web-api-no-leaked-event-listener
|
||||
window.addEventListener(eventName, handler)
|
||||
return () => window.removeEventListener(eventName, handler)
|
||||
}
|
||||
@ -222,22 +236,24 @@ export const AppDataProvider = ({
|
||||
}
|
||||
}, [refreshProxy, refreshClashConfig, refreshRules, refreshRuleProviders])
|
||||
|
||||
const { data: sysproxy, mutate: refreshSysproxy } = useSWR(
|
||||
'getSystemProxy',
|
||||
getSystemProxy,
|
||||
SWR_DEFAULTS,
|
||||
)
|
||||
const { data: sysproxy, refetch: refreshSysproxy } = useQuery({
|
||||
queryKey: ['getSystemProxy'],
|
||||
queryFn: getSystemProxy,
|
||||
...TQ_DEFAULTS,
|
||||
})
|
||||
|
||||
const { data: runningMode } = useSWR(
|
||||
'getRunningMode',
|
||||
getRunningMode,
|
||||
SWR_DEFAULTS,
|
||||
)
|
||||
const { data: runningMode } = useQuery({
|
||||
queryKey: ['getRunningMode'],
|
||||
queryFn: getRunningMode,
|
||||
...TQ_DEFAULTS,
|
||||
})
|
||||
|
||||
const { data: uptimeData } = useSWR('appUptime', getAppUptime, {
|
||||
...SWR_DEFAULTS,
|
||||
refreshInterval: 3000,
|
||||
errorRetryCount: 1,
|
||||
const { data: uptimeData } = useQuery({
|
||||
queryKey: ['appUptime'],
|
||||
queryFn: getAppUptime,
|
||||
...TQ_DEFAULTS,
|
||||
refetchInterval: 3000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
// 提供统一的刷新方法
|
||||
|
||||
12
src/services/query-client.ts
Normal file
12
src/services/query-client.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 2000,
|
||||
retry: 3,
|
||||
retryDelay: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user