Browse Source

refactor: 供应商限额管理页面重构为列表布局

- 创建 CircularProgress 圆形进度组件(颜色自动分级:绿/黄/红)
- 创建 CountdownTimer 倒计时组件(实时显示重置时间)
- 创建 ProviderQuotaListItem 列表项组件(一行展示所有限额指标)
- 创建 ProviderQuotaSortDropdown 排序组件(名称/优先级/权重/使用量)
- ProvidersQuotaManager: 添加搜索功能(防抖)和排序功能
- ProvidersQuotaClient: 从卡片网格改为列表布局,集成搜索排序
- 新增 5 种语言的 i18n 翻译(en/zh-CN/zh-TW/ja/ru)
- 保留未设置限额供应商的自动折叠功能
- 优化空间利用率,提升信息密度和可视化效果
ding113 3 months ago
parent
commit
cce1f1d2ab

+ 14 - 0
messages/en/quota.json

@@ -92,6 +92,7 @@
     "title": "Provider Quota Statistics",
     "totalCount": "{count} providers total",
     "filterCount": "Showing {filtered} / {total} providers",
+    "searchPlaceholder": "Search provider name...",
     "status": {
       "enabled": "Enabled",
       "disabled": "Disabled"
@@ -100,6 +101,19 @@
       "priority": "Priority",
       "weight": "Weight"
     },
+    "sort": {
+      "name": "By Name",
+      "priority": "By Priority",
+      "weight": "By Weight",
+      "usage": "By Usage"
+    },
+    "list": {
+      "resetIn": "Resets in",
+      "unlimited": "Unlimited",
+      "current": "Current",
+      "limit": "Limit",
+      "used": "used"
+    },
     "cost5h": {
       "label": "5-Hour Cost"
     },

+ 14 - 0
messages/ja/quota.json

@@ -92,6 +92,7 @@
     "title": "プロバイダークォータ統計",
     "totalCount": "合計 {count} 個のプロバイダー",
     "filterCount": "{filtered} / {total} 個のプロバイダーを表示",
+    "searchPlaceholder": "プロバイダー名を検索...",
     "status": {
       "enabled": "有効",
       "disabled": "無効"
@@ -100,6 +101,19 @@
       "priority": "優先度",
       "weight": "重み"
     },
+    "sort": {
+      "name": "名前順",
+      "priority": "優先度順",
+      "weight": "重み順",
+      "usage": "使用量順"
+    },
+    "list": {
+      "resetIn": "リセットまで",
+      "unlimited": "無制限",
+      "current": "現在",
+      "limit": "制限",
+      "used": "使用済み"
+    },
     "cost5h": {
       "label": "5時間コスト"
     },

+ 14 - 0
messages/ru/quota.json

@@ -90,6 +90,7 @@
     "title": "Статистика квот провайдеров",
     "totalCount": "Всего провайдеров: {count}",
     "filterCount": "Показано {filtered} из {total} провайдеров",
+    "searchPlaceholder": "Поиск по имени провайдера...",
     "status": {
       "enabled": "Включен",
       "disabled": "Отключен"
@@ -98,6 +99,19 @@
       "priority": "Приоритет",
       "weight": "Вес"
     },
+    "sort": {
+      "name": "По имени",
+      "priority": "По приоритету",
+      "weight": "По весу",
+      "usage": "По использованию"
+    },
+    "list": {
+      "resetIn": "Сброс через",
+      "unlimited": "Неограниченно",
+      "current": "Текущее",
+      "limit": "Лимит",
+      "used": "использовано"
+    },
     "cost5h": {
       "label": "Расходы за 5 часов"
     },

+ 14 - 0
messages/zh-CN/quota.json

@@ -92,6 +92,7 @@
     "title": "供应商限额统计",
     "totalCount": "共 {count} 个供应商",
     "filterCount": "显示 {filtered} / {total} 个供应商",
+    "searchPlaceholder": "搜索供应商名称...",
     "status": {
       "enabled": "启用",
       "disabled": "禁用"
@@ -100,6 +101,19 @@
       "priority": "优先级",
       "weight": "权重"
     },
