| |
| |
| |
| |
| import { useMemo, useState } from 'react'; |
| import { |
| DndContext, |
| closestCenter, |
| KeyboardSensor, |
| PointerSensor, |
| useSensor, |
| useSensors, |
| DragEndEvent, |
| DragStartEvent, |
| DragOverlay, |
| } from '@dnd-kit/core'; |
| import { |
| arrayMove, |
| SortableContext, |
| sortableKeyboardCoordinates, |
| useSortable, |
| verticalListSortingStrategy, |
| } from '@dnd-kit/sortable'; |
| import { CSS } from '@dnd-kit/utilities'; |
| import { |
| GripVertical, |
| ArrowRightLeft, |
| RefreshCw, |
| Trash2, |
| Download, |
| Fingerprint, |
| Info, |
| Lock, |
| Ban, |
| Diamond, |
| Gem, |
| Circle, |
| ToggleLeft, |
| ToggleRight, |
| Sparkles, |
| Tag, |
| X, |
| Check, |
| Clock, |
| Bot, |
| } from 'lucide-react'; |
| import { Account } from '../../types/account'; |
| import { useTranslation } from 'react-i18next'; |
| import { cn } from '../../utils/cn'; |
|
|
| import { useConfigStore } from '../../stores/useConfigStore'; |
| import { QuotaItem } from './QuotaItem'; |
| import { MODEL_CONFIG, sortModels } from '../../config/modelConfig'; |
| import { getValidationBlockedStatusLabel } from './accountValidationStatus'; |
|
|
| |
| |
| |
|
|
| interface AccountTableProps { |
| accounts: Account[]; |
| selectedIds: Set<string>; |
| refreshingIds: Set<string>; |
| onToggleSelect: (id: string) => void; |
| onToggleAll: () => void; |
| currentAccountId: string | null; |
| switchingAccountId: string | null; |
| onSwitch: (accountId: string) => void; |
| onRefresh: (accountId: string) => void; |
| onViewDevice: (accountId: string) => void; |
| onViewDetails: (accountId: string) => void; |
| onExport: (accountId: string) => void; |
| onDelete: (accountId: string) => void; |
| onToggleProxy: (accountId: string) => void; |
| onWarmup?: (accountId: string) => void; |
| onUpdateLabel?: (accountId: string, label: string) => void; |
| |
| onReorder?: (accountIds: string[]) => void; |
| onViewError: (accountId: string) => void; |
| } |
|
|
| interface SortableRowProps { |
| account: Account; |
| selected: boolean; |
| isRefreshing: boolean; |
| isCurrent: boolean; |
| isSwitching: boolean; |
| isDragging?: boolean; |
| onSelect: () => void; |
| onSwitch: () => void; |
| onRefresh: () => void; |
| onViewDevice: () => void; |
| onViewDetails: () => void; |
| onExport: () => void; |
| onDelete: () => void; |
| onToggleProxy: () => void; |
| onWarmup?: () => void; |
| onUpdateLabel?: (label: string) => void; |
| onViewError: () => void; |
| } |
|
|
| interface AccountRowContentProps { |
| account: Account; |
| isCurrent: boolean; |
| isRefreshing: boolean; |
| isSwitching: boolean; |
| isDisabled: boolean; |
| onSwitch: () => void; |
| onRefresh: () => void; |
| onViewDevice: () => void; |
| onViewDetails: () => void; |
| onExport: () => void; |
| onDelete: () => void; |
| onToggleProxy: () => void; |
| onWarmup?: () => void; |
| onUpdateLabel?: (label: string) => void; |
| onViewError: () => void; |
| } |
|
|
| |
| |
| |
|
|
|
|
|
|
| |
| |
| |
|
|
| const MODEL_GROUPS = { |
| CLAUDE: [ |
| 'claude-opus-4-6-thinking', |
| 'claude' |
| ], |
| GEMINI_PRO: [ |
| 'gemini-3.1-pro-high', |
| 'gemini-3.1-pro-low', |
| 'gemini-3.1-pro-preview', |
| 'gemini-3-pro-high', |
| 'gemini-3-pro-low', |
| 'gemini-3-pro-preview' |
| ], |
| GEMINI_FLASH: [ |
| 'gemini-3-flash' |
| ] |
| }; |
|
|
| const MODEL_ID_ALIASES: Record<string, string[]> = { |
| 'gemini-3-pro-high': ['gemini-3-pro-high', 'gemini-3.1-pro-high'], |
| 'gemini-3-pro-low': ['gemini-3-pro-low', 'gemini-3.1-pro-low'], |
| 'gemini-3-pro-preview': ['gemini-3-pro-preview', 'gemini-3.1-pro-preview'], |
| 'gemini-3.1-pro-high': ['gemini-3.1-pro-high', 'gemini-3-pro-high'], |
| 'gemini-3.1-pro-low': ['gemini-3.1-pro-low', 'gemini-3-pro-low'], |
| 'gemini-3.1-pro-preview': ['gemini-3.1-pro-preview', 'gemini-3-pro-preview'], |
| }; |
|
|
| function getModelAliases(modelId: string): string[] { |
| return MODEL_ID_ALIASES[modelId] || [modelId]; |
| } |
|
|
| function isModelProtected(protectedModels: string[] | undefined, modelName: string): boolean { |
| if (!protectedModels || protectedModels.length === 0) return false; |
| const lowerName = modelName.toLowerCase(); |
|
|
| |
| const isGroupProtected = (group: string[]) => { |
| return group.some(m => protectedModels.includes(m)); |
| }; |
|
|
| |
| if (lowerName === 'gemini-pro') return isGroupProtected(MODEL_GROUPS.GEMINI_PRO); |
| if (lowerName === 'gemini-flash') return isGroupProtected(MODEL_GROUPS.GEMINI_FLASH); |
| if (lowerName === 'claude-sonnet') return isGroupProtected(MODEL_GROUPS.CLAUDE); |
|
|
| |
| if (MODEL_GROUPS.GEMINI_PRO.some(m => lowerName === m)) { |
| return isGroupProtected(MODEL_GROUPS.GEMINI_PRO); |
| } |
|
|
| |
| if (MODEL_GROUPS.CLAUDE.some(m => lowerName === m)) { |
| return isGroupProtected(MODEL_GROUPS.CLAUDE); |
| } |
|
|
| |
| if (MODEL_GROUPS.GEMINI_FLASH.some(m => lowerName === m)) { |
| return isGroupProtected(MODEL_GROUPS.GEMINI_FLASH); |
| } |
|
|
| |
| return protectedModels.includes(lowerName); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| function SortableAccountRow({ |
| account, |
| selected, |
| isRefreshing, |
| isCurrent, |
| isSwitching, |
| isDragging, |
| onSelect, |
| onSwitch, |
| onRefresh, |
| onViewDevice, |
| onViewDetails, |
| onExport, |
| onDelete, |
| onToggleProxy, |
| onWarmup, |
| onUpdateLabel, |
| onViewError, |
| }: SortableRowProps) { |
| const { t } = useTranslation(); |
| const { |
| attributes, |
| listeners, |
| setNodeRef, |
| transform, |
| transition, |
| isDragging: isSortableDragging, |
| } = useSortable({ id: account.id }); |
|
|
| const style = { |
| transform: CSS.Transform.toString(transform), |
| transition, |
| opacity: isSortableDragging ? 0.5 : 1, |
| zIndex: isSortableDragging ? 1000 : 'auto', |
| }; |
|
|
| return ( |
| <tr |
| ref={setNodeRef} |
| style={style as React.CSSProperties} |
| className={cn( |
| "group transition-colors border-b border-gray-100 dark:border-base-200", |
| isCurrent && "bg-blue-50/50 dark:bg-blue-900/10", |
| isDragging && "bg-blue-100 dark:bg-blue-900/30 shadow-lg", |
| !isDragging && "hover:bg-gray-50 dark:hover:bg-base-200" |
| )} |
| > |
| {/* 拖拽手柄 */} |
| <td className="pl-2 py-1 w-8 align-middle"> |
| <div |
| {...attributes} |
| {...listeners} |
| className="flex items-center justify-center w-6 h-6 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded hover:bg-gray-100 dark:hover:bg-gray-700" |
| title={t('accounts.drag_to_reorder')} |
| > |
| <GripVertical className="w-4 h-4" /> |
| </div> |
| </td> |
| {/* 复选框 */} |
| <td className="px-2 py-1 w-10 align-middle"> |
| <input |
| type="checkbox" |
| className="checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]" |
| checked={selected} |
| onChange={onSelect} |
| onClick={(e) => e.stopPropagation()} |
| /> |
| </td> |
| <AccountRowContent |
| account={account} |
| isCurrent={isCurrent} |
| isRefreshing={isRefreshing} |
| isSwitching={isSwitching} |
| isDisabled={Boolean(account.disabled)} |
| onSwitch={onSwitch} |
| onRefresh={onRefresh} |
| onViewDevice={onViewDevice} |
| onViewDetails={onViewDetails} |
| onExport={onExport} |
| onDelete={onDelete} |
| onToggleProxy={onToggleProxy} |
| onWarmup={onWarmup} |
| onUpdateLabel={onUpdateLabel} |
| onViewError={onViewError} |
| /> |
| </tr> |
| ); |
| } |
|
|
| |
| |
| |
| |
| function AccountRowContent({ |
| account, |
| isCurrent, |
| isRefreshing, |
| isSwitching, |
| isDisabled, |
| onSwitch, |
| onRefresh, |
| onViewDevice, |
| onViewDetails, |
| onExport, |
| onDelete, |
| onToggleProxy, |
| onWarmup, |
| onUpdateLabel, |
| onViewError, |
| }: AccountRowContentProps) { |
| const { t } = useTranslation(); |
| const { config, showAllQuotas } = useConfigStore(); |
| const validationBlockedLabel = getValidationBlockedStatusLabel(account.validation_blocked_reason, t); |
|
|
| |
| const [isEditingLabel, setIsEditingLabel] = useState(false); |
| const [labelInput, setLabelInput] = useState(account.custom_label || ''); |
|
|
| const handleSaveLabel = () => { |
| if (onUpdateLabel) { |
| onUpdateLabel(labelInput.trim()); |
| } |
| setIsEditingLabel(false); |
| }; |
|
|
| const handleCancelLabel = () => { |
| setLabelInput(account.custom_label || ''); |
| setIsEditingLabel(false); |
| }; |
|
|
| const handleKeyDown = (e: React.KeyboardEvent) => { |
| if (e.key === 'Enter') { |
| handleSaveLabel(); |
| } else if (e.key === 'Escape') { |
| handleCancelLabel(); |
| } |
| }; |
|
|
| |
|
|
| |
| const pinnedModels = config?.pinned_quota_models?.models || Object.keys(MODEL_CONFIG); |
|
|
| |
| const uniqueLabels = new Set<string>(); |
| const displayModels = sortModels( |
| (showAllQuotas |
| ? (account.quota?.models || []).map(m => { |
| const config = MODEL_CONFIG[m.name.toLowerCase()]; |
| const label = m.display_name || (config?.i18nKey ? t(config.i18nKey) : (config?.shortLabel || config?.label || m.name)); |
| return { |
| id: m.name.toLowerCase(), |
| label: label, |
| protectedKey: config?.protectedKey || m.name.toLowerCase(), |
| data: m |
| }; |
| }) |
| : pinnedModels.map(modelId => { |
| const m = account.quota?.models.find(m => m.name === modelId || getModelAliases(modelId).includes(m.name.toLowerCase())); |
| const config = MODEL_CONFIG[modelId]; |
| if (!config && !m) return null; |
| const label = m?.display_name || (config?.i18nKey ? t(config.i18nKey) : (config?.shortLabel || config?.label || modelId)); |
| return { |
| id: modelId, |
| label: label, |
| protectedKey: config?.protectedKey || modelId, |
| data: m |
| }; |
| }).filter(Boolean) as any[] |
| ).filter(m => { |
| |
| const isHiddenThinking = m.id.includes('thinking'); |
|
|
| if (isHiddenThinking) return false; |
|
|
| |
| |
| const labelKey = `${m.label}-${m.protectedKey}`; |
| if (uniqueLabels.has(labelKey)) { |
| return false; |
| } |
| if (m.data) { |
| uniqueLabels.add(labelKey); |
| return true; |
| } |
| return true; |
| }) |
| ).filter((m, index, self) => { |
| |
| const labelKey = `${m.label}-${m.protectedKey}`; |
| return self.findIndex(t => `${t.label}-${t.protectedKey}` === labelKey) === index; |
| }); |
|
|
|
|
| return ( |
| <> |
| {/* 邮箱列 */} |
| <td className="px-2 py-1 align-middle"> |
| <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> |
| <span className={cn( |
| "font-medium text-sm break-all transition-colors", |
| isCurrent ? "text-blue-700 dark:text-blue-400" : "text-gray-900 dark:text-base-content" |
| )} title={account.email}> |
| {account.email} |
| </span> |
| |
| <div className="flex items-center gap-1.5 shrink-0"> |
| {isCurrent && ( |
| <span className="px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-[10px] font-bold shadow-sm border border-blue-200/50 dark:border-blue-800/50"> |
| {t('accounts.current').toUpperCase()} |
| </span> |
| )} |
| {isDisabled && ( |
| <span |
| className="px-2 py-0.5 rounded-md bg-rose-100 dark:bg-rose-900/50 text-rose-700 dark:text-rose-300 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-rose-200/50" |
| > |
| <Ban className="w-2.5 h-2.5" /> |
| <span>{t('accounts.disabled')}</span> |
| </span> |
| )} |
| |
| {account.proxy_disabled && ( |
| <span |
| className="px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-orange-200/50" |
| > |
| <Ban className="w-2.5 h-2.5" /> |
| <span>{t('accounts.proxy_disabled')}</span> |
| </span> |
| )} |
| |
| {account.quota?.is_forbidden && ( |
| <span className="px-2 py-0.5 rounded-md bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-red-200/50"> |
| <Lock className="w-2.5 h-2.5" /> |
| <span>{t('accounts.forbidden')}</span> |
| </span> |
| )} |
| {account.validation_blocked && ( |
| <span className="px-2 py-0.5 rounded-md bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-400 text-[10px] font-bold flex items-center gap-1 shadow-sm border border-amber-200/50"> |
| <Clock className="w-2.5 h-2.5" /> |
| <span>{validationBlockedLabel}</span> |
| </span> |
| )} |
| |
| |
| {/* 订阅类型徽章 */} |
| {account.quota?.subscription_tier && (() => { |
| const tier = account.quota.subscription_tier.toLowerCase(); |
| if (tier.includes('ultra')) { |
| return ( |
| <span className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-purple-600 to-pink-600 text-white text-[10px] font-bold shadow-sm hover:scale-105 transition-transform cursor-default"> |
| <Gem className="w-2.5 h-2.5 fill-current" /> |
| {t('accounts.ultra')} |
| </span> |
| ); |
| } else if (tier.includes('pro')) { |
| return ( |
| <span className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-[10px] font-bold shadow-sm hover:scale-105 transition-transform cursor-default"> |
| <Diamond className="w-2.5 h-2.5 fill-current" /> |
| {t('accounts.pro')} |
| </span> |
| ); |
| } else { |
| return ( |
| <span className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-400 text-[10px] font-bold shadow-sm border border-gray-200 dark:border-white/10 hover:bg-gray-200 transition-colors cursor-default"> |
| <Circle className="w-2.5 h-2.5" /> |
| {t('accounts.free')} |
| </span> |
| ); |
| } |
| })()} |
| {/* 自定义标签 */} |
| {account.custom_label && !isEditingLabel && ( |
| <span className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 text-[10px] font-bold shadow-sm border border-orange-200/50 dark:border-orange-800/50"> |
| <Tag className="w-2.5 h-2.5" /> |
| {account.custom_label} |
| </span> |
| )} |
| {/* 标签编辑输入框 */} |
| {isEditingLabel && ( |
| <div className="flex items-center gap-1"> |
| <input |
| type="text" |
| className="px-1.5 py-0.5 text-[10px] w-20 border border-orange-300 dark:border-orange-700 rounded focus:outline-none focus:ring-1 focus:ring-orange-500 bg-white dark:bg-base-200" |
| placeholder={t('accounts.custom_label_placeholder', 'Label')} |
| value={labelInput} |
| onChange={(e) => setLabelInput(e.target.value)} |
| onKeyDown={handleKeyDown} |
| autoFocus |
| maxLength={15} |
| onClick={(e) => e.stopPropagation()} |
| /> |
| <button |
| className="p-0.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-all" |
| onClick={(e) => { e.stopPropagation(); handleSaveLabel(); }} |
| > |
| <Check className="w-3 h-3" /> |
| </button> |
| <button |
| className="p-0.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-all" |
| onClick={(e) => { e.stopPropagation(); handleCancelLabel(); }} |
| > |
| <X className="w-3 h-3" /> |
| </button> |
| </div> |
| )} |
| </div> |
| |
| </div> |
| </td> |
| |
| {/* 模型配额列 */} |
| <td className="px-2 py-1 align-middle"> |
| {isDisabled || account.quota?.is_forbidden || account.validation_blocked ? ( |
| <div className={cn( |
| "flex items-center justify-center gap-3 py-1.5 px-4 rounded-xl border group/error", |
| account.validation_blocked ? "bg-amber-50/50 dark:bg-amber-900/10 border-amber-100/50 dark:border-amber-900/20" : "bg-red-50/50 dark:bg-red-900/10 border-red-100/50 dark:border-red-900/20" |
| )}> |
| <div className={cn( |
| "flex items-center gap-1.5", |
| account.validation_blocked ? "text-amber-600 dark:text-amber-400" : "text-red-600 dark:text-red-400" |
| )}> |
| {account.validation_blocked ? <Clock className="w-3.5 h-3.5" /> : (account.quota?.is_forbidden ? <Lock className="w-3.5 h-3.5" /> : <Ban className="w-3.5 h-3.5" />)} |
| <span className={cn( |
| "text-[11px] font-bold", |
| account.validation_blocked ? "text-amber-700/80 dark:text-amber-400" : "text-red-700/80 dark:text-red-400" |
| )}> |
| {account.validation_blocked ? validationBlockedLabel : (isDisabled ? t('accounts.status.disabled') : t('accounts.forbidden_msg'))} |
| </span> |
| </div> |
| <div className={cn( |
| "w-px h-3", |
| account.validation_blocked ? "bg-amber-200 dark:bg-amber-800/50" : "bg-red-200 dark:bg-red-800/50" |
| )} /> |
| <button |
| onClick={(e) => { e.stopPropagation(); onViewError(); }} |
| className="text-[10px] font-medium text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-0.5" |
| > |
| {t('accounts.view_error')} |
| </button> |
| </div> |
| ) : ( |
| <div className={cn( |
| "grid gap-x-4 gap-y-1 py-0", |
| displayModels.length === 1 ? "grid-cols-1" : "grid-cols-2" |
| )}> |
| {displayModels.map((model) => { |
| const modelData = model.data; |
| |
| return ( |
| <QuotaItem |
| key={model.id} |
| label={model.label} |
| percentage={modelData?.percentage || 0} |
| resetTime={modelData?.reset_time} |
| isProtected={isModelProtected(account.protected_models, model.protectedKey)} |
| Icon={MODEL_CONFIG[model.id]?.Icon || Bot} |
| /> |
| ); |
| })} |
| </div> |
| )} |
| </td> |
| |
| {/* 最后使用时间列 */} |
| <td className="px-2 py-1 align-middle"> |
| <div className="flex flex-col"> |
| <span className="text-xs font-medium text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap"> |
| {new Date(account.last_used * 1000).toLocaleDateString()} |
| </span> |
| <span className="text-[10px] text-gray-400 dark:text-gray-500 font-mono whitespace-nowrap leading-tight"> |
| {new Date(account.last_used * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} |
| </span> |
| </div> |
| </td> |
| |
| {/* 操作列 */} |
| <td className={cn( |
| "px-1 py-1 sticky right-0 z-10 shadow-[-12px_0_12px_-12px_rgba(0,0,0,0.1)] dark:shadow-[-12px_0_12px_-12px_rgba(255,255,255,0.05)] text-center align-middle", |
| // 动态背景色处理 |
| isCurrent |
| ? "bg-[#f1f6ff] dark:bg-[#1e2330]" // 接近 blue-50/50 的实色 |
| : "bg-white dark:bg-base-100", |
| !isCurrent && "group-hover:bg-gray-50 dark:group-hover:bg-base-200" |
| )}> |
| <div className="flex flex-wrap items-center justify-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity max-w-[180px] mx-auto"> |
| <button |
| className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-sky-600 dark:hover:text-sky-400 hover:bg-sky-50 dark:hover:bg-sky-900/30 rounded-lg transition-all" |
| onClick={(e) => { e.stopPropagation(); onViewDetails(); }} |
| title={t('common.details')} |
| > |
| <Info className="w-3.5 h-3.5" /> |
| </button> |
| <button |
| className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all" |
| onClick={(e) => { e.stopPropagation(); onViewDevice(); }} |
| title={t('accounts.device_fingerprint')} |
| > |
| <Fingerprint className="w-3.5 h-3.5" /> |
| </button> |
| {/* 自定义标签按钮 */} |
| {onUpdateLabel && ( |
| <button |
| className={cn( |
| "p-1.5 rounded-lg transition-all", |
| account.custom_label |
| ? "text-orange-500 hover:text-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/30" |
| : "text-gray-500 dark:text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/30" |
| )} |
| onClick={(e) => { e.stopPropagation(); setIsEditingLabel(true); }} |
| title={t('accounts.edit_label', 'Edit Label')} |
| > |
| <Tag className="w-3.5 h-3.5" /> |
| </button> |
| )} |
| <button |
| className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isSwitching || isDisabled) ? 'bg-blue-50 dark:bg-blue-900/10 text-blue-600 dark:text-blue-400 cursor-not-allowed' : 'hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'}`} |
| onClick={(e) => { e.stopPropagation(); onSwitch(); }} |
| title={isDisabled ? t('accounts.disabled_tooltip') : (isSwitching ? t('common.loading') : t('accounts.switch_to'))} |
| disabled={isSwitching || isDisabled} |
| > |
| <ArrowRightLeft className={`w-3.5 h-3.5 ${isSwitching ? 'animate-spin' : ''}`} /> |
| </button> |
| {onWarmup && ( |
| <button |
| className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isRefreshing || isDisabled) ? 'bg-orange-50 dark:bg-orange-900/10 text-orange-600 dark:text-orange-400 cursor-not-allowed' : 'hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30'}`} |
| onClick={(e) => { e.stopPropagation(); onWarmup(); }} |
| title={isDisabled ? t('accounts.disabled_tooltip') : (isRefreshing ? t('common.loading') : t('accounts.warmup_this', '预热该账号'))} |
| disabled={isRefreshing || isDisabled} |
| > |
| <Sparkles className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-pulse' : ''}`} /> |
| </button> |
| )} |
| <button |
| className={`p-1.5 text-gray-500 dark:text-gray-400 rounded-lg transition-all ${(isRefreshing || isDisabled) ? 'bg-green-50 dark:bg-green-900/10 text-green-600 dark:text-green-400 cursor-not-allowed' : 'hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30'}`} |
| onClick={(e) => { e.stopPropagation(); onRefresh(); }} |
| title={isDisabled ? t('accounts.disabled_tooltip') : (isRefreshing ? t('common.refreshing') : t('common.refresh'))} |
| disabled={isRefreshing || isDisabled} |
| > |
| <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} /> |
| </button> |
| <button |
| className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all" |
| onClick={(e) => { e.stopPropagation(); onExport(); }} |
| title={t('common.export')} |
| > |
| <Download className="w-3.5 h-3.5" /> |
| </button> |
| <button |
| className={cn( |
| "p-1.5 rounded-lg transition-all", |
| account.proxy_disabled |
| ? "text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30" |
| : "text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30" |
| )} |
| onClick={(e) => { e.stopPropagation(); onToggleProxy(); }} |
| title={account.proxy_disabled ? t('accounts.enable_proxy') : t('accounts.disable_proxy')} |
| > |
| {account.proxy_disabled ? ( |
| <ToggleRight className="w-3.5 h-3.5" /> |
| ) : ( |
| <ToggleLeft className="w-3.5 h-3.5" /> |
| )} |
| </button> |
| <button |
| className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all" |
| onClick={(e) => { e.stopPropagation(); onDelete(); }} |
| title={t('common.delete')} |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| </td> |
| </> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| function AccountTable({ |
| accounts, |
| selectedIds, |
| refreshingIds, |
| onToggleSelect, |
| onToggleAll, |
| currentAccountId, |
| switchingAccountId, |
| onSwitch, |
| onRefresh, |
| onViewDevice, |
| onViewDetails, |
| onExport, |
| onDelete, |
| onToggleProxy, |
| onReorder, |
| onWarmup, |
| onUpdateLabel, |
| onViewError, |
| }: AccountTableProps) { |
| const { t } = useTranslation(); |
|
|
| const [activeId, setActiveId] = useState<string | null>(null); |
| |
|
|
| |
| const sensors = useSensors( |
| useSensor(PointerSensor, { |
| activationConstraint: { distance: 8 }, |
| }), |
| useSensor(KeyboardSensor, { |
| coordinateGetter: sortableKeyboardCoordinates, |
| }) |
| ); |
|
|
| const accountIds = useMemo(() => accounts.map(a => a.id), [accounts]); |
| const activeAccount = useMemo(() => accounts.find(a => a.id === activeId), [accounts, activeId]); |
|
|
| const handleDragStart = (event: DragStartEvent) => { |
| setActiveId(event.active.id as string); |
| }; |
|
|
| const handleDragEnd = (event: DragEndEvent) => { |
| const { active, over } = event; |
| setActiveId(null); |
|
|
| if (over && active.id !== over.id) { |
| const oldIndex = accountIds.indexOf(active.id as string); |
| const newIndex = accountIds.indexOf(over.id as string); |
|
|
| if (oldIndex !== -1 && newIndex !== -1 && onReorder) { |
| onReorder(arrayMove(accountIds, oldIndex, newIndex)); |
| } |
| } |
| }; |
|
|
| if (accounts.length === 0) { |
| return ( |
| <div className="bg-white dark:bg-base-100 rounded-2xl p-12 shadow-sm border border-gray-100 dark:border-base-200 text-center"> |
| <p className="text-gray-400 mb-2">{t('accounts.empty.title')}</p> |
| <p className="text-sm text-gray-400">{t('accounts.empty.desc')}</p> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <DndContext |
| sensors={sensors} |
| collisionDetection={closestCenter} |
| onDragStart={handleDragStart} |
| onDragEnd={handleDragEnd} |
| > |
| <div className="overflow-x-auto"> |
| <table className="w-full"> |
| <thead> |
| <tr className="border-b border-gray-100 dark:border-base-200 bg-gray-50 dark:bg-base-200"> |
| <th className="pl-2 py-2 text-left w-8"> |
| <span className="sr-only">{t('accounts.drag_to_reorder')}</span> |
| </th> |
| <th className="px-2 py-2 text-left w-10"> |
| <input |
| type="checkbox" |
| className="checkbox checkbox-sm rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]" |
| checked={accounts.length > 0 && selectedIds.size === accounts.length} |
| onChange={onToggleAll} |
| /> |
| </th> |
| <th className="px-2 py-1 text-left rtl:text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-[300px] whitespace-nowrap">{t('accounts.table.email')}</th> |
| <th className="px-2 py-1 text-left rtl:text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider min-w-[380px] whitespace-nowrap"> |
| {t('accounts.table.quota')} |
| </th> |
| <th className="px-2 py-1 text-left rtl:text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-[90px] whitespace-nowrap">{t('accounts.table.last_used')}</th> |
| <th className="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap sticky right-0 w-[180px] bg-gray-50 dark:bg-base-200 z-20 shadow-[-12px_0_12px_-12px_rgba(0,0,0,0.1)] dark:shadow-[-12px_0_12px_-12px_rgba(255,255,255,0.05)] text-center">{t('accounts.table.actions')}</th> |
| </tr > |
| </thead > |
| <SortableContext items={accountIds} strategy={verticalListSortingStrategy}> |
| <tbody className="divide-y divide-gray-100 dark:divide-base-200"> |
| {accounts.map((account) => ( |
| <SortableAccountRow |
| key={account.id} |
| account={account} |
| selected={selectedIds.has(account.id)} |
| isRefreshing={refreshingIds.has(account.id)} |
| isCurrent={account.id === currentAccountId} |
| isSwitching={account.id === switchingAccountId} |
| isDragging={account.id === activeId} |
| onSelect={() => onToggleSelect(account.id)} |
| onSwitch={() => onSwitch(account.id)} |
| onRefresh={() => onRefresh(account.id)} |
| onViewDevice={() => onViewDevice(account.id)} |
| onViewDetails={() => onViewDetails(account.id)} |
| onExport={() => onExport(account.id)} |
| onDelete={() => onDelete(account.id)} |
| onToggleProxy={() => onToggleProxy(account.id)} |
| onWarmup={onWarmup ? () => onWarmup(account.id) : undefined} |
| onUpdateLabel={onUpdateLabel ? (label: string) => onUpdateLabel(account.id, label) : undefined} |
| onViewError={() => onViewError(account.id)} |
| /> |
| ))} |
| </tbody> |
| </SortableContext> |
| </table > |
| </div > |
|
|
| {/* 拖拽悬浮预览层 */} |
| <DragOverlay> |
| { |
| activeAccount ? ( |
| <table className="w-full bg-white dark:bg-base-100 shadow-2xl rounded-lg border border-blue-200 dark:border-blue-800"> |
| <tbody> |
| <tr className="bg-blue-50 dark:bg-blue-900/30"> |
| <td className="pl-2 py-1 w-8"> |
| <div className="flex items-center justify-center w-6 h-6 text-blue-500"> |
| <GripVertical className="w-4 h-4" /> |
| </div> |
| </td> |
| <td className="px-2 py-1 w-10"> |
| <input |
| type="checkbox" |
| className="checkbox checkbox-xs rounded border-2" |
| checked={selectedIds.has(activeAccount.id)} |
| readOnly |
| /> |
| </td> |
| <AccountRowContent |
| account={activeAccount} |
| isCurrent={activeAccount.id === currentAccountId} |
| isRefreshing={refreshingIds.has(activeAccount.id)} |
| isSwitching={activeAccount.id === switchingAccountId} |
| onSwitch={() => { }} |
| onRefresh={() => { }} |
| onViewDevice={() => { }} |
| onViewDetails={() => { }} |
| onExport={() => { }} |
| onDelete={() => { }} |
| onToggleProxy={() => { }} |
| isDisabled={Boolean(activeAccount.disabled)} |
| onViewError={() => { }} |
| /> |
| </tr> |
| </tbody> |
| </table> |
| ) : null |
| } |
| </DragOverlay> |
| </DndContext> |
| ); |
| } |
|
|
| export default AccountTable; |
|
|