mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
fix: keep current proxy menus interactive
This commit is contained in:
@@ -22,5 +22,6 @@
|
||||
- 统一系统代理所有权,实现原子化代理切换
|
||||
- 新增旧版 Service 迁移引导与 Sidecar 安全回退
|
||||
- 优化核心启动、停止及更新流程,减少竞态与残留进程
|
||||
- 优化首页当前节点的交互
|
||||
|
||||
</details>
|
||||
|
||||
@@ -16,10 +16,15 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
type MenuProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Paper,
|
||||
Popper,
|
||||
Select,
|
||||
type SelectChangeEvent,
|
||||
Tooltip,
|
||||
@@ -63,13 +68,19 @@ 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
|
||||
|
||||
// 代理节点信息接口
|
||||
interface ProxyOption {
|
||||
memberIndex: number
|
||||
member: ResolvedProxyMember
|
||||
value: string
|
||||
name: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
type OpenSelect = 'group' | 'proxy' | null
|
||||
|
||||
// 排序类型: 默认 | 按延迟 | 按字母
|
||||
|
||||
function convertDelayColor(
|
||||
@@ -151,6 +162,230 @@ function getSignalIcon(
|
||||
}
|
||||
}
|
||||
|
||||
const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
|
||||
`${memberIndex}:${
|
||||
member.kind === 'node' ? member.node.recordId : member.ref.name
|
||||
}`
|
||||
|
||||
const hasPointerCoordinates = (
|
||||
value: unknown,
|
||||
): value is { clientX: number; clientY: number } =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'clientX' in value &&
|
||||
typeof value.clientX === 'number' &&
|
||||
'clientY' in value &&
|
||||
typeof value.clientY === 'number'
|
||||
|
||||
const eventHitsElement = (event: unknown, element: HTMLElement | null) => {
|
||||
if (!element) return false
|
||||
|
||||
const nativeEvent =
|
||||
typeof event === 'object' && event !== null && 'nativeEvent' in event
|
||||
? event.nativeEvent
|
||||
: event
|
||||
if (!hasPointerCoordinates(nativeEvent)) return false
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
nativeEvent.clientX >= rect.left &&
|
||||
nativeEvent.clientX <= rect.right &&
|
||||
nativeEvent.clientY >= rect.top &&
|
||||
nativeEvent.clientY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
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 PersistentProxySelectProps {
|
||||
label: string
|
||||
groupName: string
|
||||
value: string
|
||||
selectedName: string
|
||||
selectedDelay: number
|
||||
options: ProxyOption[]
|
||||
open: boolean
|
||||
disabled: boolean
|
||||
keepOpenRef: React.RefObject<HTMLElement | null>
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const PersistentProxySelect = ({
|
||||
label,
|
||||
groupName,
|
||||
value,
|
||||
selectedName,
|
||||
selectedDelay,
|
||||
options,
|
||||
open,
|
||||
disabled,
|
||||
keepOpenRef,
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
}: PersistentProxySelectProps) => {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const listboxId = 'current-proxy-node-listbox'
|
||||
const labelId = 'proxy-select-label'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const closeFromKeyboard = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab') {
|
||||
onClose()
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose()
|
||||
anchorRef.current
|
||||
?.querySelector<HTMLElement>('[role="combobox"]')
|
||||
?.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', closeFromKeyboard)
|
||||
return () => document.removeEventListener('keydown', closeFromKeyboard)
|
||||
}, [onClose, open])
|
||||
|
||||
return (
|
||||
<ClickAwayListener
|
||||
onClickAway={(event) => {
|
||||
if (!open) return
|
||||
const target = event.target
|
||||
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
|
||||
<InputLabel id={labelId}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={labelId}
|
||||
value={value}
|
||||
open={false}
|
||||
onOpen={onOpen}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
renderValue={() => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography noWrap>{selectedName}</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(selectedDelay)}
|
||||
color={convertDelayColor(selectedDelay)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
SelectDisplayProps={{
|
||||
'aria-controls': open ? listboxId : undefined,
|
||||
'aria-expanded': open ? 'true' : 'false',
|
||||
}}
|
||||
>
|
||||
<MenuItem value={value}>{selectedName}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
placement="bottom-start"
|
||||
sx={{
|
||||
width: anchorRef.current?.clientWidth,
|
||||
zIndex: (theme) => theme.zIndex.modal,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
|
||||
>
|
||||
<MenuList
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={labelId}
|
||||
autoFocusItem
|
||||
variant="selectedMenu"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</Popper>
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
)
|
||||
}
|
||||
|
||||
export const CurrentProxyCard = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -237,6 +472,8 @@ export const CurrentProxyCard = () => {
|
||||
})
|
||||
|
||||
const [selectedGroupName, setSelectedGroupName] = useState('')
|
||||
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
||||
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
||||
// Sorting reads delays from a store outside React; this hands them over as a value.
|
||||
const delays = useGroupDelays(selectedGroupName || null)
|
||||
|
||||
@@ -276,10 +513,16 @@ export const CurrentProxyCard = () => {
|
||||
const optionsForGroup = useCallback(
|
||||
(group: ProxyGroupView | null): ProxyOption[] =>
|
||||
proxyView && group
|
||||
? group.members.map((member, memberIndex) => ({
|
||||
? group.members.map((memberRef, memberIndex) => {
|
||||
const member = resolveMember(proxyView, memberRef)
|
||||
return {
|
||||
memberIndex,
|
||||
member: resolveMember(proxyView, member),
|
||||
}))
|
||||
member,
|
||||
value: optionValue(memberIndex, member),
|
||||
name: member.ref.name,
|
||||
disabled: !isInteractableMember(member),
|
||||
}
|
||||
})
|
||||
: [],
|
||||
[proxyView],
|
||||
)
|
||||
@@ -344,7 +587,7 @@ export const CurrentProxyCard = () => {
|
||||
ref: { kind: 'node', name: node.name, recordId: node.recordId },
|
||||
node,
|
||||
},
|
||||
} satisfies ProxyOption)
|
||||
} satisfies Pick<ProxyOption, 'memberIndex' | 'member'>)
|
||||
: undefined
|
||||
}
|
||||
return selectedGroup
|
||||
@@ -364,18 +607,11 @@ export const CurrentProxyCard = () => {
|
||||
[isDirectMode, isGlobalMode, writeProfileScopedItem],
|
||||
)
|
||||
|
||||
const optionValue = (option: ProxyOption) =>
|
||||
`${option.memberIndex}:${
|
||||
option.member.kind === 'node'
|
||||
? option.member.node.recordId
|
||||
: option.member.ref.name
|
||||
}`
|
||||
|
||||
const handleProxyChange = useCallback(
|
||||
(event: SelectChangeEvent<string>) => {
|
||||
(value: string) => {
|
||||
if (isDirectMode) return
|
||||
const option = unsortedProxyOptions.find(
|
||||
(candidate) => optionValue(candidate) === event.target.value,
|
||||
(candidate) => candidate.value === value,
|
||||
)
|
||||
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
|
||||
return
|
||||
@@ -491,26 +727,6 @@ export const CurrentProxyCard = () => {
|
||||
autoDelayEnabled,
|
||||
])
|
||||
|
||||
// 自定义渲染选择框中的值
|
||||
const renderProxyValue = () => {
|
||||
if (!currentMember) return selectedProxyName
|
||||
const delayValue = delayManager.getDelayFix(
|
||||
currentMember,
|
||||
selectedGroupName,
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography noWrap>{selectedProxyName}</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delayValue)}
|
||||
color={convertDelayColor(delayValue)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// 排序类型变更
|
||||
const handleSortTypeChange = useCallback(() => {
|
||||
const newSortType = ((sortType + 1) % 3) as ProxySortType
|
||||
@@ -550,46 +766,40 @@ export const CurrentProxyCard = () => {
|
||||
refreshProxy()
|
||||
})
|
||||
|
||||
// 计算要显示的代理选项(增加非空校验)
|
||||
const proxyOptions = useMemo(() => {
|
||||
const sortWithLatency = (proxiesToSort: ProxyOption[]) => {
|
||||
if (!proxiesToSort || sortType === 0) return proxiesToSort
|
||||
const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
|
||||
(event, reason) => {
|
||||
if (
|
||||
reason === 'backdropClick' &&
|
||||
eventHitsElement(event, delayButtonRef.current)
|
||||
) {
|
||||
void handleCheckDelay()
|
||||
return
|
||||
}
|
||||
|
||||
const list = [...proxiesToSort]
|
||||
|
||||
if (sortType === 1) {
|
||||
const effectiveTimeout =
|
||||
typeof defaultLatencyTimeout === 'number' && defaultLatencyTimeout > 0
|
||||
? defaultLatencyTimeout
|
||||
: DEFAULT_DELAY_TIMEOUT
|
||||
|
||||
list.sort((a, b) => {
|
||||
const byDelay = compareByDelay(
|
||||
delays.of(a.member),
|
||||
delays.of(b.member),
|
||||
effectiveTimeout,
|
||||
setOpenSelect(null)
|
||||
},
|
||||
[handleCheckDelay],
|
||||
)
|
||||
if (byDelay !== 0) return byDelay
|
||||
return a.member.ref.name.localeCompare(b.member.ref.name)
|
||||
})
|
||||
} else {
|
||||
list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name))
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
if (isDirectMode) {
|
||||
return []
|
||||
}
|
||||
return sortWithLatency(unsortedProxyOptions)
|
||||
}, [
|
||||
// 计算要显示的代理选项(增加非空校验)
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
isDirectMode
|
||||
? []
|
||||
: sortProxyOptions(
|
||||
unsortedProxyOptions,
|
||||
sortType,
|
||||
delays.of,
|
||||
defaultLatencyTimeout,
|
||||
),
|
||||
[
|
||||
delays,
|
||||
isDirectMode,
|
||||
unsortedProxyOptions,
|
||||
sortType,
|
||||
defaultLatencyTimeout,
|
||||
])
|
||||
],
|
||||
)
|
||||
|
||||
// 获取排序图标
|
||||
const getSortIcon = (): React.ReactElement => {
|
||||
@@ -641,10 +851,14 @@ export const CurrentProxyCard = () => {
|
||||
>
|
||||
<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>
|
||||
@@ -762,8 +976,11 @@ export const CurrentProxyCard = () => {
|
||||
labelId="proxy-group-select-label"
|
||||
value={selectedGroupName}
|
||||
onChange={handleGroupChange}
|
||||
open={openSelect === 'group'}
|
||||
onOpen={() => setOpenSelect('group')}
|
||||
label={t('home.components.currentProxy.labels.group')}
|
||||
disabled={isGlobalMode || isDirectMode}
|
||||
MenuProps={{ onClose: handleGroupMenuClose }}
|
||||
>
|
||||
{selectableGroups.map((group) => (
|
||||
<MenuItem key={group.name} value={group.name}>
|
||||
@@ -774,70 +991,24 @@ export const CurrentProxyCard = () => {
|
||||
</FormControl>
|
||||
|
||||
{/* 代理节点选择器 */}
|
||||
<FormControl fullWidth variant="outlined" size="small" sx={{ mb: 0 }}>
|
||||
<InputLabel id="proxy-select-label">
|
||||
{t('home.components.currentProxy.labels.proxy')}
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="proxy-select-label"
|
||||
value={currentOption ? optionValue(currentOption) : ''}
|
||||
onChange={handleProxyChange}
|
||||
<PersistentProxySelect
|
||||
label={t('home.components.currentProxy.labels.proxy')}
|
||||
groupName={selectedGroupName}
|
||||
value={
|
||||
currentOption
|
||||
? optionValue(currentOption.memberIndex, currentOption.member)
|
||||
: ''
|
||||
}
|
||||
selectedName={selectedProxyName}
|
||||
selectedDelay={currentDelay}
|
||||
options={isDirectMode ? [] : proxyOptions}
|
||||
open={openSelect === 'proxy'}
|
||||
disabled={isDirectMode}
|
||||
renderValue={renderProxyValue}
|
||||
MenuProps={{
|
||||
slotProps: {
|
||||
paper: {
|
||||
style: {
|
||||
maxHeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isDirectMode
|
||||
? null
|
||||
: proxyOptions.map((option) => {
|
||||
const interactable = isInteractableMember(option.member)
|
||||
const delayValue = interactable
|
||||
? delayManager.getDelayFix(
|
||||
option.member,
|
||||
selectedGroupName,
|
||||
)
|
||||
: -1
|
||||
return (
|
||||
<MenuItem
|
||||
key={optionValue(option)}
|
||||
value={optionValue(option)}
|
||||
disabled={!interactable}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||
{option.member.ref.name}
|
||||
</Typography>
|
||||
{interactable && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delayValue)}
|
||||
color={convertDelayColor(delayValue)}
|
||||
sx={{
|
||||
minWidth: '60px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
keepOpenRef={delayButtonRef}
|
||||
onOpen={() => setOpenSelect('proxy')}
|
||||
onClose={() => setOpenSelect(null)}
|
||||
onChange={handleProxyChange}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
|
||||
Reference in New Issue
Block a user