Files
clash-verge-rev/src/pages/proxies.tsx
T
wonfen 4e5ac8683b feat(proxy): confirm before enabling chain proxy
Show the speed warning with explicit enable/keep-disabled actions, and add a close button to the empty chain panel.
2026-10-19 20:01:43 +08:00

230 lines
6.7 KiB
TypeScript

import { LanOutlined, LanRounded, WarningRounded } from '@mui/icons-material'
import { Box, Button, ButtonGroup, Typography } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog, BasePage, TooltipIcon } from '@/components/base'
import { ProviderButton } from '@/components/proxy/provider-button'
import { ProxyGroups } from '@/components/proxy/proxy-groups'
import {
useAppRefreshers,
useClashConfigData,
} from '@/providers/app-data-context'
import {
getRuntimeProxyChainConfig,
patchClashMode,
updateProxyChainConfigInRuntime,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
const MODES = ['rule', 'global', 'direct'] as const
type Mode = (typeof MODES)[number]
const MODE_SET = new Set<string>(MODES)
const isMode = (value: unknown): value is Mode =>
typeof value === 'string' && MODE_SET.has(value)
const ProxyPage = () => {
const { t } = useTranslation()
// 从 localStorage 恢复链式代理按钮状态
const [isChainMode, setIsChainMode] = useState(() => {
try {
const saved = localStorage.getItem('proxy-chain-mode-enabled')
return saved === 'true'
} catch {
return false
}
})
const [chainConfirmOpen, setChainConfirmOpen] = useState(false)
const [chainConfigData, dispatchChainConfigData] = useReducer(
(_: string | null, action: string | null) => action,
null as string | null,
)
const { clashConfig } = useClashConfigData()
const { refreshClashConfig } = useAppRefreshers()
const updateChainConfigData = useCallback((value: string | null) => {
dispatchChainConfigData(value)
}, [])
const normalizedMode = clashConfig?.mode?.toLowerCase()
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
const chainWarning = t('proxies.page.chain.warning')
const onChangeMode = useLockFn(async (mode: Mode) => {
try {
// patchClashMode 在后端 PATCH 失败时会 reject,需提示用户而非静默失败
await mutate(() => patchClashMode(mode), {
id: 'patch-clash-mode',
errorNotice: false,
})
await refreshClashConfig()
} catch (error) {
showNotice.error(error)
}
})
const applyChainMode = (enabled: boolean) => {
setIsChainMode(enabled)
localStorage.setItem('proxy-chain-mode-enabled', enabled.toString())
}
const onExitChainMode = useLockFn(async () => {
// 退出链式代理模式时,清除链式代理配置
try {
debugLog('Exiting chain mode, clearing chain configuration')
const result = await mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
if (!result.ok) return
debugLog('Chain configuration cleared successfully')
} catch (error) {
console.error('Failed to clear chain configuration:', error)
showNotice.error(error)
return
}
applyChainMode(false)
})
const onToggleChainMode = () => {
if (isChainMode) onExitChainMode().catch(() => {})
else setChainConfirmOpen(true)
}
// 当开启链式代理模式时,获取配置数据
useEffect(() => {
if (!isChainMode) {
updateChainConfigData(null)
return
}
let cancelled = false
const fetchChainConfig = async () => {
try {
const exitNode = localStorage.getItem('proxy-chain-exit-node')
if (!exitNode) {
console.error('No proxy chain exit node found in localStorage')
if (!cancelled) {
updateChainConfigData('')
}
return
}
const configData = await getRuntimeProxyChainConfig(exitNode)
if (!cancelled) {
updateChainConfigData(configData || '')
}
} catch (error) {
console.error('Failed to get runtime proxy chain config:', error)
if (!cancelled) {
updateChainConfigData('')
}
}
}
fetchChainConfig().catch(() => {})
return () => {
cancelled = true
}
}, [isChainMode, updateChainConfigData])
useEffect(() => {
if (normalizedMode && !isMode(normalizedMode)) {
onChangeMode('rule').catch(() => {})
}
}, [normalizedMode, onChangeMode])
return (
<BasePage
full
contentStyle={{ height: '100%' }}
title={
isChainMode ? (
<Box
component="span"
data-tauri-drag-region="true"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}
>
{t('proxies.page.title.chainMode')}
<TooltipIcon
title={chainWarning}
icon={WarningRounded}
color="warning"
sx={{ p: 0.25 }}
/>
</Box>
) : (
t('proxies.page.title.default')
)
}
header={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ProviderButton />
<ButtonGroup size="small">
{MODES.map((mode) => (
<Button
key={mode}
variant={mode === curMode ? 'contained' : 'outlined'}
onClick={() => onChangeMode(mode)}
sx={{ textTransform: 'capitalize' }}
>
{t(`proxies.page.modes.${mode}`)}
</Button>
))}
</ButtonGroup>
<Button
size="small"
variant={isChainMode ? 'contained' : 'outlined'}
onClick={onToggleChainMode}
sx={{ ml: 1 }}
startIcon={
isChainMode ? (
<LanRounded fontSize="small" />
) : (
<LanOutlined fontSize="small" />
)
}
>
{t('proxies.page.actions.toggleChain')}
</Button>
</Box>
}
>
<ProxyGroups
mode={curMode ?? 'rule'}
isChainMode={isChainMode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/>
<BaseDialog
open={chainConfirmOpen}
title={t('proxies.page.actions.toggleChain')}
okBtn={t('proxies.page.chain.confirmEnable')}
cancelBtn={t('proxies.page.chain.keepDisabled')}
contentSx={{ width: { xs: 320, sm: 420 } }}
onCancel={() => setChainConfirmOpen(false)}
onClose={() => setChainConfirmOpen(false)}
onOk={() => {
setChainConfirmOpen(false)
applyChainMode(true)
}}
>
<Typography variant="body2">{chainWarning}</Typography>
</BaseDialog>
</BasePage>
)
}
export default ProxyPage