mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-22 19:52:14 +08:00
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.
283 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
})
|