diff --git a/frontend/components/common/admin/file-list.tsx b/frontend/components/common/admin/file-list.tsx new file mode 100644 index 00000000..42e250bd --- /dev/null +++ b/frontend/components/common/admin/file-list.tsx @@ -0,0 +1,526 @@ +"use client" + +import * as React from "react" +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 {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", + }) +} + +export function FileList() { + 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", "my", page, pageSize, debouncedKeyword], + queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined), + }) + + 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() + } + + return ( +
+ {/* 搜索栏 */} +
+
+ + setKeyword(e.target.value)} + /> + {keyword && ( + + )} +
+ {files.length > 0 && ( + + )} + + {/* 打包下载等批量操作 */} +
+ + {selectedIds.size > 0 && ( + + + 已选 {selectedIds.size} 个 + + + + + )} + +
+ + {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 + {file.file_name} { + ;(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)} + + +
+ + + +
+
+
+ ) + })} +
+
+
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + {/* 详情查看 Sheet */} + !open && setDetailTarget(null)}> + + + 文件详情 + 查看文件的完整元数据与配置属性。 + + + {detailTarget && ( +
+ {/* 大图/格式预览 */} +
+ {detailTarget.mime_type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + {detailTarget.file_name} + ) : ( +
+ {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 && } + 确认删除 + + + + +
+ ) +} diff --git a/frontend/components/common/admin/file-stats.tsx b/frontend/components/common/admin/file-stats.tsx new file mode 100644 index 00000000..e9337470 --- /dev/null +++ b/frontend/components/common/admin/file-stats.tsx @@ -0,0 +1,483 @@ +"use client" + +import * as React from "react" +import {useQuery} from "@tanstack/react-query" +import { + Upload, + Loader2, + HardDrive, + Database, + TrendingUp, + Files, + Info, +} from "lucide-react" +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Pie, + PieChart, + XAxis, + YAxis, +} from "recharts" + +import {Button} from "@/components/ui/button" +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, +} from "@/components/ui/chart" +import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service" + +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 FileStats() { + const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count") + + const statsQuery = useQuery({ + queryKey: ["files", "stats"], + queryFn: () => UploadService.getFileStats(), + }) + + 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]) + + const maxStats = React.useMemo(() => { + if (!stats?.categories || stats.categories.length === 0) { + return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 } + } + let maxCountName = "无" + let maxCount = 0 + let maxSizeName = "无" + let maxSize = 0 + + stats.categories.forEach((cat) => { + if (cat.count > maxCount) { + maxCount = cat.count + maxCountName = cat.name + } + if (cat.size > maxSize) { + maxSize = cat.size + maxSizeName = cat.name + } + }) + + return { maxCountName, maxCount, maxSizeName, maxSize } + }, [stats?.categories]) + + 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]) + + if (statsQuery.isPending) { + return ( +
+ +
+ ) + } + + if (!stats || stats.total_count === 0) { + return ( +
+ +
+

暂无上传数据

+

上传文件后即可在此查看容量与文件分布分析。

+
+
+ ) + } + + return ( +
+ {/* 四个指标卡片 */} +
+ + + 总文件数 + + + +
+ {stats.total_count.toLocaleString()}{" "} + 个 +
+

您上传的可用文件数量汇总

+
+
+ + + + 占用存储容量 + + + +
{formatFileSize(stats.total_size)}
+

文件实际在磁盘/S3中占用的总空间

+
+
+ + + + 近 7 天新增文件 + + + +
+ +{trendSummary.count.toLocaleString()}{" "} + 个 +
+

最近 7 天上传成功的文件总数

+
+
+ + + + 近 7 天新增大小 + + + +
+{formatFileSize(trendSummary.size)}
+

最近 7 天上传所消耗的存储带宽

+
+
+
+ + {/* 7 天新增趋势折线图 */} + + +
+ 最近 7 天新增趋势 + 按日统计近一周期内的上传波动 +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + (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" + /> +
+
+
+ )} +
+ +

+ 文件类型中数量最多的是{" "} + 「{maxStats.maxCountName}」,累计上传了{" "} + {maxStats.maxCount} 个文件。 +

+
+
+
+ + {/* 饼图 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" + /> +
+
+
+ )} +
+ +

+ 文件类型中占用存储最大的是{" "} + 「{maxStats.maxSizeName}」,共消耗了{" "} + {formatFileSize(maxStats.maxSize)}{" "} + 存储容量。 +

+
+
+
+
+
+ ) +} diff --git a/frontend/components/common/admin/files.tsx b/frontend/components/common/admin/files.tsx index d4b365f7..878a874a 100644 --- a/frontend/components/common/admin/files.tsx +++ b/frontend/components/common/admin/files.tsx @@ -1,287 +1,14 @@ "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 {motion} from "motion/react" -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 - -/* ─── 文件管理主组件 ────────────────────────────────────── */ +import {FileStats} from "./file-stats" +import {FileList} from "./file-list" 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 ( {/* 顶部标题区 */}
-
-
- -
-
-

文件管理

-

- 管理您上传的所有文件,支持下载、数据统计与批量操作 -

-
+
+

+ 文件管理 +

+

+ 管理您上传的所有文件,支持下载、数据统计与批量操作 +

-
@@ -313,656 +36,15 @@ export function FilesMain() { {/* ──────── 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 天新增趋势 - 按日统计近一周期内的上传波动 -
-
- - -
-
- -
- - - - - - - - - - - - - - - (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 && ( - - )} -
- {files.length > 0 && ( - - )} - - {/* 打包下载等批量操作 */} -
- - {selectedIds.size > 0 && ( - - - 已选 {selectedIds.size} 个 - - - - - )} - -
- - {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 - {file.file_name} { - ;(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)} - - -
- - - -
-
-
- ) - })} -
-
-
- )} - - {/* 分页 */} - {totalPages > 1 && ( -
- - - {page} / {totalPages} - - -
- )} + +
- - {/* 详情查看 Sheet */} - !open && setDetailTarget(null)}> - - - 文件详情 - 查看文件的完整元数据与配置属性。 - - - {detailTarget && ( -
- {/* 大图/格式预览 */} -
- {detailTarget.mime_type.startsWith("image/") ? ( - // eslint-disable-next-line @next/next/no-img-element - {detailTarget.file_name} - ) : ( -
- {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 && } - 确认删除 - - - - ) }