mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-31 11:12:07 +08:00
* 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
651 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|