Compare commits
	
		
			4 Commits
		
	
	
		
			94f229ac1d
			...
			97042e3d7b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 97042e3d7b | |||
| 43a828a2c6 | |||
| 8d02855249 | |||
| 7aaeffa498 | 
| @@ -8,7 +8,8 @@ | ||||
|       "Bash(git push:*)", | ||||
|       "Bash(git add:*)", | ||||
|       "Bash(npm run build:*)", | ||||
|       "Bash(git add:*)" | ||||
|       "Bash(git add:*)", | ||||
|       "Bash(pm2:*)" | ||||
|     ], | ||||
|     "deny": [] | ||||
|   } | ||||
|   | ||||
| @@ -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> | ||||
|   ); | ||||
| } | ||||
| @@ -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>; | ||||
|     } | ||||
| @@ -72,7 +72,7 @@ export function AccountTable({ | ||||
|                 <SelectItem value="20">20</SelectItem> | ||||
|                 <SelectItem value="100">100</SelectItem> | ||||
|                 <SelectItem value="1000">1000</SelectItem> | ||||
|                 <SelectItem value="100000">100000</SelectItem> | ||||
|                 <SelectItem value="10000">10000</SelectItem> | ||||
|               </SelectContent> | ||||
|             </Select> | ||||
|             <span className="text-sm text-muted-foreground">条</span> | ||||
| @@ -134,8 +134,8 @@ export function AccountTable({ | ||||
|       {/* 分页器 - 移到顶部 */} | ||||
|       {renderPagination()} | ||||
|        | ||||
|       {/* 表格区域 - 直接使用原生table确保sticky正常工作 */} | ||||
|       <div className="border rounded-lg max-h-[600px] overflow-auto"> | ||||
|       {/* 表格区域 - 增加高度以显示更多数据 */} | ||||
|       <div className="border rounded-lg max-h-[800px] overflow-auto"> | ||||
|         <table className="w-full caption-bottom text-sm"> | ||||
|           <thead className="sticky top-0 bg-background z-20 border-b shadow-sm"> | ||||
|             <tr className="hover:bg-muted/50 border-b transition-colors"> | ||||
| @@ -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> | ||||
| @@ -183,8 +170,12 @@ export function AccountTable({ | ||||
|               </tr> | ||||
|             ) : ( | ||||
|               accounts.map((account) => ( | ||||
|                 <tr key={account.id} className="hover:bg-muted/50 border-b transition-colors"> | ||||
|                   <td className="p-2 align-middle w-12"> | ||||
|                 <tr  | ||||
|                   key={account.id}  | ||||
|                   className="hover:bg-muted/50 border-b transition-colors cursor-pointer" | ||||
|                   onClick={() => onSelectOne(account.id, !selectedIds.includes(account.id))} | ||||
|                 > | ||||
|                   <td className="p-2 align-middle w-12" onClick={(e) => e.stopPropagation()}> | ||||
|                     <Checkbox | ||||
|                       checked={selectedIds.includes(account.id)} | ||||
|                       onCheckedChange={(checked) => onSelectOne(account.id, checked as boolean)} | ||||
| @@ -197,9 +188,15 @@ export function AccountTable({ | ||||
|                   <td className="p-2 align-middle">{getStatusBadge(account.status)}</td> | ||||
|                   <td className="p-2 align-middle max-w-[200px] truncate">{account.notes || '-'}</td> | ||||
|                   <td className="p-2 align-middle text-sm text-muted-foreground"> | ||||
|                     {new Date(account.createdAt).toLocaleDateString('zh-CN')} | ||||
|                     {new Date(account.createdAt).toLocaleString('zh-CN', { | ||||
|                       year: 'numeric', | ||||
|                       month: '2-digit', | ||||
|                       day: '2-digit', | ||||
|                       hour: '2-digit', | ||||
|                       minute: '2-digit' | ||||
|                     })} | ||||
|                   </td> | ||||
|                   <td className="p-2 align-middle w-[120px]"> | ||||
|                   <td className="p-2 align-middle w-[120px]" onClick={(e) => e.stopPropagation()}> | ||||
|                     <div className="flex items-center space-x-1"> | ||||
|                       {onView && ( | ||||
|                         <Button | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -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} | ||||
|   | ||||
							
								
								
									
										127
									
								
								components/ui/confirm-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								components/ui/confirm-dialog.tsx
									
									
									
									
									
										Normal 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 | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										179
									
								
								docs/API文档.md
									
									
									
									
									
								
							
							
						
						
									
										179
									
								
								docs/API文档.md
									
									
									
									
									
								
							| @@ -9,7 +9,6 @@ | ||||
| - **基础URL**: `{API_BASE_URL}` | ||||
| - **数据格式**: JSON | ||||
| - **字符编码**: UTF-8 | ||||
| - **认证方式**: 需要在请求头中包含认证信息 | ||||
|  | ||||
| ### 通用响应格式 | ||||
|  | ||||
| @@ -145,8 +144,7 @@ async function acquireAccounts(ownerId, platform, count = 1) { | ||||
|   const response = await fetch(`/s/v1/${ownerId}/acquire?platform=${platform}&count=${count}`, { | ||||
|     method: 'GET', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     } | ||||
|   }); | ||||
|    | ||||
| @@ -219,8 +217,7 @@ async function updateAccountStatus(ownerId, accountId, newStatus, notes) { | ||||
|   const response = await fetch(url, { | ||||
|     method: 'GET', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     } | ||||
|   }); | ||||
|    | ||||
| @@ -302,8 +299,7 @@ async function uploadAccounts(ownerId, accounts) { | ||||
|   const response = await fetch(`/s/v1/${ownerId}/upload`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify(accounts) | ||||
|   }); | ||||
| @@ -440,8 +436,7 @@ async function getAccountsList(filters, pagination, sort) { | ||||
|   const response = await fetch('/web/v1/accounts/list', { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify({ | ||||
|       filters, | ||||
| @@ -526,8 +521,7 @@ async function batchDeleteAccounts(ids) { | ||||
|   const response = await fetch('/web/v1/accounts/delete-batch', { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify({ ids }) | ||||
|   }); | ||||
| @@ -607,8 +601,7 @@ async function batchUpdateAccounts(ids, payload) { | ||||
|   const response = await fetch('/web/v1/accounts/update-batch', { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify({ ids, payload }) | ||||
|   }); | ||||
| @@ -632,7 +625,136 @@ if (result.code === 0) { | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 7. 统计概览接口 | ||||
| ### 7. 账户导出接口 | ||||
|  | ||||
| 用于Web端批量导出账户并设置状态为已导出。 | ||||
|  | ||||
| #### 请求 | ||||
|  | ||||
| ```http | ||||
| POST /web/v1/accounts/export | ||||
| ``` | ||||
|  | ||||
| #### 请求体 | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "ids": [1, 2, 3], | ||||
|   "mode": "text" | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 参数 | ||||
|  | ||||
| | 参数名 | 位置 | 类型 | 必填 | 说明 | | ||||
| |--------|------|------|------|------| | ||||
| | ids | 请求体 | number[] | 是 | 要导出的账户ID数组 | | ||||
| | mode | 请求体 | string | 是 | 导出模式:"text"或"object" | | ||||
|  | ||||
| #### 导出模式说明 | ||||
|  | ||||
| - `text`: 文本模式,返回的data字段为字符串,每行一个账户的data字段内容 | ||||
| - `object`: 对象模式,返回的data字段为完整的账户对象数组 | ||||
|  | ||||
| #### 响应 | ||||
|  | ||||
| 文本模式成功响应: | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "code": 0, | ||||
|   "message": "Successfully exported 3 accounts.", | ||||
|   "data": { | ||||
|     "exportedCount": 3, | ||||
|     "data": "{\"username\":\"user1\",\"password\":\"pass1\"}\n{\"username\":\"user2\",\"password\":\"pass2\"}\n{\"username\":\"user3\",\"password\":\"pass3\"}" | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| 对象模式成功响应: | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "code": 0, | ||||
|   "message": "Successfully exported 3 accounts.", | ||||
|   "data": { | ||||
|     "exportedCount": 3, | ||||
|     "data": [ | ||||
|       { | ||||
|         "id": 1, | ||||
|         "ownerId": "owner123", | ||||
|         "platform": "example", | ||||
|         "customId": "user1", | ||||
|         "data": "{\"username\":\"user1\",\"password\":\"pass1\"}", | ||||
|         "status": "exported", | ||||
|         "notes": "测试账户1", | ||||
|         "lockedAt": null, | ||||
|         "createdAt": "2023-01-01T00:00:00.000Z", | ||||
|         "updatedAt": "2023-01-01T12:00:00.000Z" | ||||
|       }, | ||||
|       { | ||||
|         "id": 2, | ||||
|         "ownerId": "owner123", | ||||
|         "platform": "example", | ||||
|         "customId": "user2", | ||||
|         "data": "{\"username\":\"user2\",\"password\":\"pass2\"}", | ||||
|         "status": "exported", | ||||
|         "notes": "测试账户2", | ||||
|         "lockedAt": null, | ||||
|         "createdAt": "2023-01-01T00:00:00.000Z", | ||||
|         "updatedAt": "2023-01-01T12:00:00.000Z" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 示例 | ||||
|  | ||||
| ```javascript | ||||
| // 使用fetch导出账户 | ||||
| async function exportAccounts(ids, mode) { | ||||
|   const response = await fetch('/web/v1/accounts/export', { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify({ ids, mode }) | ||||
|   }); | ||||
|    | ||||
|   const result = await response.json(); | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| // 文本模式导出示例 | ||||
| const textResult = await exportAccounts([1, 2, 3], 'text'); | ||||
| if (textResult.code === 0) { | ||||
|   console.log('导出成功:', textResult.data.exportedCount); | ||||
|   console.log('文本数据:', textResult.data.data); | ||||
|   // 可以直接保存为文件或复制到剪贴板 | ||||
|   const lines = textResult.data.data.split('\n'); | ||||
|   lines.forEach((line, index) => { | ||||
|     console.log(`账户${index + 1}:`, line); | ||||
|   }); | ||||
| } else { | ||||
|   console.error('导出失败:', textResult.message); | ||||
| } | ||||
|  | ||||
| // 对象模式导出示例 | ||||
| const objectResult = await exportAccounts([1, 2, 3], 'object'); | ||||
| if (objectResult.code === 0) { | ||||
|   console.log('导出成功:', objectResult.data.exportedCount); | ||||
|   const accounts = objectResult.data.data; | ||||
|   accounts.forEach(account => { | ||||
|     console.log(`账户ID: ${account.id}, 状态: ${account.status}`); | ||||
|     console.log(`数据: ${account.data}`); | ||||
|   }); | ||||
| } else { | ||||
|   console.error('导出失败:', objectResult.message); | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 8. 统计概览接口 | ||||
|  | ||||
| 用于Web端获取账户统计信息。 | ||||
|  | ||||
| @@ -693,8 +815,7 @@ async function getStatsOverview() { | ||||
|   const response = await fetch('/web/v1/stats/overview', { | ||||
|     method: 'GET', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': 'Bearer YOUR_TOKEN' | ||||
|       'Content-Type': 'application/json' | ||||
|     } | ||||
|   }); | ||||
|    | ||||
| @@ -734,23 +855,17 @@ if (result.code === 0) { | ||||
| | 1001 | 资源不存在 | 检查请求的资源是否存在 | | ||||
| | 2001 | 参数无效 | 检查请求参数是否符合要求 | | ||||
| | 3001 | 资源冲突 | 检查是否有重复数据 | | ||||
| | 4001 | 权限不足 | 检查认证信息是否正确 | | ||||
| | 5001 | 业务错误 | 检查业务逻辑是否正确 | | ||||
|  | ||||
| ## 最佳实践 | ||||
|  | ||||
| 1. **认证**:所有API请求都需要在请求头中包含认证信息,例如: | ||||
|    ```http | ||||
|    Authorization: Bearer YOUR_TOKEN | ||||
|    ``` | ||||
| 1. **错误处理**:前端应该根据返回的`code`值进行相应的错误处理,而不是依赖`message`字段。 | ||||
|  | ||||
| 2. **错误处理**:前端应该根据返回的`code`值进行相应的错误处理,而不是依赖`message`字段。 | ||||
| 2. **分页**:在获取列表数据时,合理设置分页大小,建议不超过100条。 | ||||
|  | ||||
| 3. **分页**:在获取列表数据时,合理设置分页大小,建议不超过100条。 | ||||
| 3. **批量操作**:批量操作时,建议限制单次操作的数量,避免服务器压力过大。 | ||||
|  | ||||
| 4. **批量操作**:批量操作时,建议限制单次操作的数量,避免服务器压力过大。 | ||||
|  | ||||
| 5. **数据格式**:账户数据字段`data`存储的是JSON格式字符串,前端需要进行相应的序列化和反序列化操作。 | ||||
| 4. **数据格式**:账户数据字段`data`存储的是JSON格式字符串,前端需要进行相应的序列化和反序列化操作。 | ||||
|  | ||||
| ## 类型定义 | ||||
|  | ||||
| @@ -845,6 +960,18 @@ interface BatchUpdateBody { | ||||
|   payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes'>>; | ||||
| } | ||||
|  | ||||
| // 账户导出请求体 | ||||
| interface ExportAccountsBody { | ||||
|   ids: number[]; | ||||
|   mode: 'text' | 'object'; | ||||
| } | ||||
|  | ||||
| // 账户导出响应 | ||||
| interface ExportAccountsResponse { | ||||
|   exportedCount: number; | ||||
|   data: string | Account[]; | ||||
| } | ||||
|  | ||||
| // 统计概览 | ||||
| interface StatsOverview { | ||||
|   totalAccounts: number; | ||||
|   | ||||
							
								
								
									
										18
									
								
								lib/api.ts
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								lib/api.ts
									
									
									
									
									
								
							| @@ -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'); | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useState, useEffect, useCallback } from 'react'; | ||||
| import {  | ||||
|   Account,  | ||||
|   ListAccountsBody,  | ||||
| @@ -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; | ||||
| @@ -58,9 +58,24 @@ export function useAccounts(): UseAccountsReturn { | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [selectedIds, setSelectedIds] = useState<number[]>([]); | ||||
|    | ||||
|   // 从 localStorage 获取缓存的分页大小,默认为 20 | ||||
|   const getInitialPageSize = () => { | ||||
|     if (typeof window !== 'undefined') { | ||||
|       const saved = localStorage.getItem('accounts-page-size'); | ||||
|       if (saved) { | ||||
|         const pageSize = parseInt(saved, 10); | ||||
|         // 验证是否为有效值 | ||||
|         if ([20, 100, 1000, 10000].includes(pageSize)) { | ||||
|           return pageSize; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return 20; | ||||
|   }; | ||||
|    | ||||
|   const [pagination, setPagination] = useState({ | ||||
|     page: 1, | ||||
|     pageSize: 20, | ||||
|     pageSize: getInitialPageSize(), | ||||
|     total: 0, | ||||
|     totalPages: 0 | ||||
|   }); | ||||
| @@ -91,7 +106,7 @@ export function useAccounts(): UseAccountsReturn { | ||||
|   }; | ||||
|  | ||||
|   // 获取账户列表 | ||||
|   const fetchAccounts = async () => { | ||||
|   const fetchAccounts = useCallback(async () => { | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const body: ListAccountsBody = { | ||||
| @@ -118,7 +133,7 @@ export function useAccounts(): UseAccountsReturn { | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|   }, [filters, pagination.page, pagination.pageSize, sort]); | ||||
|  | ||||
|   // 处理筛选变化 | ||||
|   const handleFilterChange = (key: string, value: any) => { | ||||
| @@ -133,7 +148,16 @@ export function useAccounts(): UseAccountsReturn { | ||||
|  | ||||
|   // 处理每页大小变化 | ||||
|   const handlePageSizeChange = (pageSize: number) => { | ||||
|     setPagination({ ...pagination, pageSize, page: 1 }); | ||||
|     // 保存到 localStorage | ||||
|     if (typeof window !== 'undefined') { | ||||
|       localStorage.setItem('accounts-page-size', pageSize.toString()); | ||||
|     } | ||||
|      | ||||
|     setPagination(prev => ({ | ||||
|       ...prev,  | ||||
|       pageSize,  | ||||
|       page: 1 | ||||
|     })); | ||||
|   }; | ||||
|  | ||||
|   // 处理排序 | ||||
| @@ -160,22 +184,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('删除失败,请检查网络连接后重试'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @@ -234,7 +274,7 @@ export function useAccounts(): UseAccountsReturn { | ||||
|   // 初始化数据 | ||||
|   useEffect(() => { | ||||
|     fetchAccounts(); | ||||
|   }, [filters, pagination.page, pagination.pageSize, sort]); | ||||
|   }, [fetchAccounts]); | ||||
|  | ||||
|   // 只在开始时获取统计数据 | ||||
|   useEffect(() => { | ||||
|   | ||||
							
								
								
									
										12
									
								
								lib/types.ts
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								lib/types.ts
									
									
									
									
									
								
							| @@ -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; | ||||
|   | ||||
| @@ -5,9 +5,9 @@ | ||||
|   "scripts": { | ||||
|     "dev": "next dev --turbopack", | ||||
|     "build": "next build --turbopack",  | ||||
|     "start": "next start", | ||||
|     "start:prod": "next start -p 13007", | ||||
|     "deploy": "pm2 start npm --name accounts-manager-web -- run start:prod" | ||||
|     "start": "next start -p 13007", | ||||
|     "deploy": "NODE_ENV=production npm run build && npm run start:prod", | ||||
|     "start:prod": "next start -p 13007" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@radix-ui/react-checkbox": "^1.3.3", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user