+    "sort": {
+      "name": "按名称",
+      "priority": "按优先级",
+      "weight": "按权重",
+      "usage": "按使用量"
+    },
+    "list": {
+      "resetIn": "重置于",
+      "unlimited": "无限制",
+      "current": "当前",
+      "limit": "限制",
+      "used": "已用"
+    },
     "cost5h": {
       "label": "5小时消费"
     },

+ 14 - 0
messages/zh-TW/quota.json

@@ -90,6 +90,7 @@
     "title": "供應商限額統計",
     "totalCount": "共 {count} 個供應商",
     "filterCount": "顯示 {filtered} / {total} 個供應商",
+    "searchPlaceholder": "搜尋供應商名稱...",
     "status": {
       "enabled": "啟用",
       "disabled": "禁用"
@@ -98,6 +99,19 @@
       "priority": "優先級",
       "weight": "權重"
     },
+    "sort": {
+      "name": "按名稱",
+      "priority": "按優先級",
+      "weight": "按權重",
+      "usage": "按使用量"
+    },
+    "list": {
+      "resetIn": "重置於",
+      "unlimited": "無限制",
+      "current": "當前",
+      "limit": "限制",
+      "used": "已用"
+    },
     "cost5h": {
       "label": "5小時消費"
     },

+ 209 - 0
src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-list-item.tsx

