mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-23 04:02:10 +08:00
857 lines
24 KiB
TypeScript
857 lines
24 KiB
TypeScript
import { ExpandMoreRounded } from '@mui/icons-material'
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Chip,
|
|
IconButton,
|
|
Menu,
|
|
MenuItem,
|
|
Snackbar,
|
|
Typography,
|
|
} from '@mui/material'
|
|
import { useTheme } from '@mui/material/styles'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
|
|
import { useLockFn } from 'ahooks'
|
|
import {
|
|
type Key,
|
|
type MouseEvent,
|
|
type RefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useLocation } from 'react-router'
|
|
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
|
|
|
|
import { BaseEmpty } from '@/components/base'
|
|
import { useProxySelection } from '@/hooks/use-proxy-selection'
|
|
import { useVerge } from '@/hooks/use-verge'
|
|
import { useProxiesData } from '@/providers/app-data-context'
|
|
import { calcuProxies, updateProxyChainConfigInRuntime } from '@/services/cmds'
|
|
import delayManager from '@/services/delay'
|
|
import { debugLog } from '@/utils/debug'
|
|
|
|
import { ScrollTopButton } from '../layout/scroll-top-button'
|
|
|
|
import { ProxyChain } from './proxy-chain'
|
|
import {
|
|
DEFAULT_HOVER_DELAY,
|
|
ProxyGroupNavigator,
|
|
} from './proxy-group-navigator'
|
|
import { ProxyRender } from './proxy-render'
|
|
import type { HeadState } from './use-head-state'
|
|
import { type IRenderItem, useRenderList } from './use-render-list'
|
|
|
|
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
|
|
const ref = useRef(fn)
|
|
ref.current = fn
|
|
return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T
|
|
}
|
|
|
|
interface Props {
|
|
mode: string
|
|
isChainMode?: boolean
|
|
chainConfigData?: string | null
|
|
}
|
|
|
|
interface ProxyChainItem {
|
|
id: string
|
|
name: string
|
|
type?: string
|
|
delay?: number
|
|
}
|
|
|
|
export const ProxyGroups = (props: Props) => {
|
|
const { t } = useTranslation()
|
|
const { pathname } = useLocation()
|
|
const { mode, isChainMode = false, chainConfigData } = props
|
|
|
|
// Drive 3s polling on the shared TQ cache; data is read via granular context below
|
|
useQuery({
|
|
queryKey: ['getProxies'],
|
|
queryFn: calcuProxies,
|
|
refetchInterval: 3000,
|
|
refetchIntervalInBackground: false,
|
|
staleTime: 1500,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
})
|
|
|
|
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => {
|
|
try {
|
|
const saved = localStorage.getItem('proxy-chain-items')
|
|
if (saved) {
|
|
return JSON.parse(saved)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return []
|
|
})
|
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (proxyChain.length > 0) {
|
|
localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain))
|
|
} else {
|
|
localStorage.removeItem('proxy-chain-items')
|
|
}
|
|
}, [proxyChain])
|
|
const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>(null)
|
|
const [duplicateWarning, setDuplicateWarning] = useState<{
|
|
open: boolean
|
|
message: string
|
|
}>({ open: false, message: '' })
|
|
|
|
const { verge } = useVerge()
|
|
const { proxies: proxiesData } = useProxiesData()
|
|
const groups = proxiesData?.groups
|
|
const availableGroups = useMemo(() => {
|
|
if (!groups) return []
|
|
// 在链式代理模式下,仅显示支持选择节点的 Selector 代理组
|
|
return isChainMode
|
|
? groups.filter((g: any) => g.type === 'Selector')
|
|
: groups
|
|
}, [groups, isChainMode])
|
|
|
|
const defaultRuleGroup = useMemo(() => {
|
|
if (isChainMode && mode === 'rule' && availableGroups.length > 0) {
|
|
return availableGroups[0].name
|
|
}
|
|
return null
|
|
}, [availableGroups, isChainMode, mode])
|
|
|
|
const activeSelectedGroup = useMemo(
|
|
() => selectedGroup ?? defaultRuleGroup,
|
|
[selectedGroup, defaultRuleGroup],
|
|
)
|
|
|
|
const { renderList, onProxies, onHeadState } = useRenderList(
|
|
mode,
|
|
isChainMode,
|
|
activeSelectedGroup,
|
|
)
|
|
|
|
const getGroupHeadState = useCallback(
|
|
(groupName: string) => {
|
|
const headItem = renderList.find(
|
|
(item) => item.type === 1 && item.group?.name === groupName,
|
|
)
|
|
return headItem?.headState
|
|
},
|
|
[renderList],
|
|
)
|
|
|
|
// 统代理选择
|
|
const { handleProxyGroupChange } = useProxySelection({
|
|
onSuccess: () => {
|
|
onProxies()
|
|
},
|
|
onError: (error) => {
|
|
console.error('代理切换失败', error)
|
|
onProxies()
|
|
},
|
|
})
|
|
|
|
const timeout = verge?.default_latency_timeout || 10000
|
|
|
|
const parentRef = useRef<HTMLDivElement>(null)
|
|
const scrollPositionRef = useRef<Record<string, number>>({})
|
|
const scrollTopRef = useRef(0)
|
|
const showScrollTopRef = useRef(false)
|
|
const activeStickyIndexRef = useRef<number | null>(null)
|
|
const restoredScrollKeyRef = useRef<string | null>(null)
|
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
|
const scrollPositionKey = useMemo(
|
|
() =>
|
|
isChainMode
|
|
? `${mode}:chain:${activeSelectedGroup ?? 'all'}`
|
|
: `${mode}:normal`,
|
|
[activeSelectedGroup, isChainMode, mode],
|
|
)
|
|
const stickyGroupIndexes = useMemo(
|
|
() =>
|
|
renderList.flatMap((item, index) =>
|
|
item.type === 0 && !item.group.hidden ? [index] : [],
|
|
),
|
|
[renderList],
|
|
)
|
|
|
|
const rangeExtractor = useCallback(
|
|
(range: Parameters<typeof defaultRangeExtractor>[0]) => {
|
|
const activeStickyIndex = [...stickyGroupIndexes]
|
|
.reverse()
|
|
.find((index) => index <= range.startIndex)
|
|
activeStickyIndexRef.current = activeStickyIndex ?? null
|
|
|
|
const indexes = defaultRangeExtractor(range)
|
|
return activeStickyIndex == null || indexes.includes(activeStickyIndex)
|
|
? indexes
|
|
: [activeStickyIndex, ...indexes]
|
|
},
|
|
[stickyGroupIndexes],
|
|
)
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: renderList.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => 56,
|
|
overscan: 15,
|
|
getItemKey: (index) => renderList[index]?.key ?? index,
|
|
rangeExtractor,
|
|
})
|
|
const virtualItems = virtualizer.getVirtualItems()
|
|
const activeStickyIndex = activeStickyIndexRef.current
|
|
|
|
// 从 localStorage 恢复滚动位置
|
|
useLayoutEffect(() => {
|
|
if (renderList.length === 0) return
|
|
const node = parentRef.current
|
|
if (!node) return
|
|
if (
|
|
restoredScrollKeyRef.current === scrollPositionKey &&
|
|
node.scrollTop === scrollTopRef.current
|
|
) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const savedPositions = localStorage.getItem('proxy-scroll-positions')
|
|
if (savedPositions) {
|
|
const positions = JSON.parse(savedPositions)
|
|
scrollPositionRef.current = positions
|
|
const savedPosition = positions[scrollPositionKey]
|
|
|
|
if (savedPosition !== undefined) {
|
|
node.scrollTop = savedPosition
|
|
scrollTopRef.current = savedPosition
|
|
const nextShowScrollTop = savedPosition > 100
|
|
showScrollTopRef.current = nextShowScrollTop
|
|
queueMicrotask(() => setShowScrollTop(nextShowScrollTop))
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Error restoring scroll position:', e)
|
|
}
|
|
restoredScrollKeyRef.current = scrollPositionKey
|
|
}, [pathname, renderList.length, scrollPositionKey])
|
|
|
|
// 改为使用节流函数保存滚动位置
|
|
const saveScrollPosition = useCallback(
|
|
(scrollTop: number) => {
|
|
try {
|
|
scrollPositionRef.current[scrollPositionKey] = scrollTop
|
|
localStorage.setItem(
|
|
'proxy-scroll-positions',
|
|
JSON.stringify(scrollPositionRef.current),
|
|
)
|
|
} catch (e) {
|
|
console.error('Error saving scroll position:', e)
|
|
}
|
|
},
|
|
[scrollPositionKey],
|
|
)
|
|
|
|
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
|
|
scrollTopRef.current = nextScrollTop
|
|
|
|
if (showScrollTopRef.current !== nextShowScrollTop) {
|
|
showScrollTopRef.current = nextShowScrollTop
|
|
setShowScrollTop(nextShowScrollTop)
|
|
}
|
|
|
|
saveScrollPositionThrottled(nextScrollTop)
|
|
},
|
|
[saveScrollPositionThrottled],
|
|
)
|
|
|
|
// 添加和清理滚动事件监听器
|
|
useEffect(() => {
|
|
const node = parentRef.current
|
|
if (!node) return
|
|
|
|
const listener = handleScroll as EventListener
|
|
const options: AddEventListenerOptions = { passive: true }
|
|
|
|
node.addEventListener('scroll', listener, options)
|
|
|
|
return () => {
|
|
if (restoredScrollKeyRef.current === scrollPositionKey) {
|
|
saveScrollPosition(scrollTopRef.current)
|
|
}
|
|
node.removeEventListener('scroll', listener, options)
|
|
}
|
|
}, [handleScroll, saveScrollPosition, scrollPositionKey])
|
|
|
|
// 滚动到顶部
|
|
const scrollToTop = useCallback(() => {
|
|
parentRef.current?.scrollTo?.({
|
|
top: 0,
|
|
behavior: 'smooth',
|
|
})
|
|
scrollTopRef.current = 0
|
|
saveScrollPosition(0)
|
|
}, [saveScrollPosition])
|
|
|
|
// 关闭重复节点警告
|
|
const handleCloseDuplicateWarning = useCallback(() => {
|
|
setDuplicateWarning({ open: false, message: '' })
|
|
}, [])
|
|
|
|
const currentGroup = useMemo(() => {
|
|
if (!activeSelectedGroup) return null
|
|
return (
|
|
availableGroups.find(
|
|
(group: any) => group.name === activeSelectedGroup,
|
|
) ?? null
|
|
)
|
|
}, [activeSelectedGroup, availableGroups])
|
|
|
|
// 处理代理组选择菜单
|
|
const handleGroupMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
|
setRuleMenuAnchor(event.currentTarget)
|
|
}
|
|
|
|
const handleGroupMenuClose = () => {
|
|
setRuleMenuAnchor(null)
|
|
}
|
|
|
|
const handleGroupSelect = (groupName: string) => {
|
|
setSelectedGroup(groupName)
|
|
handleGroupMenuClose()
|
|
|
|
if (isChainMode && mode === 'rule') {
|
|
updateProxyChainConfigInRuntime(null)
|
|
localStorage.removeItem('proxy-chain-group')
|
|
localStorage.removeItem('proxy-chain-exit-node')
|
|
localStorage.removeItem('proxy-chain-items')
|
|
setProxyChain([])
|
|
}
|
|
}
|
|
|
|
const handleChangeProxy = useCallback(
|
|
(group: IProxyGroupItem, proxy: IProxyItem) => {
|
|
if (isChainMode) {
|
|
// 使用函数式更新来避免状态延迟问题
|
|
setProxyChain((prev) => {
|
|
// 检查是否已经存在相同名称的代理,防止重复添加
|
|
if (prev.some((item) => item.name === proxy.name)) {
|
|
const warningMessage = t('proxies.page.chain.duplicateNode')
|
|
setDuplicateWarning({
|
|
open: true,
|
|
message: warningMessage,
|
|
})
|
|
return prev // 返回原来的状态,不做任何更改
|
|
}
|
|
|
|
// 安全获取延迟数据,如果没有延迟数据则设为 undefined
|
|
const delay =
|
|
proxy.history && proxy.history.length > 0
|
|
? proxy.history[proxy.history.length - 1].delay
|
|
: undefined
|
|
|
|
const chainItem: ProxyChainItem = {
|
|
id: `${proxy.name}_${Date.now()}`,
|
|
name: proxy.name,
|
|
type: proxy.type,
|
|
delay: delay,
|
|
}
|
|
|
|
return [...prev, chainItem]
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
|
|
|
handleProxyGroupChange(group, proxy)
|
|
},
|
|
[handleProxyGroupChange, isChainMode, t],
|
|
)
|
|
|
|
// 测全部延迟
|
|
const handleCheckAll = useStableCallback(
|
|
useLockFn(async (groupName: string) => {
|
|
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
|
|
|
|
const proxies = renderList
|
|
.filter(
|
|
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
|
|
)
|
|
.flatMap((e) => e.proxyCol || e.proxy!)
|
|
.filter(Boolean)
|
|
|
|
debugLog(`[ProxyGroups] 找到代理数量: ${proxies.length}`)
|
|
|
|
const providers = new Set(
|
|
proxies.map((p) => p!.provider!).filter(Boolean),
|
|
)
|
|
|
|
if (providers.size) {
|
|
debugLog(`[ProxyGroups] 发现提供者,数量: ${providers.size}`)
|
|
Promise.allSettled(
|
|
[...providers].map((p) => healthcheckProxyProvider(p)),
|
|
).then(() => {
|
|
debugLog(`[ProxyGroups] 提供者健康检查完成`)
|
|
onProxies()
|
|
})
|
|
}
|
|
|
|
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name)
|
|
debugLog(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`)
|
|
|
|
const url = delayManager.getUrl(groupName)
|
|
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
|
|
|
|
try {
|
|
await Promise.race([
|
|
delayManager.checkListDelay(names, groupName, timeout),
|
|
delayGroup(groupName, url, timeout).then((result) => {
|
|
debugLog(
|
|
`[ProxyGroups] getGroupProxyDelays返回结果数量:`,
|
|
Object.keys(result || {}).length,
|
|
)
|
|
}), // 查询group delays 将清除fixed(不关注调用结果)
|
|
])
|
|
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
|
|
} catch (error) {
|
|
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
|
|
} finally {
|
|
const headState = getGroupHeadState(groupName)
|
|
if (headState?.sortType === 1) {
|
|
onHeadState(groupName, { sortType: headState.sortType })
|
|
}
|
|
onProxies()
|
|
}
|
|
}),
|
|
)
|
|
|
|
// 滚到对应的节点
|
|
const handleLocation = useStableCallback((group: IProxyGroupItem) => {
|
|
if (!group) return
|
|
const { name, now } = group
|
|
|
|
const index = renderList.findIndex(
|
|
(e) =>
|
|
e.group?.name === name &&
|
|
((e.type === 2 && e.proxy?.name === now) ||
|
|
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
|
|
)
|
|
|
|
if (index >= 0) {
|
|
virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' })
|
|
}
|
|
})
|
|
|
|
// 定位到指定的代理组
|
|
const handleGroupLocationByName = useCallback(
|
|
(groupName: string) => {
|
|
const index = renderList.findIndex(
|
|
(item) => item.type === 0 && item.group?.name === groupName,
|
|
)
|
|
|
|
if (index >= 0) {
|
|
virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' })
|
|
}
|
|
},
|
|
[renderList, virtualizer],
|
|
)
|
|
|
|
const proxyGroupNames = useMemo(() => {
|
|
const names = renderList
|
|
.filter((item) => item.type === 0 && item.group?.name)
|
|
.map((item) => item.group!.name)
|
|
return Array.from(new Set(names))
|
|
}, [renderList])
|
|
|
|
const renderProxyList = (height: string) => (
|
|
<ProxyVirtualList
|
|
parentRef={parentRef}
|
|
height={height}
|
|
totalSize={virtualizer.getTotalSize()}
|
|
virtualItems={virtualItems}
|
|
renderList={renderList}
|
|
activeStickyIndex={activeStickyIndex}
|
|
indent={mode === 'rule' || mode === 'script'}
|
|
isChainMode={isChainMode}
|
|
measureElement={virtualizer.measureElement}
|
|
onLocation={handleLocation}
|
|
onCheckAll={handleCheckAll}
|
|
onHeadState={onHeadState}
|
|
onChangeProxy={handleChangeProxy}
|
|
/>
|
|
)
|
|
|
|
if (mode === 'direct') {
|
|
return <BaseEmpty textKey="proxies.page.messages.directMode" />
|
|
}
|
|
|
|
if (isChainMode) {
|
|
// 获取所有代理组
|
|
const proxyGroups = proxiesData?.groups || []
|
|
const showRuleHeader = mode === 'rule' && proxyGroups.length > 0
|
|
|
|
return (
|
|
<>
|
|
<Box sx={{ display: 'flex', height: '100%', gap: 2 }}>
|
|
<Box sx={{ flex: 1, position: 'relative' }}>
|
|
{showRuleHeader && (
|
|
<ChainRuleHeader
|
|
title={t('proxies.page.rules.title')}
|
|
selectLabel={t('proxies.page.rules.select')}
|
|
currentGroup={currentGroup}
|
|
canSelectGroup={availableGroups.length > 0}
|
|
onMenuOpen={handleGroupMenuOpen}
|
|
/>
|
|
)}
|
|
|
|
{renderProxyList(
|
|
showRuleHeader ? 'calc(100% - 80px)' : 'calc(100% - 14px)',
|
|
)}
|
|
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
|
</Box>
|
|
|
|
<Box sx={{ width: '400px', minWidth: '300px' }}>
|
|
<ProxyChain
|
|
proxyChain={proxyChain}
|
|
onUpdateChain={setProxyChain}
|
|
chainConfigData={chainConfigData}
|
|
mode={mode}
|
|
selectedGroup={activeSelectedGroup}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Snackbar
|
|
open={duplicateWarning.open}
|
|
autoHideDuration={3000}
|
|
onClose={handleCloseDuplicateWarning}
|
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
>
|
|
<Alert
|
|
onClose={handleCloseDuplicateWarning}
|
|
severity="warning"
|
|
variant="filled"
|
|
>
|
|
{duplicateWarning.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
|
|
<GroupSelectMenu
|
|
anchorEl={ruleMenuAnchor}
|
|
groups={availableGroups}
|
|
selectedGroup={activeSelectedGroup}
|
|
emptyText="暂无可用代理组"
|
|
onClose={handleGroupMenuClose}
|
|
onSelect={handleGroupSelect}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{ position: 'relative', height: '100%', willChange: 'transform' }}
|
|
>
|
|
{/* 代理组导航栏 */}
|
|
{mode === 'rule' && (
|
|
<ProxyGroupNavigator
|
|
proxyGroupNames={proxyGroupNames}
|
|
onGroupLocation={handleGroupLocationByName}
|
|
enableHoverJump={verge?.enable_hover_jump_navigator ?? true}
|
|
hoverDelay={verge?.hover_jump_navigator_delay ?? DEFAULT_HOVER_DELAY}
|
|
/>
|
|
)}
|
|
|
|
{renderProxyList('calc(100% - 14px)')}
|
|
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type VirtualListItem = {
|
|
key: Key
|
|
index: number
|
|
start: number
|
|
end: number
|
|
}
|
|
|
|
interface ProxyVirtualListProps {
|
|
parentRef: RefObject<HTMLDivElement | null>
|
|
height: string
|
|
totalSize: number
|
|
virtualItems: VirtualListItem[]
|
|
renderList: IRenderItem[]
|
|
activeStickyIndex: number | null
|
|
indent: boolean
|
|
isChainMode?: boolean
|
|
measureElement: (node: Element | null) => void
|
|
onLocation: (group: IRenderItem['group']) => void
|
|
onCheckAll: (groupName: string) => void
|
|
onHeadState: (groupName: string, patch: Partial<HeadState>) => 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<HTMLElement>) => void
|
|
}
|
|
|
|
function ChainRuleHeader({
|
|
title,
|
|
selectLabel,
|
|
currentGroup,
|
|
canSelectGroup,
|
|
onMenuOpen,
|
|
}: ChainRuleHeaderProps) {
|
|
return (
|
|
<Box sx={{ borderBottom: '1px solid', borderColor: 'divider' }}>
|
|
<Box
|
|
sx={{
|
|
px: 2,
|
|
py: 1.5,
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px' }}>
|
|
{title}
|
|
</Typography>
|
|
|
|
{currentGroup && (
|
|
<Chip
|
|
size="small"
|
|
label={`${currentGroup.name} (${currentGroup.type})`}
|
|
variant="outlined"
|
|
sx={{
|
|
fontSize: '12px',
|
|
maxWidth: '200px',
|
|
'& .MuiChip-label': {
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{canSelectGroup && (
|
|
<IconButton
|
|
size="small"
|
|
onClick={onMenuOpen}
|
|
sx={{
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
borderRadius: '4px',
|
|
padding: '4px 8px',
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ mr: 0.5, fontSize: '12px' }}>
|
|
{selectLabel}
|
|
</Typography>
|
|
<ExpandMoreRounded fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Menu
|
|
anchorEl={anchorEl}
|
|
open={Boolean(anchorEl)}
|
|
onClose={onClose}
|
|
slotProps={{
|
|
paper: {
|
|
sx: {
|
|
maxHeight: 300,
|
|
minWidth: 200,
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
{groups.map((group) => (
|
|
<MenuItem
|
|
key={group.name}
|
|
onClick={() => onSelect(group.name)}
|
|
selected={selectedGroup === group.name}
|
|
sx={{ fontSize: '14px', py: 1 }}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
|
{group.name}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{group.type} · {group.all?.length ?? 0} 节点
|
|
</Typography>
|
|
</Box>
|
|
</MenuItem>
|
|
))}
|
|
|
|
{groups.length === 0 && (
|
|
<MenuItem disabled>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{emptyText}
|
|
</Typography>
|
|
</MenuItem>
|
|
)}
|
|
</Menu>
|
|
)
|
|
}
|
|
|
|
function ProxyVirtualList({
|
|
parentRef,
|
|
height,
|
|
totalSize,
|
|
virtualItems,
|
|
renderList,
|
|
activeStickyIndex,
|
|
indent,
|
|
isChainMode,
|
|
measureElement,
|
|
onLocation,
|
|
onCheckAll,
|
|
onHeadState,
|
|
onChangeProxy,
|
|
}: ProxyVirtualListProps) {
|
|
const theme = useTheme()
|
|
const stickyBackground =
|
|
theme.palette.mode === 'dark' ? '#1e1f27' : 'var(--background-color)'
|
|
|
|
return (
|
|
<div ref={parentRef} style={{ height, overflow: 'auto' }}>
|
|
<div style={{ height: totalSize, position: 'relative' }}>
|
|
{virtualItems.map((virtualItem) => (
|
|
<div
|
|
key={virtualItem.key}
|
|
data-index={virtualItem.index}
|
|
ref={measureElement}
|
|
style={{
|
|
position:
|
|
virtualItem.index === activeStickyIndex ? 'sticky' : 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
zIndex: virtualItem.index === activeStickyIndex ? 5 : undefined,
|
|
display:
|
|
virtualItem.index === activeStickyIndex
|
|
? 'flow-root'
|
|
: undefined,
|
|
backgroundColor:
|
|
virtualItem.index === activeStickyIndex
|
|
? stickyBackground
|
|
: undefined,
|
|
width: '100%',
|
|
transform:
|
|
virtualItem.index === activeStickyIndex
|
|
? undefined
|
|
: `translateY(${virtualItem.start}px)`,
|
|
}}
|
|
>
|
|
<ProxyRender
|
|
item={renderList[virtualItem.index]}
|
|
indent={indent}
|
|
onLocation={onLocation}
|
|
onCheckAll={onCheckAll}
|
|
onHeadState={onHeadState}
|
|
onChangeProxy={onChangeProxy}
|
|
isChainMode={isChainMode}
|
|
/>
|
|
</div>
|
|
))}
|
|
<div style={{ height: 8 }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 替换简单防抖函数为更优的节流函数
|
|
function throttle<T extends (...args: any[]) => any>(
|
|
func: T,
|
|
wait: number,
|
|
): (...args: Parameters<T>) => void {
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let previous = 0
|
|
let lastArgs: Parameters<T> | null = null
|
|
|
|
const run = (args: Parameters<T>) => {
|
|
previous = Date.now()
|
|
timer = null
|
|
lastArgs = null
|
|
func(...args)
|
|
}
|
|
|
|
return function (...args: Parameters<T>) {
|
|
const now = Date.now()
|
|
const remaining = wait - (now - previous)
|
|
lastArgs = args
|
|
|
|
if (remaining <= 0 || remaining > wait) {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
}
|
|
run(args)
|
|
} else if (!timer) {
|
|
timer = setTimeout(() => {
|
|
if (lastArgs) {
|
|
run(lastArgs)
|
|
}
|
|
}, remaining)
|
|
}
|
|
}
|
|
}
|