From c95f494569b49ff1fae871970764c9f0a3fbda59 Mon Sep 17 00:00:00 2001 From: Draculabo Date: Fri, 6 Mar 2026 21:51:19 +0800 Subject: [PATCH] feat(ui): update cloud account and provider visibility experience --- src/components/AccountCard.tsx | 54 ++-- src/components/CloudAccountCard.tsx | 327 +++++++++++---------- src/components/CloudAccountList.tsx | 152 +++++----- src/components/ModelVisibilitySettings.tsx | 172 ++++++----- src/components/ProviderGroup.tsx | 45 +-- src/localization/i18n.ts | 6 +- src/routes/proxy.tsx | 190 ++++++------ src/types/cloudAccount.ts | 50 +++- src/utils/provider-grouping.ts | 163 ++++++---- src/utils/quota-display.ts | 85 ++++++ 10 files changed, 738 insertions(+), 506 deletions(-) create mode 100644 src/utils/quota-display.ts diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index b87f172..b185244 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -11,11 +11,19 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { MoreVertical, Trash2, RefreshCw } from 'lucide-react'; -import { formatDistanceToNow } from 'date-fns'; +import { formatDistanceToNow, type Locale } from 'date-fns'; import { enUS, zhCN, ru } from 'date-fns/locale'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; +const DEFAULT_GLARE_POSITION = { x: 50, y: 50 }; +const DEFAULT_CARD_TRANSFORM = 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'; +const DATE_LOCALE_MAP: Record = { + en: enUS, + 'zh-CN': zhCN, + ru: ru, +}; + interface AccountCardProps { account: Account; isCurrent: boolean; @@ -35,8 +43,8 @@ export const AccountCard: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const cardRef = useRef(null); - const [transform, setTransform] = useState(''); - const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 }); + const [cardTransform, setCardTransform] = useState(''); + const [glarePosition, setGlarePosition] = useState(DEFAULT_GLARE_POSITION); const initials = account.name ? account.name @@ -47,38 +55,38 @@ export const AccountCard: React.FC = ({ .slice(0, 2) : account.email[0].toUpperCase(); - // 处理鼠标移动,计算 3D 旋转角度 - const handleMouseMove = (e: React.MouseEvent) => { + // Handle pointer movement and calculate 3D rotation angles + const handleMouseMove = (event: React.MouseEvent) => { if (!cardRef.current) return; const rect = cardRef.current.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; - // 计算鼠标相对于卡片中心的位置 (-1 到 1) - const relativeX = (e.clientX - centerX) / (rect.width / 2); - const relativeY = (e.clientY - centerY) / (rect.height / 2); + // Calculate pointer position relative to the card center (-1 to 1) + const relativeX = (event.clientX - centerX) / (rect.width / 2); + const relativeY = (event.clientY - centerY) / (rect.height / 2); - // 最大旋转角度 + // Maximum rotation angle const maxRotation = 15; - const rotateX = -relativeY * maxRotation; // 上下倾斜 - const rotateY = relativeX * maxRotation; // 左右倾斜 + const rotateX = -relativeY * maxRotation; // Vertical tilt + const rotateY = relativeX * maxRotation; // Horizontal tilt - setTransform( + setCardTransform( `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`, ); - // 更新光泽位置 + // Update glare position setGlarePosition({ - x: ((e.clientX - rect.left) / rect.width) * 100, - y: ((e.clientY - rect.top) / rect.height) * 100, + x: ((event.clientX - rect.left) / rect.width) * 100, + y: ((event.clientY - rect.top) / rect.height) * 100, }); }; - // 鼠标离开时重置 + // Reset when pointer leaves const handleMouseLeave = () => { - setTransform('perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'); - setGlarePosition({ x: 50, y: 50 }); + setCardTransform(DEFAULT_CARD_TRANSFORM); + setGlarePosition(DEFAULT_GLARE_POSITION); }; return ( @@ -87,18 +95,18 @@ export const AccountCard: React.FC = ({ onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} style={{ - transform, + transform: cardTransform, transformStyle: 'preserve-3d', - transition: transform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out', + transition: cardTransform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out', }} className="relative" > - {/* 光泽效果层 */} + {/* Glare overlay */}
@@ -137,7 +145,7 @@ export const AccountCard: React.FC = ({ {t('account.lastUsed', { time: formatDistanceToNow(new Date(account.last_used), { addSuffix: true, - locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS, + locale: DATE_LOCALE_MAP[i18n.language] || enUS, }), })}

diff --git a/src/components/CloudAccountCard.tsx b/src/components/CloudAccountCard.tsx index 4eb7fb9..43f935a 100644 --- a/src/components/CloudAccountCard.tsx +++ b/src/components/CloudAccountCard.tsx @@ -1,4 +1,4 @@ -import { CloudAccount } from '@/types/cloudAccount'; +import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount'; import { Button } from '@/components/ui/button'; import { Card, @@ -19,17 +19,109 @@ import { import { Checkbox } from '@/components/ui/checkbox'; import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react'; -import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns'; +import { formatDistanceToNow } from 'date-fns'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; import { useAppConfig } from '@/hooks/useAppConfig'; import { useProviderGrouping } from '@/hooks/useProviderGrouping'; import { ProviderGroup } from '@/components/ProviderGroup'; +import { + clampQuotaPercentage, + formatResetTimeLabel, + formatResetTimeTitle, + getQuotaStatus, + type QuotaStatus, +} from '@/utils/quota-display'; -interface ModelQuotaInfo { - percentage: number; - resetTime: string; +type ModelQuotaEntry = [string, CloudQuotaModelInfo]; + +const GEMINI_LEGACY_MODEL_PATTERN = /gemini-[12](\.|$|-)/i; +const GEMINI_PRO_COMBINED_MODEL_ID = 'gemini-3.1-pro-low/high'; + +const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [ + [GEMINI_PRO_COMBINED_MODEL_ID, 'Gemini 3.1 Pro (Low/High)'], + ['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'], + ['gemini-3-pro-image', 'Gemini 3 Pro Image'], + ['gemini-3.1-pro', 'Gemini 3.1 Pro'], + ['gemini-3-pro', 'Gemini 3 Pro'], + ['gemini-3-flash', 'Gemini 3 Flash'], + ['claude-sonnet-4-6-thinking', 'Claude 4.6 Sonnet (Thinking)'], + ['claude-sonnet-4-6', 'Claude 4.6 Sonnet'], + ['claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)'], + ['claude-sonnet-4-5', 'Claude 4.5 Sonnet'], + ['claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)'], + ['claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)'], + ['claude-3-5-sonnet', 'Claude 3.5 Sonnet'], +]; + +const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record = { + high: 'text-green-500', + medium: 'text-yellow-500', + low: 'text-red-500', +}; + +const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record = { + high: 'bg-emerald-500', + medium: 'bg-amber-500', + low: 'bg-rose-500', +}; + +function isGeminiProLowModel(modelName: string): boolean { + const normalizedModelName = modelName.toLowerCase(); + return normalizedModelName.includes('gemini-3.1-pro-low'); } + +function isGeminiProHighModel(modelName: string): boolean { + const normalizedModelName = modelName.toLowerCase(); + return normalizedModelName.includes('gemini-3.1-pro-high'); +} + +function isGeminiProFamilyModel(modelName: string): boolean { + const normalizedModelName = modelName.toLowerCase(); + return normalizedModelName.includes('gemini-3.1-pro'); +} + +function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record { + const mergedModels: Record = {}; + const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName)); + const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName)); + const proLowModelInfo = entries.find(([modelName]) => isGeminiProLowModel(modelName))?.[1]; + + for (const [modelName, modelInfo] of entries) { + if (isGeminiProLowModel(modelName) && hasProHighModel) { + continue; + } + + if (isGeminiProHighModel(modelName) && hasProLowModel) { + const mergedPercentage = proLowModelInfo + ? Math.min(modelInfo.percentage, proLowModelInfo.percentage) + : modelInfo.percentage; + mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = { + ...modelInfo, + percentage: mergedPercentage, + }; + continue; + } + + mergedModels[modelName] = modelInfo; + } + + return mergedModels; +} + +function formatModelDisplayName(modelName: string): string { + let displayName = modelName.replace('models/', ''); + for (const [source, target] of MODEL_DISPLAY_REPLACEMENTS) { + displayName = displayName.replace(source, target); + } + + return displayName + .replace(/-/g, ' ') + .split(' ') + .map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) + .join(' '); +} + interface CloudAccountCardProps { account: CloudAccount; onRefresh: (id: string) => void; @@ -64,114 +156,54 @@ export function CloudAccountCard({ toggleProviderCollapse, } = useProviderGrouping(); - // Helpers to get quota color - const getQuotaColor = (percentage: number) => { - if (percentage > 80) return 'text-green-500'; - if (percentage > 20) return 'text-yellow-500'; - return 'text-red-500'; + const getQuotaTextColorClass = (percentage: number) => { + const quotaStatus = getQuotaStatus(percentage); + return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]; }; - const getQuotaBarColor = (percentage: number) => { - if (percentage > 80) return 'bg-emerald-500'; - if (percentage > 20) return 'bg-amber-500'; - return 'bg-rose-500'; + const getQuotaBarColorClass = (percentage: number) => { + const quotaStatus = getQuotaStatus(percentage); + return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]; }; - const getQuotaLabel = (percentage: number) => { + const formatQuotaLabel = (percentage: number) => { if (percentage === 0) { return t('cloud.card.rateLimitedQuota'); } return `${percentage}%`; }; - const formatTimeRemaining = (dateStr: string) => { - const targetDate = new Date(dateStr); - if (Number.isNaN(targetDate.getTime())) return null; - - const now = new Date(); - if (isBefore(targetDate, now)) return '0h 0m'; - - const diffHrs = Math.max(0, differenceInHours(targetDate, now)); - const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60); - if (diffHrs >= 24) { - const diffDays = Math.floor(diffHrs / 24); - const remainingHrs = diffHrs % 24; - return `${diffDays}d ${remainingHrs}h`; - } - return `${diffHrs}h ${diffMins}m`; + const formatResetTimeLabelText = (resetTime?: string) => { + return formatResetTimeLabel(resetTime, { + prefix: t('cloud.card.resetPrefix'), + unknown: t('cloud.card.resetUnknown'), + }); }; - const getResetTimeLabel = (resetTime?: string) => { - if (!resetTime) return t('cloud.card.resetUnknown'); - const remaining = formatTimeRemaining(resetTime); - if (!remaining) return t('cloud.card.resetUnknown'); - return `${t('cloud.card.resetPrefix')}: ${remaining}`; + const formatResetTimeTitleText = (resetTime?: string) => { + return formatResetTimeTitle(resetTime, t('cloud.card.resetTime')); }; - const getResetTimeTitle = (resetTime?: string) => { - if (!resetTime) return undefined; - const resetDate = new Date(resetTime); - if (Number.isNaN(resetDate.getTime())) return undefined; - return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`; - }; - - // --- Logic for model groups --- - const rawModels = Object.entries(account.quota?.models || {}).filter( + const visibleModelEntries = Object.entries(account.quota?.models || {}).filter( ([modelName]) => config?.model_visibility?.[modelName] !== false, - ); + ) as ModelQuotaEntry[]; - // Group Gemini 3 Pro Low/High if both exist - const processedModels: Record = {}; - const hasLow = rawModels.some(([name]) => name.includes('gemini-3-pro-low')); - const hasHigh = rawModels.some(([name]) => name.includes('gemini-3-pro-high')); + const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries); - for (const [name, info] of rawModels) { - if (name.includes('gemini-3-pro-low') && hasHigh) continue; - if (name.includes('gemini-3-pro-high') && hasLow) { - // Use the lower percentage if both exist, to be safe - const lowInfo = rawModels.find(([n]) => n.includes('gemini-3-pro-low'))?.[1]; - const mergedPercentage = lowInfo - ? Math.min(info.percentage, lowInfo.percentage) - : info.percentage; - processedModels['gemini-3-pro-low/high'] = { ...info, percentage: mergedPercentage }; - continue; - } - processedModels[name] = info; - } - - const geminiModels = Object.entries(processedModels) - .filter(([name]) => name.includes('gemini') && !/gemini-[12](\.|$|-)/i.test(name)) + const geminiModels = Object.entries(mergedModelQuotas) + .filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name)) .sort((a, b) => b[1].percentage - a[1].percentage); - const claudeModels = Object.entries(processedModels) + const claudeModels = Object.entries(mergedModelQuotas) .filter(([name]) => name.includes('claude')) .sort((a, b) => b[1].percentage - a[1].percentage); const hasHighTier = geminiModels.some( - ([name, info]) => name.includes('gemini-3-pro') && info.percentage > 50, + ([name, info]) => isGeminiProFamilyModel(name) && info.percentage > 50, ); - const hasRenderableModels = geminiModels.length > 0 || claudeModels.length > 0; + const hasVisibleQuotaModels = geminiModels.length > 0 || claudeModels.length > 0; - const formatModelName = (name: string) => { - return name - .replace('models/', '') - .replace('gemini-3-pro-low/high', 'Gemini 3 Pro (Low/High)') - .replace('gemini-3-pro-preview', 'Gemini 3 Pro Preview') - .replace('gemini-3-pro-image', 'Gemini 3 Pro Image') - .replace('gemini-3-pro', 'Gemini 3 Pro') - .replace('gemini-3-flash', 'Gemini 3 Flash') - .replace('claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)') - .replace('claude-sonnet-4-5', 'Claude 4.5 Sonnet') - .replace('claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)') - .replace('claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)') - .replace('claude-3-5-sonnet', 'Claude 3.5 Sonnet') - .replace(/-/g, ' ') - .split(' ') - .map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) - .join(' '); - }; - - const renderModelGroup = (title: string, models: [string, ModelQuotaInfo][]) => { + const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => { if (models.length === 0) return null; return (
@@ -190,25 +222,25 @@ export function CloudAccountCard({ className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold" title={modelName} > - {formatModelName(modelName)} + {formatModelDisplayName(modelName)}
- {getResetTimeLabel(info.resetTime)} + {formatResetTimeLabelText(info.resetTime)}
{info.percentage}%
@@ -219,6 +251,52 @@ export function CloudAccountCard({ ); }; + const emptyQuotaState = ( +
+ + {t('cloud.card.noQuota')} +
+ ); + + const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null; + const providerGroupedQuotaSection = + providerStats && providerStats.visibleModels > 0 ? ( + <> +
+ {t('settings.providerGroupings.overall')} +
+ + {formatQuotaLabel(providerStats.overallPercentage)} + +
+
+
+
+
+ {providerStats.providers.map((statsByProvider) => ( + toggleProviderCollapse(account.id, statsByProvider.providerKey)} + getQuotaTextColorClass={getQuotaTextColorClass} + getQuotaBarColorClass={getQuotaBarColorClass} + formatQuotaLabel={formatQuotaLabel} + formatResetTimeLabel={formatResetTimeLabelText} + formatResetTimeTitle={formatResetTimeTitleText} + leftLabel={t('cloud.card.left')} + /> + ))} + + ) : ( + emptyQuotaState + ); + return ( {providerGroupingsEnabled ? ( - (() => { - const accountStats = getAccountStats(account); - if (accountStats.visibleModels === 0) { - return ( -
- - {t('cloud.card.noQuota')} -
- ); - } - return ( - <> -
- {t('settings.providerGroupings.overall')} -
- - {getQuotaLabel(accountStats.overallPercentage)} - -
-
-
-
-
- {accountStats.providers.map((providerStats) => ( - - toggleProviderCollapse(account.id, providerStats.providerKey) - } - getQuotaColor={getQuotaColor} - getQuotaBarColor={getQuotaBarColor} - getQuotaLabel={getQuotaLabel} - getResetTimeLabel={getResetTimeLabel} - getResetTimeTitle={getResetTimeTitle} - leftLabel={t('cloud.card.left')} - /> - ))} - - ); - })() - ) : hasRenderableModels ? ( + providerGroupedQuotaSection + ) : hasVisibleQuotaModels ? (
- {renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)} + {renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
- {renderModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)} + {renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
) : ( -
- - {t('cloud.card.noQuota')} -
+ emptyQuotaState )}
diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index f00fc74..a5c832d 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -44,13 +44,18 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { getLocalizedErrorMessage } from '@/utils/errorMessages'; import { useAppConfig } from '@/hooks/useAppConfig'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; - -// ... (existing code: imports and comments) +import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es'; +import { + clampQuotaPercentage, + getQuotaStatus, + roundQuotaPercentage, + type QuotaStatus, +} from '@/utils/quota-display'; export type GridLayout = 'auto' | '2-col' | '3-col' | 'list'; @@ -61,6 +66,18 @@ const GRID_LAYOUT_CLASSES: Record = { list: 'grid gap-4 grid-cols-1', }; +const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record = { + high: 'bg-emerald-500', + medium: 'bg-amber-500', + low: 'bg-rose-500', +}; + +const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record = { + high: 'text-emerald-600 dark:text-emerald-400', + medium: 'text-amber-600 dark:text-amber-400', + low: 'text-rose-600 dark:text-rose-400', +}; + export function CloudAccountList() { const { t } = useTranslation(); const { data: accounts, isLoading, isError, error, errorUpdatedAt, refetch } = useCloudAccounts(); @@ -76,75 +93,59 @@ export function CloudAccountList() { const forcePollMutation = useForcePollCloudMonitor(); const { toast } = useToast(); - const lastCloudLoadErrorToastAt = useRef(0); + const lastLoadErrorToastAtRef = useRef(0); const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto'; - const setGridLayout = async (layout: GridLayout) => { + const updateGridLayout = async (layout: GridLayout) => { if (config) { await saveConfig({ ...config, grid_layout: layout }); } }; // Calculate global quota across all accounts - const globalQuota = useMemo(() => { + const overallQuotaPercentage = useMemo(() => { if (!accounts || accounts.length === 0) { return null; } const visibilitySettings = config?.model_visibility ?? {}; - let totalPercentage = 0; - let modelCount = 0; - - accounts.forEach((account) => { + const visibleModelInfos = flatMap(accounts, (account) => { if (!account.quota?.models) { - return; + return []; } - Object.entries(account.quota.models).forEach(([modelName, info]) => { - if (visibilitySettings[modelName] !== false) { - totalPercentage += info.percentage; - modelCount++; - } - }); + + return Object.entries(account.quota.models) + .filter(([modelName]) => visibilitySettings[modelName] !== false) + .map(([, info]) => info); }); - if (modelCount === 0) { + if (isEmpty(visibleModelInfos)) { return null; } - return Math.round((totalPercentage / modelCount) * 10) / 10; + const averagePercentage = + sumBy(visibleModelInfos, (modelInfo) => modelInfo.percentage) / visibleModelInfos.length; + + return roundQuotaPercentage(averagePercentage); }, [accounts, config?.model_visibility]); - const getGlobalQuotaColor = (percentage: number) => { - if (percentage > 80) { - return 'bg-emerald-500'; - } - if (percentage > 20) { - return 'bg-amber-500'; - } - return 'bg-rose-500'; - }; - - const getGlobalQuotaTextColor = (percentage: number) => { - if (percentage > 80) { - return 'text-emerald-600 dark:text-emerald-400'; - } - if (percentage > 20) { - return 'text-amber-600 dark:text-amber-400'; - } - return 'text-rose-600 dark:text-rose-400'; - }; + const overallQuotaStatus = + overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage); + const effectiveQuotaStatus: QuotaStatus = overallQuotaStatus ?? 'low'; const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [authCode, setAuthCode] = useState(''); const [identityAccount, setIdentityAccount] = useState(null); - const totalAccounts = accounts?.length || 0; - const activeAccounts = accounts?.filter((account) => account.is_active).length || 0; - const rateLimitedAccounts = - accounts?.filter((account) => account.status === 'rate_limited').length || 0; + const totalAccounts = size(accounts); + const activeAccounts = filter(accounts, (account) => account.is_active).length; + const rateLimitedAccounts = filter( + accounts, + (account) => account.status === 'rate_limited', + ).length; - const handleAddAccount = (codeVal?: string) => { - const codeToUse = codeVal || authCode; + const submitAuthCode = useCallback((incomingAuthCode?: string) => { + const codeToUse = incomingAuthCode || authCode; if (!codeToUse) { return; } @@ -165,15 +166,14 @@ export function CloudAccountList() { }, }, ); - }; + }, [addMutation, authCode, t, toast]); // Listen for Google Auth Code useEffect(() => { if (window.electron?.onGoogleAuthCode) { - console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen); + console.log('[OAuth] Registering Google auth code IPC listener'); const cleanup = window.electron.onGoogleAuthCode((code) => { - console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...'); + console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...'); setAuthCode(code); - // Note: Auto-submit will be triggered by the authCode change effect below }); return cleanup; } @@ -182,16 +182,16 @@ export function CloudAccountList() { // Auto-submit when authCode is set and dialog is open useEffect(() => { if (authCode && isAddDialogOpen && !addMutation.isPending) { - console.log('[OAuth] Auto-submitting auth code'); - handleAddAccount(authCode); + console.log('[OAuth] Auto-submitting Google auth code'); + submitAuthCode(authCode); } - }, [authCode, isAddDialogOpen]); + }, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]); // Batch Operations State const [selectedIds, setSelectedIds] = useState>(new Set()); useEffect(() => { - if (!isError || !errorUpdatedAt || errorUpdatedAt === lastCloudLoadErrorToastAt.current) { + if (!isError || !errorUpdatedAt || errorUpdatedAt === lastLoadErrorToastAtRef.current) { return; } @@ -200,11 +200,9 @@ export function CloudAccountList() { description: getLocalizedErrorMessage(error, t), variant: 'destructive', }); - lastCloudLoadErrorToastAt.current = errorUpdatedAt; + lastLoadErrorToastAtRef.current = errorUpdatedAt; }, [error, errorUpdatedAt, isError, t, toast]); - // ... (existing code: handleRefresh, handleSwitch, handleDelete) - const handleRefresh = (id: string) => { refreshMutation.mutate( { accountId: id }, @@ -312,7 +310,7 @@ export function CloudAccountList() { }); }; - const openAuthUrl = async () => { + const openGoogleAuthSignIn = async () => { try { await startAuthFlow(); } catch (e) { @@ -325,7 +323,7 @@ export function CloudAccountList() { }; // Batch Selection Handlers - const toggleSelection = (id: string, selected: boolean) => { + const setSelectionState = (id: string, selected: boolean) => { setSelectedIds((prev) => { const next = new Set(prev); if (selected) { @@ -337,7 +335,7 @@ export function CloudAccountList() { }); }; - const toggleSelectAll = () => { + const toggleSelectAllAccounts = () => { if (selectedIds.size === accounts?.length) { setSelectedIds(new Set()); } else { @@ -345,25 +343,25 @@ export function CloudAccountList() { } }; - const handleBatchRefresh = () => { + const refreshSelectedAccounts = () => { selectedIds.forEach((id) => { refreshMutation.mutate({ accountId: id }); }); toast({ title: t('cloud.toast.quotaRefreshed'), - description: `triggered for ${selectedIds.size} accounts.`, + description: `Triggered for ${selectedIds.size} accounts.`, }); setSelectedIds(new Set()); }; - const handleBatchDelete = () => { + const deleteSelectedAccounts = () => { if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) { selectedIds.forEach((id) => { deleteMutation.mutate({ accountId: id }); }); toast({ title: t('cloud.toast.deleted'), - description: `${selectedIds.size} accounts deleted.`, + description: `Deleted ${selectedIds.size} accounts.`, }); setSelectedIds(new Set()); } @@ -427,21 +425,21 @@ export function CloudAccountList() {
{rateLimitedAccounts}
{/* Global Quota */} - {globalQuota !== null && ( + {overallQuotaPercentage !== null && (
{t('cloud.globalQuota')}
- {globalQuota}% + {overallQuotaPercentage}%
@@ -471,7 +469,7 @@ export function CloudAccountList() { @@ -535,7 +533,7 @@ export function CloudAccountList() {
@@ -567,7 +565,7 @@ export function CloudAccountList() { variant={gridLayout === '2-col' ? 'secondary' : 'ghost'} size="icon" className="h-7 w-7 cursor-pointer" - onClick={() => setGridLayout('2-col')} + onClick={() => updateGridLayout('2-col')} > @@ -580,7 +578,7 @@ export function CloudAccountList() { variant={gridLayout === '3-col' ? 'secondary' : 'ghost'} size="icon" className="h-7 w-7 cursor-pointer" - onClick={() => setGridLayout('3-col')} + onClick={() => updateGridLayout('3-col')} > @@ -593,7 +591,7 @@ export function CloudAccountList() { variant={gridLayout === 'list' ? 'secondary' : 'ghost'} size="icon" className="h-7 w-7 cursor-pointer" - onClick={() => setGridLayout('list')} + onClick={() => updateGridLayout('list')} > @@ -614,7 +612,7 @@ export function CloudAccountList() { onSwitch={handleSwitch} onManageIdentity={handleManageIdentity} isSelected={selectedIds.has(account.id)} - onToggleSelection={toggleSelection} + onToggleSelection={setSelectionState} isRefreshing={ refreshMutation.isPending && refreshMutation.variables?.accountId === account.id } @@ -653,11 +651,11 @@ export function CloudAccountList() {
- - diff --git a/src/components/ModelVisibilitySettings.tsx b/src/components/ModelVisibilitySettings.tsx index e9d4582..ae332d4 100644 --- a/src/components/ModelVisibilitySettings.tsx +++ b/src/components/ModelVisibilitySettings.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppConfig } from '@/hooks/useAppConfig'; import { useCloudAccounts } from '@/hooks/useCloudAccounts'; @@ -10,81 +10,110 @@ import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Loader2, Search, RotateCcw, Save } from 'lucide-react'; +import { filter, flatMap, includes, size, sortBy, sumBy, uniq, values } from 'lodash-es'; +import type { CloudAccount } from '@/types/cloudAccount'; -interface ModelVisibilitySettingsProps {} +function collectAvailableModelIds(accounts: CloudAccount[] | undefined): string[] { + if (!accounts) { + return []; + } -export const ModelVisibilitySettings: React.FC = () => { + const modelNames = flatMap(accounts, (account) => { + if (!account.quota?.models) { + return []; + } + + return Object.keys(account.quota.models); + }); + + return sortBy(uniq(modelNames)); +} + +function filterModelIdsByQuery(modelIds: string[], query: string): string[] { + const normalizedSearchQuery = query.toLowerCase(); + + return filter(modelIds, (modelId) => includes(modelId.toLowerCase(), normalizedSearchQuery)); +} + +export function ModelVisibilitySettings() { const { t } = useTranslation(); const { config, saveConfig } = useAppConfig(); const { data: accounts, isLoading: accountsLoading } = useCloudAccounts(); - const [searchTerm, setSearchTerm] = useState(''); - const [modelVisibility, setModelVisibility] = useState>({}); - const [providerGroupingsEnabled, setProviderGroupingsEnabled] = useState(false); - const [isSaving, setIsSaving] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [modelVisibilityMap, setModelVisibilityMap] = useState>({}); + const [providerGroupingEnabled, setProviderGroupingEnabled] = useState(false); + const [isSavingSettings, setIsSavingSettings] = useState(false); // Initialize model visibility and provider groupings from config - React.useEffect(() => { + useEffect(() => { if (config?.model_visibility) { - setModelVisibility(config.model_visibility); + setModelVisibilityMap(config.model_visibility); } if (config?.provider_groupings_enabled !== undefined) { - setProviderGroupingsEnabled(config.provider_groupings_enabled); + setProviderGroupingEnabled(config.provider_groupings_enabled); } }, [config?.model_visibility, config?.provider_groupings_enabled]); // Get all unique models from all accounts - const allModels = useMemo(() => { - if (!accounts) return []; - - const modelSet = new Set(); - accounts.forEach((account) => { - if (account.quota?.models) { - Object.keys(account.quota.models).forEach((model) => { - modelSet.add(model); - }); - } - }); - - return Array.from(modelSet).sort(); + const availableModelIds = useMemo(() => { + return collectAvailableModelIds(accounts); }, [accounts]); // Filter models based on search term - const filteredModels = useMemo(() => { - return allModels.filter((model) => model.toLowerCase().includes(searchTerm.toLowerCase())); - }, [allModels, searchTerm]); + const filteredModelIds = useMemo(() => { + return filterModelIdsByQuery(availableModelIds, searchQuery); + }, [availableModelIds, searchQuery]); - // Handle model visibility toggle - const handleModelToggle = (modelName: string, checked: boolean) => { - setModelVisibility((prev) => ({ - ...prev, - [modelName]: checked, - })); + const hiddenModelCount = useMemo(() => { + return sumBy(values(modelVisibilityMap), (isVisible) => (isVisible === false ? 1 : 0)); + }, [modelVisibilityMap]); + + const isModelVisible = (modelId: string): boolean => { + return modelVisibilityMap[modelId] !== false; }; // Reset to defaults (all models visible) - const handleReset = () => { - setModelVisibility({}); + const resetVisibilityOverrides = () => { + setModelVisibilityMap({}); }; // Save configuration - const handleSave = async () => { - if (!config) return; + const saveVisibilitySettings = async () => { + if (!config) { + return; + } - setIsSaving(true); + setIsSavingSettings(true); try { - await saveConfig({ + const nextConfig = { ...config, - model_visibility: modelVisibility, - provider_groupings_enabled: providerGroupingsEnabled, - }); + model_visibility: modelVisibilityMap, + provider_groupings_enabled: providerGroupingEnabled, + }; + await saveConfig(nextConfig); } catch (error) { console.error('Failed to save model visibility settings:', error); } finally { - setIsSaving(false); + setIsSavingSettings(false); } }; + const handleSearchQueryChange = (nextQuery: string) => { + setSearchQuery(nextQuery); + }; + + const handleProviderGroupingToggle = (checked: boolean) => { + setProviderGroupingEnabled(checked); + }; + + const handleModelVisibilityChange = (modelId: string, checked: boolean) => { + setModelVisibilityMap((prev) => ({ + ...prev, + [modelId]: checked, + })); + }; + if (accountsLoading) { return ( @@ -101,7 +130,7 @@ export const ModelVisibilitySettings: React.FC = ( {t('settings.modelVisibility.title')} - {filteredModels.length} models + {filteredModelIds.length} models {t('settings.modelVisibility.description')} @@ -111,8 +140,8 @@ export const ModelVisibilitySettings: React.FC = ( setSearchTerm(e.target.value)} + value={searchQuery} + onChange={(event) => handleSearchQueryChange(event.target.value)} className="pl-10" />
@@ -129,37 +158,39 @@ export const ModelVisibilitySettings: React.FC = (
{/* Model List */}
- {filteredModels.length === 0 ? ( + {filteredModelIds.length === 0 ? (
- {searchTerm + {searchQuery ? t('settings.modelVisibility.noModelsFound') : t('settings.modelVisibility.noModels')}
) : ( - filteredModels.map((modelName) => { - const isVisible = modelVisibility[modelName] !== false; // Default to true + filteredModelIds.map((modelId) => { + const isVisible = isModelVisible(modelId); return (
handleModelToggle(modelName, checked as boolean)} + onCheckedChange={(checked) => + handleModelVisibilityChange(modelId, checked as boolean) + } /> {!isVisible && ( @@ -176,16 +207,26 @@ export const ModelVisibilitySettings: React.FC = (
-
@@ -193,19 +234,18 @@ export const ModelVisibilitySettings: React.FC = (
- {t('settings.modelVisibility.totalModels')}: {allModels.length} + {t('settings.modelVisibility.totalModels')}: {availableModelIds.length} {t('settings.modelVisibility.visibleModels')}:{' '} - {allModels.length - Object.values(modelVisibility).filter((v) => !v).length} + {availableModelIds.length - hiddenModelCount} - {t('settings.modelVisibility.hiddenModels')}:{' '} - {Object.values(modelVisibility).filter((v) => !v).length} + {t('settings.modelVisibility.hiddenModels')}: {hiddenModelCount}
); -}; +} diff --git a/src/components/ProviderGroup.tsx b/src/components/ProviderGroup.tsx index 848ae85..50c7d08 100644 --- a/src/components/ProviderGroup.tsx +++ b/src/components/ProviderGroup.tsx @@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronRight } from 'lucide-react'; import type { ProviderStats } from '@/utils/provider-grouping'; +import { clampQuotaPercentage } from '@/utils/quota-display'; interface ProviderGroupProps { stats: ProviderStats; isCollapsed: boolean; onToggleCollapse: () => void; - getQuotaColor: (percentage: number) => string; - getQuotaBarColor: (percentage: number) => string; - getQuotaLabel: (percentage: number) => string; - getResetTimeLabel: (resetTime?: string) => string; - getResetTimeTitle: (resetTime?: string) => string | undefined; + getQuotaTextColorClass: (percentage: number) => string; + getQuotaBarColorClass: (percentage: number) => string; + formatQuotaLabel: (percentage: number) => string; + formatResetTimeLabel: (resetTime?: string) => string; + formatResetTimeTitle: (resetTime?: string) => string | undefined; leftLabel: string; } @@ -21,11 +22,11 @@ export const ProviderGroup: React.FC = ({ stats, isCollapsed, onToggleCollapse, - getQuotaColor, - getQuotaBarColor, - getQuotaLabel, - getResetTimeLabel, - getResetTimeTitle, + getQuotaTextColorClass, + getQuotaBarColorClass, + formatQuotaLabel, + formatResetTimeLabel, + formatResetTimeTitle, leftLabel, }) => { const { t } = useTranslation(); @@ -69,14 +70,14 @@ export const ProviderGroup: React.FC = ({ {earliestReset && ( - {getResetTimeLabel(earliestReset)} + {formatResetTimeLabel(earliestReset)} )}
- - {getQuotaLabel(avgPercentage)} + + {formatQuotaLabel(avgPercentage)} {avgPercentage > 0 && ( @@ -86,8 +87,8 @@ export const ProviderGroup: React.FC = ({
@@ -109,15 +110,15 @@ export const ProviderGroup: React.FC = ({
- {getResetTimeLabel(model.resetTime)} + {formatResetTimeLabel(model.resetTime)}
- {getQuotaLabel(model.percentage)} + {formatQuotaLabel(model.percentage)} {model.percentage > 0 && ( {leftLabel} @@ -125,8 +126,8 @@ export const ProviderGroup: React.FC = ({
diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 240d2dc..71968a5 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -132,7 +132,7 @@ i18n claudeKeyword: 'Claude Model (Keyword)', targetGemini: 'Target Gemini Model', addPlaceholderKey: 'e.g. op-3', - addPlaceholderValue: 'e.g. gemini-2.5-flash', + addPlaceholderValue: 'e.g. gemini-3-flash', noMappings: 'No custom mappings defined.', mapsTo: 'Maps to', default: 'Default', @@ -518,7 +518,7 @@ i18n claudeKeyword: 'Claude 模型 (关键词)', targetGemini: '目标 Gemini 模型', addPlaceholderKey: '例如 op-3', - addPlaceholderValue: '例如 gemini-2.5-flash', + addPlaceholderValue: '例如 gemini-3-flash', noMappings: '暂无自定义映射。', mapsTo: '映射到', default: '默认', @@ -903,7 +903,7 @@ i18n claudeKeyword: 'Модель Claude (Ключевое слово)', targetGemini: 'Целевая модель Gemini', addPlaceholderKey: 'например, op-3', - addPlaceholderValue: 'например, gemini-2.5-flash', + addPlaceholderValue: 'например, gemini-3-flash', noMappings: 'Нет пользовательских сопоставлений.', mapsTo: 'Мапится в', default: 'По умолчанию', diff --git a/src/routes/proxy.tsx b/src/routes/proxy.tsx index a30c84d..f47e70e 100644 --- a/src/routes/proxy.tsx +++ b/src/routes/proxy.tsx @@ -6,7 +6,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { ipc } from '@/ipc/manager'; -import { useState, useEffect } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { useAppConfig } from '@/hooks/useAppConfig'; import { ProxyConfig } from '@/types/config'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -43,6 +43,59 @@ import { DialogTitle, } from '@/components/ui/dialog'; +type ProxyProtocol = 'openai' | 'anthropic'; + +interface ExampleModel { + id: string; + name: string; + icon: ReactNode; +} + +const EXAMPLE_MODELS: ExampleModel[] = [ + { id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: }, + { id: 'gemini-3.1-pro-low', name: 'Gemini 3.1 Pro (Low)', icon: }, + { id: 'gemini-3.1-pro-high', name: 'Gemini 3.1 Pro (High)', icon: }, + { + id: 'claude-sonnet-4-6-thinking', + name: 'Claude Sonnet 4.6 (Thinking)', + icon: , + }, + { + id: 'claude-opus-4-6-thinking', + name: 'Claude Opus 4.6 (Thinking)', + icon: , + }, +]; + +const ANTHROPIC_ROUTE_OPTIONS = [ + 'claude-sonnet-4-6-thinking', + 'claude-opus-4-6-thinking', + 'gemini-3-flash', + 'gemini-3.1-pro-low', + 'gemini-3.1-pro-high', +] as const; + +const DEFAULT_ANTHROPIC_MAPPING: Record = { + 'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking', + 'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking', + opus: 'claude-opus-4-6-thinking', +}; + +function resolveAnthropicMappingValue( + anthropicMapping: Record, + keys: string[], + fallback: string, +): string { + for (const key of keys) { + const value = anthropicMapping[key]; + if (value) { + return value; + } + } + return fallback; +} + function ProxyPage() { const { t } = useTranslation(); const { config, isLoading, saveConfig } = useAppConfig(); @@ -97,14 +150,14 @@ function ProxyPage() { } else { setProxyConfig(config.proxy); } - } catch (e) { + } catch { // If status check fails, just use config value setProxyConfig(config.proxy); } }; syncServerStatus(); } - }, [config]); + }, [config, saveConfig]); // Helper to update proxyConfig and auto-save const updateProxyConfig = async (newProxyConfig: ProxyConfig) => { @@ -115,24 +168,27 @@ function ProxyPage() { }; // ===== Usage Examples State ===== - const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic'>('openai'); - const [activeModelTab, setActiveModelTab] = useState('gemini-2.5-flash'); + const [selectedProtocol, setSelectedProtocol] = useState('openai'); + const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high'); const [copied, setCopied] = useState(null); - // Models list for examples - const models = [ - { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', icon: }, - { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', icon: }, - { id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: }, - { id: 'gemini-3-pro-high', name: 'Gemini 3 Pro (High)', icon: }, - { id: 'claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5', icon: }, - { id: 'claude-opus-4-5-thinking', name: 'Claude Opus 4.5', icon: }, - ]; - // Computed values for examples const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY'; const baseUrl = `http://localhost:${proxyConfig?.port || 8045}`; + const updateAnthropicMapping = (mappingPatch: Record) => { + if (!proxyConfig) { + return; + } + updateProxyConfig({ + ...proxyConfig, + anthropic_mapping: { + ...proxyConfig.anthropic_mapping, + ...mappingPatch, + }, + }); + }; + const copyToClipboard = (text: string, type: string) => { navigator.clipboard.writeText(text); setCopied(type); @@ -415,30 +471,28 @@ print(response.choices[0].message.content)`; -
- {/* Sonnet 4.5 Card */} +
+ {/* Sonnet 4.6 Card */}

- Claude Sonnet 4.5 + Claude Sonnet 4.6 (Thinking)

{t('proxy.mapping.maps_to', 'Maps to')}

- {/* Opus 4.5 Card */} + {/* Opus 4.6 Card */}

- Claude Opus 4.5 + Claude Opus 4.6 (Thinking)

{t('proxy.mapping.maps_to', 'Maps to')}

-
- - {/* Haiku 4.5 Card */} -
-
-
-

- Claude Haiku 4.5 -

-
-

- {t('proxy.mapping.maps_to', 'Maps to')} -

-
@@ -532,11 +554,7 @@ print(response.choices[0].message.content)`; onClick={() => updateProxyConfig({ ...proxyConfig, - anthropic_mapping: { - 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking', - opus: 'claude-opus-4-5-thinking', - 'claude-haiku-4-5-20251001': 'gemini-2.5-flash', - }, + anthropic_mapping: { ...DEFAULT_ANTHROPIC_MAPPING }, }) } > @@ -607,7 +625,7 @@ print(response.choices[0].message.content)`; {/* Model Tabs */}
- {models.map((model) => ( + {EXAMPLE_MODELS.map((model) => (