diff --git a/src/components/base/base-search-panel.tsx b/src/components/base/base-search-panel.tsx new file mode 100644 index 000000000..e4cb15253 --- /dev/null +++ b/src/components/base/base-search-panel.tsx @@ -0,0 +1,279 @@ +import { CheckRounded, FilterListRounded } from "@mui/icons-material"; +import { + Badge, + Box, + Button, + IconButton, + List, + ListItemButton, + Popover, + Tooltip, + Typography, +} from "@mui/material"; +import { type ComponentProps, useRef } from "react"; + +import { BaseSearchBox } from "./base-search-box"; +import { BaseStyledTextField } from "./base-styled-text-field"; + +export type BaseSearchPanelField = { + key: T; + label: string; + count?: number; +}; + +type BaseSearchBoxProps = ComponentProps; + +type BaseSearchPanelProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSearch: BaseSearchBoxProps["onSearch"]; + searchBoxProps?: Omit; + searchPlaceholder?: string; + filterLabel?: string; + title?: string; + fields: BaseSearchPanelField[]; + activeField: T; + onActiveFieldChange: (field: T) => void; + options: string[]; + isOptionSelected: (option: string) => boolean; + onToggleOption: (option: string) => void; + searchValue: string; + onSearchValueChange: (value: string) => void; + onSearchSubmit?: (value: string) => void; + emptyText?: string; + clearLabel?: string; + clearDisabled?: boolean; + onClear?: () => void; + showIndicator?: boolean; +}; + +export const BaseSearchPanel = ({ + open, + onOpenChange, + onSearch, + searchBoxProps, + searchPlaceholder, + filterLabel, + title, + fields, + activeField, + onActiveFieldChange, + options, + isOptionSelected, + onToggleOption, + searchValue, + onSearchValueChange, + onSearchSubmit, + emptyText, + clearLabel, + clearDisabled, + onClear, + showIndicator, +}: BaseSearchPanelProps) => { + const anchorRef = useRef(null); + + const handleToggleOpen = () => { + onOpenChange(!open); + }; + + const handleClose = () => { + onOpenChange(false); + }; + + const anchorWidth = anchorRef.current?.clientWidth; + const placeholderProps = searchPlaceholder + ? { placeholder: searchPlaceholder } + : {}; + + return ( + <> + + + + + + + + + + + + + + + + {title} + {onClear && clearLabel ? ( + + ) : null} + + + + + {fields.map((field) => ( + onActiveFieldChange(field.key)} + sx={{ px: 1.25, py: 0.75 }} + > + + {field.label} + {field.count ? ( + + {field.count} + + ) : null} + + + ))} + + + + + + onSearchValueChange(event.target.value ?? "") + } + onKeyDown={(event) => { + if (event.key !== "Enter" || !onSearchSubmit) return; + event.preventDefault(); + onSearchSubmit(searchValue); + }} + /> + + + {options.length === 0 ? ( + + {emptyText} + + ) : ( + + {options.map((option) => { + const selected = isOptionSelected(option); + return ( + onToggleOption(option)} + sx={{ px: 1.5, py: 0.75 }} + > + + + {option} + + {selected ? ( + + ) : null} + + + ); + })} + + )} + + + + + + + ); +}; diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 8effa81a2..c9a9cb562 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -5,6 +5,10 @@ export { BaseFieldset } from "./base-fieldset"; export { BaseLoading } from "./base-loading"; export { BaseLoadingOverlay } from "./base-loading-overlay"; export { BasePage } from "./base-page"; +export { + BaseSearchPanel, + type BaseSearchPanelField, +} from "./base-search-panel"; export { BaseSearchBox, type SearchState } from "./base-search-box"; export { BaseSplitChipEditor, diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index fa1b8c2a4..5e56d2595 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -1,28 +1,21 @@ import { DeleteForeverRounded, - FilterListRounded, PauseCircleOutlineRounded, PlayCircleOutlineRounded, TableChartRounded, TableRowsRounded, } from "@mui/icons-material"; import { - Autocomplete, - Badge, Box, Button, ButtonGroup, Fab, IconButton, MenuItem, - Popover, - Stack, - TextField, - Tooltip, Zoom, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Virtuoso } from "react-virtuoso"; import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api"; @@ -30,8 +23,9 @@ import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api"; import { BaseEmpty, BasePage, - BaseSearchBox, + BaseSearchPanel, BaseStyledSelect, + type BaseSearchPanelField, type SearchState, } from "@/components/base"; import { @@ -111,6 +105,8 @@ type ConnectionFilters = { destinationPort: string[]; }; +type FilterField = keyof ConnectionFilters; + const EMPTY_FILTERS: ConnectionFilters = { host: [], sourceIP: [], @@ -120,6 +116,14 @@ const EMPTY_FILTERS: ConnectionFilters = { destinationPort: [], }; +const normalizeFilterValue = (field: FilterField, value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + return field === "host" || field === "network" + ? trimmed.toLowerCase() + : trimmed; +}; + const getUniqueValues = (values: Array) => { const set = new Set(); values.forEach((value) => { @@ -140,9 +144,10 @@ const ConnectionsPage = () => { "active", ); const [filters, setFilters] = useState(EMPTY_FILTERS); - const [filterAnchorEl, setFilterAnchorEl] = useState( - null, - ); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [activeFilterField, setActiveFilterField] = + useState("sourceIP"); + const [filterQuery, setFilterQuery] = useState(""); const [paused, setPaused] = useState(false); const { @@ -196,6 +201,36 @@ const ConnectionsPage = () => { }; }, [baseConnections]); + const filterFields = useMemo[]>( + () => [ + { + key: "sourceIP" as const, + label: t("connections.components.fields.sourceIP"), + }, + { + key: "destinationIP" as const, + label: t("connections.components.fields.destinationIP"), + }, + { + key: "host" as const, + label: t("connections.components.fields.host"), + }, + { + key: "network" as const, + label: t("connections.components.fields.network"), + }, + { + key: "sourcePort" as const, + label: t("connections.components.fields.sourcePort"), + }, + { + key: "destinationPort" as const, + label: t("connections.components.fields.destinationPort"), + }, + ], + [t], + ); + const normalizedFilters = useMemo( () => ({ host: new Set( @@ -222,6 +257,35 @@ const ConnectionsPage = () => { [filters], ); + useEffect(() => { + setFilterQuery(""); + }, [activeFilterField]); + + const activeFieldOptions = useMemo(() => { + const options = filterOptions[activeFilterField] ?? []; + const selected = filters[activeFilterField] ?? []; + const map = new Map(); + selected.forEach((value) => { + const normalized = normalizeFilterValue(activeFilterField, value); + if (!normalized) return; + map.set(normalized, value.trim()); + }); + options.forEach((value) => { + const normalized = normalizeFilterValue(activeFilterField, value); + if (!normalized || map.has(normalized)) return; + map.set(normalized, value); + }); + return Array.from(map.values()); + }, [activeFilterField, filterOptions, filters]); + + const visibleFieldOptions = useMemo(() => { + const query = filterQuery.trim().toLowerCase(); + if (!query) return activeFieldOptions; + return activeFieldOptions.filter((option) => + option.toLowerCase().includes(query), + ); + }, [activeFieldOptions, filterQuery]); + const [filterConn] = useMemo(() => { const orderFunc = orderFunctionMap[curOrderOpt]; let matchConns = baseConnections.filter((conn) => { @@ -322,6 +386,43 @@ const ConnectionsPage = () => { const detailRef = useRef(null!); + const isValueSelected = useCallback( + (field: FilterField, value: string) => + normalizedFilters[field].has(normalizeFilterValue(field, value)), + [normalizedFilters], + ); + + const toggleFilterValue = useCallback((field: FilterField, value: string) => { + const normalized = normalizeFilterValue(field, value); + if (!normalized) return; + const trimmed = value.trim(); + setFilters((prev) => { + const current = prev[field] ?? []; + const next = current.filter( + (item) => normalizeFilterValue(field, item) !== normalized, + ); + if (next.length === current.length) { + return { ...prev, [field]: [...current, trimmed] }; + } + return { ...prev, [field]: next }; + }); + }, []); + + const addFilterValue = useCallback((field: FilterField, value: string) => { + const normalized = normalizeFilterValue(field, value); + if (!normalized) return; + const trimmed = value.trim(); + setFilters((prev) => { + const current = prev[field] ?? []; + if ( + current.some((item) => normalizeFilterValue(field, item) === normalized) + ) { + return prev; + } + return { ...prev, [field]: [...current, trimmed] }; + }); + }, []); + const handleSearch = useCallback( (matcher: (content: string) => boolean, state: SearchState) => { setMatch(() => matcher); @@ -332,16 +433,6 @@ const ConnectionsPage = () => { const hasTableData = filterConn.length > 0; - const handleFilterChange = useCallback( - (key: keyof ConnectionFilters) => (_: unknown, values: string[]) => { - const nextValues = Array.from( - new Set(values.map((value) => value.trim()).filter(Boolean)), - ); - setFilters((prev) => ({ ...prev, [key]: nextValues })); - }, - [], - ); - const handleClearFilters = useCallback(() => { setFilters({ ...EMPTY_FILTERS }); }, []); @@ -460,158 +551,39 @@ const ConnectionsPage = () => { ))} )} - - - - setFilterAnchorEl((prev) => (prev ? null : event.currentTarget)) - } - aria-label={t("connections.components.actions.filter")} - > - - - - - setFilterAnchorEl(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} - > - - - - {t("connections.components.actions.filter")} - - - - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - - - *": { - flex: 1, - }, - }} - > - + + ({ + ...field, + count: filters[field.key].length, + }))} + activeField={activeFilterField} + onActiveFieldChange={setActiveFilterField} + options={visibleFieldOptions} + isOptionSelected={(option) => + isValueSelected(activeFilterField, option) + } + onToggleOption={(option) => + toggleFilterValue(activeFilterField, option) + } + searchValue={filterQuery} + onSearchValueChange={setFilterQuery} + onSearchSubmit={(value) => { + addFilterValue(activeFilterField, value); + setFilterQuery(""); + }} + searchPlaceholder={t("shared.placeholders.filter")} + emptyText={t("shared.statuses.empty")} + clearLabel={t("connections.components.actions.clearFilters")} + onClear={handleClearFilters} + clearDisabled={!hasActiveFilters} + />