From b98c9be7db7fa8e80b7da59fd6afcb11fd904364 Mon Sep 17 00:00:00 2001 From: wonfen Date: Wed, 29 Jul 2026 15:36:07 +0800 Subject: [PATCH] fix: keep current proxy menus interactive --- Changelog.md | 1 + src/components/home/current-proxy-card.tsx | 441 ++++++++++++++------- 2 files changed, 307 insertions(+), 135 deletions(-) diff --git a/Changelog.md b/Changelog.md index 966a215da..116e57c1a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -22,5 +22,6 @@ - 统一系统代理所有权,实现原子化代理切换 - 新增旧版 Service 迁移引导与 Sidecar 安全回退 - 优化核心启动、停止及更新流程,减少竞态与残留进程 +- 优化首页当前节点的交互 diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 4dcf2a22d..8706ee611 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -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 + 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(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('[role="combobox"]') + ?.focus() + } + } + document.addEventListener('keydown', closeFromKeyboard) + return () => document.removeEventListener('keydown', closeFromKeyboard) + }, [onClose, open]) + + return ( + { + if (!open) return + const target = event.target + if (target instanceof Node && keepOpenRef.current?.contains(target)) { + return + } + onClose() + }} + > + + + {label} + + + + theme.zIndex.modal, + }} + > + + + {options.map((option) => { + const selected = option.value === value + const delay = option.disabled + ? -1 + : delayManager.getDelayFix(option.member, groupName) + + return ( + { + if (!option.disabled) onChange(option.value) + }} + sx={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + pr: 1, + }} + > + + {option.name} + + {!option.disabled && ( + + )} + + ) + })} + + + + + + ) +} + export const CurrentProxyCard = () => { const { t } = useTranslation() const navigate = useNavigate() @@ -237,6 +472,8 @@ export const CurrentProxyCard = () => { }) const [selectedGroupName, setSelectedGroupName] = useState('') + const [openSelect, setOpenSelect] = useState(null) + const delayButtonRef = useRef(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) => ({ - memberIndex, - member: resolveMember(proxyView, member), - })) + ? 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], ) @@ -344,7 +587,7 @@ export const CurrentProxyCard = () => { ref: { kind: 'node', name: node.name, recordId: node.recordId }, node, }, - } satisfies ProxyOption) + } satisfies Pick) : 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) => { + (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 ( - - {selectedProxyName} - - - ) - } - // 排序类型变更 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 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, - ) - 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)) + const handleGroupMenuClose = useCallback>( + (event, reason) => { + if ( + reason === 'backdropClick' && + eventHitsElement(event, delayButtonRef.current) + ) { + void handleCheckDelay() + return } - return list - } + setOpenSelect(null) + }, + [handleCheckDelay], + ) - if (isDirectMode) { - return [] - } - return sortWithLatency(unsortedProxyOptions) - }, [ - delays, - isDirectMode, - unsortedProxyOptions, - sortType, - defaultLatencyTimeout, - ]) + // 计算要显示的代理选项(增加非空校验) + 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 = () => { > @@ -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) => ( @@ -774,70 +991,24 @@ export const CurrentProxyCard = () => { {/* 代理节点选择器 */} - - - {t('home.components.currentProxy.labels.proxy')} - - - + setOpenSelect('proxy')} + onClose={() => setOpenSelect(null)} + onChange={handleProxyChange} + /> ) : (