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

@@ -2,8 +2,11 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
import { Account } from '@/lib/types';
import { useAccounts } from '@/lib/hooks/use-accounts';
import { useConfirmDialog } from '@/components/ui/confirm-dialog';
// 组件导入
import { StatsCards } from './accounts/stats-cards';
@@ -14,6 +17,8 @@ import { AccountTable } from './accounts/account-table';
import { AccountDetails } from './accounts/account-details';
export function AccountsManager() {
const { showConfirm, ConfirmDialogComponent } = useConfirmDialog();
const {
accounts,
stats,
@@ -34,6 +39,11 @@ export function AccountsManager() {
setSelectedIds
} = useAccounts();
// 全局刷新函数
const handleGlobalRefresh = async () => {
await Promise.all([fetchAccounts(), fetchStats()]);
};
// 账户详情状态
const [detailsDialog, setDetailsDialog] = useState(false);
const [detailsMode, setDetailsMode] = useState<'view' | 'edit'>('view');
@@ -55,16 +65,23 @@ export function AccountsManager() {
// 处理删除单个账户
const handleDeleteAccount = async (account: Account) => {
if (!confirm('确认删除此账户?')) return;
try {
// 设置选中的账户ID
setSelectedIds([account.id]);
// 执行删除
await handleBatchDelete();
} catch (error) {
console.error('Failed to delete account:', error);
}
showConfirm({
title: '确认删除',
description: `确认删除账户 "${account.customId}"?此操作不可撤销。`,
confirmText: '确认删除',
cancelText: '取消',
variant: 'destructive',
onConfirm: async () => {
try {
// 设置选中的账户ID
setSelectedIds([account.id]);
// 执行删除
await handleBatchDelete();
} catch (error) {
console.error('Failed to delete account:', error);
}
}
});
};
// 处理保存编辑
@@ -114,7 +131,18 @@ export function AccountsManager() {
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<AccountUpload onUpload={handleUploadAccounts} stats={stats} />
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleGlobalRefresh}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
</Button>
<AccountUpload onUpload={handleUploadAccounts} stats={stats} />
</div>
</div>
{/* 统计卡片 */}
@@ -142,7 +170,8 @@ export function AccountsManager() {
selectedAccounts={accounts.filter(account => selectedIds.includes(account.id))}
stats={stats}
onBatchUpdate={handleBatchUpdate}
onBatchDelete={handleBatchDelete}
onBatchDelete={() => handleBatchDelete(showConfirm)}
onRefresh={handleGlobalRefresh}
/>
{/* 账户表格 - 移除高度限制 */}
@@ -156,7 +185,6 @@ export function AccountsManager() {
onSelectOne={handleSelectOne}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRefresh={fetchAccounts}
onView={handleViewAccount}
onEdit={handleEditAccount}
onDelete={handleDeleteAccount}
@@ -176,6 +204,9 @@ export function AccountsManager() {
onSave={handleSaveAccount}
onDelete={handleDeleteAccount}
/>
{/* 确认删除对话框 */}
<ConfirmDialogComponent />
</div>
);
}

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}

View File

@@ -0,0 +1,127 @@
"use client";
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AlertTriangle } from 'lucide-react';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: 'default' | 'destructive';
onConfirm: () => void;
onCancel?: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmText = '确认',
cancelText = '取消',
variant = 'default',
onConfirm,
onCancel
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onOpenChange(false);
};
const handleCancel = () => {
onCancel?.();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{variant === 'destructive' && (
<AlertTriangle className="h-5 w-5 text-destructive" />
)}
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{cancelText}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Hook for easier usage
export function useConfirmDialog() {
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: 'default' | 'destructive';
onConfirm: () => void;
onCancel?: () => void;
}>({
open: false,
title: '',
description: '',
onConfirm: () => {}
});
const showConfirm = (options: {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: 'default' | 'destructive';
onConfirm: () => void;
onCancel?: () => void;
}) => {
setConfirmDialog({
...options,
open: true
});
};
const hideConfirm = () => {
setConfirmDialog(prev => ({ ...prev, open: false }));
};
const ConfirmDialogComponent = () => (
<ConfirmDialog
open={confirmDialog.open}
onOpenChange={hideConfirm}
title={confirmDialog.title}
description={confirmDialog.description}
confirmText={confirmDialog.confirmText}
cancelText={confirmDialog.cancelText}
variant={confirmDialog.variant}
onConfirm={confirmDialog.onConfirm}
onCancel={confirmDialog.onCancel}
/>
);
return {
showConfirm,
hideConfirm,
ConfirmDialogComponent
};
}

View File

@@ -4,6 +4,8 @@ import {
ListAccountsResponse,
BatchDeleteBody,
BatchUpdateBody,
BatchExportBody,
BatchExportResponse,
StatsOverview,
ScriptUploadItem
} from './types';
@@ -16,12 +18,12 @@ const getApiBaseUrl = () => {
}
// 在浏览器环境中,没有配置则使用当前域名
if (typeof window !== 'undefined') {
return `${window.location.protocol}//${window.location.hostname}:3006`;
}
// if (typeof window !== 'undefined') {
// return `${window.location.protocol}//${window.location.hostname}:3006`;
// }
// 服务端渲染时的默认值
return 'http://localhost:3006';
return 'http://170.205.39.58:13007';
};
class ApiClient {
@@ -71,6 +73,14 @@ class ApiClient {
});
}
// 批量导出账户
async batchExportAccounts(body: BatchExportBody): Promise<ApiResponse<BatchExportResponse>> {
return this.request<BatchExportResponse>('/web/v1/accounts/export', {
method: 'POST',
body: JSON.stringify(body),
});
}
// 获取统计概览
async getStatsOverview(): Promise<ApiResponse<StatsOverview>> {
return this.request<StatsOverview>('/web/v1/stats/overview');

View File

@@ -46,7 +46,7 @@ export interface UseAccountsReturn {
handleSortChange: (field: keyof Account, order: 'asc' | 'desc') => void;
handleSelectAll: (checked: boolean) => void;
handleSelectOne: (id: number, checked: boolean) => void;
handleBatchDelete: () => Promise<void>;
handleBatchDelete: (showConfirm?: (options: any) => void) => Promise<void>;
handleBatchUpdate: (payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>>, targetIds?: number[]) => Promise<void>;
handleUploadAccounts: (accounts: ScriptUploadItem[], ownerId: string) => Promise<void>;
setSelectedIds: (ids: number[]) => void;
@@ -160,22 +160,38 @@ export function useAccounts(): UseAccountsReturn {
};
// 批量删除
const handleBatchDelete = async () => {
const handleBatchDelete = async (showConfirm?: (options: any) => void) => {
if (selectedIds.length === 0) return;
if (!confirm(`确认删除 ${selectedIds.length} 个账户?`)) return;
try {
const response = await apiClient.batchDeleteAccounts({ ids: selectedIds });
if (response.code === BusinessCode.Success) {
setSelectedIds([]);
await fetchAccounts();
await fetchStats();
toast.success(`成功删除 ${response.data?.deletedCount || 0} 个账户`);
const executeDelete = async () => {
try {
const response = await apiClient.batchDeleteAccounts({ ids: selectedIds });
if (response.code === BusinessCode.Success) {
setSelectedIds([]);
await fetchAccounts();
await fetchStats();
toast.success(`成功删除 ${response.data?.deletedCount || 0} 个账户`);
}
} catch (error) {
console.error('Failed to delete accounts:', error);
toast.error('删除失败,请检查网络连接后重试');
}
};
if (showConfirm) {
showConfirm({
title: '确认删除',
description: `确认删除 ${selectedIds.length} 个账户?此操作不可撤销。`,
confirmText: '确认删除',
cancelText: '取消',
variant: 'destructive',
onConfirm: executeDelete
});
} else {
// 如果没有提供确认函数,直接执行删除(用于兼容)
if (confirm(`确认删除 ${selectedIds.length} 个账户?`)) {
await executeDelete();
}
} catch (error) {
console.error('Failed to delete accounts:', error);
toast.error('删除失败,请检查网络连接后重试');
}
};

View File

@@ -86,6 +86,18 @@ export interface BatchUpdateBody {
payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>>;
}
// 批量导出请求体
export interface BatchExportBody {
ids: number[];
mode: 'text' | 'object';
}
// 批量导出响应
export interface BatchExportResponse {
exportedCount: number;
data: string;
}
// 统计概览
export interface StatsOverview {
totalAccounts: number;