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 (
{roleLabel ? (
) : (
)}
{proxy.name}
{proxy.type && (
)}
{proxy.delay !== undefined && (
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 }}
/>
)}
onRemove(proxy.id)}
sx={{
color: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
>
)
}
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 (
{t('proxies.page.chain.header')}
{currentProxyChain.length > 0 && (
{
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') || '删除链式配置'
}
>
)}
: }
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') || '连接'}
{currentProxyChain.length === 1
? t('proxies.page.chain.minimumNodesHint') ||
'链式代理至少需要2个节点,请再添加一个节点。'
: t('proxies.page.chain.instruction') ||
'按顺序点击节点添加到代理链中'}
{currentProxyChain.length === 0 ? (
{t('proxies.page.chain.empty')}
) : (
proxy.id)}
strategy={verticalListSortingStrategy}
>
{currentProxyChain.map((proxy, index) => (
1
}
onRemove={handleRemoveProxy}
/>
{index < currentProxyChain.length - 1 && (
)}
))}
)}
)
}