@@ -0,0 +1,209 @@
+"use client";
+
+import { CheckCircle, XCircle } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { CircularProgress } from "@/components/ui/circular-progress";
+import { CountdownTimer } from "@/components/ui/countdown-timer";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { getProviderTypeConfig } from "@/lib/provider-type-utils";
+import { formatCurrency, type CurrencyCode } from "@/lib/utils/currency";
+import { useTranslations } from "next-intl";
+import type { ProviderType } from "@/types/provider";
+
+interface ProviderQuota {
+  cost5h: { current: number; limit: number | null; resetInfo: string };
+  costDaily: { current: number; limit: number | null; resetAt?: Date };
+  costWeekly: { current: number; limit: number | null; resetAt: Date };
+  costMonthly: { current: number; limit: number | null; resetAt: Date };
+  concurrentSessions: { current: number; limit: number };
+}
+
+interface ProviderWithQuota {
+  id: number;
+  name: string;
+  providerType: ProviderType;
+  isEnabled: boolean;
+  priority: number;
+  weight: number;
+  quota: ProviderQuota | null;
+}
+
+interface ProviderQuotaListItemProps {
+  provider: ProviderWithQuota;
+  currencyCode?: CurrencyCode;
+}
+
+export function ProviderQuotaListItem({
+  provider,
+  currencyCode = "USD",
+}: ProviderQuotaListItemProps) {
+  const t = useTranslations("quota.providers");
+
+  // 获取供应商类型配置
+  const typeConfig = getProviderTypeConfig(provider.providerType);
+  const TypeIcon = typeConfig.icon;
+
+  // 渲染限额指标(圆形进度 + 倒计时)
+  const renderQuotaItem = (
+    label: string,
+    current: number,
+    limit: number | null,
+    resetAt?: Date
+  ) => {
+    if (!limit || limit <= 0) return null;
+
+    const percentage = Math.min((current / limit) * 100, 100);
+
+    return (
+      <TooltipProvider delayDuration={200}>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <div className="flex flex-col items-center gap-1">
+              <CircularProgress value={current} max={limit} size={48} strokeWidth={4} />
+              {resetAt && (
+                <CountdownTimer
+                  targetDate={resetAt}
+                  prefix={t("list.resetIn") + " "}
+                  className="text-[10px] text-muted-foreground"
+                />
+              )}
+            </div>
+          </TooltipTrigger>
+          <TooltipContent side="top">
+            <div className="space-y-1">
+              <div className="font-semibold">{label}</div>
+              <div className="text-xs">
+                {t("list.current")}: {formatCurrency(current, currencyCode)}
+              </div>
+              <div className="text-xs">
+                {t("list.limit")}: {formatCurrency(limit, currencyCode)}
+              </div>
+              <div className="text-xs font-semibold">
+                {percentage.toFixed(1)}% {t("list.used")}
+              </div>
+            </div>
+          </TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    );
+  };
+
+  // 渲染并发Session指标
+  const renderConcurrentSessionsItem = () => {
+    const { current, limit } = provider.quota?.concurrentSessions || { current: 0, limit: 0 };
+    if (limit <= 0) return null;
+
+    return (
+      <TooltipProvider delayDuration={200}>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <div className="flex flex-col items-center gap-1">
+              <CircularProgress value={current} max={limit} size={48} strokeWidth={4} />
+              <span className="text-[10px] text-muted-foreground">{t("concurrentSessions.label")}</span>
+            </div>
+          </TooltipTrigger>
+          <TooltipContent side="top">
+            <div className="space-y-1">
+              <div className="font-semibold">{t("concurrentSessions.label")}</div>
+              <div className="text-xs">
+                {t("list.current")}: {current}
+              </div>
+              <div className="text-xs">
+                {t("list.limit")}: {limit}
+              </div>
+              <div className="text-xs font-semibold">
+                {((current / limit) * 100).toFixed(1)}% {t("list.used")}
+              </div>
+            </div>
+          </TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    );
+  };
+
+  if (!provider.quota) {
+    return null;
+  }
+
+  return (
+    <div className="flex items-center gap-4 py-4 px-4 border-b hover:bg-muted/50 transition-colors">
+      {/* 左侧:状态 + 类型图标 + 名称 */}
+      <div className="flex items-center gap-3 min-w-[200px]">
+        {/* 启用状态指示器 */}
+        {provider.isEnabled ? (
+          <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
+        ) : (
+          <XCircle className="h-4 w-4 text-gray-400 flex-shrink-0" />
+        )}
+
+        {/* 类型图标 */}
+        <div
+          className={`flex items-center justify-center w-6 h-6 rounded ${typeConfig.bgColor} flex-shrink-0`}
+          title={provider.providerType}
+        >
+          <TypeIcon className="h-3.5 w-3.5" />
+        </div>
+
+        {/* 名称和状态徽章 */}
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2 flex-wrap">
+            <span className="font-semibold truncate">{provider.name}</span>
+            <Badge variant="outline" className="flex-shrink-0 text-xs">
+              P:{provider.priority} W:{provider.weight}
+            </Badge>
+          </div>
+        </div>
+      </div>
+
+      {/* 中间:限额指标(圆形进度) */}
+      <div className="flex items-center gap-6 flex-1 justify-center">
+        {/* 5小时限额 */}
+        {provider.quota.cost5h.limit &&
+          provider.quota.cost5h.limit > 0 &&
+          renderQuotaItem(
+            t("cost5h.label"),
+            provider.quota.cost5h.current,
+            provider.quota.cost5h.limit
+          )}
+
+        {/* 每日限额 */}
+        {provider.quota.costDaily.limit &&
+          provider.quota.costDaily.limit > 0 &&
+          renderQuotaItem(
+            t("costDaily.label"),
+            provider.quota.costDaily.current,
+            provider.quota.costDaily.limit,
+            provider.quota.costDaily.resetAt
+          )}
+
+        {/* 周限额 */}
+        {provider.quota.costWeekly.limit &&
+          provider.quota.costWeekly.limit > 0 &&
+          renderQuotaItem(
+            t("costWeekly.label"),
+            provider.quota.costWeekly.current,
+            provider.quota.costWeekly.limit,
+            provider.quota.costWeekly.resetAt
+          )}
+
+        {/* 月限额 */}
+        {provider.quota.costMonthly.limit &&
+          provider.quota.costMonthly.limit > 0 &&
+          renderQuotaItem(
+            t("costMonthly.label"),
+            provider.quota.costMonthly.current,
+            provider.quota.costMonthly.limit,
+            provider.quota.costMonthly.resetAt
+          )}
+
+        {/* 并发Session */}
+        {renderConcurrentSessionsItem()}
+      </div>
+
+      {/* 右侧:操作区域(预留) */}
+      <div className="flex items-center gap-2 flex-shrink-0">
+        {/* 可以添加操作按钮 */}
+      </div>
+    </div>
+  );
+}

+ 51 - 0
src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-sort-dropdown.tsx

