mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-06 17:12:07 +08:00
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
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { updateProxyProvider } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
@@ -48,16 +48,11 @@ const parseExpire = (expire?: number) => {
|
||||
export const ProviderButton = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { proxyProviders } = useProxiesData()
|
||||
const { refreshProxy, refreshProxyProviders } = useAppRefreshers()
|
||||
const { proxyView } = useProxiesData()
|
||||
const { refreshProxy } = useAppRefreshers()
|
||||
const [updating, setUpdating] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
refreshProxyProviders().catch(() => {})
|
||||
}, [refreshProxyProviders])
|
||||
|
||||
// 检查是否有提供者
|
||||
const hasProviders = Object.keys(proxyProviders || {}).length > 0
|
||||
const providers = proxyView?.providers ?? []
|
||||
const providerUnavailable = proxyView?.providerState === 'unavailable'
|
||||
|
||||
// 更新单个代理提供者
|
||||
const updateProvider = useLockFn(async (name: string) => {
|
||||
@@ -69,7 +64,6 @@ export const ProviderButton = () => {
|
||||
|
||||
// 刷新数据
|
||||
await refreshProxy()
|
||||
await refreshProxyProviders()
|
||||
|
||||
showNotice.success(
|
||||
'proxies.feedback.notifications.provider.updateSuccess',
|
||||
@@ -92,7 +86,7 @@ export const ProviderButton = () => {
|
||||
const updateAllProviders = useLockFn(async () => {
|
||||
try {
|
||||
// 获取所有provider的名称
|
||||
const allProviders = Object.keys(proxyProviders || {})
|
||||
const allProviders = providers.map(({ name }) => name)
|
||||
if (allProviders.length === 0) {
|
||||
showNotice.info('proxies.feedback.notifications.provider.none')
|
||||
return
|
||||
@@ -122,7 +116,6 @@ export const ProviderButton = () => {
|
||||
|
||||
// 刷新数据
|
||||
await refreshProxy()
|
||||
await refreshProxyProviders()
|
||||
|
||||
showNotice.success('proxies.feedback.notifications.provider.allUpdated')
|
||||
} catch (err) {
|
||||
@@ -139,7 +132,7 @@ export const ProviderButton = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (!hasProviders) return null
|
||||
if (providers.length === 0 && !providerUnavailable) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -148,7 +141,17 @@ export const ProviderButton = () => {
|
||||
size="small"
|
||||
startIcon={<StorageOutlined />}
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ mr: 1 }}
|
||||
disabled={providerUnavailable}
|
||||
color={providerUnavailable ? 'warning' : 'primary'}
|
||||
sx={{
|
||||
mr: 1,
|
||||
...(providerUnavailable && {
|
||||
'&.Mui-disabled': {
|
||||
color: 'warning.main',
|
||||
borderColor: 'warning.main',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{t('proxies.page.provider.title')}
|
||||
</Button>
|
||||
@@ -180,170 +183,168 @@ export const ProviderButton = () => {
|
||||
|
||||
<DialogContent>
|
||||
<List sx={{ py: 0, minHeight: 250 }}>
|
||||
{Object.entries(proxyProviders || {})
|
||||
.sort()
|
||||
.map(([key, item]) => {
|
||||
const provider = item
|
||||
const time = dayjs(provider.updatedAt)
|
||||
const isUpdating = updating[key]
|
||||
{providers.map((provider) => {
|
||||
const key = provider.name
|
||||
const updatedAt = provider.updatedAt
|
||||
? dayjs(provider.updatedAt)
|
||||
: null
|
||||
const isUpdating = updating[key]
|
||||
|
||||
// 订阅信息
|
||||
const sub = provider.subscriptionInfo
|
||||
const hasSubInfo = !!sub
|
||||
const upload = sub?.Upload || 0
|
||||
const download = sub?.Download || 0
|
||||
const total = sub?.Total || 0
|
||||
const expire = sub?.Expire || 0
|
||||
// 订阅信息
|
||||
const sub = provider.subscriptionInfo
|
||||
const hasSubInfo = !!sub
|
||||
const upload = sub?.upload || 0
|
||||
const download = sub?.download || 0
|
||||
const total = sub?.total || 0
|
||||
const expire = sub?.expire || 0
|
||||
|
||||
// 流量使用进度
|
||||
const progress =
|
||||
total > 0
|
||||
? Math.min(
|
||||
Math.round(((download + upload) * 100) / total) + 1,
|
||||
100,
|
||||
)
|
||||
: 0
|
||||
// 流量使用进度
|
||||
const progress =
|
||||
total > 0
|
||||
? Math.min(
|
||||
Math.round(((download + upload) * 100) / total) + 1,
|
||||
100,
|
||||
)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
sx={[
|
||||
{
|
||||
p: 0,
|
||||
mb: '8px',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
({ palette: { mode, primary } }) => {
|
||||
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
|
||||
const hoverColor =
|
||||
mode === 'light'
|
||||
? alpha(primary.main, 0.1)
|
||||
: alpha(primary.main, 0.2)
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
sx={[
|
||||
{
|
||||
p: 0,
|
||||
mb: '8px',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
({ palette: { mode, primary } }) => {
|
||||
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
|
||||
const hoverColor =
|
||||
mode === 'light'
|
||||
? alpha(primary.main, 0.1)
|
||||
: alpha(primary.main, 0.2)
|
||||
|
||||
return {
|
||||
backgroundColor: bgcolor,
|
||||
'&:hover': {
|
||||
backgroundColor: hoverColor,
|
||||
},
|
||||
}
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{ px: 2, py: 1 }}
|
||||
primary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
noWrap
|
||||
title={key}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<span style={{ marginRight: '8px' }}>{key}</span>
|
||||
<TypeBox component="span">
|
||||
{provider.proxies.length}
|
||||
</TypeBox>
|
||||
<TypeBox component="span">
|
||||
{provider.vehicleType}
|
||||
</TypeBox>
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
>
|
||||
<small>{t('shared.labels.updateAt')}: </small>
|
||||
{time.fromNow()}
|
||||
</Typography>
|
||||
</Box>
|
||||
return {
|
||||
backgroundColor: bgcolor,
|
||||
'&:hover': {
|
||||
backgroundColor: hoverColor,
|
||||
},
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
{/* 订阅信息 */}
|
||||
{hasSubInfo && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
title={t('shared.labels.usedTotal') as string}
|
||||
>
|
||||
{parseTraffic(upload + download)} /{' '}
|
||||
{parseTraffic(total)}
|
||||
</span>
|
||||
<span
|
||||
title={
|
||||
t('shared.labels.expireTime') as string
|
||||
}
|
||||
>
|
||||
{parseExpire(expire)}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
{/* 进度条 */}
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
opacity: total > 0 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
updateProvider(key)
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{ px: 2, py: 1 }}
|
||||
primary={
|
||||
<Box
|
||||
sx={{
|
||||
animation: isUpdating
|
||||
? 'spin 1s linear infinite'
|
||||
: 'none',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
title={t('proxies.page.provider.actions.update')}
|
||||
aria-label={t('proxies.page.provider.actions.update')}
|
||||
>
|
||||
<RefreshRounded />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
noWrap
|
||||
title={key}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<span style={{ marginRight: '8px' }}>{key}</span>
|
||||
<TypeBox component="span">
|
||||
{provider.proxyRecordIds.length}
|
||||
</TypeBox>
|
||||
<TypeBox component="span">
|
||||
{provider.vehicleType}
|
||||
</TypeBox>
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
>
|
||||
<small>{t('shared.labels.updateAt')}: </small>
|
||||
{updatedAt?.fromNow() ?? '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
{/* 订阅信息 */}
|
||||
{hasSubInfo && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
title={t('shared.labels.usedTotal') as string}
|
||||
>
|
||||
{parseTraffic(upload + download)} /{' '}
|
||||
{parseTraffic(total)}
|
||||
</span>
|
||||
<span
|
||||
title={t('shared.labels.expireTime') as string}
|
||||
>
|
||||
{parseExpire(expire)}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
{/* 进度条 */}
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
opacity: total > 0 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
updateProvider(key)
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
sx={{
|
||||
animation: isUpdating
|
||||
? 'spin 1s linear infinite'
|
||||
: 'none',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
}}
|
||||
title={t('proxies.page.provider.actions.update')}
|
||||
aria-label={t('proxies.page.provider.actions.update')}
|
||||
>
|
||||
<RefreshRounded />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
getRecord,
|
||||
rebindNode,
|
||||
type ProxyNodeView,
|
||||
type ProxyViewV1,
|
||||
} from '@/types/proxy-view'
|
||||
|
||||
export interface ProxyChainItem {
|
||||
id: string
|
||||
name: string
|
||||
recordId?: string
|
||||
source?: ProxyNodeView['source']
|
||||
type?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export const rebindProxyChainItems = (
|
||||
items: readonly ProxyChainItem[],
|
||||
candidates: readonly ProxyNodeView[],
|
||||
proxyView: ProxyViewV1,
|
||||
): ProxyChainItem[] =>
|
||||
items.map((item) => {
|
||||
const rebound = rebindNode(candidates, {
|
||||
name: item.name,
|
||||
source: item.source,
|
||||
})
|
||||
const record =
|
||||
rebound === undefined ? undefined : getRecord(proxyView, rebound.recordId)
|
||||
return {
|
||||
...item,
|
||||
recordId: rebound?.recordId,
|
||||
source: rebound?.source ?? item.source,
|
||||
type: rebound?.type ?? item.type,
|
||||
delay: record?.history.at(-1)?.delay,
|
||||
}
|
||||
})
|
||||
@@ -42,16 +42,18 @@ import {
|
||||
} 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'
|
||||
|
||||
interface ProxyChainItem {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
delay?: number
|
||||
}
|
||||
import { rebindProxyChainItems, type ProxyChainItem } from './proxy-chain-model'
|
||||
|
||||
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
|
||||
|
||||
interface ParsedChainConfig {
|
||||
proxies?: Array<{
|
||||
@@ -147,6 +149,7 @@ const SortableItem = ({
|
||||
: `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
|
||||
@@ -254,47 +257,78 @@ export const ProxyChain = ({
|
||||
const theme = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const chainWarning = t('proxies.page.chain.warning')
|
||||
const { proxies } = useProxiesData()
|
||||
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 (!proxies || proxyChain.length < 2) {
|
||||
if (!proxyView || currentProxyChain.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastNode = proxyChain[proxyChain.length - 1]
|
||||
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 proxies.global?.now === lastNode.name
|
||||
return proxyView.global?.now === lastNode.name
|
||||
}
|
||||
|
||||
if (!selectedGroup || !Array.isArray(proxies.groups)) {
|
||||
if (!selectedGroup) {
|
||||
return false
|
||||
}
|
||||
|
||||
const proxyChainGroup = proxies.groups.find(
|
||||
(group: { name: string }) => group.name === selectedGroup,
|
||||
const proxyChainGroup = proxyView.groups.find(
|
||||
(group) => group.name === selectedGroup,
|
||||
)
|
||||
|
||||
return proxyChainGroup?.now === lastNode.name
|
||||
}, [proxies, proxyChain, mode, selectedGroup])
|
||||
}, [proxyView, currentProxyChain, mode, selectedGroup])
|
||||
|
||||
// 监听链的变化,但排除从配置加载的情况
|
||||
const chainLengthRef = useRef(proxyChain.length)
|
||||
const chainLengthRef = useRef(currentProxyChain.length)
|
||||
useEffect(() => {
|
||||
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
|
||||
if (
|
||||
chainLengthRef.current !== proxyChain.length &&
|
||||
chainLengthRef.current !== currentProxyChain.length &&
|
||||
chainLengthRef.current !== 0
|
||||
) {
|
||||
markUnsavedChanges()
|
||||
}
|
||||
chainLengthRef.current = proxyChain.length
|
||||
}, [proxyChain.length, markUnsavedChanges])
|
||||
chainLengthRef.current = currentProxyChain.length
|
||||
}, [currentProxyChain.length, markUnsavedChanges])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -310,23 +344,27 @@ export const ProxyChain = ({
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = proxyChain.findIndex((item) => item.id === active.id)
|
||||
const newIndex = proxyChain.findIndex((item) => item.id === over?.id)
|
||||
const oldIndex = currentProxyChain.findIndex(
|
||||
(item) => item.id === active.id,
|
||||
)
|
||||
const newIndex = currentProxyChain.findIndex(
|
||||
(item) => item.id === over?.id,
|
||||
)
|
||||
|
||||
onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex))
|
||||
onUpdateChain(arrayMove(currentProxyChain, oldIndex, newIndex))
|
||||
markUnsavedChanges()
|
||||
}
|
||||
},
|
||||
[proxyChain, onUpdateChain, markUnsavedChanges],
|
||||
[currentProxyChain, onUpdateChain, markUnsavedChanges],
|
||||
)
|
||||
|
||||
const handleRemoveProxy = useCallback(
|
||||
(id: string) => {
|
||||
const newChain = proxyChain.filter((item) => item.id !== id)
|
||||
const newChain = currentProxyChain.filter((item) => item.id !== id)
|
||||
onUpdateChain(newChain)
|
||||
markUnsavedChanges()
|
||||
},
|
||||
[proxyChain, onUpdateChain, markUnsavedChanges],
|
||||
[currentProxyChain, onUpdateChain, markUnsavedChanges],
|
||||
)
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
@@ -344,9 +382,9 @@ export const ProxyChain = ({
|
||||
try {
|
||||
await selectNodeForGroup(targetGroup, 'DIRECT')
|
||||
} catch {
|
||||
if (proxyChain.length >= 1) {
|
||||
if (currentProxyChain.length >= 1) {
|
||||
try {
|
||||
await selectNodeForGroup(targetGroup, proxyChain[0].name)
|
||||
await selectNodeForGroup(targetGroup, currentProxyChain[0].name)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -371,7 +409,15 @@ export const ProxyChain = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (proxyChain.length < 2) {
|
||||
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
|
||||
}
|
||||
@@ -379,13 +425,13 @@ export const ProxyChain = ({
|
||||
setIsConnecting(true)
|
||||
try {
|
||||
// 第一步:保存链式代理配置
|
||||
const chainProxies = proxyChain.map((node) => node.name)
|
||||
const chainProxies = currentProxyChain.map((node) => node.name)
|
||||
debugLog('Saving chain config:', chainProxies)
|
||||
await updateProxyChainConfigInRuntime(chainProxies)
|
||||
debugLog('Chain configuration saved successfully')
|
||||
|
||||
// 第二步:连接到代理链的最后一个节点
|
||||
const lastNode = proxyChain[proxyChain.length - 1]
|
||||
const lastNode = currentProxyChain[currentProxyChain.length - 1]
|
||||
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`)
|
||||
|
||||
// 根据模式确定使用的代理组名称
|
||||
@@ -409,23 +455,16 @@ export const ProxyChain = ({
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [
|
||||
proxyChain,
|
||||
currentProxyChain,
|
||||
isConnected,
|
||||
t,
|
||||
refreshProxy,
|
||||
mode,
|
||||
proxyView,
|
||||
selectedGroup,
|
||||
onUpdateChain,
|
||||
])
|
||||
|
||||
const proxyChainRef = useRef(proxyChain)
|
||||
const onUpdateChainRef = useRef(onUpdateChain)
|
||||
|
||||
useEffect(() => {
|
||||
proxyChainRef.current = proxyChain
|
||||
onUpdateChainRef.current = onUpdateChain
|
||||
}, [proxyChain, onUpdateChain])
|
||||
|
||||
// 处理链式代理配置数据
|
||||
useEffect(() => {
|
||||
if (chainConfigData) {
|
||||
@@ -443,47 +482,6 @@ export const ProxyChain = ({
|
||||
}
|
||||
}, [chainConfigData, onUpdateChain])
|
||||
|
||||
// 定时更新延迟数据
|
||||
useEffect(() => {
|
||||
if (!proxies?.records) return
|
||||
|
||||
const updateDelays = () => {
|
||||
const currentChain = proxyChainRef.current
|
||||
if (currentChain.length === 0) return
|
||||
|
||||
const updatedChain = currentChain.map((item) => {
|
||||
const proxyRecord = proxies.records[item.name]
|
||||
if (
|
||||
proxyRecord &&
|
||||
proxyRecord.history &&
|
||||
proxyRecord.history.length > 0
|
||||
) {
|
||||
const latestDelay =
|
||||
proxyRecord.history[proxyRecord.history.length - 1].delay
|
||||
return { ...item, delay: latestDelay }
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
// 只有在延迟数据确实发生变化时才更新
|
||||
const hasChanged = updatedChain.some(
|
||||
(item, index) => item.delay !== currentChain[index]?.delay,
|
||||
)
|
||||
|
||||
if (hasChanged) {
|
||||
onUpdateChainRef.current(updatedChain)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即更新一次延迟
|
||||
updateDelays()
|
||||
|
||||
// 设置定时器,每5秒更新一次延迟
|
||||
const interval = setInterval(updateDelays, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [proxies?.records]) // 只依赖proxies.records
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={1}
|
||||
@@ -512,7 +510,7 @@ export const ProxyChain = ({
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{proxyChain.length > 0 && (
|
||||
{currentProxyChain.length > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
@@ -542,15 +540,20 @@ export const ProxyChain = ({
|
||||
onClick={handleConnect}
|
||||
disabled={
|
||||
isConnecting ||
|
||||
proxyChain.length < 2 ||
|
||||
(mode !== 'global' && !selectedGroup)
|
||||
(!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={
|
||||
proxyChain.length < 2
|
||||
!isConnected && currentProxyChain.length < 2
|
||||
? t('proxies.page.chain.minimumNodes') ||
|
||||
'链式代理至少需要2个节点'
|
||||
: undefined
|
||||
@@ -566,10 +569,10 @@ export const ProxyChain = ({
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity={proxyChain.length === 1 ? 'warning' : 'info'}
|
||||
severity={currentProxyChain.length === 1 ? 'warning' : 'info'}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{proxyChain.length === 1
|
||||
{currentProxyChain.length === 1
|
||||
? t('proxies.page.chain.minimumNodesHint') ||
|
||||
'链式代理至少需要2个节点,请再添加一个节点。'
|
||||
: t('proxies.page.chain.instruction') ||
|
||||
@@ -577,7 +580,7 @@ export const ProxyChain = ({
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{proxyChain.length === 0 ? (
|
||||
{currentProxyChain.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -596,7 +599,7 @@ export const ProxyChain = ({
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={proxyChain.map((proxy) => proxy.id)}
|
||||
items={currentProxyChain.map((proxy) => proxy.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<Box
|
||||
@@ -606,18 +609,19 @@ export const ProxyChain = ({
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
{proxyChain.map((proxy, index) => (
|
||||
{currentProxyChain.map((proxy, index) => (
|
||||
<Box key={proxy.id}>
|
||||
<SortableItem
|
||||
proxy={proxy}
|
||||
index={index}
|
||||
isFirst={index === 0}
|
||||
isLast={
|
||||
index === proxyChain.length - 1 && proxyChain.length > 1
|
||||
index === currentProxyChain.length - 1 &&
|
||||
currentProxyChain.length > 1
|
||||
}
|
||||
onRemove={handleRemoveProxy}
|
||||
/>
|
||||
{index < proxyChain.length - 1 && (
|
||||
{index < currentProxyChain.length - 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -21,24 +21,24 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useProxiesData } from '@/providers/app-data-context'
|
||||
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
|
||||
import {
|
||||
isInteractableMember,
|
||||
type ProxyGroupView,
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
|
||||
import { ScrollTopButton } from '../layout/scroll-top-button'
|
||||
|
||||
import { ProxyChain } from './proxy-chain'
|
||||
import { type ProxyChainItem, rebindProxyChainItems } from './proxy-chain-model'
|
||||
import { ProxyRender } from './proxy-render'
|
||||
import type { HeadState } from './use-head-state'
|
||||
import type { IRenderItem } from './use-render-list'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface ProxyChainItem {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
type VirtualListItem = {
|
||||
key: Key
|
||||
index: number
|
||||
@@ -46,11 +46,7 @@ type VirtualListItem = {
|
||||
end: number
|
||||
}
|
||||
|
||||
interface ProxyGroupOption {
|
||||
name: string
|
||||
type: string
|
||||
all?: unknown[]
|
||||
}
|
||||
type ProxyGroupOption = ProxyGroupView
|
||||
|
||||
// ---- Props ----
|
||||
|
||||
@@ -201,7 +197,7 @@ function GroupSelectMenu({
|
||||
{group.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{group.type} · {group.all?.length ?? 0} 节点
|
||||
{group.type} · {group.members.length} 节点
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
@@ -243,7 +239,7 @@ function ProxyVirtualList({
|
||||
onLocation: (group: any) => void
|
||||
onCheckAll: (groupName: string) => void
|
||||
onHeadState: (groupName: string, patch: Partial<HeadState>) => void
|
||||
onChangeProxy: (group: IProxyGroupItem, proxy: IProxyItem) => void
|
||||
onChangeProxy: (group: ProxyGroupView, member: ResolvedProxyMember) => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const stickyBackground =
|
||||
@@ -316,6 +312,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
onGroupSelect,
|
||||
onScrollToTop,
|
||||
} = props
|
||||
const { proxyView } = useProxiesData()
|
||||
|
||||
// Chain-specific state
|
||||
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => {
|
||||
@@ -330,13 +327,44 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
return []
|
||||
})
|
||||
|
||||
const candidateNodes = useMemo(
|
||||
() =>
|
||||
renderList.flatMap((item) => {
|
||||
const occurrences = item.memberCol ?? (item.member ? [item.member] : [])
|
||||
return occurrences.flatMap(({ member }) =>
|
||||
member.kind === 'node' ? [member.node] : [],
|
||||
)
|
||||
}),
|
||||
[renderList],
|
||||
)
|
||||
|
||||
const currentProxyChain = useMemo(
|
||||
() =>
|
||||
proxyView
|
||||
? rebindProxyChainItems(proxyChain, candidateNodes, proxyView)
|
||||
: proxyChain.map((item) => ({
|
||||
...item,
|
||||
recordId: undefined,
|
||||
delay: undefined,
|
||||
})),
|
||||
[candidateNodes, proxyChain, proxyView],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyChain.length > 0) {
|
||||
localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain))
|
||||
if (currentProxyChain.length > 0) {
|
||||
const persistedChain = currentProxyChain.map(
|
||||
({ id, name, type, delay }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
delay,
|
||||
}),
|
||||
)
|
||||
localStorage.setItem('proxy-chain-items', JSON.stringify(persistedChain))
|
||||
} else {
|
||||
localStorage.removeItem('proxy-chain-items')
|
||||
}
|
||||
}, [proxyChain])
|
||||
}, [currentProxyChain])
|
||||
|
||||
const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>(null)
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<{
|
||||
@@ -349,7 +377,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
if (!activeSelectedGroup) return null
|
||||
return (
|
||||
availableGroups.find(
|
||||
(group: any) => group.name === activeSelectedGroup,
|
||||
(group: ProxyGroupView) => group.name === activeSelectedGroup,
|
||||
) ?? null
|
||||
)
|
||||
}, [activeSelectedGroup, availableGroups])
|
||||
@@ -381,11 +409,19 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
}, [])
|
||||
|
||||
const handleChangeProxy = useCallback(
|
||||
(_group: IProxyGroupItem, proxy: IProxyItem) => {
|
||||
// 使用函数式更新来避免状态延迟问题
|
||||
(_group: ProxyGroupView, member: ResolvedProxyMember) => {
|
||||
if (!isInteractableMember(member) || member.kind !== 'node') return
|
||||
const { node } = member
|
||||
setProxyChain((prev) => {
|
||||
// 检查是否已经存在相同名称的代理,防止重复添加
|
||||
if (prev.some((item) => item.name === proxy.name)) {
|
||||
const current = proxyView
|
||||
? rebindProxyChainItems(prev, candidateNodes, proxyView)
|
||||
: prev
|
||||
if (
|
||||
current.some(
|
||||
(item) =>
|
||||
item.recordId !== undefined && item.recordId === node.recordId,
|
||||
)
|
||||
) {
|
||||
const warningMessage = t('proxies.page.chain.duplicateNode')
|
||||
setDuplicateWarning({
|
||||
open: true,
|
||||
@@ -396,21 +432,23 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
|
||||
// 安全获取延迟数据,如果没有延迟数据则设为 undefined
|
||||
const delay =
|
||||
proxy.history && proxy.history.length > 0
|
||||
? proxy.history[proxy.history.length - 1].delay
|
||||
node.history.length > 0
|
||||
? node.history[node.history.length - 1].delay
|
||||
: undefined
|
||||
|
||||
const chainItem: ProxyChainItem = {
|
||||
id: `${proxy.name}_${Date.now()}`,
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
id: `${node.name}_${Date.now()}`,
|
||||
name: node.name,
|
||||
recordId: node.recordId,
|
||||
source: node.source,
|
||||
type: node.type,
|
||||
delay,
|
||||
}
|
||||
|
||||
return [...prev, chainItem]
|
||||
return [...current, chainItem]
|
||||
})
|
||||
},
|
||||
[t],
|
||||
[candidateNodes, proxyView, t],
|
||||
)
|
||||
|
||||
// Render virtual list for chain mode
|
||||
@@ -455,7 +493,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
|
||||
<Box sx={{ width: '400px', minWidth: '300px' }}>
|
||||
<ProxyChain
|
||||
proxyChain={proxyChain}
|
||||
proxyChain={currentProxyChain}
|
||||
onUpdateChain={setProxyChain}
|
||||
chainConfigData={chainConfigData}
|
||||
mode={mode}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { delayGroup } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import {
|
||||
BaseEmpty,
|
||||
@@ -22,9 +21,13 @@ import {
|
||||
import { useProxySelection } from '@/hooks/use-proxy-selection'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useProxiesData } from '@/providers/app-data-context'
|
||||
import { calcuProxies } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
import {
|
||||
isInteractableMember,
|
||||
resolveMember,
|
||||
type ProxyGroupView,
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
import {
|
||||
@@ -58,6 +61,7 @@ function useProxyRenderState(
|
||||
activeSelectedGroup: string | null,
|
||||
) {
|
||||
const { verge } = useVerge()
|
||||
const { proxyView } = useProxiesData()
|
||||
const { renderList, onProxies, onHeadState } = useRenderList(
|
||||
mode,
|
||||
isChainMode,
|
||||
@@ -88,28 +92,27 @@ function useProxyRenderState(
|
||||
useLockFn(async (groupName: string) => {
|
||||
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
|
||||
|
||||
const proxies = renderList
|
||||
.filter(
|
||||
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
|
||||
)
|
||||
.flatMap((e) => e.proxyCol || e.proxy!)
|
||||
.filter(Boolean)
|
||||
const group =
|
||||
proxyView?.groups.find(({ name }) => name === groupName) ??
|
||||
(proxyView?.global?.name === groupName ? proxyView.global : undefined)
|
||||
const occurrences =
|
||||
proxyView && group
|
||||
? group.members.map((member, memberIndex) => ({
|
||||
memberIndex,
|
||||
member: resolveMember(proxyView, member),
|
||||
}))
|
||||
: []
|
||||
const interactable = occurrences
|
||||
.map(({ member }) => member)
|
||||
.filter(isInteractableMember)
|
||||
|
||||
debugLog(`[ProxyGroups] 找到代理数量: ${proxies.length}`)
|
||||
debugLog(`[ProxyGroups] 找到代理数量: ${interactable.length}`)
|
||||
|
||||
const url = delayManager.getUrl(groupName)
|
||||
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
delayManager.checkListDelay(proxies, groupName, timeout),
|
||||
delayGroup(groupName, url, timeout).then((result) => {
|
||||
debugLog(
|
||||
`[ProxyGroups] getGroupProxyDelays返回结果数量:`,
|
||||
Object.keys(result || {}).length,
|
||||
)
|
||||
}), // 查询group delays 将清除fixed(不关注调用结果)
|
||||
])
|
||||
await delayManager.checkListDelay(interactable, groupName, timeout)
|
||||
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
|
||||
} catch (error) {
|
||||
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
|
||||
@@ -170,16 +173,16 @@ function ChainProxyGroups(props: {
|
||||
chainConfigData?: string | null
|
||||
}) {
|
||||
const { mode, chainConfigData } = props
|
||||
const { proxies: proxiesData } = useProxiesData()
|
||||
const { proxyView } = useProxiesData()
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
||||
|
||||
const availableGroups = useMemo(() => {
|
||||
const groups = proxiesData?.groups
|
||||
const groups = proxyView?.groups
|
||||
if (!groups) return []
|
||||
return groups.filter(
|
||||
(group: any) => group.type === 'Selector' || group.type === 'URLTest',
|
||||
(group) => group.type === 'Selector' || group.type === 'URLTest',
|
||||
)
|
||||
}, [proxiesData?.groups])
|
||||
}, [proxyView?.groups])
|
||||
|
||||
const defaultRuleGroup = useMemo(() => {
|
||||
if (mode === 'rule' && availableGroups.length > 0) {
|
||||
@@ -297,16 +300,16 @@ function ChainProxyGroups(props: {
|
||||
scrollTopRef.current = 0
|
||||
}, [])
|
||||
|
||||
const handleLocation = useStableCallback((group: IProxyGroupItem) => {
|
||||
const handleLocation = useStableCallback((group: ProxyGroupView) => {
|
||||
if (!group) return
|
||||
const { name, now } = group
|
||||
|
||||
const index = renderList.findIndex(
|
||||
(item) =>
|
||||
item.group?.name === name &&
|
||||
((item.type === 2 && item.proxy?.name === now) ||
|
||||
((item.type === 2 && item.member?.member.ref.name === now) ||
|
||||
(item.type === 4 &&
|
||||
item.proxyCol?.some((proxy) => proxy.name === now))),
|
||||
item.memberCol?.some(({ member }) => member.ref.name === now))),
|
||||
)
|
||||
|
||||
if (index >= 0) {
|
||||
@@ -449,24 +452,26 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
})
|
||||
|
||||
const handleChangeProxy = useCallback(
|
||||
(group: IProxyGroupItem, proxy: IProxyItem) => {
|
||||
(group: ProxyGroupView, member: ResolvedProxyMember) => {
|
||||
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
||||
if (!isInteractableMember(member)) return
|
||||
|
||||
handleProxyGroupChange(group, proxy)
|
||||
handleProxyGroupChange(group, { name: member.ref.name })
|
||||
},
|
||||
[handleProxyGroupChange],
|
||||
)
|
||||
|
||||
// 滚到对应的节点
|
||||
const handleLocation = useStableCallback((group: IProxyGroupItem) => {
|
||||
const handleLocation = useStableCallback((group: ProxyGroupView) => {
|
||||
if (!group) return
|
||||
const { name, now } = group
|
||||
|
||||
const index = renderList.findIndex(
|
||||
(e) =>
|
||||
e.group?.name === name &&
|
||||
((e.type === 2 && e.proxy?.name === now) ||
|
||||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
|
||||
((e.type === 2 && e.member?.member.ref.name === now) ||
|
||||
(e.type === 4 &&
|
||||
e.memberCol?.some(({ member }) => member.ref.name === now))),
|
||||
)
|
||||
|
||||
if (index >= 0) {
|
||||
@@ -503,7 +508,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
|
||||
// 点击代理组改变展开状态,先滚动到sticky的代理组位置,再收起展开状态
|
||||
const handleGroupToggle = useCallback(
|
||||
async (group: IProxyGroupItem) => {
|
||||
async (group: ProxyGroupView) => {
|
||||
const index = renderList.findIndex(
|
||||
(item) => item.type === 0 && item.group.name === group.name,
|
||||
)
|
||||
@@ -594,17 +599,6 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
export const ProxyGroups = (props: Props) => {
|
||||
const { mode, isChainMode = false, chainConfigData } = props
|
||||
|
||||
// Drive 3s polling on the shared TQ cache; data is read via granular context below
|
||||
useQuery({
|
||||
queryKey: ['getProxies'],
|
||||
queryFn: calcuProxies,
|
||||
refetchInterval: 3000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 1500,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
|
||||
if (mode === 'direct') {
|
||||
return <BaseEmpty textKey="proxies.page.messages.directMode" />
|
||||
}
|
||||
|
||||
@@ -5,32 +5,46 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseLoading } from '@/components/base'
|
||||
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
|
||||
import delayManager from '@/services/delay'
|
||||
import {
|
||||
memberDetails,
|
||||
providerNameOf,
|
||||
type ProxyGroupView,
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
|
||||
interface Props {
|
||||
group: IProxyGroupItem
|
||||
proxy: IProxyItem
|
||||
group: ProxyGroupView
|
||||
member: ResolvedProxyMember
|
||||
selected: boolean
|
||||
showType?: boolean
|
||||
onClick?: (name: string) => void
|
||||
onClick?: (member: ResolvedProxyMember) => void
|
||||
}
|
||||
|
||||
// 多列布局
|
||||
export const ProxyItemMini = (props: Props) => {
|
||||
const { group, proxy, selected, showType = true, onClick } = props
|
||||
const { group, member, selected, showType = true, onClick } = props
|
||||
const details = memberDetails(member)
|
||||
const unresolved = member.kind === 'unresolved'
|
||||
const name = member.ref.name
|
||||
const type = unresolved ? member.ref.reason : (details?.type ?? '')
|
||||
const provider =
|
||||
member.kind === 'node' ? providerNameOf(member.node) : undefined
|
||||
const now = member.kind === 'group' ? member.group.now : undefined
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// -1/<=0 为不显示,-2 为 loading
|
||||
const { delayValue, isPreset, timeout, onDelay } = useProxyDelayState(
|
||||
proxy,
|
||||
member,
|
||||
group.name,
|
||||
)
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
dense
|
||||
selected={selected}
|
||||
onClick={() => onClick?.(proxy.name)}
|
||||
disabled={unresolved}
|
||||
selected={!unresolved && selected}
|
||||
onClick={unresolved ? undefined : () => onClick?.(member)}
|
||||
sx={[
|
||||
{
|
||||
height: 56,
|
||||
@@ -70,10 +84,7 @@ export const ProxyItemMini = (props: Props) => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Box
|
||||
title={`${proxy.name}\n${proxy.now ?? ''}`}
|
||||
sx={{ overflow: 'hidden' }}
|
||||
>
|
||||
<Box title={`${name}\n${now ?? ''}`} sx={{ overflow: 'hidden' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
@@ -86,7 +97,7 @@ export const ProxyItemMini = (props: Props) => {
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{proxy.name}
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
{showType && (
|
||||
@@ -98,7 +109,7 @@ export const ProxyItemMini = (props: Props) => {
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{proxy.now && (
|
||||
{now && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
@@ -112,38 +123,38 @@ export const ProxyItemMini = (props: Props) => {
|
||||
marginRight: '8px',
|
||||
}}
|
||||
>
|
||||
{proxy.now}
|
||||
{now}
|
||||
</Typography>
|
||||
)}
|
||||
{!!proxy.provider && (
|
||||
{!!provider && (
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
{proxy.provider}
|
||||
{provider}
|
||||
</TypeBox>
|
||||
)}
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
{proxy.type}
|
||||
{type}
|
||||
</TypeBox>
|
||||
{proxy.udp && (
|
||||
{!unresolved && details?.udp && (
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
UDP
|
||||
</TypeBox>
|
||||
)}
|
||||
{proxy.xudp && (
|
||||
{!unresolved && details?.xudp && (
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
XUDP
|
||||
</TypeBox>
|
||||
)}
|
||||
{proxy.tfo && (
|
||||
{!unresolved && details?.tfo && (
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
TFO
|
||||
</TypeBox>
|
||||
)}
|
||||
{proxy.mptcp && (
|
||||
{!unresolved && details?.mptcp && (
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
MPTCP
|
||||
</TypeBox>
|
||||
)}
|
||||
{proxy.smux && (
|
||||
{!unresolved && details?.smux && (
|
||||
<TypeBox color="text.secondary" component="span">
|
||||
SMUX
|
||||
</TypeBox>
|
||||
@@ -154,18 +165,18 @@ export const ProxyItemMini = (props: Props) => {
|
||||
<Box
|
||||
sx={{ ml: 0.5, color: 'primary.main', display: isPreset ? 'none' : '' }}
|
||||
>
|
||||
{delayValue === -2 && (
|
||||
{!unresolved && delayValue === -2 && (
|
||||
<Widget>
|
||||
<BaseLoading />
|
||||
</Widget>
|
||||
)}
|
||||
{delayValue !== -2 && (
|
||||
{!unresolved && delayValue !== -2 && (
|
||||
<Widget
|
||||
className="the-check"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDelay()
|
||||
void onDelay()
|
||||
}}
|
||||
sx={({ palette }) => ({
|
||||
display: 'none', // hover 时显示
|
||||
@@ -176,14 +187,14 @@ export const ProxyItemMini = (props: Props) => {
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{delayValue >= 0 && (
|
||||
{!unresolved && delayValue >= 0 && (
|
||||
// 显示延迟
|
||||
<Widget
|
||||
className="the-delay"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDelay(proxy.provider)
|
||||
void onDelay()
|
||||
}}
|
||||
sx={({ palette }) => ({
|
||||
color: delayManager.formatDelayColor(delayValue, timeout),
|
||||
@@ -193,7 +204,8 @@ export const ProxyItemMini = (props: Props) => {
|
||||
{delayManager.formatDelay(delayValue, timeout)}
|
||||
</Widget>
|
||||
)}
|
||||
{proxy.type !== 'Direct' &&
|
||||
{!unresolved &&
|
||||
type !== 'Direct' &&
|
||||
delayValue !== -2 &&
|
||||
delayValue < 0 &&
|
||||
selected && (
|
||||
@@ -204,10 +216,10 @@ export const ProxyItemMini = (props: Props) => {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{group.fixed && group.fixed === proxy.name && (
|
||||
{!unresolved && group.fixed && group.fixed === name && (
|
||||
// 展示 fixed 状态
|
||||
<span
|
||||
className={proxy.name === group.now ? 'the-pin' : 'the-unpin'}
|
||||
className={name === group.now ? 'the-pin' : 'the-unpin'}
|
||||
title={
|
||||
group.type === 'URLTest'
|
||||
? t('proxies.page.labels.delayCheckReset')
|
||||
|
||||
@@ -14,14 +14,20 @@ import {
|
||||
import { BaseLoading } from '@/components/base'
|
||||
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
|
||||
import delayManager from '@/services/delay'
|
||||
import {
|
||||
memberDetails,
|
||||
providerNameOf,
|
||||
type ProxyGroupView,
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
|
||||
interface Props {
|
||||
group: IProxyGroupItem
|
||||
proxy: IProxyItem
|
||||
group: ProxyGroupView
|
||||
member: ResolvedProxyMember
|
||||
selected: boolean
|
||||
showType?: boolean
|
||||
sx?: SxProps<Theme>
|
||||
onClick?: (name: string) => void
|
||||
onClick?: (member: ResolvedProxyMember) => void
|
||||
}
|
||||
|
||||
const Widget = styled(Box)(() => ({
|
||||
@@ -43,11 +49,18 @@ const TypeBox = styled('span')(({ theme }) => ({
|
||||
}))
|
||||
|
||||
export const ProxyItem = (props: Props) => {
|
||||
const { group, proxy, selected, showType = true, sx, onClick } = props
|
||||
const { group, member, selected, showType = true, sx, onClick } = props
|
||||
const details = memberDetails(member)
|
||||
const unresolved = member.kind === 'unresolved'
|
||||
const name = member.ref.name
|
||||
const type = unresolved ? member.ref.reason : (details?.type ?? '')
|
||||
const provider =
|
||||
member.kind === 'node' ? providerNameOf(member.node) : undefined
|
||||
const now = member.kind === 'group' ? member.group.now : undefined
|
||||
|
||||
// -1/<=0 为不显示,-2 为 loading
|
||||
const { delayValue, isPreset, timeout, onDelay } = useProxyDelayState(
|
||||
proxy,
|
||||
member,
|
||||
group.name,
|
||||
)
|
||||
|
||||
@@ -55,8 +68,9 @@ export const ProxyItem = (props: Props) => {
|
||||
<ListItem sx={sx}>
|
||||
<ListItemButton
|
||||
dense
|
||||
selected={selected}
|
||||
onClick={() => onClick?.(proxy.name)}
|
||||
disabled={unresolved}
|
||||
selected={!unresolved && selected}
|
||||
onClick={unresolved ? undefined : () => onClick?.(member)}
|
||||
sx={[
|
||||
{ borderRadius: 1 },
|
||||
({ palette: { mode, primary } }) => {
|
||||
@@ -85,7 +99,7 @@ export const ProxyItem = (props: Props) => {
|
||||
]}
|
||||
>
|
||||
<ListItemText
|
||||
title={proxy.name}
|
||||
title={name}
|
||||
secondary={
|
||||
<>
|
||||
<Box
|
||||
@@ -96,18 +110,26 @@ export const ProxyItem = (props: Props) => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{proxy.name}
|
||||
{showType && proxy.now && ` - ${proxy.now}`}
|
||||
{name}
|
||||
{showType && now && ` - ${now}`}
|
||||
</Box>
|
||||
{showType && !!proxy.provider && (
|
||||
<TypeBox>{proxy.provider}</TypeBox>
|
||||
{showType && !!provider && <TypeBox>{provider}</TypeBox>}
|
||||
{showType && <TypeBox>{type}</TypeBox>}
|
||||
{!unresolved && showType && details?.udp && (
|
||||
<TypeBox>UDP</TypeBox>
|
||||
)}
|
||||
{!unresolved && showType && details?.xudp && (
|
||||
<TypeBox>XUDP</TypeBox>
|
||||
)}
|
||||
{!unresolved && showType && details?.tfo && (
|
||||
<TypeBox>TFO</TypeBox>
|
||||
)}
|
||||
{!unresolved && showType && details?.mptcp && (
|
||||
<TypeBox>MPTCP</TypeBox>
|
||||
)}
|
||||
{!unresolved && showType && details?.smux && (
|
||||
<TypeBox>SMUX</TypeBox>
|
||||
)}
|
||||
{showType && <TypeBox>{proxy.type}</TypeBox>}
|
||||
{showType && proxy.udp && <TypeBox>UDP</TypeBox>}
|
||||
{showType && proxy.xudp && <TypeBox>XUDP</TypeBox>}
|
||||
{showType && proxy.tfo && <TypeBox>TFO</TypeBox>}
|
||||
{showType && proxy.mptcp && <TypeBox>MPTCP</TypeBox>}
|
||||
{showType && proxy.smux && <TypeBox>SMUX</TypeBox>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -119,19 +141,19 @@ export const ProxyItem = (props: Props) => {
|
||||
display: isPreset ? 'none' : '',
|
||||
}}
|
||||
>
|
||||
{delayValue === -2 && (
|
||||
{!unresolved && delayValue === -2 && (
|
||||
<Widget>
|
||||
<BaseLoading />
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{delayValue !== -2 && (
|
||||
{!unresolved && delayValue !== -2 && (
|
||||
<Widget
|
||||
className="the-check"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDelay(proxy.provider)
|
||||
void onDelay()
|
||||
}}
|
||||
sx={({ palette }) => ({
|
||||
display: 'none', // hover 时显示
|
||||
@@ -142,14 +164,14 @@ export const ProxyItem = (props: Props) => {
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{delayValue > 0 && (
|
||||
{!unresolved && delayValue > 0 && (
|
||||
// 显示延迟
|
||||
<Widget
|
||||
className="the-delay"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDelay(proxy.provider)
|
||||
void onDelay()
|
||||
}}
|
||||
sx={({ palette }) => ({
|
||||
color: delayManager.formatDelayColor(delayValue, timeout),
|
||||
@@ -160,7 +182,7 @@ export const ProxyItem = (props: Props) => {
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{delayValue !== -2 && delayValue <= 0 && selected && (
|
||||
{!unresolved && delayValue !== -2 && delayValue <= 0 && selected && (
|
||||
// 展示已选择的 icon
|
||||
<CheckCircleOutlineRounded
|
||||
className="the-icon"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useIconCache } from '@/hooks/use-icon-cache'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
||||
|
||||
import { ProxyGroupTools } from './proxy-group-tools'
|
||||
import { ProxyHead } from './proxy-head'
|
||||
@@ -36,7 +37,7 @@ interface RenderProps {
|
||||
onHeadState: (groupName: string, patch: Partial<HeadState>) => void
|
||||
onChangeProxy: (
|
||||
group: IRenderItem['group'],
|
||||
proxy: IRenderItem['proxy'] & { name: string },
|
||||
member: ResolvedProxyMember,
|
||||
) => void
|
||||
onGroupToggle?: (group: IRenderItem['group']) => void
|
||||
}
|
||||
@@ -53,7 +54,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
onGroupToggle,
|
||||
isChainMode: _ = false,
|
||||
} = props
|
||||
const { type, group, headState, proxy, proxyCol } = item
|
||||
const { type, group, headState, member, memberCol } = item
|
||||
const { verge } = useVerge()
|
||||
const enable_group_icon = verge?.enable_group_icon ?? true
|
||||
const mode = useThemeMode()
|
||||
@@ -66,22 +67,22 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
})
|
||||
|
||||
const showType = headState?.showType
|
||||
const proxyColItemsMemo = useMemo(() => {
|
||||
if (type !== 4 || !proxyCol) {
|
||||
const memberColItemsMemo = useMemo(() => {
|
||||
if (type !== 4 || !memberCol) {
|
||||
return null
|
||||
}
|
||||
|
||||
return proxyCol.map((proxyItem) => (
|
||||
return memberCol.map((occurrence) => (
|
||||
<ProxyItemMini
|
||||
key={`${item.key}-${proxyItem?.name ?? 'unknown'}`}
|
||||
key={`${item.key}-${occurrence.memberIndex}`}
|
||||
group={group}
|
||||
proxy={proxyItem}
|
||||
selected={group.now === proxyItem?.name}
|
||||
member={occurrence.member}
|
||||
selected={group.now === occurrence.member.ref.name}
|
||||
showType={showType}
|
||||
onClick={() => onChangeProxy(group, proxyItem)}
|
||||
onClick={(nextMember) => onChangeProxy(group, nextMember)}
|
||||
/>
|
||||
))
|
||||
}, [type, proxyCol, item.key, group, showType, onChangeProxy])
|
||||
}, [type, memberCol, item.key, group, showType, onChangeProxy])
|
||||
|
||||
if (type === 0) {
|
||||
return (
|
||||
@@ -198,7 +199,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${group.all.length}`}
|
||||
label={`${group.members.length}`}
|
||||
sx={{
|
||||
mr: 1,
|
||||
backgroundColor: (theme) =>
|
||||
@@ -239,11 +240,11 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
return (
|
||||
<ProxyItem
|
||||
group={group}
|
||||
proxy={proxy!}
|
||||
selected={group.now === proxy?.name}
|
||||
member={member!.member}
|
||||
selected={group.now === member?.member.ref.name}
|
||||
showType={headState?.showType}
|
||||
sx={{ py: 0, pl: 2 }}
|
||||
onClick={() => onChangeProxy(group, proxy!)}
|
||||
onClick={(nextMember) => onChangeProxy(group, nextMember)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -278,7 +279,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
gridTemplateColumns: `repeat(${item.col! || 2}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{proxyColItemsMemo}
|
||||
{memberColItemsMemo}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import delayManager from '@/services/delay'
|
||||
import { memberDetails } from '@/types/proxy-view'
|
||||
import { compileStringMatcher } from '@/utils/search-matcher'
|
||||
|
||||
import type { ResolvedMemberOccurrence } from './use-render-list'
|
||||
|
||||
// default | delay | alphabet
|
||||
export type ProxySortType = 0 | 1 | 2
|
||||
|
||||
@@ -11,7 +14,7 @@ export type ProxySearchState = {
|
||||
}
|
||||
|
||||
export function filterSort(
|
||||
proxies: IProxyItem[],
|
||||
proxies: ResolvedMemberOccurrence[],
|
||||
groupName: string,
|
||||
filterText: string,
|
||||
sortType: ProxySortType,
|
||||
@@ -34,7 +37,7 @@ const regex2 = /type=(.*)/i
|
||||
* according to the regular conditions
|
||||
*/
|
||||
function filterProxies(
|
||||
proxies: IProxyItem[],
|
||||
proxies: ResolvedMemberOccurrence[],
|
||||
groupName: string,
|
||||
filterText: string,
|
||||
searchState?: ProxySearchState,
|
||||
@@ -49,8 +52,8 @@ function filterProxies(
|
||||
const value =
|
||||
symbol2 === 'error' ? 1e5 : symbol2 === 'timeout' ? 3000 : +symbol2
|
||||
|
||||
return proxies.filter((p) => {
|
||||
const delay = delayManager.getDelayFix(p, groupName)
|
||||
return proxies.filter(({ member }) => {
|
||||
const delay = delayManager.getDelayFix(member, groupName)
|
||||
|
||||
if (delay < 0) return false
|
||||
if (symbol === '=' && symbol2 === 'error') return delay >= 1e5
|
||||
@@ -66,7 +69,9 @@ function filterProxies(
|
||||
const res2 = regex2.exec(query)
|
||||
if (res2) {
|
||||
const type = res2[1].toLowerCase()
|
||||
return proxies.filter((p) => p.type.toLowerCase().includes(type))
|
||||
return proxies.filter(({ member }) =>
|
||||
(memberDetails(member)?.type ?? '').toLowerCase().includes(type),
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -81,14 +86,14 @@ function filterProxies(
|
||||
})
|
||||
|
||||
if (!compiled.isValid) return []
|
||||
return proxies.filter((p) => compiled.matcher(p.name))
|
||||
return proxies.filter(({ member }) => compiled.matcher(member.ref.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* sort the proxy
|
||||
*/
|
||||
function sortProxies(
|
||||
proxies: IProxyItem[],
|
||||
proxies: ResolvedMemberOccurrence[],
|
||||
groupName: string,
|
||||
sortType: ProxySortType,
|
||||
latencyTimeout?: number,
|
||||
@@ -117,8 +122,8 @@ function sortProxies(
|
||||
}
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ad = delayManager.getDelayFix(a, groupName)
|
||||
const bd = delayManager.getDelayFix(b, groupName)
|
||||
const ad = delayManager.getDelayFix(a.member, groupName)
|
||||
const bd = delayManager.getDelayFix(b.member, groupName)
|
||||
const [ar, av] = categorizeDelay(ad)
|
||||
const [br, bv] = categorizeDelay(bd)
|
||||
|
||||
@@ -126,7 +131,7 @@ function sortProxies(
|
||||
return av - bv
|
||||
})
|
||||
} else {
|
||||
list.sort((a, b) => a.name.localeCompare(b.name))
|
||||
list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name))
|
||||
}
|
||||
|
||||
return list
|
||||
|
||||
@@ -4,6 +4,15 @@ import { useRuntimeConfig } from '@/hooks/use-clash'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
|
||||
import delayManager from '@/services/delay'
|
||||
import {
|
||||
isInteractableMember,
|
||||
resolveMember,
|
||||
selectGlobalChainNodes,
|
||||
selectRuleChainMembers,
|
||||
type ProxyGroupView,
|
||||
type ProxyViewV1,
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
import { filterSort } from './use-filter-sort'
|
||||
@@ -14,386 +23,225 @@ import {
|
||||
} from './use-head-state'
|
||||
import { useWindowWidth } from './use-window-width'
|
||||
|
||||
// 定义代理项接口
|
||||
interface IProxyItem {
|
||||
name: string
|
||||
type: string
|
||||
udp: boolean
|
||||
xudp: boolean
|
||||
tfo: boolean
|
||||
mptcp: boolean
|
||||
smux: boolean
|
||||
history: {
|
||||
time: string
|
||||
delay: number
|
||||
}[]
|
||||
provider?: string
|
||||
testUrl?: string
|
||||
[key: string]: any // 添加索引签名以适应其他可能的属性
|
||||
export interface ResolvedMemberOccurrence {
|
||||
memberIndex: number
|
||||
member: ResolvedProxyMember
|
||||
}
|
||||
|
||||
// 代理组类型
|
||||
type ProxyGroup = {
|
||||
name: string
|
||||
type: string
|
||||
udp: boolean
|
||||
xudp: boolean
|
||||
tfo: boolean
|
||||
mptcp: boolean
|
||||
smux: boolean
|
||||
history: {
|
||||
time: string
|
||||
delay: number
|
||||
}[]
|
||||
now: string
|
||||
all: IProxyItem[]
|
||||
hidden?: boolean
|
||||
icon?: string
|
||||
testUrl?: string
|
||||
provider?: string
|
||||
}
|
||||
type ProxyGroup = ProxyGroupView
|
||||
|
||||
export interface IRenderItem {
|
||||
// 组 | head | item | empty | item col
|
||||
type: 0 | 1 | 2 | 3 | 4
|
||||
key: string
|
||||
group: ProxyGroup
|
||||
proxy?: IProxyItem
|
||||
member?: ResolvedMemberOccurrence
|
||||
memberCol?: ResolvedMemberOccurrence[]
|
||||
col?: number
|
||||
proxyCol?: IProxyItem[]
|
||||
headState?: HeadState
|
||||
// 新增支持图标和其他元数据
|
||||
icon?: string
|
||||
provider?: string
|
||||
testUrl?: string
|
||||
}
|
||||
|
||||
type GroupCache = {
|
||||
now: string
|
||||
all: IProxyItem[]
|
||||
now: string | undefined
|
||||
members: ProxyGroupView['members']
|
||||
headState: HeadState
|
||||
col: number
|
||||
latencyTimeout: number | undefined
|
||||
items: IRenderItem[]
|
||||
}
|
||||
|
||||
// 优化列布局计算
|
||||
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
|
||||
|
||||
const resolveOccurrences = (view: ProxyViewV1, group: ProxyGroupView) =>
|
||||
group.members.map((member, memberIndex) => ({
|
||||
memberIndex,
|
||||
member: resolveMember(view, member),
|
||||
}))
|
||||
|
||||
const memberKey = (
|
||||
group: ProxyGroupView,
|
||||
occurrence: ResolvedMemberOccurrence,
|
||||
) => {
|
||||
const { memberIndex, member } = occurrence
|
||||
const identity =
|
||||
member.kind === 'node' ? member.node.recordId : member.ref.name
|
||||
return `${group.name}:${memberIndex}:${identity}`
|
||||
}
|
||||
|
||||
const calculateColumns = (width: number, configCol: number): number => {
|
||||
if (configCol > 0 && configCol < 6) return configCol
|
||||
|
||||
if (width > 1920) return 5
|
||||
if (width > 1450) return 4
|
||||
if (width > 1024) return 3
|
||||
if (width > 900) return 2
|
||||
if (width >= 600) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
// 优化分组逻辑
|
||||
const groupProxies = <T = any>(list: T[], size: number): T[][] => {
|
||||
return list.reduce((acc, item) => {
|
||||
const groupOccurrences = <T>(list: T[], size: number): T[][] =>
|
||||
list.reduce<T[][]>((acc, item) => {
|
||||
const lastGroup = acc[acc.length - 1]
|
||||
if (!lastGroup || lastGroup.length >= size) {
|
||||
acc.push([item])
|
||||
} else {
|
||||
lastGroup.push(item)
|
||||
}
|
||||
if (!lastGroup || lastGroup.length >= size) acc.push([item])
|
||||
else lastGroup.push(item)
|
||||
return acc
|
||||
}, [] as T[][])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const CHAIN_DELAY_GROUP = 'chain-mode'
|
||||
|
||||
const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
|
||||
name: CHAIN_DELAY_GROUP,
|
||||
type: 'Selector',
|
||||
alive: true,
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
history: [],
|
||||
members,
|
||||
})
|
||||
|
||||
export const useRenderList = (
|
||||
mode: string,
|
||||
isChainMode?: boolean,
|
||||
selectedGroup?: string | null,
|
||||
) => {
|
||||
// 使用全局数据提供者
|
||||
const { proxies: proxiesData } = useProxiesData()
|
||||
const { proxyView } = useProxiesData()
|
||||
const { refreshProxy } = useAppRefreshers()
|
||||
const { verge } = useVerge()
|
||||
const { width } = useWindowWidth()
|
||||
const [headStates, setHeadState] = useHeadStateNew()
|
||||
const latencyTimeout = verge?.default_latency_timeout
|
||||
|
||||
// 获取运行时配置用于链式代理模式
|
||||
const { data: runtimeConfig } = useRuntimeConfig(!!isChainMode)
|
||||
const runtimeProxies = (
|
||||
runtimeConfig as RuntimeConfigWithProxySequence | null
|
||||
)?.proxies
|
||||
|
||||
// 计算列数
|
||||
const col = useMemo(
|
||||
() => calculateColumns(width, verge?.proxy_layout_column || 6),
|
||||
[width, verge?.proxy_layout_column],
|
||||
)
|
||||
|
||||
// 确保代理数据加载
|
||||
useEffect(() => {
|
||||
if (!proxiesData) return
|
||||
const { groups, proxies } = proxiesData
|
||||
|
||||
if (
|
||||
(mode === 'rule' && !groups.length) ||
|
||||
(mode === 'global' && proxies.length < 2)
|
||||
) {
|
||||
const handle = setTimeout(() => refreshProxy(), 500)
|
||||
return () => clearTimeout(handle)
|
||||
const chainOccurrences = useMemo(() => {
|
||||
if (!proxyView || !isChainMode) return []
|
||||
if (mode === 'rule' && selectedGroup) {
|
||||
return selectRuleChainMembers(proxyView, selectedGroup)
|
||||
}
|
||||
}, [proxiesData, mode, refreshProxy])
|
||||
|
||||
// 链式代理模式节点自动计算延迟
|
||||
useEffect(() => {
|
||||
if (!isChainMode || !runtimeConfig) return
|
||||
|
||||
const allProxies: IProxyItem[] = Object.values(
|
||||
(runtimeConfig as any).proxies || {},
|
||||
if (!runtimeConfig) return []
|
||||
return selectGlobalChainNodes(proxyView, runtimeProxies).map(
|
||||
(node, memberIndex) => ({
|
||||
memberIndex,
|
||||
member: {
|
||||
kind: 'node' as const,
|
||||
ref: {
|
||||
kind: 'node' as const,
|
||||
name: node.name,
|
||||
recordId: node.recordId,
|
||||
},
|
||||
node,
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (allProxies.length === 0) return
|
||||
}, [
|
||||
isChainMode,
|
||||
mode,
|
||||
proxyView,
|
||||
runtimeConfig,
|
||||
runtimeProxies,
|
||||
selectedGroup,
|
||||
])
|
||||
|
||||
// 设置组监听器,当有延迟更新时自动刷新
|
||||
const groupListener = () => {
|
||||
debugLog('[ChainMode] 延迟更新,刷新UI')
|
||||
refreshProxy()
|
||||
}
|
||||
const chainOccurrencesRef = useRef(chainOccurrences)
|
||||
chainOccurrencesRef.current = chainOccurrences
|
||||
const chainDelayGroup =
|
||||
mode === 'rule' && selectedGroup ? selectedGroup : CHAIN_DELAY_GROUP
|
||||
const chainDelayKey = chainOccurrences
|
||||
.map(({ member }) => {
|
||||
if (member.kind !== 'node') return `${member.kind}:${member.ref.name}`
|
||||
const source = member.node.source
|
||||
return source.kind === 'provider'
|
||||
? `provider:${source.providerName}:${source.proxyName}`
|
||||
: `core:${source.proxyName}`
|
||||
})
|
||||
.join('\u0000')
|
||||
|
||||
delayManager.setGroupListener('chain-mode', groupListener)
|
||||
useEffect(() => {
|
||||
if (!isChainMode || !chainDelayKey) return
|
||||
const interactable = chainOccurrencesRef.current
|
||||
.map(({ member }) => member)
|
||||
.filter(isInteractableMember)
|
||||
if (interactable.length === 0) return
|
||||
|
||||
const calculateDelays = async () => {
|
||||
try {
|
||||
const timeout = verge?.default_latency_timeout || 10000
|
||||
|
||||
debugLog(`[ChainMode] 开始计算 ${allProxies.length} 个节点的延迟`)
|
||||
|
||||
// 使用 delayManager 计算延迟,每个节点计算完成后会自动触发监听器刷新界面
|
||||
delayManager.checkListDelay(allProxies, 'chain-mode', timeout)
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate delays for chain mode:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟执行避免阻塞
|
||||
const handle = setTimeout(calculateDelays, 100)
|
||||
const handle = setTimeout(() => {
|
||||
const timeout = verge?.default_latency_timeout || 10000
|
||||
debugLog(`[ChainMode] 开始计算 ${interactable.length} 个节点的延迟`)
|
||||
void delayManager.checkListDelay(interactable, chainDelayGroup, timeout)
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handle)
|
||||
// 清理组监听器
|
||||
delayManager.removeGroupListener('chain-mode')
|
||||
}
|
||||
}, [isChainMode, runtimeConfig, verge?.default_latency_timeout, refreshProxy])
|
||||
}, [
|
||||
chainDelayGroup,
|
||||
chainDelayKey,
|
||||
isChainMode,
|
||||
verge?.default_latency_timeout,
|
||||
])
|
||||
|
||||
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
|
||||
const prevListRef = useRef<IRenderItem[]>([])
|
||||
|
||||
// 处理渲染列表
|
||||
const renderList: IRenderItem[] = useMemo(() => {
|
||||
if (!proxiesData) return []
|
||||
const renderList = useMemo<IRenderItem[]>(() => {
|
||||
if (!proxyView) return []
|
||||
|
||||
// 链式代理模式下,显示代理组和其节点
|
||||
if (isChainMode && runtimeConfig && mode === 'rule') {
|
||||
// 使用正常的规则模式代理组
|
||||
const allGroups = proxiesData.groups.length
|
||||
? proxiesData.groups
|
||||
: [proxiesData.global!]
|
||||
|
||||
// 如果选择了特定代理组,只显示该组的节点
|
||||
if (selectedGroup) {
|
||||
const targetGroup = allGroups.find((g: any) => g.name === selectedGroup)
|
||||
if (targetGroup) {
|
||||
const proxies = filterSort(
|
||||
targetGroup.all,
|
||||
targetGroup.name,
|
||||
'',
|
||||
0,
|
||||
latencyTimeout,
|
||||
)
|
||||
|
||||
if (col > 1) {
|
||||
return groupProxies(proxies, col).map((proxyCol, colIndex) => ({
|
||||
type: 4,
|
||||
key: `chain-col-${selectedGroup}-${colIndex}`,
|
||||
group: targetGroup,
|
||||
headState: DEFAULT_STATE,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0]?.provider,
|
||||
}))
|
||||
} else {
|
||||
return proxies.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `chain-${selectedGroup}-${proxy!.name}`,
|
||||
group: targetGroup,
|
||||
proxy,
|
||||
headState: DEFAULT_STATE,
|
||||
provider: proxy.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 如果没有选择特定组,显示第一个组的节点(如果有组的话)
|
||||
if (allGroups.length > 0) {
|
||||
const firstGroup = allGroups[0]
|
||||
const proxies = filterSort(
|
||||
firstGroup.all,
|
||||
firstGroup.name,
|
||||
'',
|
||||
0,
|
||||
latencyTimeout,
|
||||
)
|
||||
|
||||
if (col > 1) {
|
||||
return groupProxies(proxies, col).map((proxyCol, colIndex) => ({
|
||||
type: 4,
|
||||
key: `chain-col-first-${colIndex}`,
|
||||
group: firstGroup,
|
||||
headState: DEFAULT_STATE,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0]?.provider,
|
||||
}))
|
||||
} else {
|
||||
return proxies.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `chain-first-${proxy!.name}`,
|
||||
group: firstGroup,
|
||||
proxy,
|
||||
headState: DEFAULT_STATE,
|
||||
provider: proxy.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有组,显示所有节点
|
||||
const allProxies: IProxyItem[] = allGroups.flatMap(
|
||||
(group: any) => group.all,
|
||||
if (isChainMode) {
|
||||
const selected =
|
||||
mode === 'rule'
|
||||
? proxyView.groups.find(({ name }) => name === selectedGroup)
|
||||
: undefined
|
||||
const group = selected ?? virtualGroup([])
|
||||
const occurrences = filterSort(
|
||||
chainOccurrences,
|
||||
selected?.name ?? CHAIN_DELAY_GROUP,
|
||||
'',
|
||||
0,
|
||||
latencyTimeout,
|
||||
)
|
||||
|
||||
// 为每个节点获取延迟信息
|
||||
const proxiesWithDelay = allProxies.map((proxy) => {
|
||||
const delay = delayManager.getDelay(proxy.name, 'chain-mode')
|
||||
return {
|
||||
...proxy,
|
||||
// 如果delayManager有延迟数据,更新history
|
||||
history:
|
||||
delay >= 0
|
||||
? [{ time: new Date().toISOString(), delay }]
|
||||
: proxy.history || [],
|
||||
}
|
||||
})
|
||||
|
||||
// 创建一个虚拟的组来容纳所有节点
|
||||
const virtualGroup: ProxyGroup = {
|
||||
name: 'All Proxies',
|
||||
type: 'Selector',
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
history: [],
|
||||
now: '',
|
||||
all: proxiesWithDelay,
|
||||
}
|
||||
|
||||
if (col > 1) {
|
||||
return groupProxies(proxiesWithDelay, col).map(
|
||||
(proxyCol, colIndex) => ({
|
||||
type: 4,
|
||||
key: `chain-col-all-${colIndex}`,
|
||||
group: virtualGroup,
|
||||
headState: DEFAULT_STATE,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0]?.provider,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
return proxiesWithDelay.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `chain-all-${proxy.name}`,
|
||||
group: virtualGroup,
|
||||
proxy,
|
||||
return groupOccurrences(occurrences, col).map((memberCol) => ({
|
||||
type: 4,
|
||||
key: `chain-col:${memberKey(group, memberCol[0])}`,
|
||||
group,
|
||||
headState: DEFAULT_STATE,
|
||||
provider: proxy.provider,
|
||||
col,
|
||||
memberCol,
|
||||
}))
|
||||
}
|
||||
return occurrences.map((member) => ({
|
||||
type: 2,
|
||||
key: `chain:${memberKey(group, member)}`,
|
||||
group,
|
||||
member,
|
||||
headState: DEFAULT_STATE,
|
||||
}))
|
||||
}
|
||||
|
||||
// 链式代理模式下的其他模式(如global)仍显示所有节点
|
||||
if (isChainMode && runtimeConfig) {
|
||||
// 从运行时配置直接获取 proxies 列表 (需要类型断言)
|
||||
const allProxies: IProxyItem[] = Object.values(
|
||||
(runtimeConfig as any).proxies || {},
|
||||
)
|
||||
|
||||
// 为每个节点获取延迟信息
|
||||
const proxiesWithDelay = allProxies.map((proxy) => {
|
||||
const delay = delayManager.getDelay(proxy.name, 'chain-mode')
|
||||
return {
|
||||
...proxy,
|
||||
// 如果delayManager有延迟数据,更新history
|
||||
history:
|
||||
delay >= 0
|
||||
? [{ time: new Date().toISOString(), delay }]
|
||||
: proxy.history || [],
|
||||
}
|
||||
})
|
||||
|
||||
// 创建一个虚拟的组来容纳所有节点
|
||||
const virtualGroup: ProxyGroup = {
|
||||
name: 'All Proxies',
|
||||
type: 'Selector',
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
history: [],
|
||||
now: '',
|
||||
all: proxiesWithDelay,
|
||||
}
|
||||
|
||||
// 返回节点列表(不显示组头)
|
||||
if (col > 1) {
|
||||
return groupProxies(proxiesWithDelay, col).map(
|
||||
(proxyCol, colIndex) => ({
|
||||
type: 4,
|
||||
key: `chain-col-${colIndex}`,
|
||||
group: virtualGroup,
|
||||
headState: DEFAULT_STATE,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0]?.provider,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
return proxiesWithDelay.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `chain-${proxy.name}`,
|
||||
group: virtualGroup,
|
||||
proxy,
|
||||
headState: DEFAULT_STATE,
|
||||
provider: proxy.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 正常模式的渲染逻辑
|
||||
const useRule = mode === 'rule' || mode === 'script'
|
||||
const renderGroups =
|
||||
useRule && proxiesData.groups.length
|
||||
? proxiesData.groups
|
||||
: [proxiesData.global!]
|
||||
|
||||
const renderGroups = useRule
|
||||
? proxyView.groups
|
||||
: proxyView.global === null
|
||||
? []
|
||||
: [proxyView.global]
|
||||
const cache = groupCacheRef.current
|
||||
let anyChanged = false
|
||||
|
||||
const retList = renderGroups.flatMap((group: ProxyGroup) => {
|
||||
const retList = renderGroups.flatMap((group) => {
|
||||
const headState = headStates[group.name] || DEFAULT_STATE
|
||||
const cached = cache.get(group.name)
|
||||
|
||||
if (
|
||||
cached &&
|
||||
cached.now === group.now &&
|
||||
cached.all === group.all &&
|
||||
cached.members === group.members &&
|
||||
cached.headState === headState &&
|
||||
cached.col === col &&
|
||||
cached.latencyTimeout === latencyTimeout
|
||||
@@ -413,9 +261,9 @@ export const useRenderList = (
|
||||
},
|
||||
]
|
||||
|
||||
if (headState?.open || !useRule) {
|
||||
const proxies = filterSort(
|
||||
group.all,
|
||||
if (headState.open || !useRule) {
|
||||
const occurrences = filterSort(
|
||||
resolveOccurrences(proxyView, group),
|
||||
group.name,
|
||||
headState.filterText,
|
||||
headState.sortType,
|
||||
@@ -426,45 +274,30 @@ export const useRenderList = (
|
||||
useRegularExpression: headState.filterUseRegularExpression,
|
||||
},
|
||||
)
|
||||
|
||||
// 全局模式下,添加组头
|
||||
if (!useRule) {
|
||||
ret.push({
|
||||
type: 1,
|
||||
key: `head-${group.name}`,
|
||||
group,
|
||||
headState,
|
||||
})
|
||||
ret.push({ type: 1, key: `head-${group.name}`, group, headState })
|
||||
}
|
||||
|
||||
if (!proxies.length) {
|
||||
ret.push({
|
||||
type: 3,
|
||||
key: `empty-${group.name}`,
|
||||
group,
|
||||
headState,
|
||||
})
|
||||
if (occurrences.length === 0) {
|
||||
ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
|
||||
} else if (col > 1) {
|
||||
ret.push(
|
||||
...groupProxies(proxies, col).map((proxyCol, colIndex) => ({
|
||||
...groupOccurrences(occurrences, col).map((memberCol) => ({
|
||||
type: 4 as const,
|
||||
key: `col-${group.name}-${proxyCol[0].name}-${colIndex}`,
|
||||
key: `col:${memberKey(group, memberCol[0])}`,
|
||||
group,
|
||||
headState,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0].provider,
|
||||
memberCol,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
ret.push(
|
||||
...proxies.map((proxy) => ({
|
||||
...occurrences.map((member) => ({
|
||||
type: 2 as const,
|
||||
key: `${group.name}-${proxy!.name}`,
|
||||
key: memberKey(group, member),
|
||||
group,
|
||||
proxy,
|
||||
member,
|
||||
headState,
|
||||
provider: proxy.provider,
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -472,7 +305,7 @@ export const useRenderList = (
|
||||
|
||||
cache.set(group.name, {
|
||||
now: group.now,
|
||||
all: group.all,
|
||||
members: group.members,
|
||||
headState,
|
||||
col,
|
||||
latencyTimeout,
|
||||
@@ -483,22 +316,21 @@ export const useRenderList = (
|
||||
|
||||
const filtered = !useRule
|
||||
? retList.slice(1)
|
||||
: retList.filter((item: IRenderItem) => !item.group.hidden)
|
||||
|
||||
: retList.filter((item) => !item.group.hidden)
|
||||
if (!anyChanged && prevListRef.current.length === filtered.length) {
|
||||
return prevListRef.current
|
||||
}
|
||||
prevListRef.current = filtered
|
||||
return filtered
|
||||
}, [
|
||||
headStates,
|
||||
proxiesData,
|
||||
mode,
|
||||
chainOccurrences,
|
||||
col,
|
||||
headStates,
|
||||
isChainMode,
|
||||
runtimeConfig,
|
||||
selectedGroup,
|
||||
latencyTimeout,
|
||||
mode,
|
||||
proxyView,
|
||||
selectedGroup,
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -508,5 +340,3 @@ export const useRenderList = (
|
||||
currentColumns: col,
|
||||
}
|
||||
}
|
||||
|
||||
// 优化建议:如有大数据量,建议用虚拟滚动(已在 ProxyGroups 组件中实现),此处无需额外处理。
|
||||
|
||||
Reference in New Issue
Block a user