feat: 实现账户批量导出功能和界面优化

- 新增批量导出功能,支持text模式导出账户数据
- 添加导出弹窗,支持文本全选和文件下载
- 移动刷新按钮到全局位置,统一刷新账户和统计数据
- 在统计卡片中将已导出状态计入可用账户
- 创建自定义确认对话框替换系统confirm弹窗
- 统一按钮尺寸,修复刷新和上传按钮大小不一致
- 添加已导出状态的中文映射和样式

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-24 05:28:45 +08:00
parent 94f229ac1d
commit 7aaeffa498
9 changed files with 338 additions and 52 deletions

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Eye, Edit, Trash2, RefreshCw } from 'lucide-react';
import { ChevronLeft, ChevronRight, Eye, Edit, Trash2 } from 'lucide-react';
import { Account } from '@/lib/types';
interface AccountTableProps {
@@ -22,7 +22,6 @@ interface AccountTableProps {
onSelectOne: (id: number, checked: boolean) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
onRefresh: () => void;
onView?: (account: Account) => void;
onEdit?: (account: Account) => void;
onDelete?: (account: Account) => void;
@@ -37,7 +36,6 @@ export function AccountTable({
onSelectOne,
onPageChange,
onPageSizeChange,
onRefresh,
onView,
onEdit,
onDelete
@@ -50,6 +48,8 @@ export function AccountTable({
return <Badge variant="secondary"></Badge>;
case 'banned':
return <Badge variant="destructive"></Badge>;
case 'exported':
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
@@ -152,20 +152,7 @@ export function AccountTable({
<th className="text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap"></th>
<th className="text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap"></th>
<th className="text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap"></th>
<th className="text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap w-[120px]">
<div className="flex items-center justify-between">
<span></span>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="h-6 w-6 p-0"
>
<RefreshCw className={`h-3 w-3 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</th>
<th className="text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap w-[120px]"></th>
</tr>
</thead>
<tbody>

View File

@@ -165,7 +165,7 @@ export function AccountUpload({ onUpload, stats }: AccountUploadProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Button size="sm">
<Upload className="h-4 w-4 mr-2" />
</Button>

View File

@@ -3,11 +3,13 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Edit3, Trash2 } from 'lucide-react';
import { Edit3, Trash2, Download } from 'lucide-react';
import { Account, StatsOverview } from '@/lib/types';
import { toast } from 'sonner';
import { PlatformSelector, OwnerSelector, StatusSelector } from '@/components/shared';
import { apiClient } from '@/lib/api';
interface BatchOperationsProps {
selectedCount: number;
@@ -15,10 +17,13 @@ interface BatchOperationsProps {
stats: StatsOverview | null;
onBatchUpdate: (payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>>, targetIds?: number[]) => Promise<void>;
onBatchDelete: () => Promise<void>;
onRefresh?: () => Promise<void>;
}
export function BatchOperations({ selectedCount, selectedAccounts, stats, onBatchUpdate, onBatchDelete }: BatchOperationsProps) {
export function BatchOperations({ selectedCount, selectedAccounts, stats, onBatchUpdate, onBatchDelete, onRefresh }: BatchOperationsProps) {
const [updateDialog, setUpdateDialog] = useState(false);
const [exportDialog, setExportDialog] = useState(false);
const [exportData, setExportData] = useState({ count: 0, text: '' });
const [updateData, setUpdateData] = useState({
status: '',
platform: '',
@@ -43,6 +48,54 @@ export function BatchOperations({ selectedCount, selectedAccounts, stats, onBatc
const selectedStats = getSelectedStats();
const handleBatchExport = async () => {
setLoading(true);
try {
const response = await apiClient.batchExportAccounts({
ids: selectedAccounts.map(account => account.id),
mode: 'text'
});
if (response.code === 0 && response.data) {
setExportData({
count: response.data.exportedCount,
text: response.data.data
});
setExportDialog(true);
toast.success(`成功导出 ${response.data.exportedCount} 个账户`);
// 导出后刷新数据
if (onRefresh) {
await onRefresh();
}
} else {
toast.error(response.message || '导出失败');
}
} catch (error) {
console.error('Export error:', error);
toast.error('导出失败,请稍后重试');
} finally {
setLoading(false);
}
};
const handleDownloadTxt = () => {
const blob = new Blob([exportData.text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `accounts_export_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const selectAllText = (event: React.MouseEvent<HTMLTextAreaElement>) => {
const target = event.target as HTMLTextAreaElement;
target.select();
};
const handleBatchUpdate = async () => {
const payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>> = {};
if (updateData.status) payload.status = updateData.status;
@@ -116,6 +169,15 @@ export function BatchOperations({ selectedCount, selectedAccounts, stats, onBatc
<Edit3 className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchExport}
disabled={loading}
>
<Download className="h-4 w-4 mr-1" />
</Button>
<Button
variant="destructive"
size="sm"
@@ -195,6 +257,43 @@ export function BatchOperations({ selectedCount, selectedAccounts, stats, onBatc
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={exportDialog} onOpenChange={setExportDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{exportData.count}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">:</label>
<Textarea
value={exportData.text}
readOnly
onClick={selectAllText}
className="min-h-[300px] font-mono text-sm"
placeholder="导出的账户数据将显示在这里..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setExportDialog(false)}
>
</Button>
<Button
onClick={handleDownloadTxt}
>
<Download className="h-4 w-4 mr-1" />
.txt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -80,8 +80,12 @@ export function StatsCards({ stats, loading }: StatsCardsProps) {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{stats.statusSummary.available || 0}
{(stats.statusSummary.available || 0) + (stats.statusSummary.exported || 0)}
</div>
<p className="text-xs text-muted-foreground">
: {stats.statusSummary.available || 0} |
: {stats.statusSummary.exported || 0}
</p>
<p className="text-xs text-muted-foreground">
: {stats.statusSummary.locked || 0} |
: {stats.statusSummary.banned || 0}