Files
clash-verge-rev/src/components/proxy/proxy-chain.tsx
T
wonfen 304f5bade9 perf(proxy): consolidate proxy data into a single IPC (#7542)
* feat(proxy): build deterministic proxy view

* feat(proxy): expose ordered proxy view command

* feat(proxy): add proxy view client contract

* fix(lint): allow Node test files

* feat(proxy): switch UI to ordered proxy view

* fix(proxy): harden ordered proxy view consumers

* fix(proxy): compare chain duplicates by current id

* fix(proxy): release mihomo guard after fetch

* fix(proxy): preserve semantic selections across refresh

* fix(proxy): block global chain without GLOBAL

* fix(proxy): handle proxy view edge states
2026-07-21 00:11:59 +08:00

651 lines
18 KiB
TypeScript

import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
ArrowDownward,
Delete as DeleteIcon,
DragIndicator,
Link,
LinkOff,
WarningRounded,
} from '@mui/icons-material'
import {
Alert,
Box,
Button,
Chip,
IconButton,
Paper,
Typography,
useTheme,
} from '@mui/material'
import yaml from 'js-yaml'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
closeAllConnections,
selectNodeForGroup,
} from 'tauri-plugin-mihomo-api'
import { TooltipIcon } from '@/components/base'
import { useRuntimeConfig } from '@/hooks/use-clash'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import {
selectGlobalChainNodes,
selectRuleChainMembers,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { rebindProxyChainItems, type ProxyChainItem } from './proxy-chain-model'
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
interface ParsedChainConfig {
proxies?: Array<{
name: string
type: string
[key: string]: any
}>
}
interface ProxyChainProps {
proxyChain: ProxyChainItem[]
onUpdateChain: (chain: ProxyChainItem[]) => void
chainConfigData?: string | null
onMarkUnsavedChanges?: () => void
mode?: string
selectedGroup?: string | null
}
interface SortableItemProps {
proxy: ProxyChainItem
index: number
isFirst: boolean
isLast: boolean
onRemove: (id: string) => void
}
const toChainItems = (
parsedConfig: ParsedChainConfig | null | undefined,
): ProxyChainItem[] => {
const timestamp = Date.now()
return (
parsedConfig?.proxies?.map((proxy, index) => ({
id: `${proxy.name}_${timestamp}_${index}`,
name: proxy.name,
type: proxy.type,
delay: undefined,
})) || []
)
}
const SortableItem = ({
proxy,
index,
isFirst,
isLast,
onRemove,
}: SortableItemProps) => {
const theme = useTheme()
const { t } = useTranslation()
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: proxy.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const roleLabel = isFirst
? t('proxies.page.chain.entryNode')
: isLast
? t('proxies.page.chain.exitNode')
: undefined
const roleColor = isFirst
? theme.palette.success.main
: isLast
? theme.palette.warning.main
: undefined
return (
<Box
ref={setNodeRef}
style={style}
sx={{
mb: 0,
display: 'flex',
alignItems: 'center',
p: 1,
backgroundColor: isDragging
? theme.palette.action.selected
: theme.palette.background.default,
borderRadius: 1,
border: roleColor
? `1.5px solid ${roleColor}`
: `1px solid ${theme.palette.divider}`,
boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1],
transition: 'box-shadow 0.2s, background-color 0.2s',
opacity: proxy.recordId === undefined ? 0.55 : undefined,
}}
>
<Box
{...attributes}
{...listeners}
sx={{
display: 'flex',
alignItems: 'center',
mr: 1,
color: theme.palette.text.secondary,
cursor: 'grab',
'&:active': {
cursor: 'grabbing',
},
}}
>
<DragIndicator />
</Box>
{roleLabel ? (
<Chip
label={roleLabel}
size="small"
sx={{
mr: 1,
fontWeight: 700,
color: '#fff',
backgroundColor: roleColor,
}}
/>
) : (
<Chip
label={`${index + 1}`}
size="small"
color="primary"
sx={{ mr: 1, minWidth: 32 }}
/>
)}
<Typography
variant="body2"
sx={{
flex: 1,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{proxy.name}
</Typography>
{proxy.type && (
<Chip
label={proxy.type}
size="small"
variant="outlined"
sx={{ mr: 1 }}
/>
)}
{proxy.delay !== undefined && (
<Chip
label={
proxy.delay > 0
? `${proxy.delay}ms`
: t('shared.labels.timeout') || '超时'
}
size="small"
color={
proxy.delay > 0 && proxy.delay < 200
? 'success'
: proxy.delay > 0 && proxy.delay < 800
? 'warning'
: 'error'
}
sx={{ mr: 1, fontSize: '0.7rem', minWidth: 50 }}
/>
)}
<IconButton
size="small"
onClick={() => onRemove(proxy.id)}
sx={{
color: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
)
}
export const ProxyChain = ({
proxyChain,
onUpdateChain,
chainConfigData,
onMarkUnsavedChanges,
mode,
selectedGroup,
}: ProxyChainProps) => {
const theme = useTheme()
const { t } = useTranslation()
const chainWarning = t('proxies.page.chain.warning')
const { proxyView } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const { data: runtimeConfig } = useRuntimeConfig(true)
const [isConnecting, setIsConnecting] = useState(false)
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.()
}, [onMarkUnsavedChanges])
const candidates = useMemo(() => {
if (!proxyView) return []
if (mode === 'rule' && selectedGroup) {
return selectRuleChainMembers(proxyView, selectedGroup).flatMap(
({ member }) => (member.kind === 'node' ? [member.node] : []),
)
}
if (!runtimeConfig) return []
const runtimeProxies = (
runtimeConfig as RuntimeConfigWithProxySequence | null
)?.proxies
return selectGlobalChainNodes(proxyView, runtimeProxies)
}, [mode, proxyView, runtimeConfig, selectedGroup])
const currentProxyChain = useMemo(
() =>
proxyView
? rebindProxyChainItems(proxyChain, candidates, proxyView)
: proxyChain.map((item) => ({
...item,
recordId: undefined,
delay: undefined,
})),
[candidates, proxyChain, proxyView],
)
const isConnected = useMemo(() => {
if (!proxyView || currentProxyChain.length === 0) {
return false
}
const lastNode = currentProxyChain[currentProxyChain.length - 1]
if (localStorage.getItem('proxy-chain-exit-node') === lastNode.name) {
return true
}
if (currentProxyChain.length < 2) return false
if (mode === 'global') {
return proxyView.global?.now === lastNode.name
}
if (!selectedGroup) {
return false
}
const proxyChainGroup = proxyView.groups.find(
(group) => group.name === selectedGroup,
)
return proxyChainGroup?.now === lastNode.name
}, [proxyView, currentProxyChain, mode, selectedGroup])
// 监听链的变化,但排除从配置加载的情况
const chainLengthRef = useRef(currentProxyChain.length)
useEffect(() => {
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
if (
chainLengthRef.current !== currentProxyChain.length &&
chainLengthRef.current !== 0
) {
markUnsavedChanges()
}
chainLengthRef.current = currentProxyChain.length
}, [currentProxyChain.length, markUnsavedChanges])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = currentProxyChain.findIndex(
(item) => item.id === active.id,
)
const newIndex = currentProxyChain.findIndex(
(item) => item.id === over?.id,
)
onUpdateChain(arrayMove(currentProxyChain, oldIndex, newIndex))
markUnsavedChanges()
}
},
[currentProxyChain, onUpdateChain, markUnsavedChanges],
)
const handleRemoveProxy = useCallback(
(id: string) => {
const newChain = currentProxyChain.filter((item) => item.id !== id)
onUpdateChain(newChain)
markUnsavedChanges()
},
[currentProxyChain, onUpdateChain, markUnsavedChanges],
)
const handleConnect = useCallback(async () => {
if (isConnected) {
setIsConnecting(true)
try {
await updateProxyChainConfigInRuntime(null)
const targetGroup =
mode === 'global'
? 'GLOBAL'
: selectedGroup || localStorage.getItem('proxy-chain-group')
if (targetGroup) {
try {
await selectNodeForGroup(targetGroup, 'DIRECT')
} catch {
if (currentProxyChain.length >= 1) {
try {
await selectNodeForGroup(targetGroup, currentProxyChain[0].name)
} catch {
// ignore
}
}
}
}
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
await closeAllConnections()
await refreshProxy()
onUpdateChain([])
} catch (error) {
console.error('Failed to disconnect from proxy chain:', error)
alert(t('proxies.page.chain.disconnectFailed') || '断开链式代理失败')
} finally {
setIsConnecting(false)
}
return
}
if (mode === 'global' && proxyView?.global === null) {
alert(t('proxies.page.chain.connectFailed') || '连接链式代理失败')
return
}
if (
currentProxyChain.length < 2 ||
currentProxyChain.some(({ recordId }) => !recordId)
) {
alert(t('proxies.page.chain.minimumNodes') || '链式代理至少需要2个节点')
return
}
setIsConnecting(true)
try {
// 第一步:保存链式代理配置
const chainProxies = currentProxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies)
await updateProxyChainConfigInRuntime(chainProxies)
debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点
const lastNode = currentProxyChain[currentProxyChain.length - 1]
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`)
// 根据模式确定使用的代理组名称
if (mode !== 'global' && !selectedGroup) {
throw new Error('规则模式下必须选择代理组')
}
const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem('proxy-chain-exit-node', lastNode.name)
// 刷新代理信息以更新连接状态
refreshProxy()
debugLog('Successfully connected to proxy chain')
} catch (error) {
console.error('Failed to connect to proxy chain:', error)
alert(t('proxies.page.chain.connectFailed') || '连接链式代理失败')
} finally {
setIsConnecting(false)
}
}, [
currentProxyChain,
isConnected,
t,
refreshProxy,
mode,
proxyView,
selectedGroup,
onUpdateChain,
])
// 处理链式代理配置数据
useEffect(() => {
if (chainConfigData) {
try {
// JSON is valid YAML, so one parser covers both persisted formats.
const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig
const chainItems = toChainItems(parsedConfig)
if (chainItems.length > 0) {
onUpdateChain(chainItems)
}
} catch (error) {
console.error('Failed to process chain config data:', error)
}
}
}, [chainConfigData, onUpdateChain])
return (
<Paper
elevation={1}
sx={{
height: '100%',
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Typography variant="h6">{t('proxies.page.chain.header')}</Typography>
<TooltipIcon
title={chainWarning}
icon={WarningRounded}
color="warning"
sx={{ p: 0.25 }}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{currentProxyChain.length > 0 && (
<IconButton
size="small"
onClick={() => {
updateProxyChainConfigInRuntime(null)
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
onUpdateChain([])
}}
sx={{
color: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
title={
t('proxies.page.actions.clearChainConfig') || '删除链式配置'
}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button
size="small"
variant="contained"
startIcon={isConnected ? <LinkOff /> : <Link />}
onClick={handleConnect}
disabled={
isConnecting ||
(!isConnected &&
(currentProxyChain.length < 2 ||
currentProxyChain.some(
({ recordId }) => recordId === undefined,
) ||
(mode === 'global' && proxyView?.global === null) ||
(mode !== 'global' && !selectedGroup)))
}
color={isConnected ? 'error' : 'success'}
sx={{
minWidth: 90,
}}
title={
!isConnected && currentProxyChain.length < 2
? t('proxies.page.chain.minimumNodes') ||
'链式代理至少需要2个节点'
: undefined
}
>
{isConnecting
? t('proxies.page.actions.connecting') || '连接中...'
: isConnected
? t('proxies.page.actions.disconnect') || '断开'
: t('proxies.page.actions.connect') || '连接'}
</Button>
</Box>
</Box>
<Alert
severity={currentProxyChain.length === 1 ? 'warning' : 'info'}
sx={{ mb: 2 }}
>
{currentProxyChain.length === 1
? t('proxies.page.chain.minimumNodesHint') ||
'链式代理至少需要2个节点,请再添加一个节点。'
: t('proxies.page.chain.instruction') ||
'按顺序点击节点添加到代理链中'}
</Alert>
<Box sx={{ flex: 1, overflow: 'auto' }}>
{currentProxyChain.length === 0 ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme.palette.text.secondary,
}}
>
<Typography>{t('proxies.page.chain.empty')}</Typography>
</Box>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={currentProxyChain.map((proxy) => proxy.id)}
strategy={verticalListSortingStrategy}
>
<Box
sx={{
borderRadius: 1,
minHeight: 60,
p: 1,
}}
>
{currentProxyChain.map((proxy, index) => (
<Box key={proxy.id}>
<SortableItem
proxy={proxy}
index={index}
isFirst={index === 0}
isLast={
index === currentProxyChain.length - 1 &&
currentProxyChain.length > 1
}
onRemove={handleRemoveProxy}
/>
{index < currentProxyChain.length - 1 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 0.25,
}}
>
<ArrowDownward
sx={{
fontSize: 20,
color: theme.palette.primary.main,
opacity: 0.7,
}}
/>
</Box>
)}
</Box>
))}
</Box>
</SortableContext>
</DndContext>
)}
</Box>
</Paper>
)
}