import { arrayMove } from '@dnd-kit/helpers' import { DragDropProvider, KeyboardSensor, PointerSensor, type DragEndEvent, } from '@dnd-kit/react' import { isSortable, useSortable } from '@dnd-kit/react/sortable' import { ArrowDownward, Delete as DeleteIcon, DragIndicator, Link, LinkOff, WarningRounded, } from '@mui/icons-material' import { Alert, Box, Button, Chip, IconButton, Paper, Typography, keyframes, useTheme, } from '@mui/material' import * as yaml from 'js-yaml' import { type Ref, 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 { useRecordSelection } from '@/hooks/use-record-selection' import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context' import { updateProxyChainConfigInRuntime } from '@/services/cmds' import { mutate } from '@/services/mutate' import { selectGlobalChainNodes, selectRuleChainMembers, } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { rebindProxyChainItems, type ProxyChainItem } from './proxy-chain-model' const chainPointerSensor = PointerSensor.configure({ activationConstraints: () => undefined, }) 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 ProxyChainItemProps { id: string proxy: ProxyChainItem index: number isFirst: boolean isLast: boolean onRemove: (id: string) => void } const arrowFadeIn = keyframes` from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } ` 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, })) || [] ) } interface ChainCardProps { proxy: ProxyChainItem index: number isFirst: boolean isLast: boolean isDragging?: boolean isDropping?: boolean handleRef?: Ref | null onRemove?: (id: string) => void } const ChainCard = ({ proxy, index, isFirst, isLast, isDragging, isDropping, handleRef, onRemove, }: ChainCardProps) => { const theme = useTheme() const { t } = useTranslation() 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 && ( onRemove(proxy.id)} sx={{ color: theme.palette.error.main, '&:hover': { backgroundColor: theme.palette.error.light + '20', }, }} > )} ) } const SortableProxyChainItem = ({ id, proxy, index, isFirst, isLast, onRemove, }: ProxyChainItemProps) => { const theme = useTheme() const [element, setElement] = useState(null) const handleRef = useRef(null) const { isDragging, isDropping } = useSortable({ id, index, element, handle: handleRef, }) return ( {!isLast && ( )} ) } 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 recordSelection = useRecordSelection() 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 handleDragEnd = useCallback( (event: DragEndEvent) => { const { operation, canceled } = event const { source, target } = operation if (canceled || !target || !isSortable(source)) return const { index: newIndex, initialIndex: oldIndex } = source.sortable if ( oldIndex < 0 || newIndex < 0 || oldIndex >= currentProxyChain.length || newIndex >= currentProxyChain.length || oldIndex === newIndex ) { return } 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 { const result = await mutate( () => updateProxyChainConfigInRuntime(null), { id: 'update-proxy-chain-runtime', errorNotice: false, }, ) if (!result.ok) return const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup || localStorage.getItem('proxy-chain-group') if (targetGroup) { try { await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), { id: `select-proxy-node:${targetGroup}`, errorNotice: false, }) await recordSelection(targetGroup, 'DIRECT') } catch { if (currentProxyChain.length >= 1) { try { await mutate( () => selectNodeForGroup(targetGroup, currentProxyChain[0].name), { id: `select-proxy-node:${targetGroup}`, errorNotice: false, }, ) await recordSelection(targetGroup, currentProxyChain[0].name) } catch { // ignore } } } } localStorage.removeItem('proxy-chain-group') localStorage.removeItem('proxy-chain-exit-node') localStorage.removeItem('proxy-chain-items') await mutate(() => closeAllConnections(), { id: 'close-all-connections', errorNotice: false, }) 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')) return } setIsConnecting(true) try { // 第一步:保存链式代理配置 const chainProxies = currentProxyChain.map((node) => node.name) debugLog('Saving chain config:', chainProxies) const result = await mutate( () => updateProxyChainConfigInRuntime(chainProxies), { id: 'update-proxy-chain-runtime', errorNotice: false, }, ) if (!result.ok) return 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 mutate( () => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name), { id: `select-proxy-node:${targetGroup || 'GLOBAL'}`, errorNotice: false, }, ) // The chain moves the group like any other selection, so the profile has to learn about // it: what the profile holds is what gets re-applied the next time the core starts. await recordSelection(targetGroup || 'GLOBAL', lastNode.name) localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL') localStorage.setItem('proxy-chain-exit-node', lastNode.name) // 刷新代理信息以更新连接状态 await 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, recordSelection, ]) // 处理链式代理配置数据 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 && ( { void mutate(() => updateProxyChainConfigInRuntime(null), { id: 'update-proxy-chain-runtime', onFulfilled: () => { localStorage.removeItem('proxy-chain-group') localStorage.removeItem('proxy-chain-exit-node') localStorage.removeItem('proxy-chain-items') onUpdateChain([]) }, }).catch(() => {}) }} 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') : t('proxies.page.chain.instruction')} {currentProxyChain.length === 0 ? ( {t('proxies.page.chain.empty')} ) : ( {currentProxyChain.map((proxy, index) => ( 1 } onRemove={handleRemoveProxy} /> ))} )} ) }