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
+178 -181
View File
@@ -4,38 +4,26 @@ 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,
HardDrive,
Database,
TrendingUp,
Files,
Info,
} from "lucide-react"
import {toast} from "sonner"
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
} from "recharts"
import {Area, AreaChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,} from "recharts"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
@@ -55,6 +43,14 @@ import {
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"
@@ -79,14 +75,49 @@ function formatDate(dateStr: string) {
})
}
const PIE_COLORS = [
"#3b82f6", // Blue (图片)
"#10b981", // Emerald (视频)
"#8b5cf6", // Purple (音频)
"#f59e0b", // Amber (文档)
"#ec4899", // Pink (压缩包)
"#64748b", // Slate (其他)
]
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
/* ─── 文件管理主组件 ────────────────────────────────────── */
@@ -100,7 +131,7 @@ export function FilesMain() {
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")
@@ -216,38 +247,28 @@ export function FilesMain() {
if (!stats?.categories) return []
return stats.categories
.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])
const categorySizeData = React.useMemo(() => {
if (!stats?.categories) return []
return stats.categories
.filter((c) => c.size > 0)
.map((c) => ({ name: c.name, value: c.size }))
}, [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 }
.map((c) => {
const key = categoryMap[c.name] || "others"
return {
name: key,
value: c.size,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
// 近 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>
<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 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>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
@@ -378,20 +403,16 @@ export function FilesMain() {
</CardHeader>
<CardContent className="pl-2 pr-4 pt-2">
<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 }}>
<defs>
<linearGradient id="colorTrend" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={trendMetric === "count" ? "#6366f1" : "#10b981"}
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor={trendMetric === "count" ? "#6366f1" : "#10b981"}
stopOpacity={0.01}
/>
<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)" />
@@ -409,38 +430,42 @@ export function FilesMain() {
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
/>
<RechartsTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0]
return (
<div className="bg-background/95 backdrop-blur-sm border border-border/50 rounded-lg p-2.5 shadow-xl text-xs space-y-1">
<p className="text-[10px] text-muted-foreground font-medium">{data.payload.date}</p>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full" style={{ backgroundColor: data.color }} />
<span className="font-medium text-foreground">
{trendMetric === "count" ? "新增文件数: " : "新增大小: "}
<span className="font-mono font-semibold text-foreground/90">
{trendMetric === "count" ? `${data.value} 个` : formatFileSize(data.value as number)}
</span>
<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>
</div>
</div>
)
}
return null
}}
</>
)
}}
/>
}
/>
<Area
type="monotone"
dataKey="value"
stroke={trendMetric === "count" ? "#6366f1" : "#10b981"}
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
strokeWidth={2.5}
fillOpacity={1}
fill="url(#colorTrend)"
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</CardContent>
</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] w-full">
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categoryCountData}
@@ -470,55 +495,41 @@ export function FilesMain() {
dataKey="value"
>
{categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<RechartsTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<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">
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: payload[0].color }} />
<span className="text-muted-foreground">{data.name}</span>
</div>
<div className="font-mono text-foreground font-semibold pl-3">
{data.value} 个
</div>
</div>
)
}
return null
}}
<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>
</>
)
}}
/>
}
/>
<Legend
verticalAlign="bottom"
height={36}
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>
)
}}
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ResponsiveContainer>
</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>
@@ -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] w-full">
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categorySizeData}
@@ -545,55 +556,41 @@ export function FilesMain() {
dataKey="value"
>
{categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<RechartsTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<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">
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: payload[0].color }} />
<span className="text-muted-foreground">{data.name}</span>
</div>
<div className="font-mono text-foreground font-semibold pl-3">
{formatFileSize(data.value)}
</div>
</div>
)
}
return null
}}
<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>
</>
)
}}
/>
}
/>
<Legend
verticalAlign="bottom"
height={36}
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>
)
}}
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ResponsiveContainer>
</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>