'use client'; import * as React from 'react'; import { useTranslations } from 'next-intl'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'motion/react'; import { Download, Eye, FileArchive, FileAudio, FileImage, FileText, FileVideo, Loader2, Search, Trash2, Upload, X, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; import { FileImagePreview } from '@/components/common/file-image-preview'; import services, { formatFileSize } from '@/lib/services'; import type { Upload as UploadRecord } from '@/lib/services/upload/types'; /* ─── 工具函数 ─────────────────────────────────────────── */ function getFileIcon(mimeType: string, className = 'size-8') { if (mimeType.startsWith('image/')) return ; if (mimeType.startsWith('video/')) return ; if (mimeType.startsWith('audio/')) return ; if ( mimeType.includes('zip') || mimeType.includes('tar') || mimeType.includes('gzip') ) return ; return ; } function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } export function FileList() { const t = useTranslations('admin.files'); const queryClient = useQueryClient(); const [keyword, setKeyword] = React.useState(''); const [debouncedKeyword, setDebouncedKeyword] = React.useState(''); const [selectedIds, setSelectedIds] = React.useState>(new Set()); const [deleteTarget, setDeleteTarget] = React.useState( null, ); const [detailTarget, setDetailTarget] = React.useState( null, ); const [page, setPage] = React.useState(1); const pageSize = 15; // 表格视图下 15 条更紧凑 // 搜索防抖 React.useEffect(() => { const timer = setTimeout(() => { setDebouncedKeyword(keyword); setPage(1); }, 400); return () => clearTimeout(timer); }, [keyword]); // 文件列表查询 const listQuery = useQuery({ queryKey: ['files', 'all', page, pageSize, debouncedKeyword], queryFn: () => services.adminUpload.listUploads( page, pageSize, debouncedKeyword || undefined, ), }); const storageDriverQuery = useQuery({ queryKey: ['admin', 'storage-config', 'driver'], queryFn: async () => { const record = await services.adminSystemConfig.getSystemConfig('storage_config'); const cfg = JSON.parse(record.value) as { driver?: string }; return cfg.driver ?? 'local'; }, }); const files = listQuery.data?.items ?? []; const total = listQuery.data?.total ?? 0; const totalPages = Math.ceil(total / pageSize); const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id)); const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected; const handleSelectAll = () => { if (isAllSelected) { setSelectedIds((prev) => { const next = new Set(prev); files.forEach((f) => next.delete(f.id)); return next; }); } else { setSelectedIds((prev) => { const next = new Set(prev); files.forEach((f) => next.add(f.id)); return next; }); } }; // 删除单文件 const deleteMutation = useMutation({ mutationFn: (id: string) => services.adminUpload.deleteFile(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['files', 'my'] }); void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] }); toast.success(t('list.deleteSuccess')); setDeleteTarget(null); }, onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')), }); // 批量 ZIP 下载 const batchDownloadMutation = useMutation({ mutationFn: (ids: string[]) => services.adminUpload.batchDownload(ids), onSuccess: (blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'batch_download.zip'; a.click(); URL.revokeObjectURL(url); toast.success(t('list.batchDownloadSuccess')); }, onError: () => toast.error(t('list.batchDownloadFailed')), }); const toggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; const clearSelection = () => setSelectedIds(new Set()); const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id))); const handleDownload = (file: UploadRecord) => { const url = services.adminUpload.getDownloadUrl(file.id); const a = document.createElement('a'); a.href = url; a.download = file.file_name; a.click(); }; return ( {/* 搜索栏 */} setKeyword(e.target.value)} /> {keyword && ( setKeyword('')} > )} {files.length > 0 && ( {selectedIds.size === files.length ? t('list.deselectAll') : t('list.selectAll')} )} {/* 打包下载等批量操作 */} {selectedIds.size > 0 && ( {t('list.selectedCount', { count: selectedIds.size })} batchDownloadMutation.mutate([...selectedIds])} disabled={batchDownloadMutation.isPending} > {batchDownloadMutation.isPending ? ( ) : ( )} {t('list.batchDownload')} )} {total > 0 && ( {t('list.totalFiles', { count: total })} )} {/* 文件列表 Table */} {listQuery.isPending ? ( ) : files.length === 0 ? ( {debouncedKeyword ? t('list.noMatch') : t('list.noFiles')} ) : ( {t('list.colPreview')} ID {t('list.colName')} {t('list.colPath')} {t('list.colType')} {t('list.colMime')} {t('list.colSize')} {t('list.colUploadedAt')} {t('list.colActions')} {files.map((file) => { const isSelected = selectedIds.has(file.id); return ( toggleSelect(file.id)} /> {file.mime_type.startsWith('image/') ? ( ) : ( getFileIcon(file.mime_type, 'size-4.5') )} {file.id} {file.file_name} {file.file_path} {file.type} {file.mime_type} {formatFileSize(file.file_size)} {formatDate(file.created_at)} setDetailTarget(file)} > handleDownload(file)} > setDeleteTarget(file)} > ); })} )} {/* 分页 */} {totalPages > 1 && ( setPage((p) => p - 1)} > {t('list.prevPage')} {page} / {totalPages} = totalPages} onClick={() => setPage((p) => p + 1)} > {t('list.nextPage')} )} {/* 详情查看 Sheet */} !open && setDetailTarget(null)} > {t('list.detailTitle')} {t('list.detailDesc')} {detailTarget && ( {/* 大图/格式预览 */} {detailTarget.mime_type.startsWith('image/') ? ( ) : ( {getFileIcon(detailTarget.mime_type, 'size-14')} {t('list.extensionFile', { ext: detailTarget.extension, })} )} {/* 元数据列表 */} {t('list.detailId')} {detailTarget.id} {t('list.detailName')} {detailTarget.file_name} {t('list.detailPath')} {detailTarget.file_path} {t('list.detailSize')} {formatFileSize(detailTarget.file_size)} ( {detailTarget.file_size.toLocaleString()} {t('list.bytes')} ) {t('list.detailMime')} {detailTarget.mime_type} {t('list.detailExtension')} {detailTarget.extension} Hash (SHA-256) {detailTarget.hash} {t('list.detailBizType')} {detailTarget.type} {t('list.detailDriver')} {storageDriverQuery.data ?? 'local'} {t('list.detailUploaderId')} {detailTarget.user_id} {t('list.detailUploadedAt')} {formatDate(detailTarget.created_at)} {detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && ( {t('list.detailMetadata')} {JSON.stringify(detailTarget.metadata, null, 2)} )} )} {/* 删除确认 Dialog */} !open && setDeleteTarget(null)} > {t('list.deleteTitle')} {t('list.deleteConfirm', { name: deleteTarget?.file_name ?? '', })} {t('list.cancel')} deleteTarget && deleteMutation.mutate(deleteTarget.id) } disabled={deleteMutation.isPending} className='bg-destructive hover:bg-destructive/90 text-destructive-foreground' > {deleteMutation.isPending && ( )} {t('list.confirmDelete')} ); }
{debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}
{JSON.stringify(detailTarget.metadata, null, 2)}