feat(proxy): unify filter search box and sync filter options (#5819)

* feat(proxy): unify filter search box and sync filter options

* docs: fix JSDoc placement

* refactor(search): unify string matcher and stabilize regex filtering

* fix(search): treat invalid regex as matching nothing

* docs: update changelog to include advanced filter search feature for proxy page

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
Sline 2025-12-19 17:12:36 +08:00 committed by GitHub
parent aa72fa9a42
commit b4e25951b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 240 additions and 140 deletions

View File

@ -7,6 +7,8 @@
<details> <details>
<summary><strong> ✨ 新增功能 </strong></summary> <summary><strong> ✨ 新增功能 </strong></summary>
- 允许代理页面允许高级过滤搜索
</details> </details>
<details> <details>

View File

@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.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 useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react";
import { buildRegex, compileStringMatcher } from "@/utils/search-matcher";
export type SearchState = { export type SearchState = {
text: string; text: string;
@ -21,11 +22,17 @@ export type SearchState = {
useRegularExpression: boolean; useRegularExpression: boolean;
}; };
type SearchOptionState = Omit<SearchState, "text">;
type SearchProps = { type SearchProps = {
value?: string;
defaultValue?: string;
autoFocus?: boolean;
placeholder?: string; placeholder?: string;
matchCase?: boolean; matchCase?: boolean;
matchWholeWord?: boolean; matchWholeWord?: boolean;
useRegularExpression?: boolean; useRegularExpression?: boolean;
searchState?: Partial<SearchOptionState>;
onSearch: (match: (content: string) => boolean, state: SearchState) => void; onSearch: (match: (content: string) => boolean, state: SearchState) => void;
}; };
@ -42,36 +49,61 @@ const StyledTextField = styled(TextField)(({ theme }) => ({
}, },
})); }));
const useControllableState = <T,>(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 = ({ export const BaseSearchBox = ({
value,
defaultValue,
autoFocus,
placeholder, placeholder,
searchState,
matchCase: defaultMatchCase = false, matchCase: defaultMatchCase = false,
matchWholeWord: defaultMatchWholeWord = false, matchWholeWord: defaultMatchWholeWord = false,
useRegularExpression: defaultUseRegularExpression = false, useRegularExpression: defaultUseRegularExpression = false,
onSearch, onSearch,
}: SearchProps) => { }: SearchProps) => {
const { t } = useTranslation(); 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<HTMLInputElement>(null);
const onSearchRef = useRef(onSearch); const onSearchRef = useRef(onSearch);
const [matchCase, setMatchCase] = useState(defaultMatchCase); const lastSearchStateRef = useRef<SearchState | null>(null);
const [matchWholeWord, setMatchWholeWord] = useState(defaultMatchWholeWord);
const [useRegularExpression, setUseRegularExpression] = useState( const [text, setText] = useControllableState<string>({
defaultUseRegularExpression, controlled: value,
); defaultValue: defaultValue ?? "",
const [errorMessage, setErrorMessage] = useState(""); });
const [matchCase, setMatchCase] = useControllableState<boolean>({
controlled: searchState?.matchCase,
defaultValue: defaultMatchCase,
});
const [matchWholeWord, setMatchWholeWord] = useControllableState<boolean>({
controlled: searchState?.matchWholeWord,
defaultValue: defaultMatchWholeWord,
});
const [useRegularExpression, setUseRegularExpression] =
useControllableState<boolean>({
controlled: searchState?.useRegularExpression,
defaultValue: defaultUseRegularExpression,
});
const iconStyle = { const iconStyle = {
style: { style: {
@ -82,97 +114,43 @@ export const BaseSearchBox = ({
inheritViewBox: true, 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(() => { useEffect(() => {
onSearchRef.current = onSearch; onSearchRef.current = onSearch;
}, [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(() => { useEffect(() => {
if (!inputRef.current) return; emitSearch({ text, matchCase, matchWholeWord, useRegularExpression });
const value = inputRef.current.value; }, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]);
const matcher = createMatcher(value);
onSearchRef.current(matcher, {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const effectiveErrorMessage = useMemo(() => {
const value = e.target?.value ?? ""; if (!useRegularExpression || !text) return "";
setErrorMessage("");
const flags = matchCase ? "" : "i"; const flags = matchCase ? "" : "i";
return buildRegex(text, flags) ? "" : t("shared.validation.invalidRegex");
}, [matchCase, t, text, useRegularExpression]);
// Validate regex input eagerly const handleChangeText = (
if (useRegularExpression && value) { e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
const isValid = validateRegex(value, flags); ) => {
if (!isValid) { const nextText = e.target?.value ?? "";
setErrorMessage(t("shared.validation.invalidRegex")); setText(nextText);
} emitSearch({
} text: nextText,
const matcher = createMatcher(value);
onSearchRef.current(matcher, {
text: value,
matchCase, matchCase,
matchWholeWord, matchWholeWord,
useRegularExpression, useRegularExpression,
@ -180,35 +158,43 @@ export const BaseSearchBox = ({
}; };
const handleToggleUseRegularExpression = () => { const handleToggleUseRegularExpression = () => {
setUseRegularExpression((prev) => { const next = !useRegularExpression;
const next = !prev; setUseRegularExpression(next);
if (!next) { emitSearch({
setErrorMessage(""); text,
} else { matchCase,
const value = inputRef.current?.value ?? ""; matchWholeWord,
const flags = matchCase ? "" : "i"; useRegularExpression: next,
if (value && !validateRegex(value, flags)) {
setErrorMessage(t("shared.validation.invalidRegex"));
}
}
return 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 ( return (
<Tooltip title={errorMessage || ""} placement="bottom-start"> <Tooltip title={effectiveErrorMessage || ""} placement="bottom-start">
<StyledTextField <StyledTextField
autoComplete="new-password" autoComplete="new-password"
inputRef={inputRef}
hiddenLabel hiddenLabel
fullWidth fullWidth
size="small" size="small"
variant="outlined" variant="outlined"
autoFocus={autoFocus}
spellCheck="false" spellCheck="false"
placeholder={placeholder ?? t("shared.placeholders.filter")} placeholder={placeholder ?? t("shared.placeholders.filter")}
sx={{ input: { py: 0.65, px: 1.25 } }} sx={{ input: { py: 0.65, px: 1.25 } }}
onChange={onChange} value={text}
error={!!errorMessage} onChange={handleChangeText}
error={!!effectiveErrorMessage}
slotProps={{ slotProps={{
input: { input: {
sx: { pr: 1 }, sx: { pr: 1 },
@ -220,7 +206,7 @@ export const BaseSearchBox = ({
component={matchCaseIcon} component={matchCaseIcon}
{...iconStyle} {...iconStyle}
aria-label={matchCase ? "active" : "inactive"} aria-label={matchCase ? "active" : "inactive"}
onClick={() => setMatchCase((prev) => !prev)} onClick={handleToggleMatchCase}
/> />
</div> </div>
</Tooltip> </Tooltip>
@ -230,7 +216,7 @@ export const BaseSearchBox = ({
component={matchWholeWordIcon} component={matchWholeWordIcon}
{...iconStyle} {...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"} aria-label={matchWholeWord ? "active" : "inactive"}
onClick={() => setMatchWholeWord((prev) => !prev)} onClick={handleToggleMatchWholeWord}
/> />
</div> </div>
</Tooltip> </Tooltip>

View File

@ -15,6 +15,7 @@ import { Box, IconButton, TextField, SxProps } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug"; import { debugLog } from "@/utils/debug";
@ -43,7 +44,16 @@ export const ProxyHead = ({
onLocation, onLocation,
onCheckDelay, onCheckDelay,
}: Props) => { }: Props) => {
const { showType, sortType, filterText, textState, testUrl } = headState; const {
showType,
sortType,
filterText,
textState,
testUrl,
filterMatchCase,
filterMatchWholeWord,
filterUseRegularExpression,
} = headState;
const { t } = useTranslation(); const { t } = useTranslation();
const [autoFocus, setAutoFocus] = useState(false); const [autoFocus, setAutoFocus] = useState(false);
@ -154,17 +164,25 @@ export const ProxyHead = ({
</IconButton> </IconButton>
{textState === "filter" && ( {textState === "filter" && (
<TextField <Box sx={{ ml: 0.5, flex: "1 1 auto" }}>
autoComplete="new-password" <BaseSearchBox
autoFocus={autoFocus} autoFocus={autoFocus}
hiddenLabel value={filterText}
value={filterText} searchState={{
size="small" matchCase: filterMatchCase,
variant="outlined" matchWholeWord: filterMatchWholeWord,
placeholder={t("shared.placeholders.filter")} useRegularExpression: filterUseRegularExpression,
onChange={(e) => onHeadState({ filterText: e.target.value })} }}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} onSearch={(_, state) =>
/> onHeadState({
filterText: state.text,
filterMatchCase: state.matchCase,
filterMatchWholeWord: state.matchWholeWord,
filterUseRegularExpression: state.useRegularExpression,
})
}
/>
</Box>
)} )}
{textState === "url" && ( {textState === "url" && (

View File

@ -2,15 +2,23 @@ import { useEffect, useMemo, useReducer } from "react";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
import { compileStringMatcher } from "@/utils/search-matcher";
// default | delay | alphabet // default | delay | alphabet
export type ProxySortType = 0 | 1 | 2; export type ProxySortType = 0 | 1 | 2;
export type ProxySearchState = {
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
};
export default function useFilterSort( export default function useFilterSort(
proxies: IProxyItem[], proxies: IProxyItem[],
groupName: string, groupName: string,
filterText: string, filterText: string,
sortType: ProxySortType, sortType: ProxySortType,
searchState?: ProxySearchState,
) { ) {
const { verge } = useVerge(); const { verge } = useVerge();
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0); const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
@ -33,7 +41,7 @@ export default function useFilterSort(
}, [groupName]); }, [groupName]);
return useMemo(() => { return useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText); const fp = filterProxies(proxies, groupName, filterText, searchState);
const sp = sortProxies( const sp = sortProxies(
fp, fp,
groupName, groupName,
@ -46,6 +54,7 @@ export default function useFilterSort(
groupName, groupName,
filterText, filterText,
sortType, sortType,
searchState,
verge?.default_latency_timeout, verge?.default_latency_timeout,
]); ]);
} }
@ -56,8 +65,9 @@ export function filterSort(
filterText: string, filterText: string,
sortType: ProxySortType, sortType: ProxySortType,
latencyTimeout?: number, latencyTimeout?: number,
searchState?: ProxySearchState,
) { ) {
const fp = filterProxies(proxies, groupName, filterText); const fp = filterProxies(proxies, groupName, filterText, searchState);
const sp = sortProxies(fp, groupName, sortType, latencyTimeout); const sp = sortProxies(fp, groupName, sortType, latencyTimeout);
return sp; return sp;
} }
@ -76,10 +86,12 @@ function filterProxies(
proxies: IProxyItem[], proxies: IProxyItem[],
groupName: string, groupName: string,
filterText: 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) { if (res1) {
const symbol = res1[1]; const symbol = res1[1];
const symbol2 = res1[2].toLowerCase(); const symbol2 = res1[2].toLowerCase();
@ -100,13 +112,25 @@ function filterProxies(
}); });
} }
const res2 = regex2.exec(filterText); const res2 = regex2.exec(query);
if (res2) { if (res2) {
const type = res2[1].toLowerCase(); const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type)); 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));
} }
/** /**

View File

@ -9,6 +9,9 @@ export interface HeadState {
showType: boolean; showType: boolean;
sortType: ProxySortType; sortType: ProxySortType;
filterText: string; filterText: string;
filterMatchCase?: boolean;
filterMatchWholeWord?: boolean;
filterUseRegularExpression?: boolean;
textState: "url" | "filter" | null; textState: "url" | "filter" | null;
testUrl: string; testUrl: string;
} }
@ -21,6 +24,9 @@ export const DEFAULT_STATE: HeadState = {
showType: true, showType: true,
sortType: 0, sortType: 0,
filterText: "", filterText: "",
filterMatchCase: false,
filterMatchWholeWord: false,
filterUseRegularExpression: false,
textState: null, textState: null,
testUrl: "", testUrl: "",
}; };

View File

@ -390,6 +390,11 @@ export const useRenderList = (
headState.filterText, headState.filterText,
headState.sortType, headState.sortType,
latencyTimeout, latencyTimeout,
{
matchCase: headState.filterMatchCase,
matchWholeWord: headState.filterMatchWholeWord,
useRegularExpression: headState.filterUseRegularExpression,
},
); );
ret.push({ ret.push({

View File

@ -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,
};
};