feat: complete stage 2 core refactor
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
appType: AppType;
|
||||
onAdd: (provider: Omit<Provider, "id">) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
||||
appType,
|
||||
onAdd,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title =
|
||||
appType === "claude"
|
||||
? t("provider.addClaudeProvider")
|
||||
: t("provider.addCodexProvider");
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title={title}
|
||||
submitText={t("common.add")}
|
||||
showPresets={true}
|
||||
onSubmit={onAdd}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddProviderModal;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import type { AppType } from "@/lib/api";
|
||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface EditProviderModalProps {
|
||||
appType: AppType;
|
||||
provider: Provider;
|
||||
onSave: (provider: Provider) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
appType,
|
||||
provider,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [effectiveProvider, setEffectiveProvider] =
|
||||
useState<Provider>(provider);
|
||||
|
||||
// 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值(Claude/Codex 均适用)
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const maybeLoadLive = async () => {
|
||||
try {
|
||||
const currentId = await window.api.getCurrentProvider(appType);
|
||||
if (currentId && currentId === provider.id) {
|
||||
const live = await window.api.getLiveProviderSettings(appType);
|
||||
if (!mounted) return;
|
||||
setEffectiveProvider({ ...provider, settingsConfig: live });
|
||||
} else {
|
||||
setEffectiveProvider(provider);
|
||||
}
|
||||
} catch (e) {
|
||||
// 读取失败则回退到原 provider
|
||||
setEffectiveProvider(provider);
|
||||
}
|
||||
};
|
||||
maybeLoadLive();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [appType, provider]);
|
||||
|
||||
const handleSubmit = (data: Omit<Provider, "id">) => {
|
||||
onSave({
|
||||
...provider,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
const title =
|
||||
appType === "claude"
|
||||
? t("provider.editClaudeProvider")
|
||||
: t("provider.editCodexProvider");
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title={title}
|
||||
submitText={t("common.save")}
|
||||
initialData={effectiveProvider}
|
||||
showPresets={false}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProviderModal;
|
||||
58
src/components/mode-toggle.tsx
Normal file
58
src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value === "light" || value === "dark" || value === "system") {
|
||||
setTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">
|
||||
{t("common.toggleTheme", { defaultValue: "切换主题" })}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{t("common.theme", { defaultValue: "主题" })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={theme}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
{t("common.lightMode", { defaultValue: "浅色" })}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
{t("common.darkMode", { defaultValue: "深色" })}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
{t("common.systemMode", { defaultValue: "跟随系统" })}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
77
src/components/providers/AddProviderDialog.tsx
Normal file
77
src/components/providers/AddProviderDialog.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppType } from "@/lib/api";
|
||||
import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appType: AppType;
|
||||
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function AddProviderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appType,
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (values: ProviderFormValues) => {
|
||||
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const providerData: Omit<Provider, "id"> = {
|
||||
name: values.name.trim(),
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
settingsConfig: parsedConfig,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
await onSubmit(providerData);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onSubmit, onOpenChange],
|
||||
);
|
||||
|
||||
const submitLabel =
|
||||
appType === "claude"
|
||||
? t("provider.addClaudeProvider", { defaultValue: "添加 Claude 供应商" })
|
||||
: t("provider.addCodexProvider", { defaultValue: "添加 Codex 供应商" });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{submitLabel}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("provider.addDescription", {
|
||||
defaultValue: "填写信息后即可在列表中快速切换供应商。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ProviderForm
|
||||
submitLabel={t("common.add", { defaultValue: "添加" })}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
84
src/components/providers/EditProviderDialog.tsx
Normal file
84
src/components/providers/EditProviderDialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Provider } from "@/types";
|
||||
import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
|
||||
interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
provider: Provider | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function EditProviderDialog({
|
||||
open,
|
||||
provider,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: EditProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (values: ProviderFormValues) => {
|
||||
if (!provider) return;
|
||||
|
||||
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const updatedProvider: Provider = {
|
||||
...provider,
|
||||
name: values.name.trim(),
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
settingsConfig: parsedConfig,
|
||||
};
|
||||
|
||||
await onSubmit(updatedProvider);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onSubmit, onOpenChange, provider],
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("provider.editProvider", { defaultValue: "编辑供应商" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("provider.editDescription", {
|
||||
defaultValue: "更新配置后将立即应用到当前供应商。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ProviderForm
|
||||
submitLabel={t("common.save", { defaultValue: "保存" })}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
initialData={{
|
||||
name: provider.name,
|
||||
websiteUrl: provider.websiteUrl,
|
||||
settingsConfig: provider.settingsConfig,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
75
src/components/providers/ProviderActions.tsx
Normal file
75
src/components/providers/ProviderActions.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BarChart3, Check, Play, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderActionsProps {
|
||||
isCurrent: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
isCurrent,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "default"}
|
||||
onClick={onSwitch}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"w-[96px]",
|
||||
isCurrent && "text-muted-foreground hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
{t("provider.inUse", { defaultValue: "已启用" })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t("provider.enable", { defaultValue: "启用" })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button size="sm" variant="outline" onClick={onEdit}>
|
||||
{t("common.edit", { defaultValue: "编辑" })}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onConfigureUsage}
|
||||
title={t("provider.configureUsage", { defaultValue: "配置用量查询" })}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"text-destructive hover:text-destructive",
|
||||
isCurrent && "text-muted-foreground hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/components/providers/ProviderCard.tsx
Normal file
155
src/components/providers/ProviderCard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useMemo } from "react";
|
||||
import { GripVertical, Link } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DraggableAttributes,
|
||||
DraggableSyntheticListeners,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppType } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes;
|
||||
listeners: DraggableSyntheticListeners;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: Provider;
|
||||
isCurrent: boolean;
|
||||
appType: AppType;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
dragHandleProps?: DragHandleProps;
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
if (provider.websiteUrl) {
|
||||
return provider.websiteUrl;
|
||||
}
|
||||
|
||||
const config = provider.settingsConfig;
|
||||
|
||||
if (config && typeof config === "object") {
|
||||
const envBase = (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL;
|
||||
if (typeof envBase === "string" && envBase.trim()) {
|
||||
return envBase;
|
||||
}
|
||||
|
||||
const baseUrl = (config as Record<string, any>)?.config;
|
||||
|
||||
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
|
||||
const match = baseUrl.match(/base_url\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackText;
|
||||
};
|
||||
|
||||
export function ProviderCard({
|
||||
provider,
|
||||
isCurrent,
|
||||
appType,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
dragHandleProps,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fallbackUrlText = t("provider.notConfigured", {
|
||||
defaultValue: "未配置接口地址",
|
||||
});
|
||||
|
||||
const displayUrl = useMemo(() => {
|
||||
return extractApiUrl(provider, fallbackUrlText);
|
||||
}, [provider, fallbackUrlText]);
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
const handleOpenWebsite = () => {
|
||||
if (!displayUrl || displayUrl === fallbackUrlText) {
|
||||
return;
|
||||
}
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-4 shadow-sm transition-[box-shadow,transform] duration-200",
|
||||
isCurrent
|
||||
? "border-primary/70 bg-primary/5"
|
||||
: "border-border hover:border-primary/40",
|
||||
dragHandleProps?.isDragging && "cursor-grabbing border-primary/60 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"mt-1 flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:border-muted hover:text-foreground",
|
||||
dragHandleProps?.isDragging && "border-primary text-primary",
|
||||
)}
|
||||
aria-label={t("provider.dragHandle", { defaultValue: "拖拽排序" })}
|
||||
{...(dragHandleProps?.attributes ?? {})}
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold leading-none">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{isCurrent && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{t("provider.currentlyUsing", { defaultValue: "当前使用" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWebsite}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary transition-colors hover:underline"
|
||||
title={displayUrl}
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{displayUrl}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UsageFooter
|
||||
providerId={provider.id}
|
||||
appType={appType}
|
||||
usageEnabled={usageEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/components/providers/ProviderEmptyState.tsx
Normal file
32
src/components/providers/ProviderEmptyState.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProviderEmptyStateProps {
|
||||
onCreate?: () => void;
|
||||
}
|
||||
|
||||
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("provider.noProviders", { defaultValue: "暂无供应商" })}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{t("provider.noProvidersDescription", {
|
||||
defaultValue: "开始添加一个供应商以快速完成切换。",
|
||||
})}
|
||||
</p>
|
||||
{onCreate && (
|
||||
<Button className="mt-6" onClick={onCreate}>
|
||||
{t("provider.addProvider", { defaultValue: "添加供应商" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/components/providers/ProviderList.tsx
Normal file
153
src/components/providers/ProviderList.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppType } from "@/lib/api";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>;
|
||||
currentProviderId: string;
|
||||
appType: AppType;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onCreate?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
providers,
|
||||
currentProviderId,
|
||||
appType,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
}: ProviderListProps) {
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
providers,
|
||||
appType,
|
||||
);
|
||||
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedProviders.length === 0) {
|
||||
return <ProviderEmptyState onCreate={onCreate} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedProviders.map((provider) => provider.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sortedProviders.map((provider) => (
|
||||
<SortableProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isCurrent={provider.id === currentProviderId}
|
||||
appType={appType}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableProviderCardProps {
|
||||
provider: Provider;
|
||||
isCurrent: boolean;
|
||||
appType: AppType;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
provider,
|
||||
isCurrent,
|
||||
appType,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: provider.id });
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<ProviderCard
|
||||
provider={provider}
|
||||
isCurrent={isCurrent}
|
||||
appType={appType}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onConfigureUsage={
|
||||
onConfigureUsage
|
||||
? (item) => onConfigureUsage(item)
|
||||
: () => undefined
|
||||
}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
dragHandleProps={{
|
||||
attributes,
|
||||
listeners,
|
||||
isDragging,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/providers/forms/ProviderForm.tsx
Normal file
166
src/components/providers/forms/ProviderForm.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
providerSchema,
|
||||
type ProviderFormData,
|
||||
} from "@/lib/schemas/provider";
|
||||
|
||||
interface ProviderFormProps {
|
||||
submitLabel: string;
|
||||
onSubmit: (values: ProviderFormData) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: {
|
||||
name?: string;
|
||||
websiteUrl?: string;
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PLACEHOLDER = `{
|
||||
"env": {},
|
||||
"config": {}
|
||||
}`;
|
||||
|
||||
export function ProviderForm({
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
() => ({
|
||||
name: initialData?.name ?? "",
|
||||
websiteUrl: initialData?.websiteUrl ?? "",
|
||||
settingsConfig: initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||
: DEFAULT_CONFIG_PLACEHOLDER,
|
||||
}),
|
||||
[initialData],
|
||||
);
|
||||
|
||||
const form = useForm<ProviderFormData>({
|
||||
resolver: zodResolver(providerSchema),
|
||||
defaultValues,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form]);
|
||||
|
||||
const isDarkMode = useMemo(() => {
|
||||
if (theme === "dark") return true;
|
||||
if (theme === "light") return false;
|
||||
return typeof window !== "undefined"
|
||||
? window.document.documentElement.classList.contains("dark")
|
||||
: false;
|
||||
}, [theme]);
|
||||
|
||||
const handleSubmit = (values: ProviderFormData) => {
|
||||
onSubmit({
|
||||
...values,
|
||||
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||||
settingsConfig: values.settingsConfig.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("provider.name", { defaultValue: "供应商名称" })}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("provider.namePlaceholder", {
|
||||
defaultValue: "例如:Claude 官方",
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("provider.websiteUrl", { defaultValue: "官网链接" })}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("provider.configJson", { defaultValue: "配置 JSON" })}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="rounded-md border">
|
||||
<JsonEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={DEFAULT_CONFIG_PLACEHOLDER}
|
||||
darkMode={isDarkMode}
|
||||
rows={14}
|
||||
showValidation
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
{t("common.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="submit">{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export type ProviderFormValues = ProviderFormData;
|
||||
120
src/components/theme-provider.tsx
Normal file
120
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "cc-switch-theme",
|
||||
}: ThemeProviderProps) {
|
||||
const getInitialTheme = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultTheme;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(storageKey) as Theme | null;
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return defaultTheme;
|
||||
};
|
||||
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
}, [theme, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const isDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (theme !== "system") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = window.document.documentElement;
|
||||
root.classList.toggle("dark", mediaQuery.matches);
|
||||
root.classList.toggle("light", !mediaQuery.matches);
|
||||
};
|
||||
|
||||
if (theme === "system") {
|
||||
handleChange();
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: (nextTheme: Theme) => {
|
||||
setThemeState(nextTheme);
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
203
src/components/ui/dropdown-menu.tsx
Normal file
203
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.704 5.292a1 1 0 0 1 .083 1.32l-.083.094-8 8a1 1 0 0 1-1.32.083l-.094-.083-4-4a1 1 0 0 1 1.32-1.497l.094.083L8 12.585l7.293-7.292a1 1 0 0 1 1.32-.083l.094.083Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<div className="h-2 w-2 rounded-full bg-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName =
|
||||
DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-muted-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName =
|
||||
DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
Reference in New Issue
Block a user