feat(providers): add preset search and sorting (#3975)

This commit is contained in:
Peng Steam
2026-06-11 17:14:53 +08:00
committed by GitHub
parent daa5595f36
commit c1aa6c3917
6 changed files with 568 additions and 73 deletions
@@ -1,7 +1,21 @@
import { useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { Zap, Star, Layers, Settings2 } from "lucide-react";
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
@@ -16,7 +30,17 @@ import {
} from "@/config/universalProviderPresets";
import { ProviderIcon } from "@/components/ProviderIcon";
type AnyPreset =
type PresetTranslator = (key: string) => unknown;
export const PresetSortMode = {
Original: "original",
NameAsc: "nameAsc",
} as const;
export type PresetSortMode =
(typeof PresetSortMode)[keyof typeof PresetSortMode];
export type AnyPreset =
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
@@ -25,11 +49,91 @@ type AnyPreset =
| OpenClawProviderPreset
| HermesProviderPreset;
type PresetEntry = {
export type PresetEntry = {
id: string;
preset: AnyPreset;
};
export function getPresetDisplayName(
preset: AnyPreset,
t: PresetTranslator,
): string {
return preset.nameKey ? String(t(preset.nameKey)) : preset.name;
}
export function getPresetSearchText(
entry: PresetEntry,
presetCategoryLabels: Record<string, string>,
t: PresetTranslator,
): string {
const presetCategory = entry.preset.category ?? "others";
const categoryLabel =
presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other"));
return [
getPresetDisplayName(entry.preset, t),
entry.preset.name,
entry.preset.websiteUrl,
categoryLabel,
]
.join(" ")
.toLowerCase();
}
export function filterPresetEntries(
entries: PresetEntry[],
query: string,
presetCategoryLabels: Record<string, string>,
t: PresetTranslator,
): PresetEntry[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return entries;
}
return entries.filter((entry) =>
getPresetSearchText(entry, presetCategoryLabels, t).includes(
normalizedQuery,
),
);
}
export function sortPresetEntries(
entries: PresetEntry[],
sortMode: PresetSortMode,
t: PresetTranslator,
): PresetEntry[] {
if (sortMode === PresetSortMode.Original) {
return [...entries];
}
return [...entries].sort((a, b) =>
getPresetDisplayName(a.preset, t).localeCompare(
getPresetDisplayName(b.preset, t),
),
);
}
export interface PresetVisibilityOptions {
query: string;
sortMode: PresetSortMode;
presetCategoryLabels: Record<string, string>;
t: PresetTranslator;
}
export function getVisiblePresetEntries(
entries: PresetEntry[],
options: PresetVisibilityOptions,
): PresetEntry[] {
const { query, sortMode, presetCategoryLabels, t } = options;
return sortPresetEntries(
filterPresetEntries(entries, query, presetCategoryLabels, t),
sortMode,
t,
);
}
interface ProviderPresetSelectorProps {
selectedPresetId: string | null;
presetEntries: PresetEntry[];
@@ -50,8 +154,24 @@ export function ProviderPresetSelector({
category,
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortMode, setSortMode] = useState<PresetSortMode>(
PresetSortMode.Original,
);
const getCategoryHint = (): React.ReactNode => {
const visiblePresetEntries = useMemo(
() =>
getVisiblePresetEntries(presetEntries, {
query: searchQuery,
sortMode,
presetCategoryLabels,
t,
}),
[presetEntries, presetCategoryLabels, searchQuery, sortMode, t],
);
const getCategoryHint = (): ReactNode => {
switch (category) {
case "official":
return t("providerForm.officialHint", {
@@ -85,6 +205,14 @@ export function ProviderPresetSelector({
}
};
const toggleSortMode = () => {
setSortMode((current) =>
current === PresetSortMode.Original
? PresetSortMode.NameAsc
: PresetSortMode.Original,
);
};
const renderPresetIcon = (preset: AnyPreset) => {
const iconType = preset.theme?.icon;
if (!iconType) return null;
@@ -130,7 +258,88 @@ export function ProviderPresetSelector({
return (
<div className="space-y-3">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1">
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className={
searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-72 p-2 border-border-default"
>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("providerPreset.searchPlaceholder", {
defaultValue: "Search presets...",
})}
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
autoFocus
/>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -144,7 +353,15 @@ export function ProviderPresetSelector({
{t("providerPreset.custom")}
</button>
{presetEntries.map((entry) => {
{visiblePresetEntries.length === 0 && (
<div className="w-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
{t("providerPreset.noSearchResults", {
defaultValue: "No matching presets.",
})}
</div>
)}
{visiblePresetEntries.map((entry) => {
const isSelected = selectedPresetId === entry.id;
const isPartner = entry.preset.isPartner;
const presetCategory = entry.preset.category ?? "others";
@@ -161,9 +378,7 @@ export function ProviderPresetSelector({
}
>
{renderPresetIcon(entry.preset)}
{entry.preset.nameKey
? t(entry.preset.nameKey)
: entry.preset.name}
{getPresetDisplayName(entry.preset, t)}
{isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
+8 -1
View File
@@ -1327,7 +1327,14 @@
"label": "Provider Preset",
"custom": "Custom Configuration",
"other": "Other",
"hint": "You can continue to adjust the fields below after selecting a preset."
"hint": "You can continue to adjust the fields below after selecting a preset.",
"searchTooltip": "Search presets",
"searchAriaLabel": "Search provider presets",
"searchPlaceholder": "Search name, website, or category...",
"sortAriaLabel": "Toggle preset sorting",
"sortNameAscTooltip": "Sort by name A-Z",
"sortOriginalTooltip": "Restore original order",
"noSearchResults": "No provider presets match your search."
},
"usage": {
"title": "Usage Statistics",
+8 -1
View File
@@ -1327,7 +1327,14 @@
"label": "プロバイダータイプ",
"custom": "カスタム設定",
"other": "その他",
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。",
"searchTooltip": "プリセットを検索",
"searchAriaLabel": "プロバイダープリセットを検索",
"searchPlaceholder": "名前、サイト、カテゴリで検索...",
"sortAriaLabel": "プリセットの並び順を切り替え",
"sortNameAscTooltip": "名前順 A-Z で並び替え",
"sortOriginalTooltip": "元の順序に戻す",
"noSearchResults": "一致するプロバイダープリセットがありません。"
},
"usage": {
"title": "利用統計",
+8 -1
View File
@@ -1299,7 +1299,14 @@
"label": "預設供應商",
"custom": "自訂設定",
"other": "其他",
"hint": "選擇預設後可繼續調整下方欄位。"
"hint": "選擇預設後可繼續調整下方欄位。",
"searchTooltip": "搜尋預設",
"searchAriaLabel": "搜尋預設供應商",
"searchPlaceholder": "搜尋名稱、官網或分類...",
"sortAriaLabel": "切換預設排序",
"sortNameAscTooltip": "按名稱 A-Z 排序",
"sortOriginalTooltip": "恢復原始順序",
"noSearchResults": "沒有相符的預設供應商。"
},
"usage": {
"title": "使用統計",
+8 -1
View File
@@ -1327,7 +1327,14 @@
"label": "预设供应商",
"custom": "自定义配置",
"other": "其他",
"hint": "选择预设后可继续调整下方字段。"
"hint": "选择预设后可继续调整下方字段。",
"searchTooltip": "搜索预设",
"searchAriaLabel": "搜索预设供应商",
"searchPlaceholder": "搜索名称、官网或分类...",
"sortAriaLabel": "切换预设排序",
"sortNameAscTooltip": "按名称 A-Z 排序",
"sortOriginalTooltip": "恢复原始顺序",
"noSearchResults": "没有匹配的预设供应商。"
},
"usage": {
"title": "使用统计",
+312 -60
View File
@@ -1,71 +1,323 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import { useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import { ProviderPresetSelector } from "@/components/providers/forms/ProviderPresetSelector";
import type { ProviderCategory } from "@/types";
import {
ProviderPresetSelector,
filterPresetEntries,
getPresetDisplayName,
getPresetSearchText,
getVisiblePresetEntries,
sortPresetEntries,
type PresetSortMode,
} from "@/components/providers/forms/ProviderPresetSelector";
describe("ProviderPresetSelector", () => {
it("按传入的预设数组顺序渲染,不按分类重新排序", () => {
const Wrapper = () => {
const form = useForm();
const presetCategoryLabels = {
official: "官方",
cn_official: "国产官方",
aggregator: "聚合服务",
third_party: "第三方",
};
return (
<Form {...form}>
<ProviderPresetSelector
selectedPresetId="custom"
presetEntries={[
{
id: "preset-0",
preset: {
name: "First",
websiteUrl: "https://first.example.com",
settingsConfig: {},
category: "third_party",
},
},
{
id: "preset-1",
preset: {
name: "Second",
websiteUrl: "https://second.example.com",
settingsConfig: {},
category: "official",
},
},
{
id: "preset-2",
preset: {
name: "Third",
websiteUrl: "https://third.example.com",
settingsConfig: {},
category: "aggregator",
},
},
{
id: "preset-3",
preset: {
name: "Fourth",
websiteUrl: "https://fourth.example.com",
settingsConfig: {},
category: "official",
},
},
]}
presetCategoryLabels={{
official: "官方",
aggregator: "聚合服务",
third_party: "第三方",
}}
onPresetChange={vi.fn()}
/>
</Form>
);
};
const translations: Record<string, string> = {
"preset.alpha": "Alpha 本地名",
"preset.gamma": "Gamma 本地名",
};
render(<Wrapper />);
const t = ((key: string) => translations[key] ?? key) as TFunction;
type TestPresetEntry = {
id: string;
preset: {
name: string;
nameKey?: string;
websiteUrl: string;
settingsConfig: Record<string, never>;
category: ProviderCategory;
};
};
const presetEntries: TestPresetEntry[] = [
{
id: "gamma",
preset: {
name: "Gamma Raw",
nameKey: "preset.gamma",
websiteUrl: "https://gamma.example.com",
settingsConfig: {},
category: "aggregator",
},
},
{
id: "alpha",
preset: {
name: "Alpha Raw",
nameKey: "preset.alpha",
websiteUrl: "https://alpha.example.com/v1",
settingsConfig: {},
category: "official",
},
},
{
id: "beta",
preset: {
name: "Beta Gateway",
websiteUrl: "https://CN-Gateway.example.com",
settingsConfig: {},
category: "cn_official",
},
},
{
id: "delta",
preset: {
name: "Delta Mirror",
websiteUrl: "https://delta.example.com",
settingsConfig: {},
category: "third_party",
},
},
] satisfies TestPresetEntry[];
function getIds(entries: ReadonlyArray<{ id: string }>) {
return entries.map((entry) => entry.id);
}
function renderSelector({
entries = presetEntries,
onPresetChange = vi.fn(),
}: {
entries?: TestPresetEntry[];
onPresetChange?: (value: string) => void;
} = {}) {
const Wrapper = () => {
const form = useForm();
return (
<Form {...form}>
<ProviderPresetSelector
selectedPresetId="custom"
presetEntries={entries}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={onPresetChange}
/>
</Form>
);
};
return render(<Wrapper />);
}
function getPresetButtonTexts() {
const knownNames = new Set([
"providerPreset.custom",
...presetEntries.flatMap((entry) => [
entry.preset.name,
entry.preset.nameKey ?? entry.preset.name,
]),
]);
return screen
.getAllByRole("button")
.map((button) => button.textContent?.trim() ?? "")
.filter((text) => knownNames.has(text));
}
function getSearchButton() {
return screen.getByRole("button", {
name: /providerPreset\.(search|searchAriaLabel|openSearch)|搜索|search/i,
});
}
function getSortButton() {
return screen.getByRole("button", {
name: /providerPreset\.(sort|sortByName|restoreOriginalOrder)|按名称排序|恢复原顺序|sort/i,
});
}
function getSearchInput() {
return screen.getByRole("textbox", {
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
});
}
describe("ProviderPresetSelector pure helpers", () => {
it("优先使用 nameKey 翻译作为显示名,否则使用原始 name", () => {
expect(getPresetDisplayName(presetEntries[1].preset, t)).toBe(
"Alpha 本地名",
);
expect(getPresetDisplayName(presetEntries[2].preset, t)).toBe(
"Beta Gateway",
);
});
it("拼接显示名、原始名称、URL、分类 label,并统一 lower-case", () => {
const searchText = getPresetSearchText(
presetEntries[1],
presetCategoryLabels,
t,
);
expect(searchText).toContain("alpha 本地名");
expect(searchText).toContain("alpha raw");
expect(searchText).toContain("https://alpha.example.com/v1");
expect(searchText).toContain("官方");
expect(searchText).toBe(searchText.toLowerCase());
});
it("空 query 返回原数组,非空 query 大小写不敏感匹配", () => {
expect(
filterPresetEntries(presetEntries, " ", presetCategoryLabels, t),
).toBe(presetEntries);
expect(
getIds(
filterPresetEntries(
presetEntries,
"ALPHA 本地名",
presetCategoryLabels,
t,
),
),
).toEqual(["alpha"]);
});
it("支持通过 URL 和分类 label 搜索", () => {
expect(
getIds(
filterPresetEntries(
presetEntries,
"cn-gateway.example.com",
presetCategoryLabels,
t,
),
),
).toEqual(["beta"]);
expect(
getIds(
filterPresetEntries(presetEntries, "聚合", presetCategoryLabels, t),
),
).toEqual(["gamma"]);
});
it("支持 A-Z 排序、original 副本恢复原顺序,并且 getVisible 先 filter 再 sort", () => {
const originalMode: PresetSortMode = "original";
const nameAscMode: PresetSortMode = "nameAsc";
const original = sortPresetEntries(presetEntries, originalMode, t);
expect(original).not.toBe(presetEntries);
expect(getIds(original)).toEqual(["gamma", "alpha", "beta", "delta"]);
expect(getIds(sortPresetEntries(presetEntries, nameAscMode, t))).toEqual([
"alpha",
"beta",
"delta",
"gamma",
]);
expect(getIds(presetEntries)).toEqual(["gamma", "alpha", "beta", "delta"]);
expect(
screen.getAllByRole("button").map((button) => button.textContent),
).toEqual(["providerPreset.custom", "First", "Second", "Third", "Fourth"]);
getIds(
getVisiblePresetEntries(presetEntries, {
query: "a",
sortMode: nameAscMode,
presetCategoryLabels,
t,
}),
),
).toEqual(["alpha", "beta", "delta", "gamma"]);
});
});
describe("ProviderPresetSelector", () => {
it("默认按传入的预设数组顺序渲染,不按分类或名称重新排序", () => {
renderSelector();
expect(getPresetButtonTexts()).toEqual([
"providerPreset.custom",
"preset.gamma",
"preset.alpha",
"Beta Gateway",
"Delta Mirror",
]);
});
it("点击排序按钮后普通 preset A-Z,再点恢复原顺序", async () => {
const user = userEvent.setup();
renderSelector();
await user.click(getSortButton());
expect(getPresetButtonTexts()).toEqual([
"providerPreset.custom",
"Beta Gateway",
"Delta Mirror",
"preset.alpha",
"preset.gamma",
]);
await user.click(getSortButton());
expect(getPresetButtonTexts()).toEqual([
"providerPreset.custom",
"preset.gamma",
"preset.alpha",
"Beta Gateway",
"Delta Mirror",
]);
});
it("搜索只过滤普通 preset,自定义配置始终保留", async () => {
const user = userEvent.setup();
renderSelector();
await user.click(getSearchButton());
await user.type(getSearchInput(), "gateway");
expect(
screen.getByRole("button", { name: "providerPreset.custom" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Beta Gateway" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "preset.gamma" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "preset.alpha" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Delta Mirror" }),
).not.toBeInTheDocument();
});
it("搜索无普通 preset 结果时保留自定义配置并显示空状态", async () => {
const user = userEvent.setup();
renderSelector();
await user.click(getSearchButton());
await user.type(getSearchInput(), "not-found");
expect(
screen.getByRole("button", { name: "providerPreset.custom" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "preset.gamma" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "preset.alpha" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Beta Gateway" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Delta Mirror" }),
).not.toBeInTheDocument();
expect(
screen.getByText(
/providerPreset\.(empty|noResults)|没有匹配|无结果|no matching presets/i,
),
).toBeInTheDocument();
});
});