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]} + + ))} + + + + ); +}