feat(proxies): add empty-state troubleshooting guidance

This commit is contained in:
wonfen
2026-07-26 01:28:49 +08:00
parent f86a591597
commit ece3c71f38
20 changed files with 567 additions and 17 deletions
@@ -0,0 +1,88 @@
import type { ProxyViewV1 } from '@/types/proxy-view'
export type ProxyEmptyStateReason =
| 'no-subscriptions'
| 'inactive-subscription'
| 'core-unavailable'
| 'no-proxy-info'
type ProfileSummary = {
current?: string
items?: Array<{
uid?: string
type?: string
}>
}
interface ProxyEmptyStateInput {
mode: string
isChainMode: boolean
profiles?: ProfileSummary
isProfilesPending: boolean
proxyView?: ProxyViewV1
isProxyViewPending: boolean
isProxyViewError: boolean
runningMode?: string
isRunningModePending: boolean
}
const isSubscriptionProfile = (item: { type?: string }): boolean =>
item.type === 'remote' || item.type === 'local'
export const hasRenderableProxyContent = (
proxyView: ProxyViewV1 | undefined,
mode: string,
isChainMode: boolean,
) => {
if (!proxyView) return false
if (isChainMode) {
return proxyView.groups.some(
(group) => group.type === 'Selector' || group.type === 'URLTest',
)
}
if (mode === 'rule' || mode === 'script') {
return proxyView.groups.some((group) => !group.hidden)
}
return proxyView.global !== null
}
export const resolveProxyEmptyState = ({
mode,
isChainMode,
profiles,
isProfilesPending,
proxyView,
isProxyViewPending,
isProxyViewError,
runningMode,
isRunningModePending,
}: ProxyEmptyStateInput): ProxyEmptyStateReason | 'loading' | null => {
if (mode === 'direct') return null
if (isProfilesPending || isProxyViewPending || isRunningModePending) {
return 'loading'
}
if (profiles) {
const subscriptions = (profiles.items ?? []).filter(isSubscriptionProfile)
if (subscriptions.length === 0) return 'no-subscriptions'
const hasActiveSubscription = subscriptions.some(
(item) => item.uid === profiles.current,
)
if (!hasActiveSubscription) return 'inactive-subscription'
}
if (hasRenderableProxyContent(proxyView, mode, isChainMode)) {
return null
}
if (runningMode === 'NotRunning' || isProxyViewError) {
return 'core-unavailable'
}
return 'no-proxy-info'
}
+118
View File
@@ -0,0 +1,118 @@
import { Alert, AlertTitle, Box, Button, Stack } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context'
import { openLogsDir, restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { revalidateQuery } from '@/services/query-client'
import type { ProxyEmptyStateReason } from './proxy-empty-state-model'
interface Props {
reason: ProxyEmptyStateReason
}
export const ProxyEmptyState = ({ reason }: Props) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { refreshProxy } = useAppRefreshers()
const [isRestarting, setIsRestarting] = useState(false)
const handleRestart = useLockFn(async () => {
setIsRestarting(true)
try {
await restartCore()
await Promise.all([refreshProxy(), revalidateQuery(['getRunningMode'])])
} catch (error) {
showNotice.error(error)
} finally {
setIsRestarting(false)
}
})
const title =
reason === 'no-subscriptions'
? t('proxies.page.empty.noSubscriptions.title')
: reason === 'inactive-subscription'
? t('proxies.page.empty.inactiveSubscription.title')
: reason === 'core-unavailable'
? t('proxies.page.empty.coreUnavailable.title')
: t('proxies.page.empty.noProxyInfo.title')
const description =
reason === 'no-subscriptions'
? t('proxies.page.empty.noSubscriptions.description')
: reason === 'inactive-subscription'
? t('proxies.page.empty.inactiveSubscription.description')
: reason === 'core-unavailable'
? t('proxies.page.empty.coreUnavailable.description')
: t('proxies.page.empty.noProxyInfo.description')
const showProfiles =
reason === 'no-subscriptions' ||
reason === 'inactive-subscription' ||
reason === 'no-proxy-info'
const showCoreActions =
reason === 'core-unavailable' || reason === 'no-proxy-info'
return (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
}}
>
<Alert
severity="warning"
variant="outlined"
sx={{ width: '100%', maxWidth: 640 }}
>
<AlertTitle>{title}</AlertTitle>
{description}
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{ mt: 2, flexWrap: 'wrap' }}
>
{showProfiles && (
<Button
size="small"
variant="contained"
onClick={() => navigate('/profile')}
>
{t('proxies.page.empty.actions.openProfiles')}
</Button>
)}
{showCoreActions && (
<>
<Button
size="small"
variant="contained"
loading={isRestarting}
onClick={() => void handleRestart()}
>
{t('proxies.page.empty.actions.restartCore')}
</Button>
<Button
size="small"
variant="outlined"
disabled={isRestarting}
onClick={() => void openLogsDir()}
>
{t('proxies.page.empty.actions.openLogs')}
</Button>
</>
)}
</Stack>
</Alert>
</Box>
)
}
+27 -1
View File
@@ -18,9 +18,10 @@ import {
StickyVirtualList,
type StickyVirtualListHandle,
} from '@/components/base'
import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useProxiesData } from '@/providers/app-data-context'
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import delayManager from '@/services/delay'
import {
isInteractableMember,
@@ -30,6 +31,8 @@ import {
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { ProxyEmptyState } from './proxy-empty-state'
import { resolveProxyEmptyState } from './proxy-empty-state-model'
import {
DEFAULT_HOVER_DELAY,
ProxyGroupNavigator,
@@ -598,11 +601,34 @@ function NormalProxyGroups(props: { mode: string }) {
export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { proxyView, isProxyViewPending, isProxyViewError } = useProxiesData()
const { runningMode, isRunningModePending } = useSystemData()
const emptyState = resolveProxyEmptyState({
mode,
isChainMode,
profiles,
isProfilesPending: !profiles && isProfilesLoading,
proxyView,
isProxyViewPending,
isProxyViewError,
runningMode,
isRunningModePending,
})
if (mode === 'direct') {
return <BaseEmpty textKey="proxies.page.messages.directMode" />
}
if (emptyState === 'loading') {
return <BaseLoading />
}
if (emptyState) {
return <ProxyEmptyState reason={emptyState} />
}
if (isChainMode) {
return <ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
}