app / src /components /accounts /AccountTable.tsx
AZILS's picture
Upload 323 files
a21c316 verified
/**
* 账号表格组件
* 支持拖拽排序功能,用户可以通过拖拽行来调整账号顺序
*/
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();
// Helper to check if any model in the group is protected
const isGroupProtected = (group: string[]) => {
return group.some(m => protectedModels.includes(m));
};
// UI Column Keys Mapping (for backward compatibility with hardcoded UI calls)
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);
// 1. Gemini Pro Group
if (MODEL_GROUPS.GEMINI_PRO.some(m => lowerName === m)) {
return isGroupProtected(MODEL_GROUPS.GEMINI_PRO);
}
// 2. Claude Group
if (MODEL_GROUPS.CLAUDE.some(m => lowerName === m)) {
return isGroupProtected(MODEL_GROUPS.CLAUDE);
}
// 3. Gemini Flash Group
if (MODEL_GROUPS.GEMINI_FLASH.some(m => lowerName === m)) {
return isGroupProtected(MODEL_GROUPS.GEMINI_FLASH);
}
// 兜底直接检查 (Strict check for exact match or normalized ID)
return protectedModels.includes(lowerName);
}
// ============================================================================
// 子组件
// ============================================================================
/**
* 可拖拽的表格行组件
* 使用 @dnd-kit/sortable 实现拖拽功能
*/
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);
// 根据 show_all 状态决定显示哪些模型
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; // Safe guard for unknown models that aren't fetched
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 => {
// 过滤特定的 Claude/Gemini 思考变体 (在列表页隐藏)
const isHiddenThinking = m.id.includes('thinking');
if (isHiddenThinking) return false;
// 基于标签去重 (例如 G3.1 Pro 只显示一次)
// 优先显示有配额数据的 ID
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) => {
// 第二次过滤:确保即使没有数据的重复 Label 也只保留一个
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);
// showAllQuotas 已经在 useConfigStore 中解构获取
// 配置拖拽传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, // 需要移动 8px 才触发拖拽
}),
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;