Files
clash-verge-rev/src/components/proxy/proxy-group-tools.tsx
T
Tunglies c599f5547e fix(proxy): validate user-entered URLs via isValidUrl
Delay test inputs now reject non-http(s) URLs with a warning toast and
fall back to the default URL instead of erroring every node in the
core. Subscription import and profile card host parsing reuse the same
validator, fixing bare-domain URLs shown as local files and
credential-bearing URLs leaking into the card.
2026-09-18 13:18:30 +08:00

283 lines
8.6 KiB
TypeScript

import AccessTimeRounded from '@mui/icons-material/AccessTimeRounded'
import MyLocationRounded from '@mui/icons-material/MyLocationRounded'
import NetworkCheckRounded from '@mui/icons-material/NetworkCheckRounded'
import SearchOffRounded from '@mui/icons-material/SearchOffRounded'
import SearchRounded from '@mui/icons-material/SearchRounded'
import SortByAlphaRounded from '@mui/icons-material/SortByAlphaRounded'
import SortRounded from '@mui/icons-material/SortRounded'
import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
interface Props {
sx?: SxProps
side?: 'left' | 'right'
url?: string
groupName: string
headState: HeadState
onLocation: () => void
onCheckDelay: () => void
onHeadState: (val: Partial<HeadState>) => void
}
export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const {
sx,
side = 'right',
url,
groupName,
headState,
onCheckDelay,
onHeadState,
onLocation,
} = props
const {
showType,
sortType,
filterText,
textState,
testUrl,
filterMatchCase,
filterMatchWholeWord,
filterUseRegularExpression,
} = headState
const { t } = useTranslation()
const { verge } = useVerge()
const defaultLatencyUrl =
verge?.default_latency_test?.trim() ||
'http://cp.cloudflare.com/generate_204'
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
// 因此对写入 headState 的动作做防抖,避免每输入一个字符就过滤一次。
const { run: applyFilter, flush: flushFilter } = useDebounceFn(
(state: SearchState) => {
onHeadState({
filterText: state.text,
filterMatchCase: state.matchCase,
filterMatchWholeWord: state.matchWholeWord,
filterUseRegularExpression: state.useRegularExpression,
})
},
{ wait: 600 },
)
// 关闭过滤框或卸载时立即应用最后一次输入,避免丢失未生效的过滤条件。
useEffect(() => {
if (textState !== 'filter') flushFilter()
}, [textState, flushFilter])
useEffect(() => () => flushFilter(), [flushFilter])
const textInput = (
<>
{textState === 'filter' && (
<Box sx={{ flex: '1 1 auto' }}>
<BaseSearchBox
inputRef={inputRef}
defaultValue={filterText}
matchCase={filterMatchCase}
matchWholeWord={filterMatchWholeWord}
useRegularExpression={filterUseRegularExpression}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onSearch={(_, state) => applyFilter(state)}
/>
</Box>
)}
{textState === 'url' && (
<TextField
inputRef={inputRef}
autoComplete="new-password"
hiddenLabel
autoSave="off"
value={testUrl}
size="small"
variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
</>
)
return (
<Box
sx={{
display: 'flex',
justifyContent: side === 'left' ? 'start' : 'end',
alignItems: 'center',
gap: 0.5,
height: 36,
flex: 1,
ml: side === 'left' ? 0 : 2,
...sx,
}}
>
{side === 'right' && textInput}
<IconButton
size="small"
color="inherit"
title={t('proxies.page.tooltips.locate')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!headState.open)
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
flushSync(() => onHeadState({ open: true }))
onLocation()
}}
>
<MyLocationRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t('proxies.page.tooltips.delayCheck')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!headState.open)
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
flushSync(() => onHeadState({ open: true }))
// Remind the user that it is custom test url
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
<NetworkCheckRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
color="inherit"
title={
[
t('proxies.page.tooltips.sortDefault'),
t('proxies.page.tooltips.sortDelay'),
t('proxies.page.tooltips.sortName'),
][sortType]
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!headState.open)
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
flushSync(() => onHeadState({ open: true }))
onHeadState({
sortType: ((sortType + 1) % 3) as ProxySortType,
})
}}
>
{sortType !== 1 && sortType !== 2 && <SortRounded fontSize="inherit" />}
{sortType === 1 && <AccessTimeRounded fontSize="inherit" />}
{sortType === 2 && <SortByAlphaRounded fontSize="inherit" />}
</IconButton>
<IconButton
size="small"
color="inherit"
title={t('proxies.page.tooltips.delayCheckUrl')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onHeadState({
textState: textState === 'url' ? null : 'url',
})
setTimeout(() => inputRef.current?.focus())
}}
>
{textState === 'url' ? (
<WifiTetheringRounded fontSize="inherit" />
) : (
<WifiTetheringOffRounded fontSize="inherit" />
)}
</IconButton>
<IconButton
size="small"
color="inherit"
title={
showType
? t('proxies.page.tooltips.showBasic')
: t('proxies.page.tooltips.showDetail')
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!headState.open)
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
flushSync(() => onHeadState({ open: true }))
onHeadState({ showType: !showType })
}}
>
{showType ? (
<VisibilityRounded fontSize="inherit" />
) : (
<VisibilityOffRounded fontSize="inherit" />
)}
</IconButton>
<IconButton
size="small"
color="inherit"
title={t('proxies.page.tooltips.filter')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!headState.open && textState !== 'filter')
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
flushSync(() => onHeadState({ open: true }))
onHeadState({ textState: textState === 'filter' ? null : 'filter' })
setTimeout(() => inputRef.current?.focus())
}}
>
{textState === 'filter' ? (
<SearchOffRounded fontSize="inherit" />
) : (
<SearchRounded fontSize="inherit" />
)}
</IconButton>
{side === 'left' && textInput}
</Box>
)
})