diff --git a/Changelog.md b/Changelog.md index 16e32b4c5..09f123fb9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -26,3 +26,7 @@ - 优化前端 CPU 性能 - 更健壮的服务模式与边缘状况内核恢复 - 优化白名单网络下的订阅 TLS 更新兼容性 + +### 👙 界面样式 + +- 代理组实现sticky scroll的效果 \ No newline at end of file diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 1561c77d0..d40f2d46b 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -12,7 +12,16 @@ import { import { useQuery } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' import { useLockFn } from 'ahooks' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type Key, + type MouseEvent, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api' @@ -32,7 +41,8 @@ import { ProxyGroupNavigator, } from './proxy-group-navigator' import { ProxyRender } from './proxy-render' -import { useRenderList } from './use-render-list' +import type { HeadState } from './use-head-state' +import { type IRenderItem, useRenderList } from './use-render-list' function useStableCallback any>(fn: T): T { const ref = useRef(fn) @@ -148,6 +158,7 @@ export const ProxyGroups = (props: Props) => { const parentRef = useRef(null) const scrollPositionRef = useRef>({}) + const showScrollTopRef = useRef(false) const [showScrollTop, setShowScrollTop] = useState(false) const virtualizer = useVirtualizer({ @@ -157,6 +168,16 @@ export const ProxyGroups = (props: Props) => { overscan: 15, getItemKey: (index) => renderList[index]?.key ?? index, }) + const virtualItems = virtualizer.getVirtualItems() + const stickyGroupByIndex = useMemo( + () => getStickyGroupByIndex(renderList), + [renderList], + ) + const stickyGroupItem = getStickyGroupItem( + stickyGroupByIndex, + virtualItems, + virtualizer.scrollOffset ?? 0, + ) // 从 localStorage 恢复滚动位置 useEffect(() => { @@ -175,6 +196,9 @@ export const ProxyGroups = (props: Props) => { restoreTimer = setTimeout(() => { if (parentRef.current) { parentRef.current.scrollTop = savedPosition + const nextShowScrollTop = savedPosition > 100 + showScrollTopRef.current = nextShowScrollTop + setShowScrollTop(nextShowScrollTop) } }, 100) } @@ -206,19 +230,27 @@ export const ProxyGroups = (props: Props) => { [mode], ) - // 使用改进的滚动处理 - const handleScroll = useMemo( - () => - throttle((event: Event) => { - const target = event.target as HTMLElement | null - const scrollTop = target?.scrollTop ?? 0 - setShowScrollTop(scrollTop > 100) - // 使用稳定的节流来保存位置,而不是setTimeout - saveScrollPosition(scrollTop) - }, 500), // 增加到500ms以确保平滑滚动 + const saveScrollPositionThrottled = useMemo( + () => throttle(saveScrollPosition, 500), [saveScrollPosition], ) + const handleScroll = useCallback( + (event: Event) => { + const target = event.target as HTMLElement | null + const nextScrollTop = target?.scrollTop ?? 0 + const nextShowScrollTop = nextScrollTop > 100 + + if (showScrollTopRef.current !== nextShowScrollTop) { + showScrollTopRef.current = nextShowScrollTop + setShowScrollTop(nextShowScrollTop) + } + + saveScrollPositionThrottled(nextScrollTop) + }, + [saveScrollPositionThrottled], + ) + // 添加和清理滚动事件监听器 useEffect(() => { const node = parentRef.current @@ -414,6 +446,24 @@ export const ProxyGroups = (props: Props) => { return Array.from(new Set(names)) }, [renderList]) + const renderProxyList = (height: string) => ( + + ) + if (mode === 'direct') { return } @@ -421,122 +471,25 @@ export const ProxyGroups = (props: Props) => { if (isChainMode) { // 获取所有代理组 const proxyGroups = proxiesData?.groups || [] + const showRuleHeader = mode === 'rule' && proxyGroups.length > 0 return ( <> - {/* 代理规则标题和代理组按钮栏 */} - {mode === 'rule' && proxyGroups.length > 0 && ( - - {/* 代理规则标题 */} - - - - {t('proxies.page.rules.title')} - - {currentGroup && ( - - - - )} - - - {availableGroups.length > 0 && ( - - - {t('proxies.page.rules.select')} - - - - )} - - + {showRuleHeader && ( + 0} + onMenuOpen={handleGroupMenuOpen} + /> )} -
0 - ? 'calc(100% - 80px)' // 只有标题的高度 - : 'calc(100% - 14px)', - overflow: 'auto', - }} - > -
- {virtualizer.getVirtualItems().map((virtualItem) => ( -
- -
- ))} -
-
-
+ {renderProxyList( + showRuleHeader ? 'calc(100% - 80px)' : 'calc(100% - 14px)', + )} @@ -566,54 +519,14 @@ export const ProxyGroups = (props: Props) => { - {/* 代理组选择菜单 */} - - {availableGroups.map((group: any) => ( - handleGroupSelect(group.name)} - selected={activeSelectedGroup === group.name} - sx={{ - fontSize: '14px', - py: 1, - }} - > - - - {group.name} - - - {group.type} · {group.all.length} 节点 - - - - ))} - {availableGroups.length === 0 && ( - - - 暂无可用代理组 - - - )} - + onSelect={handleGroupSelect} + /> ) } @@ -632,47 +545,286 @@ export const ProxyGroups = (props: Props) => { /> )} -
-
- {virtualizer.getVirtualItems().map((virtualItem) => ( -
- -
- ))} -
-
-
+ {renderProxyList('calc(100% - 14px)')}
) } +type VirtualListItem = { + key: Key + index: number + start: number + end: number +} + +interface ProxyVirtualListProps { + parentRef: RefObject + height: string + totalSize: number + virtualItems: VirtualListItem[] + renderList: IRenderItem[] + stickyItem: IRenderItem | null + indent: boolean + isChainMode?: boolean + measureElement: (node: Element | null) => void + onLocation: (group: IRenderItem['group']) => void + onCheckAll: (groupName: string) => void + onHeadState: (groupName: string, patch: Partial) => void + onChangeProxy: ( + group: IRenderItem['group'], + proxy: IRenderItem['proxy'] & { name: string }, + ) => void +} + +interface ProxyGroupOption { + name: string + type: string + all?: unknown[] +} + +interface ChainRuleHeaderProps { + title: string + selectLabel: string + currentGroup: ProxyGroupOption | null + canSelectGroup: boolean + onMenuOpen: (event: MouseEvent) => void +} + +function ChainRuleHeader({ + title, + selectLabel, + currentGroup, + canSelectGroup, + onMenuOpen, +}: ChainRuleHeaderProps) { + return ( + + + + + {title} + + + {currentGroup && ( + + )} + + + {canSelectGroup && ( + + + {selectLabel} + + + + )} + + + ) +} + +interface GroupSelectMenuProps { + anchorEl: HTMLElement | null + groups: ProxyGroupOption[] + selectedGroup: string | null + emptyText: string + onClose: () => void + onSelect: (groupName: string) => void +} + +function GroupSelectMenu({ + anchorEl, + groups, + selectedGroup, + emptyText, + onClose, + onSelect, +}: GroupSelectMenuProps) { + return ( + + {groups.map((group) => ( + onSelect(group.name)} + selected={selectedGroup === group.name} + sx={{ fontSize: '14px', py: 1 }} + > + + + {group.name} + + + {group.type} · {group.all?.length ?? 0} 节点 + + + + ))} + + {groups.length === 0 && ( + + + {emptyText} + + + )} + + ) +} + +function ProxyVirtualList({ + parentRef, + height, + totalSize, + virtualItems, + renderList, + stickyItem, + indent, + isChainMode, + measureElement, + onLocation, + onCheckAll, + onHeadState, + onChangeProxy, +}: ProxyVirtualListProps) { + return ( +
+ {stickyItem && ( + + + + + + )} + +
+ {virtualItems.map((virtualItem) => ( +
+ +
+ ))} +
+
+
+ ) +} + +function getStickyGroupByIndex(renderList: IRenderItem[]) { + let stickyGroup: IRenderItem | null = null + + return renderList.map((item) => { + if (item?.type === 0 && item.group && !item.group.hidden) { + stickyGroup = item + } + return stickyGroup + }) +} + +function getStickyGroupItem( + stickyGroupByIndex: (IRenderItem | null)[], + virtualItems: VirtualListItem[], + scrollTop: number, +) { + const firstVisibleItem = + virtualItems.find((item) => item.end > scrollTop + 1) ?? virtualItems[0] + + if (!firstVisibleItem) return null + + return stickyGroupByIndex[firstVisibleItem.index] ?? null +} + // 替换简单防抖函数为更优的节流函数 function throttle any>( func: T,