diff --git a/Changelog.md b/Changelog.md index 6b8a3ed18..ff4584007 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,8 @@
✨ 新增功能 +- 允许代理页面允许高级过滤搜索 +
diff --git a/src/components/base/base-search-box.tsx b/src/components/base/base-search-box.tsx index 47cd57011..72ccfb4e6 100644 --- a/src/components/base/base-search-box.tsx +++ b/src/components/base/base-search-box.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"; import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react"; import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react"; +import { buildRegex, compileStringMatcher } from "@/utils/search-matcher"; export type SearchState = { text: string; @@ -21,11 +22,17 @@ export type SearchState = { useRegularExpression: boolean; }; +type SearchOptionState = Omit; + type SearchProps = { + value?: string; + defaultValue?: string; + autoFocus?: boolean; placeholder?: string; matchCase?: boolean; matchWholeWord?: boolean; useRegularExpression?: boolean; + searchState?: Partial; onSearch: (match: (content: string) => boolean, state: SearchState) => void; }; @@ -42,36 +49,61 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ }, })); +const useControllableState = (options: { + controlled: T | undefined; + defaultValue: T; +}) => { + const { controlled, defaultValue } = options; + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const isControlled = controlled !== undefined; + + const value = isControlled ? controlled : uncontrolled; + + const setValue = useCallback( + (next: T) => { + if (!isControlled) setUncontrolled(next); + }, + [isControlled], + ); + + return [value, setValue] as const; +}; + export const BaseSearchBox = ({ + value, + defaultValue, + autoFocus, placeholder, + searchState, matchCase: defaultMatchCase = false, matchWholeWord: defaultMatchWholeWord = false, useRegularExpression: defaultUseRegularExpression = false, onSearch, }: SearchProps) => { const { t } = useTranslation(); - - const escapeRegex = useCallback((value: string) => { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - }, []); - - const buildRegex = useCallback((pattern: string, flags = "") => { - try { - return new RegExp(pattern, flags); - } catch (e) { - console.warn("[BaseSearchBox] buildRegex error:", e); - return null; - } - }, []); - - const inputRef = useRef(null); const onSearchRef = useRef(onSearch); - const [matchCase, setMatchCase] = useState(defaultMatchCase); - const [matchWholeWord, setMatchWholeWord] = useState(defaultMatchWholeWord); - const [useRegularExpression, setUseRegularExpression] = useState( - defaultUseRegularExpression, - ); - const [errorMessage, setErrorMessage] = useState(""); + const lastSearchStateRef = useRef(null); + + const [text, setText] = useControllableState({ + controlled: value, + defaultValue: defaultValue ?? "", + }); + + const [matchCase, setMatchCase] = useControllableState({ + controlled: searchState?.matchCase, + defaultValue: defaultMatchCase, + }); + + const [matchWholeWord, setMatchWholeWord] = useControllableState({ + controlled: searchState?.matchWholeWord, + defaultValue: defaultMatchWholeWord, + }); + + const [useRegularExpression, setUseRegularExpression] = + useControllableState({ + controlled: searchState?.useRegularExpression, + defaultValue: defaultUseRegularExpression, + }); const iconStyle = { style: { @@ -82,97 +114,43 @@ export const BaseSearchBox = ({ inheritViewBox: true, }; - // Helper that verifies whether a pattern is a valid regular expression - const validateRegex = useCallback( - (pattern: string, flags = "") => { - if (!pattern) return true; - return !!buildRegex(pattern, flags); - }, - [buildRegex], - ); - - const createMatcher = useMemo(() => { - return (searchText: string) => { - if (!searchText) { - return () => true; - } - - const flags = matchCase ? "" : "i"; - - if (useRegularExpression) { - const regex = buildRegex(searchText, flags); - if (!regex) return () => false; - - return (content: string) => { - try { - return regex.test(content); - } catch (e) { - console.warn("[BaseSearchBox] regex match error:", e); - return false; - } - }; - } - - if (matchWholeWord) { - const regex = buildRegex(`\\b${escapeRegex(searchText)}\\b`, flags); - if (!regex) return () => false; - - return (content: string) => { - try { - return regex.test(content); - } catch (e) { - console.warn("[BaseSearchBox] whole word match error:", e); - return false; - } - }; - } - - return (content: string) => { - const item = matchCase ? content : content.toLowerCase(); - const target = matchCase ? searchText : searchText.toLowerCase(); - return item.includes(target); - }; - }; - }, [ - buildRegex, - escapeRegex, - matchCase, - matchWholeWord, - useRegularExpression, - ]); - useEffect(() => { onSearchRef.current = onSearch; }, [onSearch]); + const emitSearch = useCallback((nextState: SearchState) => { + const prevState = lastSearchStateRef.current; + const isSameState = + !!prevState && + prevState.text === nextState.text && + prevState.matchCase === nextState.matchCase && + prevState.matchWholeWord === nextState.matchWholeWord && + prevState.useRegularExpression === nextState.useRegularExpression; + if (isSameState) return; + + const compiled = compileStringMatcher(nextState.text, nextState); + onSearchRef.current(compiled.matcher, nextState); + + lastSearchStateRef.current = nextState; + }, []); + useEffect(() => { - if (!inputRef.current) return; - const value = inputRef.current.value; - const matcher = createMatcher(value); - onSearchRef.current(matcher, { - text: value, - matchCase, - matchWholeWord, - useRegularExpression, - }); - }, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); + emitSearch({ text, matchCase, matchWholeWord, useRegularExpression }); + }, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]); - const onChange = (e: ChangeEvent) => { - const value = e.target?.value ?? ""; - setErrorMessage(""); + const effectiveErrorMessage = useMemo(() => { + if (!useRegularExpression || !text) return ""; const flags = matchCase ? "" : "i"; + return buildRegex(text, flags) ? "" : t("shared.validation.invalidRegex"); + }, [matchCase, t, text, useRegularExpression]); - // Validate regex input eagerly - if (useRegularExpression && value) { - const isValid = validateRegex(value, flags); - if (!isValid) { - setErrorMessage(t("shared.validation.invalidRegex")); - } - } - - const matcher = createMatcher(value); - onSearchRef.current(matcher, { - text: value, + const handleChangeText = ( + e: ChangeEvent, + ) => { + const nextText = e.target?.value ?? ""; + setText(nextText); + emitSearch({ + text: nextText, matchCase, matchWholeWord, useRegularExpression, @@ -180,35 +158,43 @@ export const BaseSearchBox = ({ }; const handleToggleUseRegularExpression = () => { - setUseRegularExpression((prev) => { - const next = !prev; - if (!next) { - setErrorMessage(""); - } else { - const value = inputRef.current?.value ?? ""; - const flags = matchCase ? "" : "i"; - if (value && !validateRegex(value, flags)) { - setErrorMessage(t("shared.validation.invalidRegex")); - } - } - return next; + const next = !useRegularExpression; + setUseRegularExpression(next); + emitSearch({ + text, + matchCase, + matchWholeWord, + useRegularExpression: next, }); }; + const handleToggleMatchCase = () => { + const next = !matchCase; + setMatchCase(next); + emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression }); + }; + + const handleToggleMatchWholeWord = () => { + const next = !matchWholeWord; + setMatchWholeWord(next); + emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression }); + }; + return ( - + setMatchCase((prev) => !prev)} + onClick={handleToggleMatchCase} /> @@ -230,7 +216,7 @@ export const BaseSearchBox = ({ component={matchWholeWordIcon} {...iconStyle} aria-label={matchWholeWord ? "active" : "inactive"} - onClick={() => setMatchWholeWord((prev) => !prev)} + onClick={handleToggleMatchWholeWord} /> diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index 6dea0d7a0..97e28105b 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -15,6 +15,7 @@ import { Box, IconButton, TextField, SxProps } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { BaseSearchBox } from "@/components/base/base-search-box"; import { useVerge } from "@/hooks/use-verge"; import delayManager from "@/services/delay"; import { debugLog } from "@/utils/debug"; @@ -43,7 +44,16 @@ export const ProxyHead = ({ onLocation, onCheckDelay, }: Props) => { - const { showType, sortType, filterText, textState, testUrl } = headState; + const { + showType, + sortType, + filterText, + textState, + testUrl, + filterMatchCase, + filterMatchWholeWord, + filterUseRegularExpression, + } = headState; const { t } = useTranslation(); const [autoFocus, setAutoFocus] = useState(false); @@ -154,17 +164,25 @@ export const ProxyHead = ({ {textState === "filter" && ( - onHeadState({ filterText: e.target.value })} - sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} - /> + + + onHeadState({ + filterText: state.text, + filterMatchCase: state.matchCase, + filterMatchWholeWord: state.matchWholeWord, + filterUseRegularExpression: state.useRegularExpression, + }) + } + /> + )} {textState === "url" && ( diff --git a/src/components/proxy/use-filter-sort.ts b/src/components/proxy/use-filter-sort.ts index 15e68cb09..a4145978a 100644 --- a/src/components/proxy/use-filter-sort.ts +++ b/src/components/proxy/use-filter-sort.ts @@ -2,15 +2,23 @@ import { useEffect, useMemo, useReducer } from "react"; import { useVerge } from "@/hooks/use-verge"; import delayManager from "@/services/delay"; +import { compileStringMatcher } from "@/utils/search-matcher"; // default | delay | alphabet export type ProxySortType = 0 | 1 | 2; +export type ProxySearchState = { + matchCase?: boolean; + matchWholeWord?: boolean; + useRegularExpression?: boolean; +}; + export default function useFilterSort( proxies: IProxyItem[], groupName: string, filterText: string, sortType: ProxySortType, + searchState?: ProxySearchState, ) { const { verge } = useVerge(); const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0); @@ -33,7 +41,7 @@ export default function useFilterSort( }, [groupName]); return useMemo(() => { - const fp = filterProxies(proxies, groupName, filterText); + const fp = filterProxies(proxies, groupName, filterText, searchState); const sp = sortProxies( fp, groupName, @@ -46,6 +54,7 @@ export default function useFilterSort( groupName, filterText, sortType, + searchState, verge?.default_latency_timeout, ]); } @@ -56,8 +65,9 @@ export function filterSort( filterText: string, sortType: ProxySortType, latencyTimeout?: number, + searchState?: ProxySearchState, ) { - const fp = filterProxies(proxies, groupName, filterText); + const fp = filterProxies(proxies, groupName, filterText, searchState); const sp = sortProxies(fp, groupName, sortType, latencyTimeout); return sp; } @@ -76,10 +86,12 @@ function filterProxies( proxies: IProxyItem[], groupName: string, filterText: string, + searchState?: ProxySearchState, ) { - if (!filterText) return proxies; + const query = filterText.trim(); + if (!query) return proxies; - const res1 = regex1.exec(filterText); + const res1 = regex1.exec(query); if (res1) { const symbol = res1[1]; const symbol2 = res1[2].toLowerCase(); @@ -100,13 +112,25 @@ function filterProxies( }); } - const res2 = regex2.exec(filterText); + const res2 = regex2.exec(query); if (res2) { const type = res2[1].toLowerCase(); return proxies.filter((p) => p.type.toLowerCase().includes(type)); } - return proxies.filter((p) => p.name.includes(filterText.trim())); + const { + matchCase = false, + matchWholeWord = false, + useRegularExpression = false, + } = searchState ?? {}; + const compiled = compileStringMatcher(query, { + matchCase, + matchWholeWord, + useRegularExpression, + }); + + if (!compiled.isValid) return []; + return proxies.filter((p) => compiled.matcher(p.name)); } /** diff --git a/src/components/proxy/use-head-state.ts b/src/components/proxy/use-head-state.ts index 842a379d2..303e9cfe7 100644 --- a/src/components/proxy/use-head-state.ts +++ b/src/components/proxy/use-head-state.ts @@ -9,6 +9,9 @@ export interface HeadState { showType: boolean; sortType: ProxySortType; filterText: string; + filterMatchCase?: boolean; + filterMatchWholeWord?: boolean; + filterUseRegularExpression?: boolean; textState: "url" | "filter" | null; testUrl: string; } @@ -21,6 +24,9 @@ export const DEFAULT_STATE: HeadState = { showType: true, sortType: 0, filterText: "", + filterMatchCase: false, + filterMatchWholeWord: false, + filterUseRegularExpression: false, textState: null, testUrl: "", }; diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index dd7e96d6e..a9c267f30 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -390,6 +390,11 @@ export const useRenderList = ( headState.filterText, headState.sortType, latencyTimeout, + { + matchCase: headState.filterMatchCase, + matchWholeWord: headState.filterMatchWholeWord, + useRegularExpression: headState.filterUseRegularExpression, + }, ); ret.push({ diff --git a/src/utils/search-matcher.ts b/src/utils/search-matcher.ts new file mode 100644 index 000000000..ea64fd8e2 --- /dev/null +++ b/src/utils/search-matcher.ts @@ -0,0 +1,59 @@ +export type SearchMatcherOptions = { + matchCase?: boolean; + matchWholeWord?: boolean; + useRegularExpression?: boolean; +}; + +export type CompileStringMatcherResult = { + matcher: (content: string) => boolean; + isValid: boolean; +}; + +export const escapeRegex = (value: string) => { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}; + +export const buildRegex = (pattern: string, flags = "") => { + try { + return new RegExp(pattern, flags); + } catch { + return null; + } +}; + +export const compileStringMatcher = ( + query: string, + options: SearchMatcherOptions = {}, +): CompileStringMatcherResult => { + if (!query) return { matcher: () => true, isValid: true }; + + const matchCase = options.matchCase ?? false; + const matchWholeWord = options.matchWholeWord ?? false; + const useRegularExpression = options.useRegularExpression ?? false; + const flags = matchCase ? "" : "i"; + + if (useRegularExpression) { + const regex = buildRegex(query, flags); + if (!regex) return { matcher: () => false, isValid: false }; + return { matcher: (content: string) => regex.test(content), isValid: true }; + } + + if (matchWholeWord) { + const regex = buildRegex(`\\b${escapeRegex(query)}\\b`, flags); + if (!regex) return { matcher: () => false, isValid: false }; + return { matcher: (content: string) => regex.test(content), isValid: true }; + } + + if (matchCase) { + return { + matcher: (content: string) => content.includes(query), + isValid: true, + }; + } + + const target = query.toLowerCase(); + return { + matcher: (content: string) => content.toLowerCase().includes(target), + isValid: true, + }; +};