import { DragDropProvider } from '@dnd-kit/react' 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 { type Key, type MouseEvent, type RefObject, useCallback, useEffect, useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' import { useProxiesData } from '@/providers/app-data-context' import { updateProxyChainConfigInRuntime } from '@/services/cmds' import { mutate } from '@/services/mutate' import { isInteractableMember, type ProxyGroupView, type ResolvedProxyMember, } from '@/types/proxy-view' import { ScrollTopButton } from '../layout/scroll-top-button' import { ProxyChain } from './proxy-chain' import { type ProxyChainItem, rebindProxyChainItems } from './proxy-chain-model' import { ProxyRender } from './proxy-render' import type { HeadState } from './use-head-state' import { PROXY_GROUP_HEADER_SENSORS, useProxyGroupHeaderLayout, } from './use-proxy-group-header-layout' import type { IRenderItem } from './use-render-list' // ---- Types ---- type VirtualListItem = { key: Key index: number start: number end: number } type ProxyGroupOption = ProxyGroupView // ---- Props ---- interface ChainRuleHeaderProps { title: string selectLabel: string currentGroup: ProxyGroupOption | null canSelectGroup: boolean onMenuOpen: (event: MouseEvent) => void } interface GroupSelectMenuProps { anchorEl: HTMLElement | null groups: ProxyGroupOption[] selectedGroup: string | null emptyText: string nodeCountLabel: (count: number) => string onClose: () => void onSelect: (groupName: string) => void } interface ProxyGroupsChainProps { mode: string chainConfigData?: string | null onExitChainMode: () => void availableGroups: any[] activeSelectedGroup: string | null showScrollTop: boolean // Virtual list data (from parent's virtualizer) parentRef: RefObject totalSize: number virtualItems: VirtualListItem[] renderList: IRenderItem[] activeStickyIndex: number | null measureElement: (node: Element | null) => void // Shared callbacks onCheckAll: (groupName: string) => void onHeadState: (groupName: string, patch: Partial) => void onLocation: (group: any) => void onGroupSelect: (groupName: string) => void onScrollToTop: () => void } // ---- Sub-components ---- function ChainRuleHeader({ title, selectLabel, currentGroup, canSelectGroup, onMenuOpen, }: ChainRuleHeaderProps) { return ( {title} {currentGroup && ( )} {canSelectGroup && ( {selectLabel} )} ) } function GroupSelectMenu({ anchorEl, groups, selectedGroup, emptyText, nodeCountLabel, onClose, onSelect, }: GroupSelectMenuProps) { return ( {groups.map((group) => ( onSelect(group.name)} selected={selectedGroup === group.name} sx={{ fontSize: '14px', py: 1 }} > {group.name} {group.type} · {nodeCountLabel(group.members.length)} ))} {groups.length === 0 && ( {emptyText} )} ) } function ProxyVirtualList({ parentRef, height, totalSize, virtualItems, renderList, activeStickyIndex, isChainMode, measureElement, onLocation, onCheckAll, onHeadState, onChangeProxy, }: { parentRef: RefObject height: string totalSize: number virtualItems: VirtualListItem[] renderList: IRenderItem[] activeStickyIndex: number | null isChainMode?: boolean measureElement: (node: Element | null) => void onLocation: (group: any) => void onCheckAll: (groupName: string) => void onHeadState: (groupName: string, patch: Partial) => void onChangeProxy: (group: ProxyGroupView, member: ResolvedProxyMember) => void }) { const theme = useTheme() const stickyBackground = theme.palette.mode === 'dark' ? '#1e1f27' : 'var(--background-color)' return (
{virtualItems.map((virtualItem) => (
))}
) } // ---- Main Chain Component ---- export function ProxyGroupsChain(props: ProxyGroupsChainProps) { const { t } = useTranslation() const { mode, chainConfigData, onExitChainMode, availableGroups, activeSelectedGroup, showScrollTop, parentRef, totalSize, virtualItems, renderList, activeStickyIndex, measureElement, onCheckAll, onHeadState, onLocation, onGroupSelect, onScrollToTop, } = props const { proxyView } = useProxiesData() const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout() // Chain-specific state const [proxyChain, setProxyChain] = useState(() => { try { const saved = localStorage.getItem('proxy-chain-items') if (saved) { return JSON.parse(saved) } } catch { // ignore } return [] }) const candidateNodes = useMemo( () => renderList.flatMap((item) => { const occurrences = item.memberCol ?? (item.member ? [item.member] : []) return occurrences.flatMap(({ member }) => member.kind === 'node' ? [member.node] : [], ) }), [renderList], ) const currentProxyChain = useMemo( () => proxyView ? rebindProxyChainItems(proxyChain, candidateNodes, proxyView) : proxyChain.map((item) => ({ ...item, recordId: undefined, delay: undefined, })), [candidateNodes, proxyChain, proxyView], ) useEffect(() => { if (currentProxyChain.length > 0) { const persistedChain = currentProxyChain.map( ({ id, name, type, delay }) => ({ id, name, type, delay, }), ) localStorage.setItem('proxy-chain-items', JSON.stringify(persistedChain)) } else { localStorage.removeItem('proxy-chain-items') } }, [currentProxyChain]) const [ruleMenuAnchor, setRuleMenuAnchor] = useState(null) const [duplicateWarning, setDuplicateWarning] = useState<{ open: boolean message: string }>({ open: false, message: '' }) // Compute current group for rule header const currentGroup = useMemo(() => { if (!activeSelectedGroup) return null return ( availableGroups.find( (group: ProxyGroupView) => group.name === activeSelectedGroup, ) ?? null ) }, [activeSelectedGroup, availableGroups]) // Handlers const handleGroupMenuOpen = (event: React.MouseEvent) => { setRuleMenuAnchor(event.currentTarget) } const handleGroupMenuClose = () => { setRuleMenuAnchor(null) } const handleGroupSelect = async (groupName: string) => { if (mode === 'rule') { try { const result = await mutate( () => updateProxyChainConfigInRuntime(null), { id: 'update-proxy-chain-runtime', }, ) if (!result.ok) return } catch { return } localStorage.removeItem('proxy-chain-group') localStorage.removeItem('proxy-chain-exit-node') localStorage.removeItem('proxy-chain-items') setProxyChain([]) } onGroupSelect(groupName) handleGroupMenuClose() } const handleCloseDuplicateWarning = useCallback(() => { setDuplicateWarning({ open: false, message: '' }) }, []) const handleChangeProxy = useCallback( (_group: ProxyGroupView, member: ResolvedProxyMember) => { if (!isInteractableMember(member) || member.kind !== 'node') return const { node } = member setProxyChain((prev) => { const current = proxyView ? rebindProxyChainItems(prev, candidateNodes, proxyView) : prev if ( current.some( (item) => item.recordId !== undefined && item.recordId === node.recordId, ) ) { const warningMessage = t('proxies.page.chain.duplicateNode') setDuplicateWarning({ open: true, message: warningMessage, }) return prev // 返回原来的状态,不做任何更改 } // 安全获取延迟数据,如果没有延迟数据则设为 undefined const delay = node.history.length > 0 ? node.history[node.history.length - 1].delay : undefined const chainItem: ProxyChainItem = { id: `${node.name}_${Date.now()}`, name: node.name, recordId: node.recordId, source: node.source, type: node.type, delay, } return [...current, chainItem] }) }, [candidateNodes, proxyView, t], ) // Render virtual list for chain mode const renderProxyList = (height: string) => ( ) const showRuleHeader = mode === 'rule' && availableGroups.length > 0 return ( <> {showRuleHeader && ( 0} onMenuOpen={handleGroupMenuOpen} /> )} {renderProxyList( showRuleHeader ? 'calc(100% - 80px)' : 'calc(100% - 14px)', )} {duplicateWarning.message} t('proxies.page.labels.nodeCount', { count }) } onClose={handleGroupMenuClose} onSelect={handleGroupSelect} /> ) }