@@ -0,0 +1,51 @@
+"use client";
+
+import { ArrowUpDown } from "lucide-react";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { useTranslations } from "next-intl";
+
+export type QuotaSortKey = "name" | "priority" | "weight" | "usage";
+
+interface ProviderQuotaSortDropdownProps {
+  value: QuotaSortKey;
+  onChange: (value: QuotaSortKey) => void;
+}
+
+export function ProviderQuotaSortDropdown({ value, onChange }: ProviderQuotaSortDropdownProps) {
+  const t = useTranslations("quota.providers.sort");
+  const selectedValue = value ?? "priority";
+
+  const SORT_OPTIONS: { value: QuotaSortKey; label: string }[] = [
+    { value: "name", label: t("name") },
+    { value: "priority", label: t("priority") },
+    { value: "weight", label: t("weight") },
+    { value: "usage", label: t("usage") },
+  ];
+
+  return (
+    <div className="flex items-center gap-2">
+      <ArrowUpDown className="h-4 w-4 text-muted-foreground" />
+      <Select
+        value={selectedValue}
+        onValueChange={(nextValue) => onChange(nextValue as QuotaSortKey)}
+      >
+        <SelectTrigger className="w-[160px]">
+          <SelectValue />
+        </SelectTrigger>
+        <SelectContent>
+          {SORT_OPTIONS.map((option) => (
+            <SelectItem key={option.value} value={option.value}>
+              {option.label}
+            </SelectItem>
+          ))}
+        </SelectContent>
+      </Select>
+    </div>
+  );
+}

+ 118 - 206
src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx

@@ -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>
   );
 }

+ 47 - 6
src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx

@@ -1,8 +1,12 @@
 "use client";
 
 import { useState } from "react";
+import { Search, X } from "lucide-react";
 import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_components/provider-type-filter";
+import { ProviderQuotaSortDropdown, type QuotaSortKey } from "./provider-quota-sort-dropdown";
 import { ProvidersQuotaClient } from "./providers-quota-client";
