mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 19:22:44 +08:00
* feat(tray): add a per-group latency test The tray's proxy group submenus offered no way to test a group: refreshing the delays shown there meant opening the dashboard and running a test from the proxy page. Add a "Test Latency" entry at the head of each group submenu. It measures the group through the proxy page's own view, so member resolution and provider attribution match the page exactly: provider nodes are health checked on their provider, anything else goes through the proxy delay endpoint, and members the page cannot resolve are skipped. Nested groups are therefore tested through their currently selected node, as the page does. The menu is rebuilt when the run finishes. The test URL follows the page's chain -- the group's own test URL, then the configured default, then the built-in one -- including the page's validity check, so a group with a malformed URL falls back where the page falls back. A group test started from the app rebuilds the tray as well, so the two views do not drift. Co-Authored-By: Claude <noreply@anthropic.com> * fix(tray): keep latency tests and displayed results consistent * fix(tray): isolate latency histories and preserve usable menus * fix(tray): keep settings usable when proxy reads fail * fix(tray): show selected node latency for proxy groups and display timeouts as T/O Co-authored-by: wonfen <eiwonfen@gmail.com> * fix(tray): refresh proxy latency on menu interaction --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: wonfen <eiwonfen@gmail.com>
1168 lines
34 KiB
TypeScript
1168 lines
34 KiB
TypeScript
/* eslint-disable @eslint-react/set-state-in-effect */
|
|
import {
|
|
AccessTimeRounded,
|
|
ArrowDropDown,
|
|
ChevronRight,
|
|
NetworkCheckRounded,
|
|
WifiOff as SignalError,
|
|
SignalWifi3Bar as SignalGood,
|
|
SignalWifi2Bar as SignalMedium,
|
|
SignalWifi0Bar as SignalNone,
|
|
SignalWifi4Bar as SignalStrong,
|
|
SignalWifi1Bar as SignalWeak,
|
|
SortByAlphaRounded,
|
|
SortRounded,
|
|
} from '@mui/icons-material'
|
|
import {
|
|
Box,
|
|
Button,
|
|
ButtonBase,
|
|
Chip,
|
|
ClickAwayListener,
|
|
IconButton,
|
|
MenuItem,
|
|
MenuList,
|
|
Paper,
|
|
Popper,
|
|
type PopperProps,
|
|
Tooltip,
|
|
Typography,
|
|
alpha,
|
|
useTheme,
|
|
} from '@mui/material'
|
|
import { useLockFn } from 'ahooks'
|
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate } from 'react-router'
|
|
|
|
import { EnhancedCard } from '@/components/home/enhanced-card'
|
|
import type { ProxySortType } from '@/components/proxy/use-filter-sort'
|
|
import { useGroupDelays } from '@/hooks/use-group-delays'
|
|
import { useProfiles } from '@/hooks/use-profiles'
|
|
import { useProxySelection } from '@/hooks/use-proxy-selection'
|
|
import { useVerge } from '@/hooks/use-verge'
|
|
import {
|
|
useAppRefreshers,
|
|
useClashConfigData,
|
|
useCoreDataStatus,
|
|
useProxiesData,
|
|
} from '@/providers/app-data-context'
|
|
import { syncTrayProxySelection } from '@/services/cmds'
|
|
import delayManager from '@/services/delay'
|
|
import { mutate } from '@/services/mutate'
|
|
import { profileTestUrls } from '@/services/proxy-test-url'
|
|
import {
|
|
findCurrentGroupMember,
|
|
getRecord,
|
|
isInteractableMember,
|
|
memberDetails,
|
|
resolveMember,
|
|
type ProxyGroupView,
|
|
type ResolvedProxyMember,
|
|
} from '@/types/proxy-view'
|
|
import { debugLog } from '@/utils/debug'
|
|
import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
|
|
|
|
const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group'
|
|
const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
|
|
|
|
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
|
|
const AUTO_CHECK_INITIAL_DELAY_MS = 100
|
|
const PROXY_MENU_MAX_HEIGHT = 500
|
|
const PROXY_MENU_VIEWPORT_MARGIN = 8
|
|
|
|
const proxyMenuModifiers: PopperProps['modifiers'] = [
|
|
{
|
|
name: 'menuSize',
|
|
enabled: true,
|
|
phase: 'beforeRead',
|
|
fn: ({ state }) => {
|
|
const popper = state.elements.popper
|
|
const anchor = state.elements.reference.getBoundingClientRect()
|
|
const viewportHeight = popper.ownerDocument.documentElement.clientHeight
|
|
// Use the larger side before flip runs so sizing cannot alternate with placement.
|
|
const availableHeight =
|
|
Math.max(anchor.top, viewportHeight - anchor.bottom) -
|
|
PROXY_MENU_VIEWPORT_MARGIN
|
|
popper.style.width = `${anchor.width}px`
|
|
popper.style.setProperty(
|
|
'--proxy-menu-max-height',
|
|
`${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`,
|
|
)
|
|
state.rects.popper.width = popper.offsetWidth
|
|
state.rects.popper.height = popper.offsetHeight
|
|
},
|
|
},
|
|
{
|
|
name: 'showMenu',
|
|
enabled: true,
|
|
phase: 'beforeWrite',
|
|
requires: ['computeStyles'],
|
|
fn: ({ state }) => {
|
|
state.styles.popper.visibility = 'visible'
|
|
},
|
|
},
|
|
]
|
|
|
|
interface ProxyOption {
|
|
memberIndex: number
|
|
member: ResolvedProxyMember
|
|
value: string
|
|
name: string
|
|
disabled: boolean
|
|
}
|
|
|
|
type OpenSelect = 'group' | 'proxy' | null
|
|
|
|
function convertDelayColor(
|
|
delayValue: number,
|
|
): 'success' | 'warning' | 'error' | 'primary' | 'default' {
|
|
const colorStr = delayManager.formatDelayColor(delayValue)
|
|
if (!colorStr) return 'default'
|
|
|
|
const mainColor = colorStr.split('.')[0]
|
|
|
|
switch (mainColor) {
|
|
case 'success':
|
|
return 'success'
|
|
case 'warning':
|
|
return 'warning'
|
|
case 'error':
|
|
return 'error'
|
|
case 'primary':
|
|
return 'primary'
|
|
default:
|
|
return 'default'
|
|
}
|
|
}
|
|
|
|
function getSignalIcon(
|
|
delay: number,
|
|
translate: (key: string) => string,
|
|
): {
|
|
icon: React.ReactElement
|
|
text: string
|
|
color: string
|
|
} {
|
|
if (delay === -2)
|
|
return {
|
|
icon: <SignalNone />,
|
|
text: translate('home.components.currentProxy.status.testing'),
|
|
color: 'text.secondary',
|
|
}
|
|
if (delay === -1)
|
|
return {
|
|
icon: <SignalNone />,
|
|
text: translate('home.components.currentProxy.status.untested'),
|
|
color: 'text.secondary',
|
|
}
|
|
if (delay > 1e5)
|
|
return {
|
|
icon: <SignalError />,
|
|
text: translate('home.components.currentProxy.status.error'),
|
|
color: 'error.main',
|
|
}
|
|
if (delay === 0 || delay >= 10000)
|
|
return {
|
|
icon: <SignalError />,
|
|
text: translate('home.components.currentProxy.status.timeout'),
|
|
color: 'error.main',
|
|
}
|
|
if (delay >= 500)
|
|
return {
|
|
icon: <SignalWeak />,
|
|
text: translate('home.components.currentProxy.status.latencyHigh'),
|
|
color: 'error.main',
|
|
}
|
|
if (delay >= 300)
|
|
return {
|
|
icon: <SignalMedium />,
|
|
text: translate('home.components.currentProxy.status.latencyMedium'),
|
|
color: 'warning.main',
|
|
}
|
|
if (delay >= 200)
|
|
return {
|
|
icon: <SignalGood />,
|
|
text: translate('home.components.currentProxy.status.latencyGood'),
|
|
color: 'info.main',
|
|
}
|
|
return {
|
|
icon: <SignalStrong />,
|
|
text: translate('home.components.currentProxy.status.latencyExcellent'),
|
|
color: 'success.main',
|
|
}
|
|
}
|
|
|
|
const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
|
|
`${memberIndex}:${
|
|
member.kind === 'node' ? member.node.recordId : member.ref.name
|
|
}`
|
|
|
|
const sortProxyOptions = (
|
|
options: ProxyOption[],
|
|
sortType: ProxySortType,
|
|
delayOf: (member: ResolvedProxyMember) => number,
|
|
latencyTimeout?: number,
|
|
) => {
|
|
if (sortType === 0) return options
|
|
if (sortType === 2) {
|
|
return [...options].sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
|
|
const effectiveTimeout =
|
|
typeof latencyTimeout === 'number' && latencyTimeout > 0
|
|
? latencyTimeout
|
|
: DEFAULT_DELAY_TIMEOUT
|
|
|
|
return options
|
|
.map((option) => ({ option, delay: delayOf(option.member) }))
|
|
.sort((a, b) => {
|
|
const byDelay = compareByDelay(a.delay, b.delay, effectiveTimeout)
|
|
return byDelay || a.option.name.localeCompare(b.option.name)
|
|
})
|
|
.map(({ option }) => option)
|
|
}
|
|
|
|
interface PersistentSelectProps {
|
|
label: string
|
|
displayValue: React.ReactNode
|
|
open: boolean
|
|
disabled: boolean
|
|
keepOpenRef: React.RefObject<HTMLElement | null>
|
|
onOpenChange: (open: boolean) => void
|
|
renderOptions: () => React.ReactNode
|
|
}
|
|
|
|
const PersistentSelect = ({
|
|
label,
|
|
displayValue,
|
|
open,
|
|
disabled,
|
|
keepOpenRef,
|
|
onOpenChange,
|
|
renderOptions,
|
|
}: PersistentSelectProps) => {
|
|
const id = useId()
|
|
const labelId = `${id}-label`
|
|
const valueId = `${id}-value`
|
|
const listboxId = `${id}-listbox`
|
|
const anchorRef = useRef<HTMLButtonElement>(null)
|
|
const listRef = useRef<HTMLUListElement>(null)
|
|
|
|
const popperOptions = useMemo<PopperProps['popperOptions']>(
|
|
() => ({
|
|
onFirstUpdate: () => {
|
|
const list = listRef.current
|
|
const paper = list?.parentElement
|
|
const item =
|
|
list?.querySelector<HTMLElement>(
|
|
'[aria-selected="true"]:not([aria-disabled="true"])',
|
|
) ??
|
|
list?.querySelector<HTMLElement>(
|
|
'[role="option"]:not([aria-disabled="true"])',
|
|
)
|
|
if (!item || !paper) return
|
|
|
|
// Focusing before positioning can scroll the page instead of just the menu.
|
|
item.focus({ preventScroll: true })
|
|
paper.scrollTop +=
|
|
item.getBoundingClientRect().top -
|
|
paper.getBoundingClientRect().top -
|
|
(paper.clientHeight - item.offsetHeight) / 2
|
|
},
|
|
}),
|
|
[],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
const closeFromKeyboard = (event: KeyboardEvent) => {
|
|
if (event.key === 'Tab') {
|
|
onOpenChange(false)
|
|
} else if (event.key === 'Escape') {
|
|
onOpenChange(false)
|
|
anchorRef.current?.focus({ preventScroll: true })
|
|
}
|
|
}
|
|
document.addEventListener('keydown', closeFromKeyboard)
|
|
return () => document.removeEventListener('keydown', closeFromKeyboard)
|
|
}, [onOpenChange, open])
|
|
|
|
return (
|
|
<ClickAwayListener
|
|
onClickAway={(event) => {
|
|
if (!open) return
|
|
const target = event.target
|
|
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
|
|
return
|
|
}
|
|
onOpenChange(false)
|
|
}}
|
|
>
|
|
<Box>
|
|
<Box
|
|
sx={{
|
|
position: 'relative',
|
|
borderRadius: 1,
|
|
'& fieldset': {
|
|
borderColor: (theme) =>
|
|
alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
|
|
},
|
|
...(!disabled && {
|
|
'&:hover fieldset': { borderColor: 'text.primary' },
|
|
'&:focus-within fieldset': {
|
|
borderColor: 'primary.main',
|
|
borderWidth: 2,
|
|
},
|
|
'&:focus-within legend': { color: 'primary.main' },
|
|
}),
|
|
}}
|
|
>
|
|
<ButtonBase
|
|
ref={anchorRef}
|
|
type="button"
|
|
role="combobox"
|
|
aria-labelledby={`${labelId} ${valueId}`}
|
|
aria-haspopup="listbox"
|
|
aria-controls={open ? listboxId : undefined}
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
onClick={() => onOpenChange(!open)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
onOpenChange(true)
|
|
}
|
|
}}
|
|
sx={{
|
|
width: '100%',
|
|
height: 40,
|
|
px: 1.75,
|
|
borderRadius: 'inherit',
|
|
justifyContent: 'space-between',
|
|
textAlign: 'left',
|
|
color: disabled ? 'text.disabled' : 'text.primary',
|
|
}}
|
|
>
|
|
<Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
|
|
{displayValue}
|
|
</Box>
|
|
<ArrowDropDown
|
|
sx={{
|
|
ml: 1,
|
|
color: disabled ? 'action.disabled' : 'action.active',
|
|
transform: open ? 'rotate(180deg)' : undefined,
|
|
}}
|
|
/>
|
|
</ButtonBase>
|
|
<Box
|
|
component="fieldset"
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: '-5px 0 0',
|
|
m: 0,
|
|
px: 1,
|
|
minWidth: 0,
|
|
border: '1px solid',
|
|
borderRadius: 'inherit',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
<Box
|
|
component="legend"
|
|
id={labelId}
|
|
sx={{
|
|
px: 0.5,
|
|
typography: 'caption',
|
|
lineHeight: '11px',
|
|
color: disabled ? 'text.disabled' : 'text.secondary',
|
|
}}
|
|
>
|
|
{label}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Popper
|
|
open={open}
|
|
anchorEl={anchorRef.current}
|
|
placement="bottom-start"
|
|
modifiers={proxyMenuModifiers}
|
|
popperOptions={popperOptions}
|
|
sx={{
|
|
visibility: 'hidden',
|
|
zIndex: (theme) => theme.zIndex.modal,
|
|
}}
|
|
>
|
|
<Paper
|
|
elevation={8}
|
|
sx={{
|
|
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
<MenuList
|
|
ref={listRef}
|
|
id={listboxId}
|
|
role="listbox"
|
|
aria-labelledby={labelId}
|
|
variant="selectedMenu"
|
|
>
|
|
{open && renderOptions()}
|
|
</MenuList>
|
|
</Paper>
|
|
</Popper>
|
|
</Box>
|
|
</ClickAwayListener>
|
|
)
|
|
}
|
|
|
|
interface PersistentProxySelectProps
|
|
extends Omit<PersistentSelectProps, 'displayValue' | 'renderOptions'> {
|
|
value: string
|
|
selectedName: string
|
|
groupName: string
|
|
fixed?: string
|
|
selectedDelay: number
|
|
options: ProxyOption[]
|
|
onChange: (value: string) => void
|
|
}
|
|
|
|
const PersistentProxySelect = ({
|
|
groupName,
|
|
fixed,
|
|
selectedName,
|
|
selectedDelay,
|
|
options,
|
|
value,
|
|
onChange,
|
|
...props
|
|
}: PersistentProxySelectProps) => {
|
|
const fixedProxyInUsed = selectedName === fixed
|
|
|
|
return (
|
|
<PersistentSelect
|
|
{...props}
|
|
displayValue={
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Typography noWrap>{selectedName}</Typography>
|
|
<Chip
|
|
size="small"
|
|
label={delayManager.formatDelay(selectedDelay)}
|
|
color={convertDelayColor(selectedDelay)}
|
|
/>
|
|
{fixedProxyInUsed && (
|
|
<span
|
|
style={{
|
|
position: 'absolute',
|
|
fontSize: '12px',
|
|
top: '-3px',
|
|
right: '25px',
|
|
}}
|
|
>
|
|
📌
|
|
</span>
|
|
)}
|
|
</Box>
|
|
}
|
|
renderOptions={() =>
|
|
options.map((option) => {
|
|
const selected = option.value === value
|
|
const isFixed = option.name === fixed
|
|
const delay = option.disabled
|
|
? -1
|
|
: delayManager.getDelayFix(option.member, groupName)
|
|
|
|
return (
|
|
<MenuItem
|
|
key={option.value}
|
|
role="option"
|
|
aria-selected={selected}
|
|
selected={selected}
|
|
disabled={option.disabled}
|
|
onClick={() => {
|
|
if (!option.disabled) onChange(option.value)
|
|
}}
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
width: '100%',
|
|
pr: 1,
|
|
}}
|
|
>
|
|
{isFixed && (
|
|
<span
|
|
style={{
|
|
position: 'absolute',
|
|
fontSize: '12px',
|
|
top: '-5px',
|
|
right: '5px',
|
|
...(!fixedProxyInUsed && {
|
|
filter: 'grayscale(1)',
|
|
}),
|
|
}}
|
|
>
|
|
📌
|
|
</span>
|
|
)}
|
|
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
|
{option.name}
|
|
</Typography>
|
|
{!option.disabled && (
|
|
<Chip
|
|
size="small"
|
|
label={delayManager.formatDelay(delay)}
|
|
color={convertDelayColor(delay)}
|
|
sx={{
|
|
minWidth: '60px',
|
|
height: '22px',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
</MenuItem>
|
|
)
|
|
})
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export const CurrentProxyCard = () => {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const theme = useTheme()
|
|
const { proxyView } = useProxiesData()
|
|
const { clashConfig } = useClashConfigData()
|
|
const { refreshProxy } = useAppRefreshers()
|
|
const { isCoreDataPending } = useCoreDataStatus()
|
|
const { verge } = useVerge()
|
|
const { current: currentProfile } = useProfiles()
|
|
const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false
|
|
const defaultLatencyTimeout = verge?.default_latency_timeout
|
|
const autoDelayIntervalMs = useMemo(() => {
|
|
const rawInterval = verge?.auto_delay_detection_interval_minutes
|
|
const intervalMinutes =
|
|
typeof rawInterval === 'number' && rawInterval > 0
|
|
? rawInterval
|
|
: AUTO_CHECK_DEFAULT_INTERVAL_MINUTES
|
|
return Math.max(1, Math.round(intervalMinutes)) * 60 * 1000
|
|
}, [verge?.auto_delay_detection_interval_minutes])
|
|
const currentProfileId = currentProfile?.uid || null
|
|
|
|
const getProfileStorageKey = useCallback(
|
|
(baseKey: string) =>
|
|
currentProfileId ? `${baseKey}:${currentProfileId}` : baseKey,
|
|
[currentProfileId],
|
|
)
|
|
|
|
const readProfileScopedItem = useCallback(
|
|
(baseKey: string) => {
|
|
if (typeof window === 'undefined') return null
|
|
const profileKey = getProfileStorageKey(baseKey)
|
|
const profileValue = localStorage.getItem(profileKey)
|
|
if (profileValue != null) {
|
|
return profileValue
|
|
}
|
|
|
|
if (profileKey !== baseKey) {
|
|
const legacyValue = localStorage.getItem(baseKey)
|
|
if (legacyValue != null) {
|
|
localStorage.removeItem(baseKey)
|
|
localStorage.setItem(profileKey, legacyValue)
|
|
return legacyValue
|
|
}
|
|
}
|
|
|
|
return null
|
|
},
|
|
[getProfileStorageKey],
|
|
)
|
|
|
|
const writeProfileScopedItem = useCallback(
|
|
(baseKey: string, value: string) => {
|
|
if (typeof window === 'undefined') return
|
|
const profileKey = getProfileStorageKey(baseKey)
|
|
localStorage.setItem(profileKey, value)
|
|
if (profileKey !== baseKey) {
|
|
localStorage.removeItem(baseKey)
|
|
}
|
|
},
|
|
[getProfileStorageKey],
|
|
)
|
|
|
|
const { handleSelectChange } = useProxySelection({
|
|
onSuccess: () => {
|
|
refreshProxy()
|
|
},
|
|
onError: (error) => {
|
|
console.error('代理切换失败', error)
|
|
refreshProxy()
|
|
},
|
|
})
|
|
|
|
const mode = clashConfig?.mode?.toLowerCase() || 'rule'
|
|
const isGlobalMode = mode === 'global'
|
|
const isDirectMode = mode === 'direct'
|
|
|
|
const [sortType, setSortType] = useState<ProxySortType>(() => {
|
|
const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE)
|
|
return savedSortType ? (Number(savedSortType) as ProxySortType) : 0
|
|
})
|
|
|
|
const [selectedGroupName, setSelectedGroupName] = useState('')
|
|
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
|
|
|
useEffect(() => {
|
|
setOpenSelect(null)
|
|
}, [mode])
|
|
|
|
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
|
const delays = useGroupDelays(selectedGroupName || null)
|
|
|
|
const autoCheckInProgressRef = useRef(false)
|
|
const latestTimeoutRef = useRef<number>(
|
|
verge?.default_latency_timeout || 10000,
|
|
)
|
|
const latestProxyMemberRef = useRef<ResolvedProxyMember | null>(null)
|
|
|
|
useEffect(() => {
|
|
latestTimeoutRef.current = verge?.default_latency_timeout || 10000
|
|
}, [verge?.default_latency_timeout])
|
|
|
|
const selectableGroups = useMemo(() => {
|
|
if (!proxyView) return []
|
|
return proxyView.groups.filter(
|
|
(group) =>
|
|
!group.hidden &&
|
|
(group.type === 'Selector' || group.type === 'URLTest'),
|
|
)
|
|
}, [proxyView])
|
|
|
|
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
|
|
if (!proxyView || isDirectMode) return null
|
|
if (isGlobalMode) return proxyView.global
|
|
return (
|
|
selectableGroups.find(({ name }) => name === selectedGroupName) ?? null
|
|
)
|
|
}, [
|
|
isDirectMode,
|
|
isGlobalMode,
|
|
proxyView,
|
|
selectableGroups,
|
|
selectedGroupName,
|
|
])
|
|
|
|
const optionsForGroup = useCallback(
|
|
(group: ProxyGroupView | null): ProxyOption[] =>
|
|
proxyView && group
|
|
? group.members.map((memberRef, memberIndex) => {
|
|
const member = resolveMember(proxyView, memberRef)
|
|
return {
|
|
memberIndex,
|
|
member,
|
|
value: optionValue(memberIndex, member),
|
|
name: member.ref.name,
|
|
disabled: !isInteractableMember(member),
|
|
}
|
|
})
|
|
: [],
|
|
[proxyView],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!selectedGroup) return
|
|
const customUrl = currentProfile
|
|
? profileTestUrls(currentProfile)[selectedGroup.name]
|
|
: undefined
|
|
delayManager.setUrl(
|
|
selectedGroup.name,
|
|
customUrl?.trim() ||
|
|
selectedGroup.testUrl?.trim() ||
|
|
verge?.default_latency_test?.trim() ||
|
|
'http://cp.cloudflare.com/generate_204',
|
|
)
|
|
}, [selectedGroup, currentProfile, verge?.default_latency_test])
|
|
|
|
const unsortedProxyOptions = useMemo(
|
|
() => optionsForGroup(selectedGroup),
|
|
[optionsForGroup, selectedGroup],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!proxyView) return
|
|
if (isDirectMode) {
|
|
setSelectedGroupName('DIRECT')
|
|
return
|
|
}
|
|
if (isGlobalMode) {
|
|
setSelectedGroupName(proxyView.global?.name ?? 'GLOBAL')
|
|
return
|
|
}
|
|
|
|
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
|
|
const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择']
|
|
const primaryGroup =
|
|
selectableGroups.find((group) =>
|
|
primaryKeywords.some((keyword) =>
|
|
group.name.toLowerCase().includes(keyword.toLowerCase()),
|
|
),
|
|
) ?? selectableGroups[0]
|
|
const nextGroup = selectableGroups.some(
|
|
({ name }) => name === selectedGroupName,
|
|
)
|
|
? selectedGroupName
|
|
: selectableGroups.some(({ name }) => name === savedGroup)
|
|
? savedGroup!
|
|
: (primaryGroup?.name ?? '')
|
|
if (nextGroup !== selectedGroupName) {
|
|
setSelectedGroupName(nextGroup)
|
|
if (nextGroup) writeProfileScopedItem(STORAGE_KEY_GROUP, nextGroup)
|
|
}
|
|
}, [
|
|
isDirectMode,
|
|
isGlobalMode,
|
|
proxyView,
|
|
readProfileScopedItem,
|
|
selectableGroups,
|
|
selectedGroupName,
|
|
writeProfileScopedItem,
|
|
])
|
|
|
|
const currentOption = useMemo(() => {
|
|
if (!proxyView) return undefined
|
|
if (isDirectMode) {
|
|
const node =
|
|
proxyView.direct == null
|
|
? undefined
|
|
: getRecord(proxyView, proxyView.direct)
|
|
return node
|
|
? ({
|
|
memberIndex: 0,
|
|
member: {
|
|
kind: 'node',
|
|
ref: { kind: 'node', name: node.name, recordId: node.recordId },
|
|
node,
|
|
},
|
|
} satisfies Pick<ProxyOption, 'memberIndex' | 'member'>)
|
|
: undefined
|
|
}
|
|
return selectedGroup
|
|
? findCurrentGroupMember(proxyView, selectedGroup)
|
|
: undefined
|
|
}, [isDirectMode, proxyView, selectedGroup])
|
|
|
|
latestProxyMemberRef.current = currentOption?.member ?? null
|
|
|
|
const handleGroupChange = useCallback(
|
|
(newGroupName: string) => {
|
|
if (isGlobalMode || isDirectMode) return
|
|
setSelectedGroupName(newGroupName)
|
|
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
|
|
},
|
|
[isDirectMode, isGlobalMode, writeProfileScopedItem],
|
|
)
|
|
|
|
const handleProxyChange = useCallback(
|
|
(value: string) => {
|
|
if (isDirectMode) return
|
|
const option = unsortedProxyOptions.find(
|
|
(candidate) => candidate.value === value,
|
|
)
|
|
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
|
|
return
|
|
}
|
|
const previousProxy = selectedGroup.now
|
|
const nextName = option.member.ref.name
|
|
// The profile selection is the durable source across core restarts and run modes.
|
|
handleSelectChange(
|
|
selectedGroup.name,
|
|
previousProxy,
|
|
selectedGroup.fixed,
|
|
)({
|
|
target: { value: nextName },
|
|
})
|
|
},
|
|
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
|
|
)
|
|
|
|
const goToProxies = useCallback(() => {
|
|
navigate('/proxies')
|
|
}, [navigate])
|
|
|
|
const currentMember = currentOption?.member
|
|
const currentProxy = currentMember ? memberDetails(currentMember) : undefined
|
|
const selectedProxyName = currentMember?.ref.name ?? ''
|
|
|
|
const currentDelay =
|
|
currentMember && selectedGroupName
|
|
? delayManager.getDelayFix(currentMember, selectedGroupName)
|
|
: -1
|
|
|
|
const signalInfo =
|
|
currentProxy && selectedGroupName
|
|
? getSignalIcon(currentDelay, t)
|
|
: {
|
|
icon: <SignalNone />,
|
|
text: t('home.components.currentProxy.status.uninitialized'),
|
|
color: 'text.secondary',
|
|
}
|
|
|
|
const checkCurrentProxyDelay = useCallback(async () => {
|
|
if (autoCheckInProgressRef.current) return
|
|
if (isDirectMode) return
|
|
|
|
const groupName = selectedGroupName
|
|
const proxyName = selectedProxyName
|
|
|
|
if (!groupName || !proxyName) return
|
|
|
|
const proxyMember = latestProxyMemberRef.current
|
|
if (!proxyMember || !isInteractableMember(proxyMember)) {
|
|
debugLog(
|
|
`[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`,
|
|
)
|
|
return
|
|
}
|
|
|
|
autoCheckInProgressRef.current = true
|
|
|
|
const timeout = latestTimeoutRef.current || 10000
|
|
|
|
try {
|
|
debugLog(
|
|
`[CurrentProxyCard] 自动检测当前节点延迟,组: ${groupName}, 节点: ${proxyName}`,
|
|
)
|
|
await delayManager.checkDelay(proxyMember, groupName, timeout)
|
|
} catch (error) {
|
|
console.error(
|
|
`[CurrentProxyCard] 自动检测当前节点延迟失败,组: ${groupName}, 节点: ${proxyName}`,
|
|
error,
|
|
)
|
|
} finally {
|
|
autoCheckInProgressRef.current = false
|
|
refreshProxy()
|
|
}
|
|
}, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName])
|
|
|
|
useEffect(() => {
|
|
if (isDirectMode) return
|
|
if (!autoDelayEnabled) return
|
|
if (!selectedGroupName || !selectedProxyName) return
|
|
|
|
let disposed = false
|
|
let intervalTimer: ReturnType<typeof setTimeout> | null = null
|
|
let initialTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const runAndSchedule = async () => {
|
|
if (disposed) return
|
|
await checkCurrentProxyDelay()
|
|
if (disposed) return
|
|
intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs)
|
|
}
|
|
|
|
initialTimer = setTimeout(async () => {
|
|
await checkCurrentProxyDelay()
|
|
if (disposed) return
|
|
intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs)
|
|
}, AUTO_CHECK_INITIAL_DELAY_MS)
|
|
|
|
return () => {
|
|
disposed = true
|
|
if (initialTimer) clearTimeout(initialTimer)
|
|
if (intervalTimer) clearTimeout(intervalTimer)
|
|
}
|
|
}, [
|
|
checkCurrentProxyDelay,
|
|
autoDelayIntervalMs,
|
|
isDirectMode,
|
|
selectedGroupName,
|
|
selectedProxyName,
|
|
autoDelayEnabled,
|
|
])
|
|
|
|
const handleSortTypeChange = useCallback(() => {
|
|
const newSortType = ((sortType + 1) % 3) as ProxySortType
|
|
setSortType(newSortType)
|
|
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString())
|
|
}, [sortType])
|
|
|
|
const handleCheckDelay = useLockFn(async () => {
|
|
const groupName = selectedGroupName
|
|
if (!groupName || isDirectMode) return
|
|
|
|
debugLog(`[CurrentProxyCard] 开始测试所有延迟,组: ${groupName}`)
|
|
|
|
const timeout = verge?.default_latency_timeout || 10000
|
|
|
|
const interactable = unsortedProxyOptions
|
|
.map(({ member }) => member)
|
|
.filter(isInteractableMember)
|
|
.filter(({ ref }) => ref.name !== 'DIRECT' && ref.name !== 'REJECT')
|
|
|
|
if (interactable.length > 0) {
|
|
const url = delayManager.getUrl(groupName)
|
|
debugLog(`[CurrentProxyCard] 测试URL: ${url}, 超时: ${timeout}ms`)
|
|
|
|
try {
|
|
await delayManager.checkListDelay(interactable, groupName, timeout)
|
|
debugLog(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`)
|
|
} catch (error) {
|
|
console.error(
|
|
`[CurrentProxyCard] 延迟测试出错,组: ${groupName}`,
|
|
error,
|
|
)
|
|
}
|
|
}
|
|
|
|
refreshProxy()
|
|
mutate(() => syncTrayProxySelection(), {
|
|
id: 'sync-tray-selection',
|
|
errorNotice: false,
|
|
}).catch((error) => {
|
|
console.error('[CurrentProxyCard] Failed to sync tray latency:', error)
|
|
})
|
|
})
|
|
|
|
const proxyOptions = useMemo(
|
|
() =>
|
|
isDirectMode || openSelect !== 'proxy'
|
|
? []
|
|
: sortProxyOptions(
|
|
unsortedProxyOptions,
|
|
sortType,
|
|
delays.of,
|
|
defaultLatencyTimeout,
|
|
),
|
|
[
|
|
delays,
|
|
isDirectMode,
|
|
openSelect,
|
|
unsortedProxyOptions,
|
|
sortType,
|
|
defaultLatencyTimeout,
|
|
],
|
|
)
|
|
|
|
const getSortIcon = (): React.ReactElement => {
|
|
switch (sortType) {
|
|
case 1:
|
|
return <AccessTimeRounded fontSize="small" />
|
|
case 2:
|
|
return <SortByAlphaRounded fontSize="small" />
|
|
default:
|
|
return <SortRounded fontSize="small" />
|
|
}
|
|
}
|
|
|
|
const getSortTooltip = (): string => {
|
|
switch (sortType) {
|
|
case 0:
|
|
return t('proxies.page.tooltips.sortDefault')
|
|
case 1:
|
|
return t('proxies.page.tooltips.sortDelay')
|
|
case 2:
|
|
return t('proxies.page.tooltips.sortName')
|
|
default:
|
|
return ''
|
|
}
|
|
}
|
|
|
|
return (
|
|
<EnhancedCard
|
|
title={t('home.components.currentProxy.title')}
|
|
icon={
|
|
<Tooltip
|
|
title={
|
|
currentProxy
|
|
? `${signalInfo.text}: ${delayManager.formatDelay(currentDelay)}`
|
|
: t('home.components.currentProxy.status.noProxyNode')
|
|
}
|
|
>
|
|
<Box sx={{ color: signalInfo.color }}>
|
|
{currentProxy ? signalInfo.icon : <SignalNone color="disabled" />}
|
|
</Box>
|
|
</Tooltip>
|
|
}
|
|
iconColor={currentProxy ? 'primary' : undefined}
|
|
action={
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Tooltip
|
|
title={t('home.components.currentProxy.actions.refreshDelay')}
|
|
>
|
|
<span>
|
|
<IconButton
|
|
ref={delayButtonRef}
|
|
size="small"
|
|
color="inherit"
|
|
onClick={handleCheckDelay}
|
|
disabled={isDirectMode || unsortedProxyOptions.length === 0}
|
|
aria-label={t(
|
|
'home.components.currentProxy.actions.refreshDelay',
|
|
)}
|
|
>
|
|
<NetworkCheckRounded />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip title={getSortTooltip()}>
|
|
<IconButton
|
|
size="small"
|
|
color="inherit"
|
|
onClick={handleSortTypeChange}
|
|
>
|
|
{getSortIcon()}
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={goToProxies}
|
|
sx={{ borderRadius: 1.5 }}
|
|
endIcon={<ChevronRight fontSize="small" />}
|
|
>
|
|
{t('layout.components.navigation.tabs.proxies')}
|
|
</Button>
|
|
</Box>
|
|
}
|
|
>
|
|
{isCoreDataPending ? (
|
|
<Box sx={{ py: 4, height: 24 }} />
|
|
) : currentProxy || (!isDirectMode && selectedGroup) ? (
|
|
<Box>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
p: 1,
|
|
mb: 2,
|
|
borderRadius: 1,
|
|
bgcolor: alpha(theme.palette.primary.main, 0.05),
|
|
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
|
}}
|
|
>
|
|
<Box>
|
|
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
|
{currentProxy?.name ??
|
|
t('home.components.currentProxy.labels.noActiveNode')}
|
|
</Typography>
|
|
|
|
<Box
|
|
sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ mr: 1 }}
|
|
>
|
|
{currentProxy?.type}
|
|
</Typography>
|
|
{isGlobalMode && (
|
|
<Chip
|
|
size="small"
|
|
label={t('home.components.currentProxy.labels.globalMode')}
|
|
color="primary"
|
|
sx={{ mr: 0.5 }}
|
|
/>
|
|
)}
|
|
{isDirectMode && (
|
|
<Chip
|
|
size="small"
|
|
label={t('home.components.currentProxy.labels.directMode')}
|
|
color="success"
|
|
sx={{ mr: 0.5 }}
|
|
/>
|
|
)}
|
|
{currentProxy?.udp && (
|
|
<Chip size="small" label="UDP" variant="outlined" />
|
|
)}
|
|
{currentProxy?.tfo && (
|
|
<Chip size="small" label="TFO" variant="outlined" />
|
|
)}
|
|
{currentProxy?.xudp && (
|
|
<Chip size="small" label="XUDP" variant="outlined" />
|
|
)}
|
|
{currentProxy?.mptcp && (
|
|
<Chip size="small" label="MPTCP" variant="outlined" />
|
|
)}
|
|
{currentProxy?.smux && (
|
|
<Chip size="small" label="SMUX" variant="outlined" />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{currentProxy && !isDirectMode && (
|
|
<Chip
|
|
size="small"
|
|
label={delayManager.formatDelay(currentDelay)}
|
|
color={convertDelayColor(currentDelay)}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ mb: 1.5 }}>
|
|
<PersistentSelect
|
|
label={t('home.components.currentProxy.labels.group')}
|
|
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
|
|
open={openSelect === 'group'}
|
|
disabled={isGlobalMode || isDirectMode}
|
|
keepOpenRef={delayButtonRef}
|
|
onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
|
|
renderOptions={() =>
|
|
selectableGroups.map((group) => (
|
|
<MenuItem
|
|
key={group.name}
|
|
role="option"
|
|
aria-selected={group.name === selectedGroupName}
|
|
selected={group.name === selectedGroupName}
|
|
onClick={() => handleGroupChange(group.name)}
|
|
>
|
|
<Typography noWrap>{group.name}</Typography>
|
|
</MenuItem>
|
|
))
|
|
}
|
|
/>
|
|
</Box>
|
|
|
|
<PersistentProxySelect
|
|
label={t('home.components.currentProxy.labels.proxy')}
|
|
groupName={selectedGroupName}
|
|
fixed={selectedGroup?.fixed}
|
|
value={
|
|
currentOption
|
|
? optionValue(currentOption.memberIndex, currentOption.member)
|
|
: ''
|
|
}
|
|
selectedName={selectedProxyName}
|
|
selectedDelay={currentDelay}
|
|
options={proxyOptions}
|
|
open={openSelect === 'proxy'}
|
|
disabled={isDirectMode}
|
|
keepOpenRef={delayButtonRef}
|
|
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
|
|
onChange={handleProxyChange}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
|
<Typography
|
|
sx={{ height: 24 }}
|
|
variant="body1"
|
|
color="text.secondary"
|
|
>
|
|
{t('home.components.currentProxy.labels.noActiveNode')}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</EnhancedCard>
|
|
)
|
|
}
|