mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
调整预设供应商按钮外观与搜索框位置 (#4183)
* 调整预设供应商按钮外观与搜索框位置 1. 调整预设供应商按钮外观,显示默认图标,大小统一; 2. 调整预设供应商搜索框位置。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(provider): 新增预设按钮外观与 inline 搜索的单元测试 覆盖: 1. 所有预设按钮固定 200px 宽度,视觉对齐一致 2. preset.icon 存在时按钮内渲染 ProviderIcon 3. preset 无 icon 且无 theme.icon 时渲染占位元素保持文字对齐 4. 点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(provider-preset): responsive grid layout and search polish - Replace fixed-width preset buttons with a responsive CSS grid (auto-fill, 150px min column) - Add a leading placeholder to the custom button so its label aligns with iconed presets - Close the inline search box on outside click, restoring the old Popover behavior - Span the empty-state hint across the full grid row - Update component tests for the new layout and behaviors --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -1,19 +1,8 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, 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 { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
@@ -141,6 +130,25 @@ export function ProviderPresetSelector({
|
||||
const [sortMode, setSortMode] = useState<PresetSortMode>(
|
||||
PresetSortMode.Original,
|
||||
);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为
|
||||
useEffect(() => {
|
||||
if (!searchOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
searchContainerRef.current &&
|
||||
!searchContainerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [searchOpen]);
|
||||
|
||||
const visiblePresetEntries = useMemo(
|
||||
() =>
|
||||
@@ -195,26 +203,38 @@ export function ProviderPresetSelector({
|
||||
};
|
||||
|
||||
const renderPresetIcon = (preset: AnyPreset) => {
|
||||
const iconType = preset.theme?.icon;
|
||||
if (!iconType) return null;
|
||||
|
||||
switch (iconType) {
|
||||
case "claude":
|
||||
return <ClaudeIcon size={14} />;
|
||||
case "codex":
|
||||
return <CodexIcon size={14} />;
|
||||
case "gemini":
|
||||
return <GeminiIcon size={14} />;
|
||||
case "generic":
|
||||
return <Zap size={14} />;
|
||||
default:
|
||||
return null;
|
||||
if (preset.icon) {
|
||||
return (
|
||||
<ProviderIcon
|
||||
icon={preset.icon}
|
||||
name={preset.name}
|
||||
color={preset.iconColor}
|
||||
size={16}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const iconType = preset.theme?.icon;
|
||||
if (iconType) {
|
||||
switch (iconType) {
|
||||
case "claude":
|
||||
return <ClaudeIcon size={14} />;
|
||||
case "codex":
|
||||
return <CodexIcon size={14} />;
|
||||
case "gemini":
|
||||
return <GeminiIcon size={14} />;
|
||||
case "generic":
|
||||
return <Zap size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />;
|
||||
};
|
||||
|
||||
const getPresetButtonClass = (isSelected: boolean, preset: AnyPreset) => {
|
||||
const baseClass =
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||
"inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full";
|
||||
|
||||
if (isSelected) {
|
||||
if (preset.theme?.backgroundColor) {
|
||||
@@ -241,101 +261,95 @@ export function ProviderPresetSelector({
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<div ref={searchContainerRef} className="flex items-center gap-2">
|
||||
{searchOpen && (
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearchQuery("");
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("providerPreset.searchPlaceholder", {
|
||||
defaultValue: "Search presets...",
|
||||
})}
|
||||
aria-label={t("providerPreset.searchAriaLabel", {
|
||||
defaultValue: "Search provider presets",
|
||||
})}
|
||||
className="w-48 h-8"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("providerPreset.searchAriaLabel", {
|
||||
defaultValue: "Search provider presets",
|
||||
})}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => {
|
||||
setSearchOpen((v) => !v);
|
||||
if (searchOpen) setSearchQuery("");
|
||||
}}
|
||||
title={t("providerPreset.searchTooltip", {
|
||||
defaultValue: "Search presets",
|
||||
})}
|
||||
className={
|
||||
searchOpen || searchQuery.trim()
|
||||
? "size-8 bg-accent text-foreground"
|
||||
: "size-8"
|
||||
}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("providerPreset.sortAriaLabel", {
|
||||
defaultValue: "Toggle preset sorting",
|
||||
})}
|
||||
aria-pressed={sortMode === PresetSortMode.NameAsc}
|
||||
onClick={toggleSortMode}
|
||||
title={
|
||||
sortMode === PresetSortMode.NameAsc
|
||||
? t("providerPreset.sortOriginalTooltip", {
|
||||
defaultValue: "Restore original order",
|
||||
})
|
||||
: t("providerPreset.sortNameAscTooltip", {
|
||||
defaultValue: "Sort A-Z",
|
||||
})
|
||||
}
|
||||
className={
|
||||
sortMode === PresetSortMode.NameAsc
|
||||
? "size-8 bg-accent text-foreground"
|
||||
: "size-8"
|
||||
}
|
||||
>
|
||||
<ArrowUpAZ className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPresetChange("custom")}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
className={`inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full ${
|
||||
selectedPresetId === "custom"
|
||||
? "bg-blue-500 text-white dark:bg-blue-600"
|
||||
: "bg-accent text-muted-foreground hover:bg-accent/80"
|
||||
}`}
|
||||
>
|
||||
{t("providerPreset.custom")}
|
||||
<span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">{t("providerPreset.custom")}</span>
|
||||
</button>
|
||||
|
||||
{visiblePresetEntries.length === 0 && (
|
||||
<div className="w-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="col-span-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.",
|
||||
})}
|
||||
@@ -359,7 +373,9 @@ export function ProviderPresetSelector({
|
||||
}
|
||||
>
|
||||
{renderPresetIcon(entry.preset)}
|
||||
{getPresetDisplayName(entry.preset, t)}
|
||||
<span className="truncate">
|
||||
{getPresetDisplayName(entry.preset, t)}
|
||||
</span>
|
||||
{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" />
|
||||
@@ -372,20 +388,25 @@ export function ProviderPresetSelector({
|
||||
|
||||
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
|
||||
{universalProviderPresets.map((preset) => (
|
||||
<button
|
||||
key={`universal-${preset.providerType}`}
|
||||
type="button"
|
||||
onClick={() => onUniversalPresetSelect(preset)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
|
||||
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
|
||||
title={t("universalProvider.hint", {
|
||||
defaultValue:
|
||||
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
|
||||
})}
|
||||
>
|
||||
<ProviderIcon icon={preset.icon} name={preset.name} size={14} />
|
||||
{preset.name}
|
||||
<ProviderIcon
|
||||
icon={preset.icon}
|
||||
name={preset.name}
|
||||
size={14}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Layers className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
@@ -395,15 +416,17 @@ export function ProviderPresetSelector({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageUniversalProviders}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80"
|
||||
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
|
||||
title={t("universalProvider.manage", {
|
||||
defaultValue: "管理统一供应商",
|
||||
})}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("universalProvider.manage", {
|
||||
defaultValue: "管理",
|
||||
})}
|
||||
<Settings2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("universalProvider.manage", {
|
||||
defaultValue: "管理",
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,29 @@ import {
|
||||
type PresetSortMode,
|
||||
} from "@/components/providers/forms/ProviderPresetSelector";
|
||||
|
||||
// Mock ProviderIcon 以避免依赖图标库的实际内容
|
||||
vi.mock("@/components/ProviderIcon", () => ({
|
||||
ProviderIcon: ({
|
||||
icon,
|
||||
name,
|
||||
color,
|
||||
size,
|
||||
}: {
|
||||
icon?: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}) => (
|
||||
<span
|
||||
data-testid="provider-icon"
|
||||
data-icon={icon}
|
||||
data-name={name}
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const presetCategoryLabels = {
|
||||
official: "官方",
|
||||
cn_official: "国产官方",
|
||||
@@ -295,4 +318,130 @@ describe("ProviderPresetSelector", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("所有预设按钮填满网格列宽(w-full)实现等宽对齐", () => {
|
||||
renderSelector();
|
||||
|
||||
const presetButtons = screen.getAllByRole("button");
|
||||
const fullWidthButtons = presetButtons.filter((btn) =>
|
||||
btn.className.includes("w-full"),
|
||||
);
|
||||
|
||||
// 至少包含 custom + 4 个预设 = 5 个等宽按钮(搜索/排序按钮为 size-8 不计入)
|
||||
expect(fullWidthButtons.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("preset.icon 存在时按钮内渲染图标元素(img/svg)", () => {
|
||||
const entriesWithIcon = [
|
||||
{
|
||||
id: "with-icon",
|
||||
preset: {
|
||||
name: "With Icon",
|
||||
websiteUrl: "https://icon.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official" as ProviderCategory,
|
||||
icon: "claude-api",
|
||||
iconColor: "#D4915D",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
renderSelector({ entries: entriesWithIcon });
|
||||
|
||||
const button = screen.getByRole("button", { name: /with icon/i });
|
||||
const icon = button.querySelector('[data-testid="provider-icon"]');
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon?.getAttribute("data-icon")).toBe("claude-api");
|
||||
expect(icon?.getAttribute("data-color")).toBe("#D4915D");
|
||||
});
|
||||
|
||||
it("preset 无 icon 且无 theme.icon 时,按钮内仍渲染占位元素保持文字对齐", () => {
|
||||
const entriesWithoutIcon = [
|
||||
{
|
||||
id: "no-icon",
|
||||
preset: {
|
||||
name: "No Icon",
|
||||
websiteUrl: "https://noicon.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official" as ProviderCategory,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
renderSelector({ entries: entriesWithoutIcon });
|
||||
|
||||
const button = screen.getByRole("button", { name: /no icon/i });
|
||||
// 占位 span(16x16)应该存在,保证文字位置与有图标的按钮对齐
|
||||
const placeholder = button.querySelector("span[aria-hidden]");
|
||||
expect(placeholder).not.toBeNull();
|
||||
});
|
||||
|
||||
it("custom 按钮同样渲染占位元素,文字与带图标的预设按钮对齐", () => {
|
||||
renderSelector();
|
||||
|
||||
const customButton = screen.getByRole("button", {
|
||||
name: "providerPreset.custom",
|
||||
});
|
||||
const placeholder = customButton.querySelector("span[aria-hidden]");
|
||||
expect(placeholder).not.toBeNull();
|
||||
});
|
||||
|
||||
it("点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
// 初始没有搜索输入框
|
||||
expect(
|
||||
screen.queryByRole("textbox", {
|
||||
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// 点击放大镜展开输入框
|
||||
await user.click(getSearchButton());
|
||||
const input = getSearchInput();
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// 输入关键字过滤
|
||||
await user.type(input, "gateway");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Beta Gateway" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ESC 收起输入框并清空
|
||||
await user.keyboard("{Escape}");
|
||||
expect(
|
||||
screen.queryByRole("textbox", {
|
||||
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
// 收起后所有预设恢复显示
|
||||
expect(
|
||||
screen.getByRole("button", { name: "preset.gamma" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击搜索区域外自动收起并清空", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
await user.click(getSearchButton());
|
||||
await user.type(getSearchInput(), "gateway");
|
||||
expect(getSearchInput()).toBeInTheDocument();
|
||||
|
||||
// 点击搜索区域外的元素(custom 按钮)应收起搜索框
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "providerPreset.custom" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("textbox", {
|
||||
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
// 收起后清空 query,所有预设恢复显示
|
||||
expect(
|
||||
screen.getByRole("button", { name: "preset.gamma" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user