refactor(frontend): split files coordinator into separate tab components

This commit is contained in:
ryan
2026-06-13 12:53:15 +08:00
parent 9453af2aa0
commit 4bf8a4806e
3 changed files with 1023 additions and 932 deletions
@@ -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 <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
}
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<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(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 (
<div className="space-y-6">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder="搜索文件名..."
className="pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{keyword && (
<button
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setKeyword("")}
>
<X className="size-3" />
</button>
)}
</div>
{files.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-xs h-8 border border-dashed text-muted-foreground"
onClick={selectedIds.size === files.length ? clearSelection : selectAll}
>
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
</Button>
)}
{/* 打包下载等批量操作 */}
<div className="flex items-center gap-2 ml-auto shrink-0">
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex items-center gap-2"
>
<Badge variant="secondary" className="text-xs px-2.5">
已选 {selectedIds.size} 个
</Badge>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8"
onClick={() => batchDownloadMutation.mutate([...selectedIds])}
disabled={batchDownloadMutation.isPending}
>
{batchDownloadMutation.isPending ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<FileArchive className="size-3.5 mr-1" />
)}
打包下载
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs h-8 px-2"
onClick={clearSelection}
>
<X className="size-3.5" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
{total > 0 && (
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
)}
</div>
{/* 文件列表 Table */}
{listQuery.isPending ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-sky-500" />
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground">
<Upload className="size-12 text-muted-foreground/30" />
<p className="text-sm">
{debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
</p>
</div>
) : (
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-dashed">
<TableHead className="w-[50px] pl-4 py-3">
<Checkbox
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-[80px] py-3">预览</TableHead>
<TableHead className="w-[180px] py-3">ID</TableHead>
<TableHead className="py-3">文件名</TableHead>
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
<TableHead className="w-[100px] py-3">业务类别</TableHead>
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
<TableHead className="w-[100px] py-3">大小</TableHead>
<TableHead className="w-[150px] py-3">上传时间</TableHead>
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => {
const isSelected = selectedIds.has(file.id)
return (
<TableRow
key={file.id}
className={`border-dashed hover:bg-muted/30 transition-colors ${
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
}`}
>
<TableCell className="pl-4 py-3">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
{file.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getFileUrl(file.id, "low") ?? undefined}
alt={file.file_name}
className="size-full object-cover"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
) : (
getFileIcon(file.mime_type, "size-4.5")
)}
</div>
</TableCell>
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
{file.id}
</TableCell>
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
{file.file_name}
</TableCell>
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
{file.file_path}
</TableCell>
<TableCell className="py-3">
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
{file.type}
</Badge>
</TableCell>
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
{file.mime_type}
</TableCell>
<TableCell className="py-3 text-xs">
{formatFileSize(file.file_size)}
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
{formatDate(file.created_at)}
</TableCell>
<TableCell className="py-3 text-right pr-4">
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看详情"
onClick={() => setDetailTarget(file)}
>
<Eye className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted"
title="下载"
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<span className="text-xs text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
)}
{/* 详情查看 Sheet */}
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
<SheetHeader className="border-b pb-4 px-0">
<SheetTitle>文件详情</SheetTitle>
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
</SheetHeader>
{detailTarget && (
<div className="space-y-6 py-4">
{/* 大图/格式预览 */}
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
{detailTarget.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getFileUrl(detailTarget.id, "low") ?? undefined}
alt={detailTarget.file_name}
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
/>
) : (
<div className="flex flex-col items-center gap-3">
{getFileIcon(detailTarget.mime_type, "size-14")}
<span className="text-xs font-medium text-muted-foreground uppercase">
{detailTarget.extension} 文件
</span>
</div>
)}
</div>
{/* 元数据列表 */}
<div className="space-y-4 text-xs">
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件 ID</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件名</span>
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储路径</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">大小</span>
<span className="col-span-2 text-foreground/90">
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">MIME 类型</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件后缀</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">业务类型</span>
<span className="col-span-2">
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
{detailTarget.type}
</Badge>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储驱动</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.storage_driver || "local"}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传者 ID</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传时间</span>
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
</div>
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
<div className="space-y-1.5 pt-2">
<span className="text-muted-foreground font-medium block">额外元数据</span>
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
{JSON.stringify(detailTarget.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -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<string, string> = {
"图片": "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 (
<div className="flex items-center justify-center py-32">
<Loader2 className="size-8 animate-spin text-sky-500" />
</div>
)
}
if (!stats || stats.total_count === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4">
<Upload className="size-14 text-muted-foreground/25" />
<div className="text-center space-y-1">
<p className="font-medium">暂无上传数据</p>
<p className="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* 四个指标卡片 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">总文件数</CardTitle>
<Files className="size-4 text-sky-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-sky-500">
{stats.total_count.toLocaleString()}{" "}
<span className="text-xs font-normal text-muted-foreground">个</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">您上传的可用文件数量汇总</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">占用存储容量</CardTitle>
<Database className="size-4 text-emerald-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-emerald-500">{formatFileSize(stats.total_size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">文件实际在磁盘/S3中占用的总空间</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增文件</CardTitle>
<TrendingUp className="size-4 text-indigo-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-indigo-500">
+{trendSummary.count.toLocaleString()}{" "}
<span className="text-xs font-normal text-muted-foreground">个</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增大小</CardTitle>
<HardDrive className="size-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-amber-500">+{formatFileSize(trendSummary.size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
</CardContent>
</Card>
</div>
{/* 7 天新增趋势折线图 */}
<Card className="bg-card/20 border-border/40">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-bold">最近 7 天新增趋势</CardTitle>
<CardDescription className="text-xs">按日统计近一周期内的上传波动</CardDescription>
</div>
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30">
<Button
size="sm"
variant={trendMetric === "count" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("count")}
>
新增数量 (个)
</Button>
<Button
size="sm"
variant={trendMetric === "size" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("size")}
>
新增大小 (MB)
</Button>
</div>
</CardHeader>
<CardContent className="pl-2 pr-4 pt-2">
<div className="h-[280px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
</linearGradient>
<linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
<XAxis
dataKey="formattedDate"
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value) => {
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
const formattedValue =
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
return (
<>
<div className="flex items-center gap-1.5">
<span
className="size-2 rounded-full animate-pulse"
style={{
backgroundColor:
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
}}
/>
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{formattedValue}
</span>
</>
)
}}
/>
}
/>
<Area
type="monotone"
dataKey="value"
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
strokeWidth={2.5}
fillOpacity={1}
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
/>
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
{/* 两个饼图分布 */}
<div className="grid gap-6 md:grid-cols-2">
{/* 饼图 1: 格式数量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<CardHeader>
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
{categoryCountData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
暂无分类数据
</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categoryCountData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{value} 个
</span>
</>
)
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ChartContainer>
</div>
)}
<div className="bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400">
<Info className="size-4 shrink-0 mt-0.5" />
<p className="leading-normal">
文件类型中数量最多的是{" "}
<strong className="font-bold">「{maxStats.maxCountName}」</strong>,累计上传了{" "}
<strong className="font-mono font-bold">{maxStats.maxCount}</strong> 个文件。
</p>
</div>
</CardContent>
</Card>
{/* 饼图 2: 格式容量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<CardHeader>
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
{categorySizeData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
暂无容量数据
</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categorySizeData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{formatFileSize(Number(value))}
</span>
</>
)
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ChartContainer>
</div>
)}
<div className="bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400">
<Info className="size-4 shrink-0 mt-0.5" />
<p className="leading-normal">
文件类型中占用存储最大的是{" "}
<strong className="font-bold">「{maxStats.maxSizeName}」</strong>,共消耗了{" "}
<strong className="font-mono font-bold">{formatFileSize(maxStats.maxSize)}</strong>{" "}
存储容量。
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
+14 -932
View File
@@ -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 <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
}
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<string, string> = {
"图片": "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<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(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 (
<motion.div
@@ -292,18 +19,14 @@ export function FilesMain() {
>
{/* 顶部标题区 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-5">
<div className="flex items-center gap-3 pb-2">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<FolderOpen className="size-5" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">文件管理</h1>
<p className="text-xs text-muted-foreground mt-0.5">
管理您上传的所有文件,支持下载、数据统计与批量操作
</p>
</div>
<div>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">
文件管理
</h1>
<p className="text-sm text-muted-foreground">
管理您上传的所有文件,支持下载、数据统计与批量操作
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
@@ -313,656 +36,15 @@ export function FilesMain() {
</TabsList>
{/* ──────── TAB 1: 统计看板 ──────── */}
<TabsContent value="stats" className="space-y-6 outline-hidden">
{statsQuery.isPending ? (
<div className="flex items-center justify-center py-32">
<Loader2 className="size-8 animate-spin text-sky-500" />
</div>
) : !stats || stats.total_count === 0 ? (
<div className="flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4">
<Upload className="size-14 text-muted-foreground/25" />
<div className="text-center space-y-1">
<p className="font-medium">暂无上传数据</p>
<p className="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
</div>
</div>
) : (
<div className="space-y-6">
{/* 四个指标卡片 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">总文件数</CardTitle>
<Files className="size-4 text-sky-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-sky-500">{stats.total_count.toLocaleString()} <span className="text-xs font-normal text-muted-foreground">个</span></div>
<p className="text-[10px] text-muted-foreground mt-1">您上传的可用文件数量汇总</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">占用存储容量</CardTitle>
<Database className="size-4 text-emerald-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-emerald-500">{formatFileSize(stats.total_size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">文件实际在磁盘/S3中占用的总空间</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增文件</CardTitle>
<TrendingUp className="size-4 text-indigo-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-indigo-500">+{trendSummary.count.toLocaleString()} <span className="text-xs font-normal text-muted-foreground">个</span></div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增大小</CardTitle>
<HardDrive className="size-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-amber-500">+{formatFileSize(trendSummary.size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
</CardContent>
</Card>
</div>
{/* 7 天新增趋势折线图 */}
<Card className="bg-card/20 border-border/40">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-bold">最近 7 天新增趋势</CardTitle>
<CardDescription className="text-xs">按日统计近一周期内的上传波动</CardDescription>
</div>
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30">
<Button
size="sm"
variant={trendMetric === "count" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("count")}
>
新增数量 (个)
</Button>
<Button
size="sm"
variant={trendMetric === "size" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("size")}
>
新增大小 (MB)
</Button>
</div>
</CardHeader>
<CardContent className="pl-2 pr-4 pt-2">
<div className="h-[280px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
</linearGradient>
<linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
<XAxis
dataKey="formattedDate"
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value) => {
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
const formattedValue = trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
return (
<>
<div className="flex items-center gap-1.5">
<span
className="size-2 rounded-full animate-pulse"
style={{ backgroundColor: trendMetric === "count" ? "var(--color-count)" : "var(--color-size)" }}
/>
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{formattedValue}
</span>
</>
)
}}
/>
}
/>
<Area
type="monotone"
dataKey="value"
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
strokeWidth={2.5}
fillOpacity={1}
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
/>
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
{/* 两个饼图分布 */}
<div className="grid gap-6 md:grid-cols-2">
{/* 饼图 1: 格式数量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<CardHeader>
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
{categoryCountData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">暂无分类数据</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categoryCountData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{value} 个
</span>
</>
)
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ChartContainer>
</div>
)}
</CardContent>
</Card>
{/* 饼图 2: 格式容量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<CardHeader>
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
{categorySizeData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">暂无容量数据</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categorySizeData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{formatFileSize(Number(value))}
</span>
</>
)
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ChartContainer>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
<TabsContent value="stats" className="outline-hidden">
<FileStats />
</TabsContent>
{/* ──────── TAB 2: 文件列表 ──────── */}
<TabsContent value="list" className="space-y-6 outline-hidden">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder="搜索文件名..."
className="pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{keyword && (
<button
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setKeyword("")}
>
<X className="size-3" />
</button>
)}
</div>
{files.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-xs h-8 border border-dashed text-muted-foreground"
onClick={selectedIds.size === files.length ? clearSelection : selectAll}
>
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
</Button>
)}
{/* 打包下载等批量操作 */}
<div className="flex items-center gap-2 ml-auto shrink-0">
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex items-center gap-2"
>
<Badge variant="secondary" className="text-xs px-2.5">
已选 {selectedIds.size} 个
</Badge>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8"
onClick={() => batchDownloadMutation.mutate([...selectedIds])}
disabled={batchDownloadMutation.isPending}
>
{batchDownloadMutation.isPending ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<FileArchive className="size-3.5 mr-1" />
)}
打包下载
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs h-8 px-2"
onClick={clearSelection}
>
<X className="size-3.5" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
{total > 0 && (
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
)}
</div>
{/* 文件列表 Table */}
{listQuery.isPending ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-sky-500" />
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground">
<Upload className="size-12 text-muted-foreground/30" />
<p className="text-sm">
{debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
</p>
</div>
) : (
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-dashed">
<TableHead className="w-[50px] pl-4 py-3">
<Checkbox
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-[80px] py-3">预览</TableHead>
<TableHead className="w-[180px] py-3">ID</TableHead>
<TableHead className="py-3">文件名</TableHead>
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
<TableHead className="w-[100px] py-3">业务类别</TableHead>
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
<TableHead className="w-[100px] py-3">大小</TableHead>
<TableHead className="w-[150px] py-3">上传时间</TableHead>
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => {
const isSelected = selectedIds.has(file.id)
return (
<TableRow
key={file.id}
className={`border-dashed hover:bg-muted/30 transition-colors ${
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
}`}
>
<TableCell className="pl-4 py-3">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
{file.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getFileUrl(file.id, "low") ?? undefined}
alt={file.file_name}
className="size-full object-cover"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
) : (
getFileIcon(file.mime_type, "size-4.5")
)}
</div>
</TableCell>
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
{file.id}
</TableCell>
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
{file.file_name}
</TableCell>
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
{file.file_path}
</TableCell>
<TableCell className="py-3">
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
{file.type}
</Badge>
</TableCell>
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
{file.mime_type}
</TableCell>
<TableCell className="py-3 text-xs">
{formatFileSize(file.file_size)}
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
{formatDate(file.created_at)}
</TableCell>
<TableCell className="py-3 text-right pr-4">
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看详情"
onClick={() => setDetailTarget(file)}
>
<Eye className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted"
title="下载"
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<span className="text-xs text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
)}
<TabsContent value="list" className="outline-hidden">
<FileList />
</TabsContent>
</Tabs>
{/* 详情查看 Sheet */}
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
<SheetHeader className="border-b pb-4 px-0">
<SheetTitle>文件详情</SheetTitle>
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
</SheetHeader>
{detailTarget && (
<div className="space-y-6 py-4">
{/* 大图/格式预览 */}
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
{detailTarget.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getFileUrl(detailTarget.id, "low") ?? undefined}
alt={detailTarget.file_name}
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
/>
) : (
<div className="flex flex-col items-center gap-3">
{getFileIcon(detailTarget.mime_type, "size-14")}
<span className="text-xs font-medium text-muted-foreground uppercase">
{detailTarget.extension} 文件
</span>
</div>
)}
</div>
{/* 元数据列表 */}
<div className="space-y-4 text-xs">
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件 ID</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件名</span>
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储路径</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">大小</span>
<span className="col-span-2 text-foreground/90">
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">MIME 类型</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件后缀</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">业务类型</span>
<span className="col-span-2">
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
{detailTarget.type}
</Badge>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储驱动</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.storage_driver || "local"}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传者 ID</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传时间</span>
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
</div>
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
<div className="space-y-1.5 pt-2">
<span className="text-muted-foreground font-medium block">额外元数据</span>
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
{JSON.stringify(detailTarget.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
)
}