mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
63cd906cfc
- Merge all files inside openflare-server to the repository root directory. - Relocate agent, relay, and flared subprojects from internal/ to internal/apps/. - Combine docker-compose files and update build context paths to root. - Update GitHub workflows and Dockerfiles to refer to new directories and package names. - Rewrite Go package imports across all files. - Resolve database renew test race condition and clean up docs.
469 lines
19 KiB
TypeScript
469 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import {useQuery} from "@tanstack/react-query"
|
|
import {Database, Files, HardDrive, Info, Loader2, TrendingUp, Upload,} 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,
|
|
ChartLegend,
|
|
ChartLegendContent,
|
|
ChartTooltip,
|
|
ChartTooltipContent,
|
|
} 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(),
|
|
staleTime: 0,
|
|
refetchOnMount: "always",
|
|
})
|
|
|
|
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.isLoading) {
|
|
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>
|
|
)
|
|
}
|