|
|
@@ -1,15 +1,13 @@
|
|
|
"use client";
|
|
|
|
|
|
import { useMemo, useState } from "react";
|
|
|
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
-import { Progress } from "@/components/ui/progress";
|
|
|
-import { Badge } from "@/components/ui/badge";
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
|
-import { ChevronDown } from "lucide-react";
|
|
|
-import { formatCurrency, type CurrencyCode } from "@/lib/utils/currency";
|
|
|
-import { formatDateDistance } from "@/lib/utils/date-format";
|
|
|
-import { useLocale, useTranslations } from "next-intl";
|
|
|
+import { ChevronDown, Globe } from "lucide-react";
|
|
|
+import { useTranslations } from "next-intl";
|
|
|
import type { ProviderType } from "@/types/provider";
|
|
|
+import type { CurrencyCode } from "@/lib/utils/currency";
|
|
|
+import type { QuotaSortKey } from "./provider-quota-sort-dropdown";
|
|
|
+import { ProviderQuotaListItem } from "./provider-quota-list-item";
|
|
|
|
|
|
interface ProviderQuota {
|
|
|
cost5h: { current: number; limit: number | null; resetInfo: string };
|
|
|
@@ -32,6 +30,8 @@ interface ProviderWithQuota {
|
|
|
interface ProvidersQuotaClientProps {
|
|
|
providers: ProviderWithQuota[];
|
|
|
typeFilter?: ProviderType | "all";
|
|
|
+ sortBy?: QuotaSortKey;
|
|
|
+ searchTerm?: string;
|
|
|
currencyCode?: CurrencyCode;
|
|
|
}
|
|
|
|
|
|
@@ -47,25 +47,59 @@ function hasQuotaLimit(quota: ProviderQuota | null): boolean {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// 计算供应商的最高使用率(用于按使用量排序)
|
|
|
+function calculateMaxUsage(provider: ProviderWithQuota): number {
|
|
|
+ if (!provider.quota) return 0;
|
|
|
+
|
|
|
+ const usages: number[] = [];
|
|
|
+
|
|
|
+ if (provider.quota.cost5h.limit && provider.quota.cost5h.limit > 0) {
|
|
|
+ usages.push((provider.quota.cost5h.current / provider.quota.cost5h.limit) * 100);
|
|
|
+ }
|
|
|
+ if (provider.quota.costDaily.limit && provider.quota.costDaily.limit > 0) {
|
|
|
+ usages.push((provider.quota.costDaily.current / provider.quota.costDaily.limit) * 100);
|
|
|
+ }
|
|
|
+ if (provider.quota.costWeekly.limit && provider.quota.costWeekly.limit > 0) {
|
|
|
+ usages.push((provider.quota.costWeekly.current / provider.quota.costWeekly.limit) * 100);
|
|
|
+ }
|
|
|
+ if (provider.quota.costMonthly.limit && provider.quota.costMonthly.limit > 0) {
|
|
|
+ usages.push((provider.quota.costMonthly.current / provider.quota.costMonthly.limit) * 100);
|
|
|
+ }
|
|
|
+ if (provider.quota.concurrentSessions.limit > 0) {
|
|
|
+ usages.push(
|
|
|
+ (provider.quota.concurrentSessions.current / provider.quota.concurrentSessions.limit) * 100
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return usages.length > 0 ? Math.max(...usages) : 0;
|
|
|
+}
|
|
|
+
|
|
|
export function ProvidersQuotaClient({
|
|
|
providers,
|
|
|
typeFilter = "all",
|
|
|
+ sortBy = "priority",
|
|
|
+ searchTerm = "",
|
|
|
currencyCode = "USD",
|
|
|
}: ProvidersQuotaClientProps) {
|
|
|
// 折叠状态
|
|
|
const [isUnlimitedOpen, setIsUnlimitedOpen] = useState(false);
|
|
|
- const locale = useLocale();
|
|
|
const t = useTranslations("quota.providers");
|
|
|
|
|
|
- // 筛选、排序和分组供应商
|
|
|
+ // 筛选、搜索、排序和分组供应商
|
|
|
const { providersWithQuota, providersWithoutQuota } = useMemo(() => {
|
|
|
- // 先按类型筛选
|
|
|
- const filtered =
|
|
|
+ // 1. 按类型筛选
|
|
|
+ let filtered =
|
|
|
typeFilter === "all"
|
|
|
? providers
|
|
|
: providers.filter((provider) => provider.providerType === typeFilter);
|
|
|
|
|
|
- // 分组
|
|
|
+ // 2. 按搜索词过滤
|
|
|
+ if (searchTerm) {
|
|
|
+ const term = searchTerm.toLowerCase();
|
|
|
+ filtered = filtered.filter((p) => p.name.toLowerCase().includes(term));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 分组:有限额 vs 无限额
|
|
|
const withQuota: ProviderWithQuota[] = [];
|
|
|
const withoutQuota: ProviderWithQuota[] = [];
|
|
|
|
|
|
@@ -77,214 +111,92 @@ export function ProvidersQuotaClient({
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- // 有限额的供应商:按优先级降序,优先级相同按权重降序
|
|
|
+ // 4. 排序(仅对有限额的供应商排序)
|
|
|
withQuota.sort((a, b) => {
|
|
|
- if (b.priority !== a.priority) {
|
|
|
- return b.priority - a.priority;
|
|
|
+ switch (sortBy) {
|
|
|
+ case "name":
|
|
|
+ return a.name.localeCompare(b.name);
|
|
|
+ case "priority":
|
|
|
+ // 优先级:数值越小越优先,升序排列
|
|
|
+ return a.priority - b.priority;
|
|
|
+ case "weight":
|
|
|
+ // 权重:数值越大越优先,降序排列
|
|
|
+ return b.weight - a.weight;
|
|
|
+ case "usage": {
|
|
|
+ // 使用量:按最高使用率降序排列
|
|
|
+ const usageA = calculateMaxUsage(a);
|
|
|
+ const usageB = calculateMaxUsage(b);
|
|
|
+ return usageB - usageA;
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ return 0;
|
|
|
}
|
|
|
- return b.weight - a.weight;
|
|
|
});
|
|
|
|
|
|
- // 无限额的供应商:保持原有顺序(由数据库查询决定)
|
|
|
- // 不需要额外排序
|
|
|
-
|
|
|
return {
|
|
|
providersWithQuota: withQuota,
|
|
|
providersWithoutQuota: withoutQuota,
|
|
|
};
|
|
|
- }, [providers, typeFilter]);
|
|
|
-
|
|
|
- // 渲染供应商卡片的函数
|
|
|
- const renderProviderCard = (provider: ProviderWithQuota) => (
|
|
|
- <Card key={provider.id}>
|
|
|
- <CardHeader>
|
|
|
- <div className="flex items-center justify-between">
|
|
|
- <CardTitle className="text-base">{provider.name}</CardTitle>
|
|
|
- <div className="flex gap-2">
|
|
|
- <Badge variant={provider.isEnabled ? "default" : "secondary"}>
|
|
|
- {provider.isEnabled ? t("status.enabled") : t("status.disabled")}
|
|
|
- </Badge>
|
|
|
- <Badge variant="outline">{provider.providerType}</Badge>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <CardDescription>
|
|
|
- {t("card.priority")}: {provider.priority} · {t("card.weight")}: {provider.weight}
|
|
|
- </CardDescription>
|
|
|
- </CardHeader>
|
|
|
- <CardContent className="space-y-4">
|
|
|
- {provider.quota ? (
|
|
|
- <>
|
|
|
- {/* 5小时消费 */}
|
|
|
- {provider.quota.cost5h.limit && provider.quota.cost5h.limit > 0 && (
|
|
|
- <div className="space-y-2">
|
|
|
- <div className="flex items-center justify-between text-sm">
|
|
|
- <span className="text-muted-foreground">{t("cost5h.label")}</span>
|
|
|
- <span className="font-medium">
|
|
|
- {formatCurrency(provider.quota.cost5h.current, currencyCode)} /{" "}
|
|
|
- {formatCurrency(provider.quota.cost5h.limit, currencyCode)}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <Progress
|
|
|
- value={(provider.quota.cost5h.current / (provider.quota.cost5h.limit || 1)) * 100}
|
|
|
- className="h-2"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">{provider.quota.cost5h.resetInfo}</p>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {provider.quota.costDaily.limit && provider.quota.costDaily.limit > 0 && (
|
|
|
- <div className="space-y-2">
|
|
|
- <div className="flex items-center justify-between text-sm">
|
|
|
- <span className="text-muted-foreground">{t("costDaily.label")}</span>
|
|
|
- {provider.quota.costDaily.resetAt && (
|
|
|
- <span className="text-xs text-muted-foreground">
|
|
|
- {t("costDaily.resetAt")}{" "}
|
|
|
- {formatDateDistance(provider.quota.costDaily.resetAt, new Date(), locale)}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- <div className="flex items-center justify-between text-sm font-mono">
|
|
|
- <span>
|
|
|
- {formatCurrency(provider.quota.costDaily.current, currencyCode)} /{" "}
|
|
|
- {formatCurrency(provider.quota.costDaily.limit, currencyCode)}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <Progress
|
|
|
- value={
|
|
|
- (provider.quota.costDaily.current / (provider.quota.costDaily.limit || 1)) * 100
|
|
|
- }
|
|
|
- className="h-2"
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 周消费 */}
|
|
|
- {provider.quota.costWeekly.limit && provider.quota.costWeekly.limit > 0 && (
|
|
|
- <div className="space-y-2">
|
|
|
- <div className="flex items-center justify-between text-sm">
|
|
|
- <span className="text-muted-foreground">{t("costWeekly.label")}</span>
|
|
|
- <span className="font-medium">
|
|
|
- {formatCurrency(provider.quota.costWeekly.current, currencyCode)} /{" "}
|
|
|
- {formatCurrency(provider.quota.costWeekly.limit, currencyCode)}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <Progress
|
|
|
- value={
|
|
|
- (provider.quota.costWeekly.current / (provider.quota.costWeekly.limit || 1)) *
|
|
|
- 100
|
|
|
- }
|
|
|
- className="h-2"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t("costWeekly.resetAt")}{" "}
|
|
|
- {formatDateDistance(
|
|
|
- new Date(provider.quota.costWeekly.resetAt),
|
|
|
- new Date(),
|
|
|
- locale
|
|
|
- )}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 月消费 */}
|
|
|
- {provider.quota.costMonthly.limit && provider.quota.costMonthly.limit > 0 && (
|
|
|
- <div className="space-y-2">
|
|
|
- <div className="flex items-center justify-between text-sm">
|
|
|
- <span className="text-muted-foreground">{t("costMonthly.label")}</span>
|
|
|
- <span className="font-medium">
|
|
|
- {formatCurrency(provider.quota.costMonthly.current, currencyCode)} /{" "}
|
|
|
- {formatCurrency(provider.quota.costMonthly.limit, currencyCode)}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <Progress
|
|
|
- value={
|
|
|
- (provider.quota.costMonthly.current / (provider.quota.costMonthly.limit || 1)) *
|
|
|
- 100
|
|
|
- }
|
|
|
- className="h-2"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t("costMonthly.resetAt")}{" "}
|
|
|
- {formatDateDistance(
|
|
|
- new Date(provider.quota.costMonthly.resetAt),
|
|
|
- new Date(),
|
|
|
- locale
|
|
|
- )}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 并发 Session */}
|
|
|
- {provider.quota.concurrentSessions.limit > 0 && (
|
|
|
- <div className="space-y-2">
|
|
|
- <div className="flex items-center justify-between text-sm">
|
|
|
- <span className="text-muted-foreground">{t("concurrentSessions.label")}</span>
|
|
|
- <span className="font-medium">
|
|
|
- {provider.quota.concurrentSessions.current} /{" "}
|
|
|
- {provider.quota.concurrentSessions.limit}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <Progress
|
|
|
- value={
|
|
|
- (provider.quota.concurrentSessions.current /
|
|
|
- provider.quota.concurrentSessions.limit) *
|
|
|
- 100
|
|
|
- }
|
|
|
- className="h-2"
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {!hasQuotaLimit(provider.quota) && (
|
|
|
- <p className="text-sm text-muted-foreground">{t("noQuotaSet")}</p>
|
|
|
- )}
|
|
|
- </>
|
|
|
- ) : (
|
|
|
- <p className="text-sm text-muted-foreground">{t("noQuotaData")}</p>
|
|
|
- )}
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- );
|
|
|
+ }, [providers, typeFilter, sortBy, searchTerm]);
|
|
|
|
|
|
const totalProviders = providersWithQuota.length + providersWithoutQuota.length;
|
|
|
|
|
|
+ // 空状态
|
|
|
+ if (totalProviders === 0) {
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col items-center justify-center py-12 px-4">
|
|
|
+ <div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center mb-3">
|
|
|
+ <Globe className="h-6 w-6 text-muted-foreground" />
|
|
|
+ </div>
|
|
|
+ <h3 className="font-medium text-foreground mb-1">{t("noMatches")}</h3>
|
|
|
+ <p className="text-sm text-muted-foreground text-center">
|
|
|
+ {searchTerm ? t("noMatchesDesc") : t("noProvidersDesc")}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
return (
|
|
|
- <>
|
|
|
- {totalProviders === 0 ? (
|
|
|
- <Card>
|
|
|
- <CardContent className="flex items-center justify-center py-10">
|
|
|
- <p className="text-muted-foreground">{t("noMatches")}</p>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- ) : (
|
|
|
- <div className="space-y-6">
|
|
|
- {/* 有限额的供应商 */}
|
|
|
- {providersWithQuota.length > 0 && (
|
|
|
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
|
- {providersWithQuota.map(renderProviderCard)}
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ <div className="space-y-6">
|
|
|
+ {/* 有限额的供应商(列表形式) */}
|
|
|
+ {providersWithQuota.length > 0 && (
|
|
|
+ <div className="border rounded-lg overflow-hidden">
|
|
|
+ {providersWithQuota.map((provider) => (
|
|
|
+ <ProviderQuotaListItem
|
|
|
+ key={provider.id}
|
|
|
+ provider={provider}
|
|
|
+ currencyCode={currencyCode}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- {/* 无限额的供应商(折叠区域) */}
|
|
|
- {providersWithoutQuota.length > 0 && (
|
|
|
- <Collapsible open={isUnlimitedOpen} onOpenChange={setIsUnlimitedOpen}>
|
|
|
- <CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border bg-card p-4 text-sm font-medium hover:bg-accent">
|
|
|
- <span className="text-muted-foreground">
|
|
|
- {t("unlimitedSection", { count: providersWithoutQuota.length })}
|
|
|
- </span>
|
|
|
- <ChevronDown
|
|
|
- className={`h-4 w-4 transition-transform ${isUnlimitedOpen ? "rotate-180" : ""}`}
|
|
|
- />
|
|
|
- </CollapsibleTrigger>
|
|
|
- <CollapsibleContent className="mt-4">
|
|
|
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
|
- {providersWithoutQuota.map(renderProviderCard)}
|
|
|
+ {/* 无限额的供应商(折叠区域) */}
|
|
|
+ {providersWithoutQuota.length > 0 && (
|
|
|
+ <Collapsible open={isUnlimitedOpen} onOpenChange={setIsUnlimitedOpen}>
|
|
|
+ <CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border bg-card p-4 text-sm font-medium hover:bg-accent transition-colors">
|
|
|
+ <span className="text-muted-foreground">
|
|
|
+ {t("unlimitedSection", { count: providersWithoutQuota.length })}
|
|
|
+ </span>
|
|
|
+ <ChevronDown
|
|
|
+ className={`h-4 w-4 transition-transform ${isUnlimitedOpen ? "rotate-180" : ""}`}
|
|
|
+ />
|
|
|
+ </CollapsibleTrigger>
|
|
|
+ <CollapsibleContent className="mt-4">
|
|
|
+ <div className="border rounded-lg overflow-hidden">
|
|
|
+ {providersWithoutQuota.map((provider) => (
|
|
|
+ <div
|
|
|
+ key={provider.id}
|
|
|
+ className="flex items-center gap-4 py-3 px-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
|
|
+ >
|
|
|
+ <span className="font-medium">{provider.name}</span>
|
|
|
+ <span className="text-sm text-muted-foreground">{t("noQuotaSet")}</span>
|
|
|
</div>
|
|
|
- </CollapsibleContent>
|
|
|
- </Collapsible>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </CollapsibleContent>
|
|
|
+ </Collapsible>
|
|
|
)}
|
|
|
- </>
|
|
|
+ </div>
|
|
|
);
|
|
|
}
|