+import { Input } from "@/components/ui/input";
+import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { ProviderType } from "@/types/provider";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import { useTranslations } from "next-intl";
@@ -35,9 +39,14 @@ export function ProvidersQuotaManager({
   currencyCode = "USD",
 }: ProvidersQuotaManagerProps) {
   const [typeFilter, setTypeFilter] = useState<ProviderType | "all">("all");
+  const [sortBy, setSortBy] = useState<QuotaSortKey>("priority");
+  const [searchTerm, setSearchTerm] = useState("");
+  const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
   const t = useTranslations("quota.providers");
+  const tSearch = useTranslations("settings.providers.search");
 
-  // 计算筛选后的供应商数量
+  // 计算筛选后的供应商数量(不包括搜索)
   const filteredCount =
     typeFilter === "all"
       ? providers.length
@@ -45,18 +54,50 @@ export function ProvidersQuotaManager({
 
   return (
     <div className="space-y-4">
-      {/* 类型筛选器 */}
-      <div className="flex items-center justify-between">
-        <ProviderTypeFilter value={typeFilter} onChange={setTypeFilter} />
-        <div className="text-sm text-muted-foreground">
-          {t("filterCount", { filtered: filteredCount, total: providers.length })}
+      {/* 筛选和搜索工具栏 */}
+      <div className="flex flex-col gap-3">
+        <div className="flex items-center gap-2">
+          <ProviderTypeFilter value={typeFilter} onChange={setTypeFilter} />
+          <ProviderQuotaSortDropdown value={sortBy} onChange={setSortBy} />
+          <div className="relative flex-1">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+            <Input
+              type="search"
+              placeholder={t("searchPlaceholder")}
+              value={searchTerm}
+              onChange={(e) => setSearchTerm(e.target.value)}
+              className="pl-9 pr-9"
+            />
+            {searchTerm && (
+              <button
+                onClick={() => setSearchTerm("")}
+                className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
+                aria-label={tSearch("clear")}
+              >
+                <X className="h-4 w-4" />
+              </button>
+            )}
+          </div>
         </div>
+
+        {/* 搜索结果提示或筛选统计 */}
+        {debouncedSearchTerm ? (
+          <p className="text-sm text-muted-foreground">
+            {tSearch("found", { count: filteredCount })}
+          </p>
+        ) : (
+          <div className="text-sm text-muted-foreground">
+            {t("filterCount", { filtered: filteredCount, total: providers.length })}
+          </div>
+        )}
       </div>
 
       {/* 供应商列表 */}
       <ProvidersQuotaClient
         providers={providers}
         typeFilter={typeFilter}
+        sortBy={sortBy}
+        searchTerm={debouncedSearchTerm}
         currencyCode={currencyCode}
       />
     </div>

+ 88 - 0
src/components/ui/circular-progress.tsx

@@ -0,0 +1,88 @@
+import { cn } from "@/lib/utils";
+
+interface CircularProgressProps {
+  /** 当前值 */
+  value: number;
+  /** 最大值 */
+  max: number;
+  /** 尺寸(像素) */
+  size?: number;
+  /** 线条粗细 */
+  strokeWidth?: number;
+  /** 显示文本(默认显示百分比) */
+  label?: string;
+  /** 是否显示百分比数字 */
+  showPercentage?: boolean;
+  /** 自定义类名 */
+  className?: string;
+}
+
+/**
+ * 圆形进度条组件
+ * 根据使用率自动显示不同颜色:
+ * - 绿色:< 70%
+ * - 黄色:70% - 90%
+ * - 红色:> 90%
+ */
+export function CircularProgress({
+  value,
+  max,
+  size = 48,
+  strokeWidth = 4,
+  label,
+  showPercentage = true,
+  className,
+}: CircularProgressProps) {
+  // 计算百分比
+  const percentage = max > 0 ? Math.min(Math.round((value / max) * 100), 100) : 0;
+
+  // 根据百分比确定颜色
+  const getColor = () => {
+    if (percentage >= 90) return "text-red-500";
+    if (percentage >= 70) return "text-yellow-500";
+    return "text-green-500";
+  };
+
+  // SVG 圆形参数
+  const radius = (size - strokeWidth) / 2;
+  const circumference = 2 * Math.PI * radius;
+  const offset = circumference - (percentage / 100) * circumference;
+
+  return (
+    <div className={cn("relative inline-flex items-center justify-center", className)}>
+      <svg width={size} height={size} className="transform -rotate-90">
+        {/* 背景圆环 */}
+        <circle
+          cx={size / 2}
+          cy={size / 2}
+          r={radius}
+          stroke="currentColor"
+          strokeWidth={strokeWidth}
+          fill="none"
+          className="text-muted/30"
+        />
+        {/* 进度圆环 */}
+        <circle
+          cx={size / 2}
+          cy={size / 2}
+          r={radius}
+          stroke="currentColor"
+          strokeWidth={strokeWidth}
+          fill="none"
+          strokeDasharray={circumference}
+          strokeDashoffset={offset}
+          strokeLinecap="round"
+          className={cn("transition-all duration-500 ease-in-out", getColor())}
+        />
+      </svg>
+
+      {/* 中心文本 */}
+      <div className="absolute inset-0 flex flex-col items-center justify-center">
+        {showPercentage && (
+          <span className={cn("text-xs font-semibold", getColor())}>{percentage}%</span>
+        )}
+        {label && <span className="text-[10px] text-muted-foreground">{label}</span>}
+      </div>
+    </div>
+  );
+}

+ 48 - 0
src/components/ui/countdown-timer.tsx

@@ -0,0 +1,48 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useLocale } from "next-intl";
+import { formatDateDistance } from "@/lib/utils/date-format";
+
+interface CountdownTimerProps {
+  /** 目标时间 */
+  targetDate: Date;
+  /** 前缀文本 */
+  prefix?: string;
+  /** 自定义类名 */
+  className?: string;
+}
+
+/**
+ * 倒计时组件
+ * 实时显示距离目标时间的剩余时间
+ */
+export function CountdownTimer({ targetDate, prefix, className }: CountdownTimerProps) {
+  const locale = useLocale();
+  const [timeLeft, setTimeLeft] = useState<string>("");
+
+  useEffect(() => {
+    // 更新倒计时显示
+    const updateCountdown = () => {
+      const formatted = formatDateDistance(targetDate, new Date(), locale);
+      setTimeLeft(formatted);
+    };
+
+    // 立即更新一次
+    updateCountdown();
+
+    // 每30秒更新一次(减少不必要的渲染)
+    const interval = setInterval(updateCountdown, 30000);
+
+    return () => clearInterval(interval);
+  }, [targetDate, locale]);
+
+  if (!timeLeft) return null;
+
+  return (
+    <span className={className}>
+      {prefix}
+      {timeLeft}
+    </span>
+  );
+}