Files
accounts-manager-web/components/accounts/batch-operations.tsx
cloud370 7aaeffa498 feat: 实现账户批量导出功能和界面优化
- 新增批量导出功能,支持text模式导出账户数据
- 添加导出弹窗,支持文本全选和文件下载
- 移动刷新按钮到全局位置,统一刷新账户和统计数据
- 在统计卡片中将已导出状态计入可用账户
- 创建自定义确认对话框替换系统confirm弹窗
- 统一按钮尺寸,修复刷新和上传按钮大小不一致
- 添加已导出状态的中文映射和样式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 05:28:45 +08:00

299 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
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, 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;
selectedAccounts: Account[];
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, 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: '',
ownerId: '',
notes: ''
});
const [loading, setLoading] = useState(false);
// 计算选中账户的统计信息
const getSelectedStats = () => {
const platforms = new Set(selectedAccounts.map(account => account.platform));
const owners = new Set(selectedAccounts.map(account => account.ownerId));
return {
platformCount: platforms.size,
ownerCount: owners.size,
platforms: Array.from(platforms).slice(0, 3), // 只显示前3个
owners: Array.from(owners).slice(0, 3) // 只显示前3个
};
};
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;
if (updateData.platform) payload.platform = updateData.platform;
if (updateData.ownerId) payload.ownerId = updateData.ownerId;
if (updateData.notes) payload.notes = updateData.notes;
if (Object.keys(payload).length === 0) {
toast.warning('请至少选择一个字段进行更新');
return;
}
setLoading(true);
try {
await onBatchUpdate(payload);
setUpdateDialog(false);
setUpdateData({ status: '', platform: '', ownerId: '', notes: '' });
} finally {
setLoading(false);
}
};
const handleBatchDelete = async () => {
setLoading(true);
try {
await onBatchDelete();
} finally {
setLoading(false);
}
};
if (selectedCount === 0) {
return null;
}
return (
<>
<div className="flex items-center space-x-2 p-3 bg-muted rounded-lg">
<div className="flex-1">
<div className="flex items-center space-x-4">
<span className="text-sm font-medium"> {selectedCount} </span>
<div className="flex items-center space-x-3 text-xs text-muted-foreground">
<span>
{selectedStats.ownerCount}
{selectedStats.ownerCount > 0 && (
<span className="ml-1">
({selectedStats.owners.join(', ')}
{selectedStats.ownerCount > 3 && `${selectedStats.ownerCount}`})
</span>
)}
</span>
<span></span>
<span>
{selectedStats.platformCount}
{selectedStats.platformCount > 0 && (
<span className="ml-1">
({selectedStats.platforms.join(', ')}
{selectedStats.platformCount > 3 && `${selectedStats.platformCount}`})
</span>
)}
</span>
</div>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setUpdateDialog(true)}
disabled={loading}
>
<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"
onClick={handleBatchDelete}
disabled={loading}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
<Dialog open={updateDialog} onOpenChange={setUpdateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedCount}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-6">
<div className="space-y-3">
<label className="text-sm font-medium"></label>
<PlatformSelector
value={updateData.platform}
onValueChange={(value) => setUpdateData({...updateData, platform: value})}
stats={stats}
showNoneOption={true}
placeholder="选择新平台(可选)"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"></label>
<StatusSelector
value={updateData.status}
onValueChange={(value) => setUpdateData({...updateData, status: value})}
stats={stats}
showNoneOption={true}
placeholder="选择新状态(可选)"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-medium">ID</label>
<OwnerSelector
value={updateData.ownerId}
onValueChange={(value) => setUpdateData({...updateData, ownerId: value})}
stats={stats}
showNoneOption={true}
placeholder="选择所有者(可选)"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"></label>
<Input
placeholder="输入新的备注(可选)"
value={updateData.notes}
onChange={(e) => setUpdateData({...updateData, notes: e.target.value})}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setUpdateDialog(false)}
disabled={loading}
>
</Button>
<Button
onClick={handleBatchUpdate}
disabled={loading}
>
{loading ? '更新中...' : '确认更新'}
</Button>
</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>
</>
);
}