"use client" import * as React from "react" import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" import {AnimatePresence, motion} from "motion/react" import { Database, Download, Eye, FileArchive, FileAudio, FileImage, Files, FileText, FileVideo, FolderOpen, HardDrive, Loader2, Search, Trash2, TrendingUp, Upload, X, } from "lucide-react" import {toast} from "sonner" import {Area, AreaChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,} from "recharts" 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 {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import { ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart" import {formatFileSize, getFileUrl, UploadService} from "@/lib/services/upload/upload.service" 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", }) } const categoryMap: Record = { "图片": "images", "视频": "videos", "音频": "audio", "文档": "documents", "压缩包": "archives", "其他": "others", } const statsChartConfig = { images: { label: "图片", color: "hsl(var(--chart-1))", }, videos: { label: "视频", color: "hsl(var(--chart-2))", }, audio: { label: "音频", color: "hsl(var(--chart-3))", }, documents: { label: "文档", color: "hsl(var(--chart-4))", }, archives: { label: "压缩包", color: "hsl(var(--chart-5))", }, others: { label: "其他", color: "hsl(var(--chart-6))", }, count: { label: "新增文件数", color: "hsl(var(--primary))", }, size: { label: "新增大小", color: "hsl(var(--chart-2))", }, } satisfies ChartConfig /* ─── 文件管理主组件 ────────────────────────────────────── */ export function FilesMain() { const queryClient = useQueryClient() const [activeTab, setActiveTab] = React.useState("stats") 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 条更紧凑 // 趋势图指标类型切换 (数量 vs 大小) const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count") // 搜索防抖 React.useEffect(() => { const timer = setTimeout(() => { setDebouncedKeyword(keyword) setPage(1) }, 400) return () => clearTimeout(timer) }, [keyword]) // 文件列表查询 const listQuery = useQuery({ queryKey: ["files", "my", page, pageSize, debouncedKeyword], queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined), enabled: activeTab === "list", }) // 统计信息查询 const statsQuery = useQuery({ queryKey: ["files", "stats"], queryFn: () => UploadService.getFileStats(), enabled: activeTab === "stats", }) 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) => UploadService.deleteFile(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["files", "my"] }) void queryClient.invalidateQueries({ queryKey: ["files", "stats"] }) toast.success("文件已删除") setDeleteTarget(null) }, onError: (err: Error) => toast.error(err.message || "删除失败"), }) // 批量 ZIP 下载 const batchDownloadMutation = useMutation({ mutationFn: (ids: string[]) => UploadService.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("批量下载已开始") }, onError: () => toast.error("批量下载失败"), }) 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 = UploadService.getDownloadUrl(file.id) const a = document.createElement("a") a.href = url a.download = file.file_name a.click() } // ─── 数据处理 ─── const stats = statsQuery.data const trendData = React.useMemo(() => { if (!stats?.trend) return [] return stats.trend.map((item) => ({ ...item, formattedDate: item.date.substring(5), // YYYY-MM-DD -> MM-DD value: trendMetric === "count" ? item.count : item.size, })) }, [stats?.trend, trendMetric]) const categoryCountData = React.useMemo(() => { if (!stats?.categories) return [] return stats.categories .filter((c) => c.count > 0) .map((c) => { const key = categoryMap[c.name] || "others" return { name: key, value: c.count, fill: `var(--color-${key})`, } }) }, [stats?.categories]) const categorySizeData = React.useMemo(() => { if (!stats?.categories) return [] return stats.categories .filter((c) => c.size > 0) .map((c) => { const key = categoryMap[c.name] || "others" return { name: key, value: c.size, fill: `var(--color-${key})`, } }) }, [stats?.categories]) // 近 7 天趋势总新增统计 const trendSummary = React.useMemo(() => { if (!stats?.trend) return { count: 0, size: 0 } return stats.trend.reduce( (acc, curr) => ({ count: acc.count + curr.count, size: acc.size + curr.size, }), { count: 0, size: 0 } ) }, [stats?.trend]) return ( {/* 顶部标题区 */} 文件管理 管理您上传的所有文件,支持下载、数据统计与批量操作 文件存储信息 文件列表 {/* ──────── TAB 1: 统计看板 ──────── */} {statsQuery.isPending ? ( ) : !stats || stats.total_count === 0 ? ( 暂无上传数据 上传文件后即可在此查看容量与文件分布分析。 ) : ( {/* 四个指标卡片 */} 总文件数 {stats.total_count.toLocaleString()} 个 您上传的可用文件数量汇总 占用存储容量 {formatFileSize(stats.total_size)} 文件实际在磁盘/S3中占用的总空间 近 7 天新增文件 +{trendSummary.count.toLocaleString()} 个 最近 7 天上传成功的文件总数 近 7 天新增大小 +{formatFileSize(trendSummary.size)} 最近 7 天上传所消耗的存储带宽 {/* 7 天新增趋势折线图 */} 最近 7 天新增趋势 按日统计近一周期内的上传波动 setTrendMetric("count")} > 新增数量 (个) setTrendMetric("size")} > 新增大小 (MB) (trendMetric === "size" ? formatFileSize(v) : v.toString())} /> { const label = trendMetric === "count" ? "新增文件数" : "新增大小" const formattedValue = trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个` return ( <> {label} {formattedValue} > ) }} /> } /> {/* 两个饼图分布 */} {/* 饼图 1: 格式数量分布 */} 文件格式数量分布 各种格式类型的文件个数占比 {categoryCountData.length === 0 ? ( 暂无分类数据 ) : ( {categoryCountData.map((entry, index) => ( ))} { const configObj = statsChartConfig[name as keyof typeof statsChartConfig] const label = configObj?.label || name const color = configObj?.color || "hsl(var(--muted))" return ( <> {label} {value} 个 > ) }} /> } /> } className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4" /> )} {/* 饼图 2: 格式容量分布 */} 文件占用容量分布 各种格式类型的文件大小占比 {categorySizeData.length === 0 ? ( 暂无容量数据 ) : ( {categorySizeData.map((entry, index) => ( ))} { const configObj = statsChartConfig[name as keyof typeof statsChartConfig] const label = configObj?.label || name const color = configObj?.color || "hsl(var(--muted))" return ( <> {label} {formatFileSize(Number(value))} > ) }} /> } /> } className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4" /> )} )} {/* ──────── TAB 2: 文件列表 ──────── */} {/* 搜索栏 */} setKeyword(e.target.value)} /> {keyword && ( setKeyword("")} > )} {files.length > 0 && ( {selectedIds.size === files.length ? "取消全选" : "全选本页"} )} {/* 打包下载等批量操作 */} {selectedIds.size > 0 && ( 已选 {selectedIds.size} 个 batchDownloadMutation.mutate([...selectedIds])} disabled={batchDownloadMutation.isPending} > {batchDownloadMutation.isPending ? ( ) : ( )} 打包下载 )} {total > 0 && ( 共 {total} 个文件 )} {/* 文件列表 Table */} {listQuery.isPending ? ( ) : files.length === 0 ? ( {debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"} ) : ( 预览 ID 文件名 路径 业务类别 MIME类型 大小 上传时间 操作 {files.map((file) => { const isSelected = selectedIds.has(file.id) return ( toggleSelect(file.id)} /> {file.mime_type.startsWith("image/") ? ( // eslint-disable-next-line @next/next/no-img-element { ;(e.currentTarget as HTMLImageElement).style.display = "none" }} /> ) : ( 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)} > 上一页 {page} / {totalPages} = totalPages} onClick={() => setPage((p) => p + 1)} > 下一页 )} {/* 详情查看 Sheet */} !open && setDetailTarget(null)}> 文件详情 查看文件的完整元数据与配置属性。 {detailTarget && ( {/* 大图/格式预览 */} {detailTarget.mime_type.startsWith("image/") ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( {getFileIcon(detailTarget.mime_type, "size-14")} {detailTarget.extension} 文件 )} {/* 元数据列表 */} 文件 ID {detailTarget.id} 文件名 {detailTarget.file_name} 存储路径 {detailTarget.file_path} 大小 {formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节) MIME 类型 {detailTarget.mime_type} 文件后缀 {detailTarget.extension} Hash (SHA-256) {detailTarget.hash} 业务类型 {detailTarget.type} 存储驱动 {detailTarget.storage_driver || "local"} 上传者 ID {detailTarget.user_id} 上传时间 {formatDate(detailTarget.created_at)} {detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && ( 额外元数据 {JSON.stringify(detailTarget.metadata, null, 2)} )} )} {/* 删除确认 Dialog */} !open && setDeleteTarget(null)}> 确认删除文件 确定要删除文件{" "} 「{deleteTarget?.file_name}」{" "} 吗?此操作不可撤销。 取消 deleteTarget && deleteMutation.mutate(deleteTarget.id)} disabled={deleteMutation.isPending} className="bg-destructive hover:bg-destructive/90 text-destructive-foreground" > {deleteMutation.isPending && } 确认删除 ) }
管理您上传的所有文件,支持下载、数据统计与批量操作
暂无上传数据
上传文件后即可在此查看容量与文件分布分析。
您上传的可用文件数量汇总
文件实际在磁盘/S3中占用的总空间
最近 7 天上传成功的文件总数
最近 7 天上传所消耗的存储带宽
{debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
{JSON.stringify(detailTarget.metadata, null, 2)}