From d303706d513bf9d525438ed62070222295cab70a Mon Sep 17 00:00:00 2001 From: TinsFox Date: Mon, 22 Dec 2025 15:46:11 +0800 Subject: [PATCH] feat: add provider search filter (#435) * feat: add provider search filter * feat: add provider search overlay --- src/components/providers/ProviderList.tsx | 145 +++++++++++++++++++++- src/i18n/locales/en.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/zh.json | 6 + tests/components/ProviderList.test.tsx | 43 +++++++ 5 files changed, 200 insertions(+), 6 deletions(-) diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index f99579130..db5fb2212 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -5,13 +5,24 @@ import { useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { CSSProperties } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Search, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { useDragSort } from "@/hooks/useDragSort"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; interface ProviderListProps { providers: Record; @@ -44,9 +55,10 @@ export function ProviderList({ isProxyRunning = false, // 默认值为 false isProxyTakeover = false, // 默认值为 false }: ProviderListProps) { + const { t } = useTranslation(); const { sortedProviders, sensors, handleDragEnd } = useDragSort( providers, - appId, + appId ); // 流式健康检查 @@ -56,13 +68,56 @@ export function ProviderList({ checkProvider(provider.id, provider.name); }; + const [searchTerm, setSearchTerm] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if ((event.metaKey || event.ctrlKey) && key === "f") { + event.preventDefault(); + setIsSearchOpen(true); + return; + } + + if (key === "escape") { + setIsSearchOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + useEffect(() => { + if (isSearchOpen) { + const frame = requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + return () => cancelAnimationFrame(frame); + } + }, [isSearchOpen]); + + const filteredProviders = useMemo(() => { + const keyword = searchTerm.trim().toLowerCase(); + if (!keyword) return sortedProviders; + return sortedProviders.filter((provider) => { + const fields = [provider.name, provider.notes, provider.websiteUrl]; + return fields.some((field) => + field?.toString().toLowerCase().includes(keyword) + ); + }); + }, [searchTerm, sortedProviders]); + if (isLoading) { return (
{[0, 1, 2].map((index) => (
))}
@@ -73,18 +128,18 @@ export function ProviderList({ return ; } - return ( + const renderProviderList = () => ( provider.id)} + items={filteredProviders.map((provider) => provider.id)} strategy={verticalListSortingStrategy} >
- {sortedProviders.map((provider) => ( + {filteredProviders.map((provider) => ( ); + + return ( +
+ + {isSearchOpen && ( + +
+
+ + setSearchTerm(event.target.value)} + placeholder={t("provider.searchPlaceholder", { + defaultValue: "Search name, notes, or URL...", + })} + aria-label={t("provider.searchAriaLabel", { + defaultValue: "Search providers", + })} + className="pr-16 pl-9" + /> + {searchTerm && ( + + )} + +
+
+ + {t("provider.searchScopeHint", { + defaultValue: "Matches provider name, notes, and URL.", + })} + + + {t("provider.searchCloseHint", { + defaultValue: "Press Esc to close", + })} + +
+
+
+ )} +
+ + {filteredProviders.length === 0 ? ( +
+ {t("provider.noSearchResults", { + defaultValue: "No providers match your search.", + })} +
+ ) : ( + renderProviderList() + )} +
+ ); } interface SortableProviderCardProps { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34ca1b526..051d4affc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "Remove from Claude plugin", "dragToReorder": "Drag to reorder", "dragHandle": "Drag to reorder", + "searchPlaceholder": "Search name, notes, or URL...", + "searchAriaLabel": "Search providers", + "searchScopeHint": "Matches provider name, notes, and URL.", + "searchCloseHint": "Press Esc to close", + "searchCloseAriaLabel": "Close provider search", + "noSearchResults": "No providers match your search.", "duplicate": "Duplicate", "sortUpdateFailed": "Failed to update sort order", "configureUsage": "Configure usage query", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9ca5e909a..6554839b7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "Claude プラグインから解除", "dragToReorder": "ドラッグで並べ替え", "dragHandle": "ドラッグで並べ替え", + "searchPlaceholder": "名前・メモ・URLで検索...", + "searchAriaLabel": "プロバイダーを検索", + "searchScopeHint": "名前・メモ・URL を対象に検索します。", + "searchCloseHint": "Esc で閉じる", + "searchCloseAriaLabel": "検索を閉じる", + "noSearchResults": "一致するプロバイダーがありません。", "duplicate": "複製", "sortUpdateFailed": "並び順の更新に失敗しました", "configureUsage": "利用状況を設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5976a0cf5..2b7d7cf5e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -87,6 +87,12 @@ "removeFromClaudePlugin": "从 Claude 插件移除", "dragToReorder": "拖拽以重新排序", "dragHandle": "拖拽排序", + "searchPlaceholder": "按名称/备注/网址搜索供应商...", + "searchAriaLabel": "搜索供应商", + "searchScopeHint": "根据名称、备注和官网链接匹配结果。", + "searchCloseHint": "按 Esc 关闭", + "searchCloseAriaLabel": "关闭供应商搜索", + "noSearchResults": "没有符合搜索条件的供应商。", "duplicate": "复制", "sortUpdateFailed": "排序更新失败", "configureUsage": "配置用量查询", diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index 4c0a16884..f50db3437 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -235,4 +235,47 @@ describe("ProviderList Component", () => { "claude", ); }); + + it("filters providers with the search input", () => { + const providerAlpha = createProvider({ id: "alpha", name: "Alpha Labs" }); + const providerBeta = createProvider({ id: "beta", name: "Beta Works" }); + + useDragSortMock.mockReturnValue({ + sortedProviders: [providerAlpha, providerBeta], + sensors: [], + handleDragEnd: vi.fn(), + }); + + render( + , + ); + + fireEvent.keyDown(window, { key: "f", metaKey: true }); + const searchInput = screen.getByPlaceholderText( + "Search name, notes, or URL...", + ); + // Initially both providers are rendered + expect(screen.getByTestId("provider-card-alpha")).toBeInTheDocument(); + expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "beta" } }); + expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument(); + expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "gamma" } }); + expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument(); + expect(screen.queryByTestId("provider-card-beta")).not.toBeInTheDocument(); + expect( + screen.getByText("No providers match your search."), + ).toBeInTheDocument(); + }); });