refactor(frontend): colocate admin components and split service layer

Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
This commit is contained in:
ryan
2026-06-18 10:55:14 +08:00
parent de8a21a49f
commit beae8d2dd9
56 changed files with 594 additions and 888 deletions
@@ -0,0 +1,530 @@
"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 services, {formatFileSize, getFileUrl} from "@/lib/services"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
/* ─── 工具函数 ─────────────────────────────────────────── */
function getFileIcon(mimeType: string, className = "size-8") {
if (mimeType.startsWith("image/")) return <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", "all", page, pageSize, debouncedKeyword],
queryFn: () => services.adminUpload.listUploads(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) => services.adminUpload.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[]) => services.adminUpload.batchDownload(ids),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "batch_download.zip"
a.click()
URL.revokeObjectURL(url)
toast.success("批量下载已开始")
},
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 = services.adminUpload.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}
loading="lazy"
decoding="async"
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}
loading="lazy"
decoding="async"
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 services, {formatFileSize} from "@/lib/services"
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: () => services.adminUpload.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>
)
}
@@ -0,0 +1,420 @@
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Database, Loader2, Play, Save} from "lucide-react"
import {toast} from "sonner"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"
import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field"
import {Input} from "@/components/ui/input"
import {Progress} from "@/components/ui/progress"
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Switch} from "@/components/ui/switch"
import services from "@/lib/services"
import type {
ObjectStorageConfig,
StorageConfig,
StorageDriver,
TaskExecution,
TaskExecutionStatus
} from "@/lib/services/admin/types"
const storageConfigKey = "storage_config"
const storageMigrationTaskType = "storage_migration"
const driverLabels: Record<StorageDriver, string> = {
local: "本地文件系统",
s3: "AWS S3",
r2: "Cloudflare R2",
minio: "MinIO",
oss: "阿里云 OSS",
webdav: "WebDAV",
}
const emptyObjectConfig: ObjectStorageConfig = {
endpoint: "",
region: "",
bucket: "",
access_key_id: "",
secret_access_key: "",
path_style: false,
key_prefix: "",
cdn_url: "",
}
function normalizeConfig(config: StorageConfig): StorageConfig {
const s3 = {...emptyObjectConfig, ...config.s3}
const r2 = {...emptyObjectConfig, ...config.r2}
const minio = {...emptyObjectConfig, ...config.minio}
const oss = {...emptyObjectConfig, ...config.oss}
const webdav = {...config.webdav}
return {
...config,
local: {root: config.local?.root || "."},
s3: {...s3, region: s3.region || "us-east-1"},
r2: {...r2, region: r2.region || "auto"},
minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true},
oss,
webdav: {
endpoint: webdav.endpoint || "",
username: webdav.username || "",
password: webdav.password || "",
base_path: webdav.base_path || "",
},
}
}
type StorageMigrationView = {
state: "idle" | TaskExecutionStatus
id?: string
task_id?: string
source_driver?: StorageDriver
target_driver?: StorageDriver
error?: string
}
type StorageMigrationPayload = {
target: StorageConfig
}
function parseMigrationPayload(payload: string): StorageConfig | undefined {
try {
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
return parsed.target ? normalizeConfig(parsed.target) : undefined
} catch {
return undefined
}
}
function driverLabel(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : "未知"
}
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
const execution = executions[0]
if (!execution || execution.status === "succeeded") {
return {state: "idle"}
}
const target = parseMigrationPayload(execution.payload)
return {
state: execution.status,
id: execution.id,
task_id: execution.task_id,
source_driver: current?.driver,
target_driver: target?.driver,
error: execution.error_message,
}
}
export function StorageConfigTab() {
const queryClient = useQueryClient()
const [config, setConfig] = React.useState<StorageConfig | null>(null)
const query = useQuery({
queryKey: ["admin", "storage-config"],
queryFn: async () => {
const [configRecord, executions] = await Promise.all([
services.adminSystemConfig.getSystemConfig(storageConfigKey),
services.adminTask.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}),
])
const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig)
const migration = latestMigration(executions.items, current)
return {
config: current,
migration,
}
},
refetchInterval: (result) => {
const state = result.state.data?.migration.state
return state === "pending" || state === "running" ? 2000 : false
},
})
React.useEffect(() => {
if (query.data?.config) {
setConfig((current) => {
const normalized = normalizeConfig(query.data.config)
if (current) {
return {
...normalized,
driver: current.driver,
}
}
return normalized
})
}
}, [query.data?.config])
const saveMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
const activeDriver = query.data?.config.driver || value.driver
const valueToSave = {
...value,
driver: activeDriver,
}
await services.adminSystemConfig.updateSystemConfig(storageConfigKey, {
value: JSON.stringify(valueToSave),
})
},
onSuccess: () => {
toast.success("存储配置已保存")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
onError: (error: Error) => {
toast.error(error.message || "保存存储配置失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
})
const migrateMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
const payload: StorageMigrationPayload = {target: value}
return services.adminTask.dispatchTask({
task_type: storageMigrationTaskType,
payload: JSON.stringify(payload),
})
},
onSuccess: () => {
toast.success("存储迁移任务已下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
onError: (error: Error) => {
toast.error(error.message || "下发存储迁移任务失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
})
const runMutation = useMutation({
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
onSuccess: () => {
toast.success("存储迁移任务已重新下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
})
if (query.isPending || !config) {
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
}
const migration = query.data?.migration
const isReadOnly = migration ? migration.state !== "idle" : false
const isFormDisabled =
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
saveMutation.isPending ||
migrateMutation.isPending
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
setConfig((current) => current ? {
...current,
[driver]: {...current[driver], ...patch},
} : current)
}
return (
<div className="flex w-full flex-col gap-6">
{isReadOnly && migration && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
存储维护模式
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
{migration.state}
</Badge>
</CardTitle>
<CardDescription>
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Progress value={migration.state === "succeeded" ? 100 : undefined} />
<p className="text-sm text-muted-foreground">
迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}
</p>
{migration.error && <p className="text-sm text-destructive">{migration.error}</p>}
</CardContent>
<CardFooter>
<Button
variant="outline"
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
onClick={() => migration.id && runMutation.mutate(migration.id)}
>
{runMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
重试迁移
</Button>
</CardFooter>
</Card>
)}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
<Database className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">文件存储</CardTitle>
<CardDescription className="text-xs">
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<FieldGroup className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field className="flex flex-col gap-1.5 md:col-span-2">
<FieldLabel className="text-xs font-semibold">存储类型</FieldLabel>
<Select
value={config.driver}
disabled={isFormDisabled}
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
>
<SelectTrigger className="w-full border-dashed bg-card text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map((driver) => (
<SelectItem key={driver} value={driver}>{driverLabels[driver]}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{config.driver === "local" && (
<div className="md:col-span-2">
<TextField
label="根目录"
value={config.local.root}
placeholder="."
onChange={(root) => setConfig({...config, local: {root}})}
/>
</div>
)}
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
<ObjectFields
driver={config.driver as "s3" | "r2" | "minio" | "oss"}
value={config[config.driver as "s3" | "r2" | "minio" | "oss"]}
onChange={(patch) => updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)}
/>
)}
{config.driver === "webdav" && (
<>
<div className="md:col-span-2">
<TextField label="服务地址" value={config.webdav.endpoint} placeholder="https://dav.example.com" onChange={(endpoint) => setConfig({...config, webdav: {...config.webdav, endpoint}})} />
</div>
<TextField label="用户名" value={config.webdav.username} onChange={(username) => setConfig({...config, webdav: {...config.webdav, username}})} />
<TextField label="密码" type="password" value={config.webdav.password} onChange={(password) => setConfig({...config, webdav: {...config.webdav, password}})} />
<div className="md:col-span-2">
<TextField label="基础路径" value={config.webdav.base_path} placeholder="wavelet" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
</div>
</>
)}
</div>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
{config.driver !== query.data?.config.driver && (
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
⚠️ 您已切换存储类型。请先点击“保存配置”保存各存储端的配置凭据,然后点击“开始迁移”手动执行文件迁移。
</span>
)}
<div className="flex gap-2">
<Button
variant="outline"
disabled={isFormDisabled}
onClick={() => saveMutation.mutate(config)}
className="border-dashed"
>
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
保存配置
</Button>
{config.driver !== query.data?.config.driver && (
<Button
disabled={isFormDisabled}
onClick={() => migrateMutation.mutate(config)}
>
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
开始迁移
</Button>
)}
</div>
</CardFooter>
</Card>
</div>
)
}
function ObjectFields({
driver,
value,
onChange,
}: {
driver: "s3" | "r2" | "minio" | "oss"
value: ObjectStorageConfig
onChange: (patch: Partial<ObjectStorageConfig>) => void
}) {
return (
<>
{driver === "r2" && (
<div className="md:col-span-2">
<TextField label="Account ID" value={value.account_id || ""} onChange={(account_id) => onChange({account_id})} />
</div>
)}
{driver !== "s3" && (
<div className="md:col-span-2">
<TextField label="Endpoint" value={value.endpoint} placeholder="https://..." onChange={(endpoint) => onChange({endpoint})} />
</div>
)}
<TextField label="Region" value={value.region} onChange={(region) => onChange({region})} />
<TextField label="Bucket" value={value.bucket} onChange={(bucket) => onChange({bucket})} />
<TextField label="Access Key ID" value={value.access_key_id} onChange={(access_key_id) => onChange({access_key_id})} />
<TextField label="Secret Access Key" type="password" value={value.secret_access_key} onChange={(secret_access_key) => onChange({secret_access_key})} />
<TextField label="对象前缀" value={value.key_prefix} placeholder="uploads" onChange={(key_prefix) => onChange({key_prefix})} />
<TextField label="CDN 地址" value={value.cdn_url} placeholder="https://cdn.example.com" onChange={(cdn_url) => onChange({cdn_url})} />
{(driver === "s3" || driver === "minio") && (
<div className="md:col-span-2">
<Field orientation="horizontal" className="flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30">
<div className="flex flex-col gap-0.5">
<FieldLabel className="text-xs font-semibold cursor-pointer">Path Style</FieldLabel>
<FieldDescription className="text-[11px] leading-relaxed text-muted-foreground">MinIO 等自托管 S3 通常需要开启。</FieldDescription>
</div>
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
</Field>
</div>
)}
</>
)
}
function TextField({
label,
value,
onChange,
placeholder,
type = "text",
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
type?: React.HTMLInputTypeAttribute
}) {
return (
<Field className="flex flex-col gap-1.5">
<FieldLabel className="text-xs font-semibold">{label}</FieldLabel>
<Input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-dashed bg-card text-xs"
/>
</Field>
)
}