feat: add provider search filter (#435)

* feat: add provider search filter

* feat: add provider search overlay
This commit is contained in:
TinsFox
2025-12-22 15:46:11 +08:00
committed by GitHub
parent 97495d1550
commit d303706d51
5 changed files with 200 additions and 6 deletions
+139 -6
View File
@@ -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<string, Provider>;
@@ -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<HTMLInputElement>(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 (
<div className="space-y-3">
{[0, 1, 2].map((index) => (
<div
key={index}
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
/>
))}
</div>
@@ -73,18 +128,18 @@ export function ProviderList({
return <ProviderEmptyState onCreate={onCreate} />;
}
return (
const renderProviderList = () => (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedProviders.map((provider) => provider.id)}
items={filteredProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sortedProviders.map((provider) => (
{filteredProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
@@ -106,6 +161,84 @@ export function ProviderList({
</SortableContext>
</DndContext>
);
return (
<div className="mt-4 space-y-4">
<AnimatePresence>
{isSearchOpen && (
<motion.div
key="provider-search"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
>
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
<div className="relative flex items-center gap-2">
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(event) => 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 && (
<Button
variant="ghost"
size="sm"
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
onClick={() => setSearchTerm("")}
>
{t("common.clear", { defaultValue: "Clear" })}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => setIsSearchOpen(false)}
aria-label={t("provider.searchCloseAriaLabel", {
defaultValue: "Close provider search",
})}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>
{t("provider.searchScopeHint", {
defaultValue: "Matches provider name, notes, and URL.",
})}
</span>
<span>
{t("provider.searchCloseHint", {
defaultValue: "Press Esc to close",
})}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{filteredProviders.length === 0 ? (
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
{t("provider.noSearchResults", {
defaultValue: "No providers match your search.",
})}
</div>
) : (
renderProviderList()
)}
</div>
);
}
interface SortableProviderCardProps {