fix: keep current proxy menus interactive

This commit is contained in:
wonfen
2026-07-29 15:36:07 +08:00
parent 7e0cf4f9f6
commit b98c9be7db
2 changed files with 307 additions and 135 deletions
+1
View File
@@ -22,5 +22,6 @@
- 统一系统代理所有权,实现原子化代理切换 - 统一系统代理所有权,实现原子化代理切换
- 新增旧版 Service 迁移引导与 Sidecar 安全回退 - 新增旧版 Service 迁移引导与 Sidecar 安全回退
- 优化核心启动、停止及更新流程,减少竞态与残留进程 - 优化核心启动、停止及更新流程,减少竞态与残留进程
- 优化首页当前节点的交互
</details> </details>
+306 -135
View File
@@ -16,10 +16,15 @@ import {
Box, Box,
Button, Button,
Chip, Chip,
ClickAwayListener,
FormControl, FormControl,
IconButton, IconButton,
InputLabel, InputLabel,
type MenuProps,
MenuItem, MenuItem,
MenuList,
Paper,
Popper,
Select, Select,
type SelectChangeEvent, type SelectChangeEvent,
Tooltip, 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_DEFAULT_INTERVAL_MINUTES = 5
const AUTO_CHECK_INITIAL_DELAY_MS = 100 const AUTO_CHECK_INITIAL_DELAY_MS = 100
const PROXY_MENU_MAX_HEIGHT = 500
// 代理节点信息接口 // 代理节点信息接口
interface ProxyOption { interface ProxyOption {
memberIndex: number memberIndex: number
member: ResolvedProxyMember member: ResolvedProxyMember
value: string
name: string
disabled: boolean
} }
type OpenSelect = 'group' | 'proxy' | null
// 排序类型: 默认 | 按延迟 | 按字母 // 排序类型: 默认 | 按延迟 | 按字母
function convertDelayColor( 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 = () => { export const CurrentProxyCard = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -237,6 +472,8 @@ export const CurrentProxyCard = () => {
}) })
const [selectedGroupName, setSelectedGroupName] = useState('') 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. // Sorting reads delays from a store outside React; this hands them over as a value.
const delays = useGroupDelays(selectedGroupName || null) const delays = useGroupDelays(selectedGroupName || null)
@@ -276,10 +513,16 @@ export const CurrentProxyCard = () => {
const optionsForGroup = useCallback( const optionsForGroup = useCallback(
(group: ProxyGroupView | null): ProxyOption[] => (group: ProxyGroupView | null): ProxyOption[] =>
proxyView && group proxyView && group
? group.members.map((member, memberIndex) => ({ ? group.members.map((memberRef, memberIndex) => {
memberIndex, const member = resolveMember(proxyView, memberRef)
member: resolveMember(proxyView, member), return {
})) memberIndex,
member,
value: optionValue(memberIndex, member),
name: member.ref.name,
disabled: !isInteractableMember(member),
}
})
: [], : [],
[proxyView], [proxyView],
) )
@@ -344,7 +587,7 @@ export const CurrentProxyCard = () => {
ref: { kind: 'node', name: node.name, recordId: node.recordId }, ref: { kind: 'node', name: node.name, recordId: node.recordId },
node, node,
}, },
} satisfies ProxyOption) } satisfies Pick<ProxyOption, 'memberIndex' | 'member'>)
: undefined : undefined
} }
return selectedGroup return selectedGroup
@@ -364,18 +607,11 @@ export const CurrentProxyCard = () => {
[isDirectMode, isGlobalMode, writeProfileScopedItem], [isDirectMode, isGlobalMode, writeProfileScopedItem],
) )
const optionValue = (option: ProxyOption) =>
`${option.memberIndex}:${
option.member.kind === 'node'
? option.member.node.recordId
: option.member.ref.name
}`
const handleProxyChange = useCallback( const handleProxyChange = useCallback(
(event: SelectChangeEvent<string>) => { (value: string) => {
if (isDirectMode) return if (isDirectMode) return
const option = unsortedProxyOptions.find( const option = unsortedProxyOptions.find(
(candidate) => optionValue(candidate) === event.target.value, (candidate) => candidate.value === value,
) )
if (!selectedGroup || !option || !isInteractableMember(option.member)) { if (!selectedGroup || !option || !isInteractableMember(option.member)) {
return return
@@ -491,26 +727,6 @@ export const CurrentProxyCard = () => {
autoDelayEnabled, 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 handleSortTypeChange = useCallback(() => {
const newSortType = ((sortType + 1) % 3) as ProxySortType const newSortType = ((sortType + 1) % 3) as ProxySortType
@@ -550,46 +766,40 @@ export const CurrentProxyCard = () => {
refreshProxy() refreshProxy()
}) })
// 计算要显示的代理选项(增加非空校验) const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
const proxyOptions = useMemo(() => { (event, reason) => {
const sortWithLatency = (proxiesToSort: ProxyOption[]) => { if (
if (!proxiesToSort || sortType === 0) return proxiesToSort reason === 'backdropClick' &&
eventHitsElement(event, delayButtonRef.current)
const list = [...proxiesToSort] ) {
void handleCheckDelay()
if (sortType === 1) { return
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,
)
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 setOpenSelect(null)
} },
[handleCheckDelay],
)
if (isDirectMode) { // 计算要显示的代理选项(增加非空校验)
return [] const proxyOptions = useMemo(
} () =>
return sortWithLatency(unsortedProxyOptions) isDirectMode
}, [ ? []
delays, : sortProxyOptions(
isDirectMode, unsortedProxyOptions,
unsortedProxyOptions, sortType,
sortType, delays.of,
defaultLatencyTimeout, defaultLatencyTimeout,
]) ),
[
delays,
isDirectMode,
unsortedProxyOptions,
sortType,
defaultLatencyTimeout,
],
)
// 获取排序图标 // 获取排序图标
const getSortIcon = (): React.ReactElement => { const getSortIcon = (): React.ReactElement => {
@@ -641,10 +851,14 @@ export const CurrentProxyCard = () => {
> >
<span> <span>
<IconButton <IconButton
ref={delayButtonRef}
size="small" size="small"
color="inherit" color="inherit"
onClick={handleCheckDelay} onClick={handleCheckDelay}
disabled={isDirectMode || unsortedProxyOptions.length === 0} disabled={isDirectMode || unsortedProxyOptions.length === 0}
aria-label={t(
'home.components.currentProxy.actions.refreshDelay',
)}
> >
<NetworkCheckRounded /> <NetworkCheckRounded />
</IconButton> </IconButton>
@@ -762,8 +976,11 @@ export const CurrentProxyCard = () => {
labelId="proxy-group-select-label" labelId="proxy-group-select-label"
value={selectedGroupName} value={selectedGroupName}
onChange={handleGroupChange} onChange={handleGroupChange}
open={openSelect === 'group'}
onOpen={() => setOpenSelect('group')}
label={t('home.components.currentProxy.labels.group')} label={t('home.components.currentProxy.labels.group')}
disabled={isGlobalMode || isDirectMode} disabled={isGlobalMode || isDirectMode}
MenuProps={{ onClose: handleGroupMenuClose }}
> >
{selectableGroups.map((group) => ( {selectableGroups.map((group) => (
<MenuItem key={group.name} value={group.name}> <MenuItem key={group.name} value={group.name}>
@@ -774,70 +991,24 @@ export const CurrentProxyCard = () => {
</FormControl> </FormControl>
{/* 代理节点选择器 */} {/* 代理节点选择器 */}
<FormControl fullWidth variant="outlined" size="small" sx={{ mb: 0 }}> <PersistentProxySelect
<InputLabel id="proxy-select-label"> label={t('home.components.currentProxy.labels.proxy')}
{t('home.components.currentProxy.labels.proxy')} groupName={selectedGroupName}
</InputLabel> value={
<Select currentOption
labelId="proxy-select-label" ? optionValue(currentOption.memberIndex, currentOption.member)
value={currentOption ? optionValue(currentOption) : ''} : ''
onChange={handleProxyChange} }
label={t('home.components.currentProxy.labels.proxy')} selectedName={selectedProxyName}
disabled={isDirectMode} selectedDelay={currentDelay}
renderValue={renderProxyValue} options={isDirectMode ? [] : proxyOptions}
MenuProps={{ open={openSelect === 'proxy'}
slotProps: { disabled={isDirectMode}
paper: { keepOpenRef={delayButtonRef}
style: { onOpen={() => setOpenSelect('proxy')}
maxHeight: 500, onClose={() => setOpenSelect(null)}
}, onChange={handleProxyChange}
}, />
},
}}
>
{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,
}}
/>
)}
</MenuItem>
)
})}
</Select>
</FormControl>
</Box> </Box>
) : ( ) : (
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: 'center', py: 4 }}>