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:
wonfen
2026-07-21 00:11:59 +08:00
committed by GitHub
parent fd70d4c62b
commit 304f5bade9
26 changed files with 2737 additions and 1459 deletions
+172 -171
View File
@@ -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>
+36
View File
@@ -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,
}
})
+94 -90
View File
@@ -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',
+68 -30
View File
@@ -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}
+37 -43
View File
@@ -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" />
}
+42 -30
View File
@@ -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')
+46 -24
View File
@@ -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"
+16 -15
View File
@@ -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>
)
}
+15 -10
View File
@@ -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
+168 -338
View File
@@ -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 组件中实现),此处无需额外处理。