diff --git a/.prettierignore b/.prettierignore
index 3b8383857..4bcf9aea9 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -50,6 +50,9 @@ data
# Seed data (large auto-generated JSON files)
public/seed/litellm-prices.json
+# Documentation (preserve manual formatting)
+CHANGELOG.md
+
# Package manager
bun.lock
package-lock.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7551a6086..428477403 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,13 @@
# Changelog
+All notable changes to this project will be documented in this file.
+
## [Unreleased]
-### Changed
-- 供应商页面增加排行榜入口 (#168) @ding113
-- 合并若干优化 (#162) @ding113
+### Added
+
+- Add dark mode support with theme switcher in Dashboard and settings pages (#171) @ding113
+
+### Fixed
+
+- Fix CI failures: Prettier formatting and React Hooks ESLint error in theme-switcher (#173) @ding113
diff --git a/messages/en/common.json b/messages/en/common.json
index 3bab67c2a..199af2797 100644
--- a/messages/en/common.json
+++ b/messages/en/common.json
@@ -40,5 +40,10 @@
"info": "Info",
"noData": "No data",
"emptyState": "No data to display",
- "core": "Core"
+ "core": "Core",
+ "appearance": "Appearance",
+ "theme": "Theme",
+ "light": "Light",
+ "dark": "Dark",
+ "system": "System"
}
diff --git a/messages/en/quota.json b/messages/en/quota.json
index 1d059b2ed..af4e25951 100644
--- a/messages/en/quota.json
+++ b/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"
},
@@ -121,6 +135,8 @@
"noQuotaSet": "No quota set",
"noQuotaData": "Unable to retrieve quota information",
"noMatches": "No matching providers",
+ "noMatchesDesc": "No providers match your search criteria. Try adjusting your filters.",
+ "noProvidersDesc": "No providers have been configured yet.",
"unlimitedSection": "Providers without quota ({count})"
},
"keys": {
diff --git a/messages/ja/common.json b/messages/ja/common.json
index d6ce67dfc..a9d9e2691 100644
--- a/messages/ja/common.json
+++ b/messages/ja/common.json
@@ -40,5 +40,10 @@
"info": "情報",
"noData": "データがありません",
"emptyState": "表示するデータがありません",
- "core": "コア"
+ "core": "コア",
+ "appearance": "外観",
+ "theme": "テーマ",
+ "light": "ライト",
+ "dark": "ダーク",
+ "system": "システム設定"
}
diff --git a/messages/ja/quota.json b/messages/ja/quota.json
index bfe57a67d..533041d78 100644
--- a/messages/ja/quota.json
+++ b/messages/ja/quota.json
@@ -92,6 +92,7 @@
"title": "プロバイダークォータ統計",
"totalCount": "合計 {count} 個のプロバイダー",
"filterCount": "{filtered} / {total} 個のプロバイダーを表示",
+ "searchPlaceholder": "プロバイダー名を検索...",
"status": {
"enabled": "有効",
"disabled": "無効"
@@ -100,9 +101,26 @@
"priority": "優先度",
"weight": "重み"
},
+ "sort": {
+ "name": "名前順",
+ "priority": "優先度順",
+ "weight": "重み順",
+ "usage": "使用量順"
+ },
+ "list": {
+ "resetIn": "リセットまで",
+ "unlimited": "無制限",
+ "current": "現在",
+ "limit": "制限",
+ "used": "使用済み"
+ },
"cost5h": {
"label": "5時間コスト"
},
+ "costDaily": {
+ "label": "日次コスト",
+ "resetAt": "リセット時刻"
+ },
"costWeekly": {
"label": "週次コスト",
"resetAt": "リセット時刻"
@@ -117,6 +135,8 @@
"noQuotaSet": "クォータ未設定",
"noQuotaData": "クォータ情報を取得できません",
"noMatches": "一致するプロバイダーがありません",
+ "noMatchesDesc": "検索条件に一致するプロバイダーがありません。フィルターを調整してください。",
+ "noProvidersDesc": "まだプロバイダーが設定されていません。",
"unlimitedSection": "クォータ未設定のプロバイダー ({count}個)"
},
"keys": {
@@ -136,6 +156,7 @@
"keyName": "キー名",
"quotaType": "クォータタイプ",
"cost5h": "5時間クォータ",
+ "costDaily": "日次クォータ",
"costWeekly": "週次クォータ",
"costMonthly": "月次クォータ",
"concurrentSessions": "同時制限",
@@ -160,6 +181,26 @@
"placeholder": "無制限",
"current": "現在使用: {currency}{current} / {currency}{limit}"
},
+ "costDaily": {
+ "label": "日次クォータ (USD)",
+ "placeholder": "無制限",
+ "current": "現在使用: {currency}{current} / {currency}{limit}"
+ },
+ "dailyResetMode": {
+ "label": "日次リセットモード",
+ "options": {
+ "fixed": "固定時刻リセット",
+ "rolling": "ローリングウィンドウ(24時間)"
+ },
+ "desc": {
+ "fixed": "毎日固定時刻にクォータをリセット",
+ "rolling": "最初のリクエストから24時間のローリングウィンドウ"
+ }
+ },
+ "dailyResetTime": {
+ "label": "日次リセット時刻",
+ "placeholder": "HH:mm"
+ },
"costWeekly": {
"label": "週次クォータ (USD)",
"placeholder": "無制限",
@@ -225,6 +266,27 @@
"placeholder": "空欄の場合は無制限",
"description": "5時間以内の最大消費金額"
},
+ "limitDailyUsd": {
+ "label": "日次消費上限 (USD)",
+ "placeholder": "空欄の場合は無制限",
+ "description": "1日の最大消費金額"
+ },
+ "dailyResetMode": {
+ "label": "日次リセットモード",
+ "options": {
+ "fixed": "固定時刻リセット",
+ "rolling": "ローリングウィンドウ(24時間)"
+ },
+ "desc": {
+ "fixed": "毎日指定時刻にクォータをリセット",
+ "rolling": "最初のリクエストから24時間のローリングウィンドウで計算"
+ }
+ },
+ "dailyResetTime": {
+ "label": "日次リセット時刻",
+ "placeholder": "HH:mm",
+ "description": "日次制限のリセット時刻(システムタイムゾーン使用)"
+ },
"limitWeeklyUsd": {
"label": "週間消費上限 (USD)",
"placeholder": "空欄の場合は無制限",
diff --git a/messages/ru/common.json b/messages/ru/common.json
index 5269cc1dc..844498382 100644
--- a/messages/ru/common.json
+++ b/messages/ru/common.json
@@ -40,5 +40,10 @@
"info": "Информация",
"noData": "Нет данных",
"emptyState": "Нечего отображать",
- "core": "Основной"
+ "core": "Основной",
+ "appearance": "Внешний вид",
+ "theme": "Тема",
+ "light": "Светлая",
+ "dark": "Тёмная",
+ "system": "Системная"
}
diff --git a/messages/ru/quota.json b/messages/ru/quota.json
index c2ee975de..5727be80b 100644
--- a/messages/ru/quota.json
+++ b/messages/ru/quota.json
@@ -90,6 +90,7 @@
"title": "Статистика квот провайдеров",
"totalCount": "Всего провайдеров: {count}",
"filterCount": "Показано {filtered} из {total} провайдеров",
+ "searchPlaceholder": "Поиск по имени провайдера...",
"status": {
"enabled": "Включен",
"disabled": "Отключен"
@@ -98,9 +99,26 @@
"priority": "Приоритет",
"weight": "Вес"
},
+ "sort": {
+ "name": "По имени",
+ "priority": "По приоритету",
+ "weight": "По весу",
+ "usage": "По использованию"
+ },
+ "list": {
+ "resetIn": "Сброс через",
+ "unlimited": "Неограниченно",
+ "current": "Текущее",
+ "limit": "Лимит",
+ "used": "использовано"
+ },
"cost5h": {
"label": "Расходы за 5 часов"
},
+ "costDaily": {
+ "label": "Ежедневные расходы",
+ "resetAt": "Сброс в"
+ },
"costWeekly": {
"label": "Еженедельные расходы",
"resetAt": "Сброс в"
@@ -115,6 +133,8 @@
"noQuotaSet": "Квота не установлена",
"noQuotaData": "Не удалось получить информацию о квоте",
"noMatches": "Провайдеры не найдены",
+ "noMatchesDesc": "Нет провайдеров, соответствующих вашим критериям поиска. Попробуйте изменить фильтры.",
+ "noProvidersDesc": "Провайдеры еще не настроены.",
"unlimitedSection": "Провайдеры без квот ({count})"
},
"keys": {
@@ -134,6 +154,7 @@
"keyName": "Название ключа",
"quotaType": "Тип квоты",
"cost5h": "5-часовая квота",
+ "costDaily": "Дневная квота",
"costWeekly": "Еженедельная квота",
"costMonthly": "Ежемесячная квота",
"concurrentSessions": "Лимит параллельных",
@@ -158,6 +179,26 @@
"placeholder": "Неограниченно",
"current": "Использовано: {currency}{current} из {currency}{limit}"
},
+ "costDaily": {
+ "label": "Дневная квота (USD)",
+ "placeholder": "Неограниченно",
+ "current": "Использовано: {currency}{current} из {currency}{limit}"
+ },
+ "dailyResetMode": {
+ "label": "Режим дневного сброса",
+ "options": {
+ "fixed": "Сброс в фиксированное время",
+ "rolling": "Скользящее окно (24ч)"
+ },
+ "desc": {
+ "fixed": "Сброс квоты в фиксированное время каждый день",
+ "rolling": "24-часовое скользящее окно от первого запроса"
+ }
+ },
+ "dailyResetTime": {
+ "label": "Время дневного сброса",
+ "placeholder": "ЧЧ:мм"
+ },
"costWeekly": {
"label": "Еженедельная квота (USD)",
"placeholder": "Неограниченно",
@@ -223,6 +264,27 @@
"placeholder": "Оставьте пустым для отсутствия ограничений",
"description": "Максимальная сумма расходов за 5 часов"
},
+ "limitDailyUsd": {
+ "label": "Дневной лимит расходов (USD)",
+ "placeholder": "Оставьте пустым для отсутствия ограничений",
+ "description": "Максимальная сумма расходов в день"
+ },
+ "dailyResetMode": {
+ "label": "Режим дневного сброса",
+ "options": {
+ "fixed": "Сброс в фиксированное время",
+ "rolling": "Скользящее окно (24ч)"
+ },
+ "desc": {
+ "fixed": "Сброс квоты в указанное время каждый день",
+ "rolling": "24-часовое скользящее окно от первого запроса"
+ }
+ },
+ "dailyResetTime": {
+ "label": "Время дневного сброса",
+ "placeholder": "ЧЧ:мм",
+ "description": "Время сброса дневного лимита (используется системный часовой пояс)"
+ },
"limitWeeklyUsd": {
"label": "Еженедельный лимит расходов (USD)",
"placeholder": "Оставьте пустым для отсутствия ограничений",
diff --git a/messages/zh-CN/common.json b/messages/zh-CN/common.json
index 1b1203a6c..453a483d2 100644
--- a/messages/zh-CN/common.json
+++ b/messages/zh-CN/common.json
@@ -40,5 +40,10 @@
"info": "信息",
"noData": "暂无数据",
"emptyState": "没有数据可显示",
- "core": "核心"
+ "core": "核心",
+ "appearance": "外观",
+ "theme": "主题",
+ "light": "浅色",
+ "dark": "深色",
+ "system": "跟随系统"
}
diff --git a/messages/zh-CN/quota.json b/messages/zh-CN/quota.json
index 1c405481c..084e50917 100644
--- a/messages/zh-CN/quota.json
+++ b/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小时消费"
},
@@ -121,6 +135,8 @@
"noQuotaSet": "未设置限额",
"noQuotaData": "无法获取限额信息",
"noMatches": "没有匹配的供应商",
+ "noMatchesDesc": "没有供应商匹配您的搜索条件,请尝试调整筛选条件。",
+ "noProvidersDesc": "尚未配置任何供应商。",
"unlimitedSection": "未设置限额的供应商 ({count}个)"
},
"keys": {
diff --git a/messages/zh-TW/common.json b/messages/zh-TW/common.json
index 18de8aae2..3f26f97ec 100644
--- a/messages/zh-TW/common.json
+++ b/messages/zh-TW/common.json
@@ -40,5 +40,10 @@
"info": "資訊",
"noData": "暫無資料",
"emptyState": "沒有資料可顯示",
- "core": "核心"
+ "core": "核心",
+ "appearance": "外觀",
+ "theme": "主題",
+ "light": "淺色",
+ "dark": "深色",
+ "system": "跟隨系統"
}
diff --git a/messages/zh-TW/quota.json b/messages/zh-TW/quota.json
index 4b79af938..0d3496c87 100644
--- a/messages/zh-TW/quota.json
+++ b/messages/zh-TW/quota.json
@@ -90,6 +90,7 @@
"title": "供應商限額統計",
"totalCount": "共 {count} 個供應商",
"filterCount": "顯示 {filtered} / {total} 個供應商",
+ "searchPlaceholder": "搜尋供應商名稱...",
"status": {
"enabled": "啟用",
"disabled": "禁用"
@@ -98,9 +99,26 @@
"priority": "優先級",
"weight": "權重"
},
+ "sort": {
+ "name": "按名稱",
+ "priority": "按優先級",
+ "weight": "按權重",
+ "usage": "按使用量"
+ },
+ "list": {
+ "resetIn": "重置於",
+ "unlimited": "無限制",
+ "current": "當前",
+ "limit": "限制",
+ "used": "已用"
+ },
"cost5h": {
"label": "5小時消費"
},
+ "costDaily": {
+ "label": "每日消費",
+ "resetAt": "重置於"
+ },
"costWeekly": {
"label": "周消費",
"resetAt": "重置於"
@@ -115,6 +133,8 @@
"noQuotaSet": "未設置限額",
"noQuotaData": "無法獲取限額資訊",
"noMatches": "沒有匹配的供應商",
+ "noMatchesDesc": "沒有供應商符合您的搜尋條件,請嘗試調整篩選條件。",
+ "noProvidersDesc": "尚未配置任何供應商。",
"unlimitedSection": "未設置限額的供應商 ({count}個)"
},
"keys": {
@@ -134,6 +154,7 @@
"keyName": "密鑰名稱",
"quotaType": "限額類型",
"cost5h": "5小時限額",
+ "costDaily": "每日限額",
"costWeekly": "周限額",
"costMonthly": "月限額",
"concurrentSessions": "並發限制",
@@ -158,6 +179,26 @@
"placeholder": "不限制",
"current": "當前已用: {currency}{current} / {currency}{limit}"
},
+ "costDaily": {
+ "label": "每日限額 (USD)",
+ "placeholder": "不限制",
+ "current": "當前已用: {currency}{current} / {currency}{limit}"
+ },
+ "dailyResetMode": {
+ "label": "每日重置模式",
+ "options": {
+ "fixed": "固定時間重置",
+ "rolling": "滾動窗口(24小時)"
+ },
+ "desc": {
+ "fixed": "每天在指定時間重置額度",
+ "rolling": "從首次請求開始計算24小時滾動窗口"
+ }
+ },
+ "dailyResetTime": {
+ "label": "每日重置時間",
+ "placeholder": "HH:mm"
+ },
"costWeekly": {
"label": "周限額 (USD)",
"placeholder": "不限制",
@@ -223,6 +264,27 @@
"placeholder": "留空表示無限制",
"description": "5小時內最大消費金額"
},
+ "limitDailyUsd": {
+ "label": "每日消費上限 (USD)",
+ "placeholder": "留空表示無限制",
+ "description": "每日最大消費金額"
+ },
+ "dailyResetMode": {
+ "label": "每日重置模式",
+ "options": {
+ "fixed": "固定時間重置",
+ "rolling": "滾動窗口(24小時)"
+ },
+ "desc": {
+ "fixed": "每天在指定時間重置額度",
+ "rolling": "從首次請求開始計算24小時滾動窗口"
+ }
+ },
+ "dailyResetTime": {
+ "label": "每日重置時間",
+ "placeholder": "HH:mm",
+ "description": "每日限額的重置時間(使用系統時區)"
+ },
"limitWeeklyUsd": {
"label": "週消費上限 (USD)",
"placeholder": "留空表示無限制",
diff --git a/src/app/[locale]/dashboard/_components/dashboard-header.tsx b/src/app/[locale]/dashboard/_components/dashboard-header.tsx
index f6d9f8306..28d4c1bf1 100644
--- a/src/app/[locale]/dashboard/_components/dashboard-header.tsx
+++ b/src/app/[locale]/dashboard/_components/dashboard-header.tsx
@@ -6,6 +6,7 @@ import { DashboardNav, type DashboardNavItem } from "./dashboard-nav";
import { UserMenu } from "./user-menu";
import { VersionUpdateNotifier } from "@/components/customs/version-update-notifier";
import { LanguageSwitcher } from "@/components/ui/language-switcher";
+import { ThemeSwitcher } from "@/components/ui/theme-switcher";
import { useTranslations } from "next-intl";
interface DashboardHeaderProps {
@@ -33,6 +34,7 @@ export function DashboardHeader({ session }: DashboardHeaderProps) {
+
{session &&
}
{session ? (
diff --git a/src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-list-item.tsx b/src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-list-item.tsx
new file mode 100644
index 000000000..6587ae755
--- /dev/null
+++ b/src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-list-item.tsx
@@ -0,0 +1,228 @@
+"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,
+ resetInfo?: string
+ ) => {
+ if (!limit || limit <= 0) return null;
+
+ const percentage = Math.min((current / limit) * 100, 100);
+
+ return (
+
+
+
+
+ {/* 标题 */}
+ {label}
+ {/* 圆环进度 */}
+
+ {/* 倒计时或重置信息 */}
+ {resetAt ? (
+
+ ) : resetInfo ? (
+ {resetInfo}
+ ) : null}
+
+
+
+
+
{label}
+
+ {t("list.current")}: {formatCurrency(current, currencyCode)}
+
+
+ {t("list.limit")}: {formatCurrency(limit, currencyCode)}
+
+
+ {percentage.toFixed(1)}% {t("list.used")}
+
+
+
+
+
+ );
+ };
+
+ // 渲染并发Session指标
+ const renderConcurrentSessionsItem = () => {
+ const { current, limit } = provider.quota?.concurrentSessions || { current: 0, limit: 0 };
+ if (limit <= 0) return null;
+
+ // 计算百分比,确保上限为100%
+ const percentage = Math.min((current / limit) * 100, 100);
+
+ return (
+
+
+
+
+ {/* 标题 */}
+
+ {t("concurrentSessions.label")}
+
+ {/* 圆环进度 */}
+
+ {/* 占位符,保持对齐 */}
+ -
+
+
+
+
+
{t("concurrentSessions.label")}
+
+ {t("list.current")}: {current}
+
+
+ {t("list.limit")}: {limit}
+
+
+ {percentage.toFixed(1)}% {t("list.used")}
+
+
+
+
+
+ );
+ };
+
+ if (!provider.quota) {
+ console.warn(
+ `Provider ${provider.name} (ID: ${provider.id}) has no quota data - skipping render`
+ );
+ return null;
+ }
+
+ return (
+
+ {/* 左侧:状态 + 类型图标 + 名称 */}
+
+ {/* 启用状态指示器 */}
+ {provider.isEnabled ? (
+
+ ) : (
+
+ )}
+
+ {/* 类型图标 */}
+
+
+
+
+ {/* 名称和状态徽章 */}
+
+
+ {provider.name}
+
+ P:{provider.priority} W:{provider.weight}
+
+
+
+
+
+ {/* 中间:限额指标(圆形进度) */}
+
+ {/* 5小时限额 */}
+ {provider.quota.cost5h.limit &&
+ provider.quota.cost5h.limit > 0 &&
+ renderQuotaItem(
+ t("cost5h.label"),
+ provider.quota.cost5h.current,
+ provider.quota.cost5h.limit,
+ undefined,
+ provider.quota.cost5h.resetInfo
+ )}
+
+ {/* 每日限额 */}
+ {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()}
+
+
+ {/* 右侧:操作区域(预留) */}
+
{/* 可以添加操作按钮 */}
+
+ );
+}
diff --git a/src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-sort-dropdown.tsx b/src/app/[locale]/dashboard/quotas/providers/_components/provider-quota-sort-dropdown.tsx
new file mode 100644
index 000000000..0ffd11cd6
--- /dev/null
+++ b/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 (
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx
index 38f29b370..71738f21d 100644
--- a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx
+++ b/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,98 @@ export function ProvidersQuotaClient({
}
});
- // 有限额的供应商:按优先级降序,优先级相同按权重降序
- withQuota.sort((a, b) => {
- if (b.priority !== a.priority) {
- return b.priority - a.priority;
- }
- return b.weight - a.weight;
- });
-
- // 无限额的供应商:保持原有顺序(由数据库查询决定)
- // 不需要额外排序
+ // 4. 排序(仅对有限额的供应商排序)
+ if (sortBy === "usage") {
+ // 预计算 usage 值以提升排序性能
+ const usageMap = new Map
();
+ withQuota.forEach((p) => usageMap.set(p.id, calculateMaxUsage(p)));
+
+ withQuota.sort((a, b) => {
+ const usageA = usageMap.get(a.id) ?? 0;
+ const usageB = usageMap.get(b.id) ?? 0;
+ return usageB - usageA;
+ });
+ } else {
+ withQuota.sort((a, b) => {
+ switch (sortBy) {
+ case "name":
+ return a.name.localeCompare(b.name);
+ case "priority":
+ // 优先级:数值越小越优先,升序排列
+ return a.priority - b.priority;
+ case "weight":
+ // 权重:数值越大越优先,降序排列
+ return b.weight - a.weight;
+ default:
+ return 0;
+ }
+ });
+ }
return {
providersWithQuota: withQuota,
providersWithoutQuota: withoutQuota,
};
- }, [providers, typeFilter]);
-
- // 渲染供应商卡片的函数
- const renderProviderCard = (provider: ProviderWithQuota) => (
-
-
-
-
{provider.name}
-
-
- {provider.isEnabled ? t("status.enabled") : t("status.disabled")}
-
- {provider.providerType}
-
-
-
- {t("card.priority")}: {provider.priority} · {t("card.weight")}: {provider.weight}
-
-
-
- {provider.quota ? (
- <>
- {/* 5小时消费 */}
- {provider.quota.cost5h.limit && provider.quota.cost5h.limit > 0 && (
-
-
- {t("cost5h.label")}
-
- {formatCurrency(provider.quota.cost5h.current, currencyCode)} /{" "}
- {formatCurrency(provider.quota.cost5h.limit, currencyCode)}
-
-
-
-
{provider.quota.cost5h.resetInfo}
-
- )}
-
- {provider.quota.costDaily.limit && provider.quota.costDaily.limit > 0 && (
-
-
- {t("costDaily.label")}
- {provider.quota.costDaily.resetAt && (
-
- {t("costDaily.resetAt")}{" "}
- {formatDateDistance(provider.quota.costDaily.resetAt, new Date(), locale)}
-
- )}
-
-
-
- {formatCurrency(provider.quota.costDaily.current, currencyCode)} /{" "}
- {formatCurrency(provider.quota.costDaily.limit, currencyCode)}
-
-
-
-
- )}
-
- {/* 周消费 */}
- {provider.quota.costWeekly.limit && provider.quota.costWeekly.limit > 0 && (
-
-
- {t("costWeekly.label")}
-
- {formatCurrency(provider.quota.costWeekly.current, currencyCode)} /{" "}
- {formatCurrency(provider.quota.costWeekly.limit, currencyCode)}
-
-
-
-
- {t("costWeekly.resetAt")}{" "}
- {formatDateDistance(
- new Date(provider.quota.costWeekly.resetAt),
- new Date(),
- locale
- )}
-
-
- )}
-
- {/* 月消费 */}
- {provider.quota.costMonthly.limit && provider.quota.costMonthly.limit > 0 && (
-
-
- {t("costMonthly.label")}
-
- {formatCurrency(provider.quota.costMonthly.current, currencyCode)} /{" "}
- {formatCurrency(provider.quota.costMonthly.limit, currencyCode)}
-
-
-
-
- {t("costMonthly.resetAt")}{" "}
- {formatDateDistance(
- new Date(provider.quota.costMonthly.resetAt),
- new Date(),
- locale
- )}
-
-
- )}
-
- {/* 并发 Session */}
- {provider.quota.concurrentSessions.limit > 0 && (
-
-
- {t("concurrentSessions.label")}
-
- {provider.quota.concurrentSessions.current} /{" "}
- {provider.quota.concurrentSessions.limit}
-
-
-
-
- )}
-
- {!hasQuotaLimit(provider.quota) && (
- {t("noQuotaSet")}
- )}
- >
- ) : (
- {t("noQuotaData")}
- )}
-
-
- );
+ }, [providers, typeFilter, sortBy, searchTerm]);
const totalProviders = providersWithQuota.length + providersWithoutQuota.length;
+ // 空状态
+ if (totalProviders === 0) {
+ return (
+
+
+
+
+
{t("noMatches")}
+
+ {searchTerm ? t("noMatchesDesc") : t("noProvidersDesc")}
+
+
+ );
+ }
+
return (
- <>
- {totalProviders === 0 ? (
-
-
- {t("noMatches")}
-
-
- ) : (
-
- {/* 有限额的供应商 */}
- {providersWithQuota.length > 0 && (
-
- {providersWithQuota.map(renderProviderCard)}
-
- )}
+
+ {/* 有限额的供应商(列表形式) */}
+ {providersWithQuota.length > 0 && (
+
+ {providersWithQuota.map((provider) => (
+
+ ))}
+
+ )}
- {/* 无限额的供应商(折叠区域) */}
- {providersWithoutQuota.length > 0 && (
-
-
-
- {t("unlimitedSection", { count: providersWithoutQuota.length })}
-
-
-
-
-
- {providersWithoutQuota.map(renderProviderCard)}
+ {/* 无限额的供应商(折叠区域) */}
+ {providersWithoutQuota.length > 0 && (
+
+
+
+ {t("unlimitedSection", { count: providersWithoutQuota.length })}
+
+
+
+
+
+ {providersWithoutQuota.map((provider) => (
+
+ {provider.name}
+ {t("noQuotaSet")}
-
-
- )}
-
+ ))}
+
+
+
)}
- >
+
);
}
diff --git a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx
index bb6b98c0a..0ca9ed579 100644
--- a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx
+++ b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx
@@ -1,8 +1,12 @@
"use client";
-import { useState } from "react";
+import { useState, useMemo } 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,28 +39,72 @@ export function ProvidersQuotaManager({
currencyCode = "USD",
}: ProvidersQuotaManagerProps) {
const [typeFilter, setTypeFilter] = useState
("all");
+ const [sortBy, setSortBy] = useState("priority");
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
+
const t = useTranslations("quota.providers");
+ const tSearch = useTranslations("settings.providers.search");
+
+ // 计算筛选后的供应商数量(包括搜索)
+ const filteredCount = useMemo(() => {
+ let filtered =
+ typeFilter === "all" ? providers : providers.filter((p) => p.providerType === typeFilter);
- // 计算筛选后的供应商数量
- const filteredCount =
- typeFilter === "all"
- ? providers.length
- : providers.filter((p) => p.providerType === typeFilter).length;
+ if (debouncedSearchTerm) {
+ const term = debouncedSearchTerm.toLowerCase();
+ filtered = filtered.filter((p) => p.name.toLowerCase().includes(term));
+ }
+
+ return filtered.length;
+ }, [providers, typeFilter, debouncedSearchTerm]);
return (
- {/* 类型筛选器 */}
-
-
-
- {t("filterCount", { filtered: filteredCount, total: providers.length })}
+ {/* 筛选和搜索工具栏 */}
+
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {searchTerm && (
+
+ )}
+
+
+ {/* 搜索结果提示或筛选统计 */}
+ {debouncedSearchTerm ? (
+
+ {tSearch("found", { count: filteredCount })}
+
+ ) : (
+
+ {t("filterCount", { filtered: filteredCount, total: providers.length })}
+
+ )}
{/* 供应商列表 */}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 92b1a74fb..35d10cfdd 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -72,7 +72,7 @@ export default async function RootLayout({
const messages = await getMessages();
return (
-
+
diff --git a/src/app/[locale]/settings/_components/settings-nav.tsx b/src/app/[locale]/settings/_components/settings-nav.tsx
index 61ad591bf..5cc668124 100644
--- a/src/app/[locale]/settings/_components/settings-nav.tsx
+++ b/src/app/[locale]/settings/_components/settings-nav.tsx
@@ -4,6 +4,8 @@ import { Link, usePathname } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import type { SettingsNavItem } from "../_lib/nav-items";
+import { ThemeSwitcher } from "@/components/ui/theme-switcher";
+import { useTranslations } from "next-intl";
interface SettingsNavProps {
items: SettingsNavItem[];
@@ -11,6 +13,7 @@ interface SettingsNavProps {
export function SettingsNav({ items }: SettingsNavProps) {
const pathname = usePathname();
+ const t = useTranslations("common");
if (items.length === 0) {
return null;
@@ -65,6 +68,17 @@ export function SettingsNav({ items }: SettingsNavProps) {
);
})}
+
+
+
+
+ {t("appearance")}
+
+
{t("theme")}
+
+
+
+
);
}
diff --git a/src/app/[locale]/usage-doc/page.tsx b/src/app/[locale]/usage-doc/page.tsx
index bcf48ad62..8bd7e3896 100644
--- a/src/app/[locale]/usage-doc/page.tsx
+++ b/src/app/[locale]/usage-doc/page.tsx
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TocNav, type TocItem } from "./_components/toc-nav";
import { QuickLinks } from "./_components/quick-links";
-import { LanguageSwitcher } from "@/components/ui/language-switcher";
const headingClasses = {
h2: "scroll-m-20 text-2xl font-semibold leading-snug text-foreground",
@@ -1542,11 +1541,6 @@ export default function UsageDocPage() {
{t("skipLinks.tableOfContents")}
- {/* Language Switcher - Fixed position */}
-
-
-
-
{/* 左侧主文档 */}
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 6b3516744..250221f11 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -2,6 +2,7 @@
import { useState, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ThemeProvider } from "next-themes";
interface AppProvidersProps {
children: ReactNode;
@@ -10,5 +11,18 @@ interface AppProvidersProps {
export function AppProviders({ children }: AppProvidersProps) {
const [queryClient] = useState(() => new QueryClient());
- return
{children};
+ return (
+
+
+ {children}
+
+
+ );
}
diff --git a/src/components/ui/circular-progress.tsx b/src/components/ui/circular-progress.tsx
new file mode 100644
index 000000000..821552f8a
--- /dev/null
+++ b/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 (
+
+
+
+ {/* 中心文本 */}
+
+ {showPercentage && (
+ {percentage}%
+ )}
+ {label && {label}}
+
+
+ );
+}
diff --git a/src/components/ui/countdown-timer.tsx b/src/components/ui/countdown-timer.tsx
new file mode 100644
index 000000000..0862feff1
--- /dev/null
+++ b/src/components/ui/countdown-timer.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useState, useEffect, useMemo } 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();
+
+ // 使用 useMemo 计算初始值,避免 SSR 与客户端不匹配
+ const initialTimeLeft = useMemo(
+ () => formatDateDistance(targetDate, new Date(), locale),
+ [targetDate, locale]
+ );
+
+ const [timeLeft, setTimeLeft] = useState
(initialTimeLeft);
+
+ useEffect(() => {
+ // 更新倒计时显示
+ const updateCountdown = () => {
+ const formatted = formatDateDistance(targetDate, new Date(), locale);
+ setTimeLeft(formatted);
+ };
+
+ // 立即更新一次(处理 SSR 后的首次渲染)
+ updateCountdown();
+
+ // 每30秒更新一次(减少不必要的渲染)
+ const interval = setInterval(updateCountdown, 30000);
+
+ return () => clearInterval(interval);
+ }, [targetDate, locale]);
+
+ if (!timeLeft) return null;
+
+ return (
+
+ {prefix}
+ {timeLeft}
+
+ );
+}
diff --git a/src/components/ui/theme-switcher.tsx b/src/components/ui/theme-switcher.tsx
new file mode 100644
index 000000000..fdee95b11
--- /dev/null
+++ b/src/components/ui/theme-switcher.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Laptop, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+
+type ThemeValue = "light" | "dark" | "system";
+
+interface ThemeSwitcherProps {
+ className?: string;
+ size?: "sm" | "default";
+ showLabel?: boolean;
+}
+
+export function ThemeSwitcher({
+ className,
+ size = "sm",
+ showLabel = false,
+}: ThemeSwitcherProps) {
+ const t = useTranslations("common");
+ const [mounted, setMounted] = useState(false);
+
+ // Always call useTheme unconditionally (Rules of Hooks requirement)
+ const { theme, setTheme } = useTheme();
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Simplified theme options with better type inference
+ const options = [
+ { value: "light" as ThemeValue, icon: Sun },
+ { value: "dark" as ThemeValue, icon: Moon },
+ { value: "system" as ThemeValue, icon: Laptop },
+ ];
+
+ const labelMap: Record = {
+ light: t("light"),
+ dark: t("dark"),
+ system: t("system"),
+ };
+
+ // Simplified: directly use theme for display (shows "system" when selected)
+ const currentTheme = (theme ?? "system") as ThemeValue;
+
+ const triggerSize = size === "sm" ? "icon" : "default";
+
+ // Handle theme changes with error handling
+ const handleThemeChange = (value: string) => {
+ try {
+ setTheme(value as ThemeValue);
+ } catch (error) {
+ console.error("Failed to change theme:", error);
+ // Optionally show toast notification
+ // toast.error("Unable to change theme. Please check browser settings.");
+ }
+ };
+
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {t("theme")}
+
+ {options.map(({ value, icon: Icon }) => (
+
+
+ {labelMap[value]}
+
+ ))}
+
+
+
+ );
+}