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