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') || '删除链式配置' } > )} {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 && ( )} ))} )} ) }