refactor(frontend): style files stats tab with native shadcn

refactor(frontend): style files stats tab with native shadcn charts
This commit is contained in:
ryan
2026-06-13 12:43:17 +08:00
parent b262880189
commit 9453af2aa0
+177 -180
View File
@@ -4,38 +4,26 @@ import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {AnimatePresence, motion} from "motion/react" import {AnimatePresence, motion} from "motion/react"
import { import {
Database,
Download, Download,
Eye, Eye,
FileArchive, FileArchive,
FileAudio, FileAudio,
FileImage, FileImage,
Files,
FileText, FileText,
FileVideo, FileVideo,
FolderOpen,
HardDrive,
Loader2, Loader2,
Search, Search,
Trash2, Trash2,
TrendingUp,
Upload, Upload,
X, X,
HardDrive,
Database,
TrendingUp,
Files,
Info,
} from "lucide-react" } from "lucide-react"
import {toast} from "sonner" import {toast} from "sonner"
import { import {Area, AreaChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,} from "recharts"
Area,
AreaChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
} from "recharts"
import {Button} from "@/components/ui/button" import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input" import {Input} from "@/components/ui/input"
@@ -55,6 +43,14 @@ import {
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet" import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" 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 {formatFileSize, getFileUrl, UploadService} from "@/lib/services/upload/upload.service"
import type {Upload as UploadRecord} from "@/lib/services/upload/types" import type {Upload as UploadRecord} from "@/lib/services/upload/types"
@@ -79,14 +75,49 @@ function formatDate(dateStr: string) {
}) })
} }
const PIE_COLORS = [ const categoryMap: Record<string, string> = {
"#3b82f6", // Blue (图片) "图片": "images",
"#10b981", // Emerald (视频) "视频": "videos",
"#8b5cf6", // Purple (音频) "音频": "audio",
"#f59e0b", // Amber (文档) "文档": "documents",
"#ec4899", // Pink (压缩包) "压缩包": "archives",
"#64748b", // Slate (其他) "其他": "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
/* ─── 文件管理主组件 ────────────────────────────────────── */ /* ─── 文件管理主组件 ────────────────────────────────────── */
@@ -216,38 +247,28 @@ export function FilesMain() {
if (!stats?.categories) return [] if (!stats?.categories) return []
return stats.categories return stats.categories
.filter((c) => c.count > 0) .filter((c) => c.count > 0)
.map((c) => ({ name: c.name, value: c.count })) .map((c) => {
const key = categoryMap[c.name] || "others"
return {
name: key,
value: c.count,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories]) }, [stats?.categories])
const categorySizeData = React.useMemo(() => { const categorySizeData = React.useMemo(() => {
if (!stats?.categories) return [] if (!stats?.categories) return []
return stats.categories return stats.categories
.filter((c) => c.size > 0) .filter((c) => c.size > 0)
.map((c) => ({ name: c.name, value: c.size })) .map((c) => {
}, [stats?.categories]) const key = categoryMap[c.name] || "others"
return {
// 计算最大占用与最大数量文件类型 name: key,
const maxStats = React.useMemo(() => { value: c.size,
if (!stats?.categories || stats.categories.length === 0) { fill: `var(--color-${key})`,
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]) }, [stats?.categories])
// 近 7 天趋势总新增统计 // 近 7 天趋势总新增统计
@@ -271,14 +292,18 @@ 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 flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-5">
<div> <div className="flex items-center gap-3 pb-2">
<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"> <div className="p-2 bg-primary/10 rounded-lg text-primary">
文件管理 <FolderOpen className="size-5" />
</h1> </div>
<p className="text-sm text-muted-foreground"> <div>
管理您上传的所有文件,支持下载、数据统计与批量操作 <h1 className="text-2xl font-bold tracking-tight">文件管理</h1>
</p> <p className="text-xs text-muted-foreground mt-0.5">
管理您上传的所有文件,支持下载、数据统计与批量操作
</p>
</div>
</div> </div>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
@@ -378,20 +403,16 @@ export function FilesMain() {
</CardHeader> </CardHeader>
<CardContent className="pl-2 pr-4 pt-2"> <CardContent className="pl-2 pr-4 pt-2">
<div className="h-[280px] w-full"> <div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ChartContainer config={statsChartConfig} className="w-full h-full">
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}> <AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs> <defs>
<linearGradient id="colorTrend" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop <stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
offset="5%" <stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
stopColor={trendMetric === "count" ? "#6366f1" : "#10b981"} </linearGradient>
stopOpacity={0.3} <linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
/> <stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
<stop <stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
offset="95%"
stopColor={trendMetric === "count" ? "#6366f1" : "#10b981"}
stopOpacity={0.01}
/>
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
@@ -409,38 +430,42 @@ export function FilesMain() {
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())} tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
/> />
<RechartsTooltip <ChartTooltip
content={({ active, payload }) => { cursor={false}
if (active && payload && payload.length) { content={
const data = payload[0] <ChartTooltipContent
return ( hideLabel
<div className="bg-background/95 backdrop-blur-sm border border-border/50 rounded-lg p-2.5 shadow-xl text-xs space-y-1"> formatter={(value) => {
<p className="text-[10px] text-muted-foreground font-medium">{data.payload.date}</p> const label = trendMetric === "count" ? "新增文件数" : "新增大小"
<div className="flex items-center gap-2"> const formattedValue = trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
<span className="size-2 rounded-full" style={{ backgroundColor: data.color }} /> return (
<span className="font-medium text-foreground"> <>
{trendMetric === "count" ? "新增文件数: " : "新增大小: "} <div className="flex items-center gap-1.5">
<span className="font-mono font-semibold text-foreground/90"> <span
{trendMetric === "count" ? `${data.value} 个` : formatFileSize(data.value as number)} className="size-2 rounded-full animate-pulse"
</span> 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> </span>
</div> </>
</div> )
) }}
} />
return null }
}}
/> />
<Area <Area
type="monotone" type="monotone"
dataKey="value" dataKey="value"
stroke={trendMetric === "count" ? "#6366f1" : "#10b981"} stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
strokeWidth={2.5} strokeWidth={2.5}
fillOpacity={1} fillOpacity={1}
fill="url(#colorTrend)" fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ChartContainer>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -458,7 +483,7 @@ export function FilesMain() {
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">暂无分类数据</div> <div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">暂无分类数据</div>
) : ( ) : (
<div className="h-[240px] w-full"> <div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart> <PieChart>
<Pie <Pie
data={categoryCountData} data={categoryCountData}
@@ -470,55 +495,41 @@ export function FilesMain() {
dataKey="value" dataKey="value"
> >
{categoryCountData.map((entry, index) => ( {categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} /> <Cell key={`cell-${index}`} fill={entry.fill} />
))} ))}
</Pie> </Pie>
<RechartsTooltip <ChartTooltip
content={({ active, payload }) => { cursor={false}
if (active && payload && payload.length) { content={
const data = payload[0].payload <ChartTooltipContent
return ( hideLabel
<div className="bg-background/95 backdrop-blur-sm border border-border/50 rounded-lg p-2 shadow-xl text-xs font-medium space-y-1"> formatter={(value, name) => {
<div className="flex items-center gap-1.5"> const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
<span className="size-2 rounded-full" style={{ backgroundColor: payload[0].color }} /> const label = configObj?.label || name
<span className="text-muted-foreground">{data.name}</span> const color = configObj?.color || "hsl(var(--muted))"
</div> return (
<div className="font-mono text-foreground font-semibold pl-3"> <>
{data.value} 个 <div className="flex items-center gap-1.5">
</div> <span className="size-2 rounded-full" style={{ backgroundColor: color }} />
</div> <span className="text-muted-foreground">{label}</span>
) </div>
} <span className="text-foreground font-mono font-medium tabular-nums ml-auto">
return null {value} 个
}} </span>
</>
)
}}
/>
}
/> />
<Legend <ChartLegend
verticalAlign="bottom" content={<ChartLegendContent nameKey="name" />}
height={36} className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
content={({ payload }) => {
if (!payload) return null
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground pt-4">
{payload.map((entry: { value: string; color?: string }) => (
<div key={entry.value} className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span>{entry.value}</span>
</div>
))}
</div>
)
}}
/> />
</PieChart> </PieChart>
</ResponsiveContainer> </ChartContainer>
</div> </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> </CardContent>
</Card> </Card>
@@ -533,7 +544,7 @@ export function FilesMain() {
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">暂无容量数据</div> <div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">暂无容量数据</div>
) : ( ) : (
<div className="h-[240px] w-full"> <div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart> <PieChart>
<Pie <Pie
data={categorySizeData} data={categorySizeData}
@@ -545,55 +556,41 @@ export function FilesMain() {
dataKey="value" dataKey="value"
> >
{categorySizeData.map((entry, index) => ( {categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} /> <Cell key={`cell-${index}`} fill={entry.fill} />
))} ))}
</Pie> </Pie>
<RechartsTooltip <ChartTooltip
content={({ active, payload }) => { cursor={false}
if (active && payload && payload.length) { content={
const data = payload[0].payload <ChartTooltipContent
return ( hideLabel
<div className="bg-background/95 backdrop-blur-sm border border-border/50 rounded-lg p-2 shadow-xl text-xs font-medium space-y-1"> formatter={(value, name) => {
<div className="flex items-center gap-1.5"> const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
<span className="size-2 rounded-full" style={{ backgroundColor: payload[0].color }} /> const label = configObj?.label || name
<span className="text-muted-foreground">{data.name}</span> const color = configObj?.color || "hsl(var(--muted))"
</div> return (
<div className="font-mono text-foreground font-semibold pl-3"> <>
{formatFileSize(data.value)} <div className="flex items-center gap-1.5">
</div> <span className="size-2 rounded-full" style={{ backgroundColor: color }} />
</div> <span className="text-muted-foreground">{label}</span>
) </div>
} <span className="text-foreground font-mono font-medium tabular-nums ml-auto">
return null {formatFileSize(Number(value))}
}} </span>
</>
)
}}
/>
}
/> />
<Legend <ChartLegend
verticalAlign="bottom" content={<ChartLegendContent nameKey="name" />}
height={36} className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
content={({ payload }) => {
if (!payload) return null
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground pt-4">
{payload.map((entry: { value: string; color?: string }) => (
<div key={entry.value} className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span>{entry.value}</span>
</div>
))}
</div>
)
}}
/> />
</PieChart> </PieChart>
</ResponsiveContainer> </ChartContainer>
</div> </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> </CardContent>
</Card> </Card>
</div> </div>